Compare commits

...

32 Commits

Author SHA1 Message Date
2b48ade2f1 #13 Чистка, исправление кода
Reviewed-on: #15
Reviewed-by: Илья Куприец <kkzemeow@gmail.com>
2025-10-29 11:21:55 +00:00
b0fdf03cc6 fix: Fix icons in `top map sightbar 2025-10-29 14:19:00 +03:00
Микаэл Оганесян
349c7009c6 закрыл задачу 13 2025-10-29 02:32:14 +03:00
50ad374cf5 feat: Add selected city functional with some debugging 2025-10-22 03:04:58 +03:00
9e47ab667f feat: Update admin panel 2025-10-22 02:55:04 +03:00
1b8fc3d215 fix: fix upload bug 3d 2025-10-20 20:00:28 +03:00
f5142ec95d fix: Env 2025-10-13 14:34:56 +03:00
cdb96dfb8b fix: Fix errors 2025-10-10 08:40:39 +03:00
c50ccb3a0c fix: Add more filters by city for station list and sight list 2025-10-06 10:35:15 +03:00
4bcc2e2cca fix: Fix webp + delete ctrl z + filter by city 2025-10-06 10:03:41 +03:00
26e4d70b95 fix: Fix 3d models 2025-10-02 22:20:37 +03:00
a357994025 feat: Update pop-up logic 2025-10-02 04:45:43 +03:00
7382a85082 feat: Update city logic in map page with scrollbar 2025-10-02 04:38:15 +03:00
db64beb3ee feat: Select city in top of the page for next usage in create/edit pages 2025-09-28 10:41:13 +03:00
Микаэл Оганесян
1abd6b30a4 build fix 2025-09-27 22:31:14 -07:00
Микаэл Оганесян
b25df42960 hotfix admin panel 2025-09-27 22:29:13 -07:00
34ba3c1db0 Add Dockerfile and Makefile for containerization and build automation
- Created a Dockerfile with a multi-stage build process to containerize the application.
- Added Makefile for managing build, export, and cleanup tasks.
2025-07-29 17:39:21 +03:00
4f038551a2 fix: Fix problems and bugs 2025-07-28 08:18:21 +03:00
470a58a3fa fix: Fix panorama + route scale data 2025-07-26 11:48:41 +03:00
89d7fc2748 feat: Add scale on group click, add cache for map entities, fix map preview loading 2025-07-15 05:29:27 +03:00
97f95fc394 feat: Group map entities + delete useless logs 2025-07-13 20:56:25 +03:00
bf117ef048 feat: Add preview_video for sights 2025-07-13 20:26:45 +03:00
ced3067915 fix: Fix name on map and fix city name in sight list 2025-07-13 14:36:57 +03:00
a908c63771 fix: Hot fix 3D model view in right preview 2025-07-10 11:53:00 +03:00
06eafee3f4 feat: Add copy device_uuid functionality 2025-07-10 11:40:45 +03:00
717031cd7a fix: Fix video using in route pages 2025-07-10 06:17:43 +03:00
2d4a1e169b Add hover effects and dynamic anchor computation to Station component
- Integrated `onTextHover` callback for handling text hover states.
- Implemented dynamic anchor calculation using `useMemo` for improved positioning.
- Updated visual feedback by highlighting labels on hover.
- Adjusted offset calculations and added interactive pointer movement refinements.
- Added `packageManager` field to `package.json`.
2025-07-09 21:12:54 +03:00
e2547cb571 fix: Update map with tables fixes 2025-07-09 18:56:18 +03:00
78800ee2ae feat: Add vercel.json 2025-06-16 12:37:25 +03:00
d415441af8 fix: Add route-station link area 2025-06-16 12:26:19 +03:00
32a7cb44d1 fix: Hot bug fix 2025-06-15 20:38:48 +03:00
481385c2f4 fix: Fix tables + add open navigation 2025-06-15 15:03:36 +03:00
140 changed files with 13636 additions and 4767 deletions

1
.env
View File

@@ -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
View 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
View 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

View File

@@ -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>

3486
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 750 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

3
public/favicon_ship.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.2 KiB

21
public/sight_icon.svg Normal file
View 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

View File

@@ -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

View File

@@ -1 +0,0 @@

View 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;
}
}

View File

@@ -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 = () => (
<GlobalErrorBoundary>
<ThemeProvider theme={CustomTheme.Light}>
<ToastContainer />
<Router />
</ThemeProvider>
</GlobalErrorBoundary>
);

View File

@@ -18,7 +18,7 @@ import {
StationListPage,
// VehicleListPage,
ArticleListPage,
CityPreviewPage,
// CountryPreviewPage,
// VehiclePreviewPage,
// CarrierPreviewPage,
@@ -26,7 +26,7 @@ import {
CountryCreatePage,
CityCreatePage,
CarrierCreatePage,
// VehicleCreatePage,
VehicleCreatePage,
CountryEditPage,
CityEditPage,
UserCreatePage,
@@ -40,6 +40,7 @@ import {
RoutePreview,
RouteEditPage,
ArticlePreviewPage,
CountryAddPage,
} from "@pages";
import { authStore, createSightStore, editSightStore } from "@shared";
import { Layout } from "@widgets";
@@ -57,7 +58,7 @@ import {
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated } = authStore;
if (isAuthenticated) {
return <Navigate to="/sight" replace />;
return <Navigate to="/map" replace />;
}
return <>{children}</>;
};
@@ -69,7 +70,7 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
return <Navigate to="/login" replace />;
}
if (location.pathname === "/") {
return <Navigate to="/sight" replace />;
return <Navigate to="/map" replace />;
}
return <>{children}</>;
};
@@ -134,12 +135,13 @@ const router = createBrowserRouter([
// Country
{ path: "country", element: <CountryListPage /> },
{ path: "country/create", element: <CountryCreatePage /> },
{ path: "country/add", element: <CountryAddPage /> },
// { path: "country/:id", element: <CountryPreviewPage /> },
{ path: "country/:id/edit", element: <CountryEditPage /> },
// City
{ path: "city", element: <CityListPage /> },
{ path: "city/create", element: <CityCreatePage /> },
{ path: "city/:id", element: <CityPreviewPage /> },
// { path: "city/:id", element: <CityPreviewPage /> },
{ path: "city/:id/edit", element: <CityEditPage /> },
// Route
{ path: "route", element: <RouteListPage /> },
@@ -166,7 +168,7 @@ const router = createBrowserRouter([
{ path: "station/:id/edit", element: <StationEditPage /> },
// Vehicle
// { path: "vehicle", element: <VehicleListPage /> },
// { path: "vehicle/create", element: <VehicleCreatePage /> },
{ path: "vehicle/create", element: <VehicleCreatePage /> },
// { path: "vehicle/:id", element: <VehiclePreviewPage /> },
// { path: "vehicle/:id/edit", element: <VehicleEditPage /> },
// Article

View File

@@ -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

View File

@@ -5,6 +5,7 @@ export interface NavigationItem {
label: string;
icon: LucideIcon;
path?: string;
for_admin?: boolean;
onClick?: () => void;
nestedItems?: NavigationItem[];
}

View File

@@ -10,12 +10,14 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import type { NavigationItem } from "../model";
import { useNavigate, useLocation } from "react-router-dom";
import { Plus } from "lucide-react";
import { authStore } from "@shared";
interface NavigationItemProps {
item: NavigationItem;
open: boolean;
onClick?: () => void;
isNested?: boolean;
onDrawerOpen?: () => void;
}
export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
@@ -23,15 +25,31 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
open,
onClick,
isNested = false,
onDrawerOpen,
}) => {
const Icon = item.icon;
const navigate = useNavigate();
const location = useLocation();
const [isExpanded, setIsExpanded] = React.useState(false);
const { payload } = authStore;
// @ts-ignore
const isAdmin = payload?.is_admin || false;
const isActive = item.path ? location.pathname.startsWith(item.path) : false;
const filteredNestedItems = item.nestedItems?.filter((nestedItem) => {
if (nestedItem.for_admin) {
return isAdmin;
}
return true;
});
const handleClick = () => {
if (item.id === "all" && !open) {
onDrawerOpen?.();
}
if (item.nestedItems) {
setIsExpanded(!isExpanded);
} else if (onClick) {
@@ -103,15 +121,16 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
},
]}
/>
{item.nestedItems &&
{filteredNestedItems &&
filteredNestedItems.length > 0 &&
open &&
(isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />)}
</ListItemButton>
</ListItem>
{item.nestedItems && (
{filteredNestedItems && filteredNestedItems.length > 0 && (
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{item.nestedItems.map((nestedItem) => (
{filteredNestedItems.map((nestedItem) => (
<NavigationItemComponent
key={nestedItem.id}
item={nestedItem}

View File

@@ -1,11 +1,36 @@
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;
}
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 (
<>
@@ -15,20 +40,23 @@ export const NavigationList = ({ open }: { open: boolean }) => {
key={item.id}
item={item as NavigationItem}
open={open}
onDrawerOpen={onDrawerOpen}
/>
))}
</List>
<Divider />
<List>
{secondaryItems.map((item) => (
{NAVIGATION_ITEMS.secondary.map((item) => (
<NavigationItemComponent
key={item.id}
item={item as NavigationItem}
open={open}
onClick={item.onClick ? item.onClick : undefined}
onDrawerOpen={onDrawerOpen}
/>
))}
</List>
</>
);
};
}
);

View File

@@ -1,5 +1,6 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
button {
cursor: pointer;
}

View File

@@ -2,14 +2,19 @@ import React from "react";
import { useNavigate } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { LanguageSwitcher } from "@widgets";
import { articlesStore } from "@shared";
const ArticleCreatePage: React.FC = () => {
const navigate = useNavigate();
const { articleData } = articlesStore;
return (
<div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
<h1 className="text-3xl break-words">Создание статьи</h1>
<h1 className="text-3xl break-words">
{articleData?.ru?.heading || "Создание статьи"}
</h1>
</div>
<LanguageSwitcher />
<div className="flex items-center gap-4">

View File

@@ -2,7 +2,7 @@ import React, { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { LanguageSwitcher } from "@widgets";
import { articlesStore } from "@shared";
import { articlesStore, languageStore } from "@shared";
import { observer } from "mobx-react-lite";
const ArticleEditPage: React.FC = observer(() => {
@@ -11,6 +11,11 @@ const ArticleEditPage: React.FC = observer(() => {
const { articleData, getArticle } = articlesStore;
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
useEffect(() => {
if (id) {
// Fetch data for all languages

View File

@@ -1,10 +1,12 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { articlesStore, languageStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Trash2, Eye, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { DeleteModal, LanguageSwitcher } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const ArticleListPage = observer(() => {
const { articleList, getArticleList, deleteArticles } = articlesStore;
@@ -14,9 +16,15 @@ export const ArticleListPage = observer(() => {
const [rowId, setRowId] = useState<string | null>(null);
const { language } = languageStore;
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
getArticleList();
const fetchArticles = async () => {
setIsLoading(true);
await getArticleList();
setIsLoading(false);
};
fetchArticles();
}, [language]);
const columns: GridColDef[] = [
@@ -38,6 +46,7 @@ export const ArticleListPage = observer(() => {
{
field: "actions",
headerName: "Действия",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
@@ -93,10 +102,21 @@ export const ArticleListPage = observer(() => {
columns={columns}
hideFooterPagination
checkboxSelection
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter
slots={{
noRowsOverlay: () => (
<Box
sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}
>
{isLoading ? <CircularProgress size={20} /> : "Нет статей"}
</Box>
),
}}
/>
</div>
</div>

View File

@@ -44,7 +44,7 @@ export const PreviewRightWidget = observer(() => {
},
}}
>
<MediaViewer media={articleMedia} fullWidth />
<MediaViewer media={articleMedia} fullWidth fullHeight />
</Box>
) : (
<Box

View File

@@ -12,7 +12,13 @@ import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { carrierStore, cityStore, mediaStore } from "@shared";
import {
carrierStore,
cityStore,
mediaStore,
languageStore,
useSelectedCity,
} from "@shared";
import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
import {
@@ -23,11 +29,9 @@ import {
export const CarrierCreatePage = observer(() => {
const navigate = useNavigate();
const [fullName, setFullName] = useState("");
const [shortName, setShortName] = useState("");
const [cityId, setCityId] = useState<number | null>(null);
const [slogan, setSlogan] = useState("");
const { createCarrierData, setCreateCarrierData } = carrierStore;
const { language } = languageStore;
const { selectedCityId } = useSelectedCity();
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
@@ -35,24 +39,34 @@ export const CarrierCreatePage = observer(() => {
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
useEffect(() => {
cityStore.getCities("ru");
mediaStore.getMedia();
languageStore.setLanguage("ru");
}, []);
// Автоматически устанавливаем выбранный город при загрузке страницы
useEffect(() => {
if (selectedCityId && !createCarrierData.city_id) {
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
selectedCityId,
createCarrierData[language].slogan,
selectedMediaId || "",
language
);
}
}, [selectedCityId, createCarrierData.city_id]);
const handleCreate = async () => {
try {
setIsLoading(true);
await carrierStore.createCarrier(
fullName,
shortName,
cityId!,
slogan,
selectedMediaId!
);
await carrierStore.createCarrier();
toast.success("Перевозчик успешно создан");
navigate("/carrier");
} catch (error) {
@@ -69,6 +83,14 @@ export const CarrierCreatePage = observer(() => {
media_type: number;
}) => {
setSelectedMediaId(media.id);
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
createCarrierData[language].slogan,
media.id,
language
);
};
const selectedMedia = selectedMediaId
@@ -89,19 +111,28 @@ export const CarrierCreatePage = observer(() => {
</div>
<div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
<h1 className="text-3xl break-words">Создание перевозчика</h1>
</div>
<FormControl fullWidth>
<InputLabel>Город</InputLabel>
<Select
value={cityId || ""}
value={createCarrierData.city_id || ""}
label="Город"
required
onChange={(e) => setCityId(e.target.value as number)}
onChange={(e) =>
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
e.target.value as number,
createCarrierData[language].slogan,
selectedMediaId || "",
language
)
}
>
{cityStore.cities.ru.data.map((city) => (
{cityStore.cities["ru"].data.map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>
@@ -112,24 +143,51 @@ export const CarrierCreatePage = observer(() => {
<TextField
fullWidth
label="Полное название"
value={fullName}
value={createCarrierData[language].full_name}
required
onChange={(e) => setFullName(e.target.value)}
onChange={(e) =>
setCreateCarrierData(
e.target.value,
createCarrierData[language].short_name,
createCarrierData.city_id,
createCarrierData[language].slogan,
selectedMediaId || "",
language
)
}
/>
<TextField
fullWidth
label="Короткое название"
value={shortName}
value={createCarrierData[language].short_name}
required
onChange={(e) => setShortName(e.target.value)}
onChange={(e) =>
setCreateCarrierData(
createCarrierData[language].full_name,
e.target.value,
createCarrierData.city_id,
createCarrierData[language].slogan,
selectedMediaId || "",
language
)
}
/>
<TextField
fullWidth
label="Слоган"
value={slogan}
onChange={(e) => setSlogan(e.target.value)}
value={createCarrierData[language].slogan}
onChange={(e) =>
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
e.target.value,
selectedMediaId || "",
language
)
}
/>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
@@ -144,14 +202,22 @@ export const CarrierCreatePage = observer(() => {
onDeleteImageClick={() => {
setSelectedMediaId(null);
setActiveMenuType(null);
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
createCarrierData[language].slogan,
"",
language
);
}}
onSelectFileClick={() => {
setActiveMenuType("thumbnail");
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("thumbnail");
setActiveMenuType("image");
}}
/>
</div>
@@ -162,7 +228,10 @@ export const CarrierCreatePage = observer(() => {
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={
isLoading || !fullName || !shortName || !cityId || !selectedMediaId
isLoading ||
!createCarrierData[language].full_name ||
!createCarrierData[language].short_name ||
!createCarrierData.city_id
}
>
{isLoading ? (
@@ -177,12 +246,14 @@ export const CarrierCreatePage = observer(() => {
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={3}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={createCarrierData[language].full_name}
contextType="carrier"
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>

View File

@@ -14,11 +14,11 @@ import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import { carrierStore, cityStore, mediaStore, languageStore } from "@shared";
import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets";
import {
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
UploadMediaDialog,
} from "@shared";
export const CarrierEditPage = observer(() => {
@@ -32,8 +32,9 @@ export const CarrierEditPage = observer(() => {
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [isDeleteLogoModalOpen, setIsDeleteLogoModalOpen] = useState(false);
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
useEffect(() => {
@@ -72,6 +73,9 @@ export const CarrierEditPage = observer(() => {
mediaStore.getMedia();
})();
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, [id]);
const handleEdit = async () => {
@@ -141,7 +145,7 @@ export const CarrierEditPage = observer(() => {
)
}
>
{cityStore.cities[language].data?.map((city) => (
{cityStore.cities["ru"].data?.map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>
@@ -209,23 +213,15 @@ export const CarrierEditPage = observer(() => {
setMediaId(selectedMedia?.id ?? "");
}}
onDeleteImageClick={() => {
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
"",
language
);
setActiveMenuType(null);
setIsDeleteLogoModalOpen(true);
}}
onSelectFileClick={() => {
setActiveMenuType("thumbnail");
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("thumbnail");
setActiveMenuType("image");
}}
/>
</div>
@@ -238,15 +234,13 @@ export const CarrierEditPage = observer(() => {
disabled={
isLoading ||
!editCarrierData[language].full_name ||
!editCarrierData[language].short_name ||
!editCarrierData.city_id ||
!editCarrierData.logo
!editCarrierData.city_id
}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Обновить"
"Сохранить"
)}
</Button>
</div>
@@ -255,12 +249,14 @@ export const CarrierEditPage = observer(() => {
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={3}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={editCarrierData[language].full_name}
contextType="carrier"
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
@@ -270,6 +266,23 @@ export const CarrierEditPage = observer(() => {
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
<DeleteModal
open={isDeleteLogoModalOpen}
onDelete={() => {
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
"",
language
);
setIsDeleteLogoModalOpen(false);
}}
onCancel={() => setIsDeleteLogoModalOpen(false)}
edit
/>
</Paper>
);
});

View File

@@ -1,24 +1,34 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { carrierStore, languageStore } from "@shared";
import { ruRU } from "@mui/x-data-grid/locales";
import { carrierStore, cityStore, languageStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const CarrierListPage = observer(() => {
const { carriers, getCarriers, deleteCarrier } = carrierStore;
const { getCities, cities } = cityStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
useEffect(() => {
(async () => {
const fetchData = async () => {
setIsLoading(true);
await getCities("ru");
await getCities("en");
await getCities("zh");
await getCarriers(language);
})();
setIsLoading(false);
};
fetchData();
}, [language]);
const columns: GridColDef[] = [
@@ -55,14 +65,17 @@ export const CarrierListPage = observer(() => {
},
},
{
field: "city",
field: "city_id",
headerName: "Город",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
const city = cities[language]?.data.find(
(city) => city.id == params.value
);
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
{city && city.name ? (
city.name
) : (
<Minus size={20} className="text-red-500" />
)}
@@ -75,6 +88,7 @@ export const CarrierListPage = observer(() => {
headerName: "Действия",
headerAlign: "center",
width: 200,
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
@@ -103,7 +117,7 @@ export const CarrierListPage = observer(() => {
id: carrier.id,
full_name: carrier.full_name,
short_name: carrier.short_name,
city: carrier.city,
city_id: carrier.city_id,
}));
return (
@@ -131,12 +145,24 @@ export const CarrierListPage = observer(() => {
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
checkboxSelection
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter
checkboxSelection
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? (
<CircularProgress size={20} />
) : (
"Нет перевозчиков"
)}
</Box>
),
}}
/>
</div>

View File

@@ -8,7 +8,7 @@ import {
InputLabel,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save, Minus } from "lucide-react";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
@@ -31,7 +31,7 @@ export const CityCreatePage = observer(() => {
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
const { getCountries } = countryStore;
const { getMedia } = mediaStore;
@@ -132,15 +132,9 @@ export const CityCreatePage = observer(() => {
</FormControl>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
{!selectedMedia && (
<div className="flex items-center gap-2 text-red-500">
<Minus size={20} />
<span className="text-sm">Герб города не выбран</span>
</div>
)}
<ImageUploadCard
title="Герб города"
imageKey="thumbnail"
imageKey="image"
imageUrl={selectedMedia?.id}
onImageClick={() => {
setIsPreviewMediaOpen(true);
@@ -156,12 +150,15 @@ export const CityCreatePage = observer(() => {
setActiveMenuType(null);
}}
onSelectFileClick={() => {
setActiveMenuType("thumbnail");
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("thumbnail");
setActiveMenuType("image");
}}
setHardcodeType={() => {
setActiveMenuType("image");
}}
/>
</div>
@@ -185,14 +182,18 @@ export const CityCreatePage = observer(() => {
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={3} // Тип медиа для иконок
mediaType={1} // Тип медиа для иконок
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={createCityData[language]?.name}
contextType="city"
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
hardcodeType={
activeMenuType as "thumbnail" | "watermark_lu" | "watermark_rd" | null
}
/>
<PreviewMediaDialog

View File

@@ -35,7 +35,7 @@ export const CityEditPage = observer(() => {
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
const { language } = languageStore;
const { id } = useParams();
@@ -43,6 +43,11 @@ export const CityEditPage = observer(() => {
const { getCountries } = countryStore;
const { getMedia, getOneMedia } = mediaStore;
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
const handleEdit = async () => {
try {
setIsLoading(true);
@@ -58,6 +63,7 @@ export const CityEditPage = observer(() => {
useEffect(() => {
(async () => {
if (id) {
await getCountries("ru");
// Fetch data for all languages
const ruData = await getCity(id as string, "ru");
const enData = await getCity(id as string, "en");
@@ -69,7 +75,7 @@ export const CityEditPage = observer(() => {
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
await getOneMedia(ruData.arms as string);
await getCountries("ru");
await getMedia();
}
})();
@@ -151,7 +157,7 @@ export const CityEditPage = observer(() => {
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard
title="Герб города"
imageKey="thumbnail"
imageKey="image"
imageUrl={selectedMedia?.id}
onImageClick={() => {
setIsPreviewMediaOpen(true);
@@ -167,12 +173,15 @@ export const CityEditPage = observer(() => {
setActiveMenuType(null);
}}
onSelectFileClick={() => {
setActiveMenuType("thumbnail");
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("thumbnail");
setActiveMenuType("image");
}}
setHardcodeType={() => {
setActiveMenuType("image");
}}
/>
</div>
@@ -189,7 +198,7 @@ export const CityEditPage = observer(() => {
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Обновить"
"Сохранить"
)}
</Button>
</div>
@@ -198,14 +207,23 @@ export const CityEditPage = observer(() => {
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={3} // Тип медиа для иконок
mediaType={1} // Тип медиа для иконок
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={editCityData[language].name}
contextType="city"
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
hardcodeType={
activeMenuType as
| "thumbnail"
| "watermark_lu"
| "watermark_rd"
| "image"
| null
}
/>
<PreviewMediaDialog

View File

@@ -1,23 +1,57 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, cityStore } from "@shared";
import { ruRU } from "@mui/x-data-grid/locales";
import { languageStore, cityStore, countryStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { toast } from "react-toastify";
import { Box, CircularProgress } from "@mui/material";
export const CityListPage = observer(() => {
const { cities, getCities, deleteCity } = cityStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [rows, setRows] = useState<any[]>([]);
const { language } = languageStore;
useEffect(() => {
getCities(language);
const fetchData = async () => {
setIsLoading(true);
await countryStore.getCountries("ru");
await countryStore.getCountries("en");
await countryStore.getCountries("zh");
await getCities(language);
setIsLoading(false);
};
fetchData();
}, [language]);
useEffect(() => {
let newRows = cities[language]?.data?.map((city) => ({
id: city.id,
name: city.name,
country: city.country_code,
}));
let newRows2: any[] = [];
for (const city of newRows) {
const name = countryStore.countries[language]?.data?.find(
(country) => country.code === city.country
)?.name;
if (name) {
newRows2.push(city);
}
}
setRows(newRows2 || []);
}, [cities, countryStore.countries, language, isLoading]);
const columns: GridColDef[] = [
{
field: "country",
@@ -27,7 +61,9 @@ export const CityListPage = observer(() => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
countryStore.countries[language]?.data?.find(
(country) => country.code === params.value
)?.name
) : (
<Minus size={20} className="text-red-500" />
)}
@@ -57,18 +93,19 @@ export const CityListPage = observer(() => {
align: "center",
headerAlign: "center",
width: 200,
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/city/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
<button onClick={() => navigate(`/city/${params.row.id}`)}>
{/* <button onClick={() => navigate(`/city/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
</button> */}
<button
onClick={() => {
onClick={(e) => {
e.stopPropagation();
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
@@ -81,12 +118,6 @@ export const CityListPage = observer(() => {
},
];
const rows = cities[language]?.data?.map((city) => ({
id: city.id,
name: city.name,
country: city.country,
}));
return (
<>
<LanguageSwitcher />
@@ -96,11 +127,37 @@ export const CityListPage = observer(() => {
<h1 className="text-2xl">Города</h1>
<CreateButton label="Создать город" path="/city/create" />
</div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
checkboxSelection
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет городов"}
</Box>
),
}}
/>
</div>
@@ -119,6 +176,20 @@ export const CityListPage = observer(() => {
setRowId(null);
}}
/>
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteCity(id.toString())));
toast.success("Города успешно удалены");
getCities(language);
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</>
);
});

View 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>
);
});

View File

@@ -16,6 +16,11 @@ export const CountryEditPage = observer(() => {
const { editCountryData, editCountry, getCountry, setEditCountryData } =
countryStore;
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
const handleEdit = async () => {
try {
setIsLoading(true);
@@ -88,7 +93,7 @@ export const CountryEditPage = observer(() => {
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Обновить"
"Сохранить"
)}
</Button>
</div>

View File

@@ -1,20 +1,30 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { countryStore, languageStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Trash2, Minus } from "lucide-react";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const CountryListPage = observer(() => {
const { countries, getCountries, deleteCountry } = countryStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
useEffect(() => {
getCountries(language);
const fetchCountries = async () => {
setIsLoading(true);
await getCountries(language);
setIsLoading(false);
};
fetchCountries();
}, [language]);
const columns: GridColDef[] = [
@@ -40,19 +50,21 @@ export const CountryListPage = observer(() => {
align: "center",
headerAlign: "center",
width: 200,
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button
{/* <button
onClick={() => navigate(`/country/${params.row.code}/edit`)}
>
<Pencil size={20} className="text-blue-500" />
</button>
</button> */}
{/* <button onClick={() => navigate(`/country/${params.row.code}`)}>
<Eye size={20} className="text-green-500" />
</button> */}
<button
onClick={() => {
onClick={(e) => {
e.stopPropagation();
setIsDeleteModalOpen(true);
setRowId(params.row.code);
}}
@@ -78,16 +90,47 @@ export const CountryListPage = observer(() => {
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Страны</h1>
<CreateButton label="Создать страну" path="/country/create" />
<CreateButton label="Добавить страну" path="/country/add" />
</div>
<DataGrid rows={rows} columns={columns} hideFooter />
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows || []}
columns={columns}
hideFooter
checkboxSelection
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет стран"}
</Box>
),
}}
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (!rowId) return;
await deleteCountry(rowId, language);
await deleteCountry(rowId);
setRowId(null);
setIsDeleteModalOpen(false);
}}
@@ -96,6 +139,19 @@ export const CountryListPage = observer(() => {
setIsDeleteModalOpen(false);
}}
/>
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteCountry(id.toString())));
getCountries(language);
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</>
);
});

View File

@@ -2,3 +2,4 @@ export * from "./CountryListPage";
export * from "./CountryPreviewPage";
export * from "./CountryCreatePage";
export * from "./CountryEditPage";
export * from "./CountryAddPage";

View File

@@ -19,7 +19,7 @@ export const EditSightPage = observer(() => {
const { getArticles } = articlesStore;
const { id } = useParams();
const { getRuCities } = cityStore;
const { getCities } = cityStore;
let blocker = useBlocker(
({ currentLocation, nextLocation }) =>
@@ -33,13 +33,13 @@ export const EditSightPage = observer(() => {
useEffect(() => {
const fetchData = async () => {
if (id) {
await getCities("ru");
await getSightInfo(+id, "ru");
await getSightInfo(+id, "en");
await getSightInfo(+id, "zh");
await getArticles("ru");
await getArticles("en");
await getArticles("zh");
await getRuCities();
}
};
fetchData();

View File

@@ -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,12 +78,31 @@ 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>
<Paper
elevation={3}
sx={{
p: 4,
borderRadius: 2,
backgroundColor: "white",
width: "100%",
maxWidth: "400px",
}}
>
<Typography
variant="h4"
component="h1"
className="text-center pb-[50px]"
>
Вход в систему
</Typography>
<Box
@@ -63,7 +113,6 @@ export const LoginPage = () => {
flexDirection: "column",
gap: 2,
width: "100%",
maxWidth: "400px",
}}
>
{error && (
@@ -95,6 +144,16 @@ export const LoginPage = () => {
disabled={isLoading}
error={!!error}
/>
<FormControlLabel
control={
<Checkbox
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
disabled={isLoading}
/>
}
label="Запомнить пароль"
/>
<Button
variant="contained"
color="primary"
@@ -114,6 +173,7 @@ export const LoginPage = () => {
{isLoading ? <CircularProgress size={24} sx={{}} /> : "Войти"}
</Button>
</Box>
</Paper>
</Box>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -55,7 +55,7 @@ class MapStore {
getRoutes = async () => {
const response = await languageInstance("ru").get("/route");
console.log(response.data);
const routesIds = response.data.map((route: any) => route.id);
for (const id of routesIds) {
const route = await languageInstance("ru").get(`/route/${id}`);
@@ -116,7 +116,6 @@ class MapStore {
const updatedStations: any[] = [];
const parsedJSON = JSON.parse(json);
console.log("Данные для сохранения (GeoJSON):", parsedJSON);
for (const feature of parsedJSON.features) {
const { geometry, properties, id } = feature;
@@ -211,13 +210,6 @@ class MapStore {
const requests: Promise<any>[] = [];
console.log(
`К созданию: ${newStations.length} станций, ${newRoutes.length} маршрутов, ${newSights.length} достопримечательностей.`
);
console.log(
`К обновлению: ${updatedStations.length} станций, ${updatedRoutes.length} маршрутов, ${updatedSights.length} достопримечательностей.`
);
newStations.forEach((data) =>
requests.push(languageInstance("ru").post("/station", data))
);
@@ -239,13 +231,11 @@ class MapStore {
);
if (requests.length === 0) {
console.log("Нет изменений для сохранения.");
return;
}
try {
await Promise.all(requests);
console.log("Все изменения успешно сохранены!");
await Promise.all([
this.getRoutes(),

View File

@@ -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)) {

View File

@@ -1,10 +1,12 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal } from "@widgets";
import { DeleteModal } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const MediaListPage = observer(() => {
const { media, getMedia, deleteMedia } = mediaStore;
@@ -13,10 +15,16 @@ export const MediaListPage = observer(() => {
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null);
const [ids, setIds] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
useEffect(() => {
getMedia();
const fetchMedia = async () => {
setIsLoading(true);
await getMedia();
setIsLoading(false);
};
fetchMedia();
}, [language]);
const columns: GridColDef[] = [
@@ -61,6 +69,7 @@ export const MediaListPage = observer(() => {
width: 200,
align: "center",
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
@@ -91,11 +100,6 @@ export const MediaListPage = observer(() => {
return (
<>
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Медиа</h1>
<CreateButton label="Создать медиа" path="/media/create" />
</div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
@@ -114,10 +118,19 @@ export const MediaListPage = observer(() => {
columns={columns}
hideFooterPagination
checkboxSelection
loading={isLoading}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as string[]);
}}
hideFooter
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет медиафайлов"}
</Box>
),
}}
/>
</div>

View File

@@ -15,13 +15,14 @@ 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 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 flex-col items-center gap-4 bg-[#998879] p-4 rounded-md">
<div className="flex-1 flex flex-col items-center gap-4 bg-[#998879] p-4 rounded-md">
<p className="text-white text-center">
Чтобы скачать файл, нажмите на кнопку ниже
</p>
@@ -40,5 +41,6 @@ export const MediaPreviewPage = observer(() => {
</div>
)}
</div>
</div>
);
});

View 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;

View File

@@ -6,19 +6,28 @@ import {
MenuItem,
FormControl,
InputLabel,
// Typography,
Typography,
Box,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { LanguageSwitcher } from "@widgets";
import { MediaViewer } from "@widgets";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useEffect, useState } from "react";
import { ArrowLeft, Loader2, Save, Plus } from "lucide-react";
import { useEffect, useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { carrierStore } from "../../../shared/store/CarrierStore";
import { articlesStore } from "../../../shared/store/ArticlesStore";
import { Route, routeStore } from "../../../shared/store/RouteStore";
import { languageStore } from "@shared";
import {
languageStore,
SelectArticleModal,
SelectMediaDialog,
selectedCityStore,
} from "@shared";
export const RouteCreatePage = observer(() => {
const navigate = useNavigate();
@@ -33,13 +42,90 @@ export const RouteCreatePage = observer(() => {
const [turn, setTurn] = useState("");
const [centerLat, setCenterLat] = useState("");
const [centerLng, setCenterLng] = useState("");
const [videoPreview, setVideoPreview] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
useState(false);
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const { language } = languageStore;
useEffect(() => {
carrierStore.getCarriers(language);
articlesStore.getArticleList();
}, [language]);
// Фильтруем перевозчиков только из выбранного города
const filteredCarriers = useMemo(() => {
const carriers =
carrierStore.carriers[language as keyof typeof carrierStore.carriers]
.data || [];
if (!selectedCityStore.selectedCityId) {
return carriers;
}
return carriers.filter(
(carrier: any) => carrier.city_id === selectedCityStore.selectedCityId
);
}, [carrierStore.carriers, language, selectedCityStore.selectedCityId]);
const validateCoordinates = (value: string) => {
try {
const lines = value.trim().split("\n");
const coordinates = lines.map((line) => {
const [lat, lon] = line
.trim()
.split(/[\s,]+/)
.map(Number);
return [lat, lon];
});
if (coordinates.length === 0) {
return "Введите хотя бы одну пару координат";
}
if (
!coordinates.every(
(point) => Array.isArray(point) && point.length === 2
)
) {
return "Каждая строка должна содержать две координаты";
}
if (
!coordinates.every((point) =>
point.every((coord) => !isNaN(coord) && typeof coord === "number")
)
) {
return "Координаты должны быть числами";
}
return true;
} catch {
return "Неверный формат координат";
}
};
const handleArticleSelect = (articleId: number) => {
setGovernorAppeal(articleId.toString());
setIsSelectArticleDialogOpen(false);
};
const handleVideoSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setVideoPreview(media.id);
setIsSelectVideoDialogOpen(false);
};
const handleVideoPreviewClick = () => {
setIsVideoPreviewOpen(true);
};
const handleCreateRoute = async () => {
try {
setIsLoading(true);
@@ -52,16 +138,24 @@ export const RouteCreatePage = observer(() => {
const center_latitude = centerLat ? Number(centerLat) : undefined;
const center_longitude = centerLng ? Number(centerLng) : undefined;
const route_direction = direction === "forward";
const validationResult = validateCoordinates(routeCoords);
if (validationResult !== true) {
toast.error(validationResult);
return;
}
// Координаты маршрута как массив массивов чисел
const path = routeCoords
.trim()
.split("\n")
.map((line) =>
line
.split(" ")
.map((coord) => Number(coord.trim()))
.filter((n) => !isNaN(n))
)
.filter((arr) => arr.length === 2);
.map((line) => {
const [lat, lon] = line
.trim()
.split(/[\s,]+/)
.map(Number);
return [lat, lon];
});
// Собираем объект маршрута
const newRoute: Partial<Route> = {
@@ -80,6 +174,8 @@ export const RouteCreatePage = observer(() => {
center_latitude,
center_longitude,
path,
video_preview:
videoPreview && videoPreview !== "" ? videoPreview : undefined,
};
await routeStore.createRoute(newRoute);
@@ -93,9 +189,13 @@ export const RouteCreatePage = observer(() => {
}
};
// Получаем название выбранной статьи для отображения
const selectedArticle = articlesStore.articleList.ru.data.find(
(article) => article.id === Number(governorAppeal)
);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
@@ -114,16 +214,10 @@ export const RouteCreatePage = observer(() => {
value={carrier}
label="Выберите перевозчика"
onChange={(e) => setCarrier(e.target.value as string)}
disabled={
carrierStore.carriers[
language as keyof typeof carrierStore.carriers
].data?.length === 0
}
disabled={filteredCarriers.length === 0}
>
<MenuItem value="">Не выбрано</MenuItem>
{carrierStore.carriers[
language as keyof typeof carrierStore.carriers
].data?.map((carrier) => (
{filteredCarriers.map((carrier: any) => (
<MenuItem key={carrier.id} value={carrier.id}>
{carrier.full_name}
</MenuItem>
@@ -141,9 +235,49 @@ export const RouteCreatePage = observer(() => {
className="w-full"
label="Координаты маршрута"
multiline
minRows={3}
minRows={2}
maxRows={10}
value={routeCoords}
onChange={(e) => setRouteCoords(e.target.value)}
onChange={(e) => {
const newValue = e.target.value;
setRouteCoords(newValue);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
const lines = routeCoords.split("\n");
const lastLine = lines[lines.length - 1];
// Если мы на последней строке и она не пустая
if (lastLine && lastLine.trim()) {
e.preventDefault();
const newValue = routeCoords + "\n";
setRouteCoords(newValue);
}
}
}}
error={validateCoordinates(routeCoords) !== true}
helperText={
typeof validateCoordinates(routeCoords) === "string"
? validateCoordinates(routeCoords)
: "Формат: широта долгота"
}
placeholder="55.7558 37.6173&#10;55.7539 37.6208"
sx={{
"& .MuiInputBase-root": {
maxHeight: "500px",
overflow: "auto",
},
"& .MuiInputBase-input": {
fontFamily: "monospace",
fontSize: "0.8rem",
lineHeight: "1.2",
padding: "8px 12px",
},
"& .MuiFormHelperText-root": {
fontSize: "0.75rem",
marginTop: "2px",
},
}}
/>
<TextField
className="w-full"
@@ -152,24 +286,101 @@ export const RouteCreatePage = observer(() => {
value={govRouteNumber}
onChange={(e) => setGovRouteNumber(e.target.value)}
/>
<FormControl fullWidth required>
<InputLabel>Обращение губернатора</InputLabel>
<Select
value={governorAppeal}
label="Обращение губернатора"
onChange={(e) => setGovernorAppeal(e.target.value as string)}
disabled={articlesStore.articleList.ru.data.length === 0}
{/* Заменяем 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 }}
>
<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>
)
Выбрать
</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>
)}
</Select>
</FormControl>
</Box>
</Box>
<Button
variant="outlined"
onClick={() => setIsSelectVideoDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
</Box>
<FormControl fullWidth required>
<InputLabel>Прямой/обратный маршрут</InputLabel>
<Select
@@ -229,6 +440,49 @@ export const RouteCreatePage = observer(() => {
</Button>
</div>
</div>
{/* Модальное окно выбора статьи */}
<SelectArticleModal
open={isSelectArticleDialogOpen}
onClose={() => setIsSelectArticleDialogOpen(false)}
onSelectArticle={handleArticleSelect}
/>
{/* Модальное окно выбора видео */}
<SelectMediaDialog
open={isSelectVideoDialogOpen}
onClose={() => setIsSelectVideoDialogOpen(false)}
onSelectMedia={handleVideoSelect}
mediaType={2}
/>
{/* Модальное окно предпросмотра видео */}
{videoPreview && videoPreview !== "" && (
<Dialog
open={isVideoPreviewOpen}
onClose={() => setIsVideoPreviewOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>Предпросмотр видео</DialogTitle>
<DialogContent>
<Box className="flex justify-center items-center p-4">
<MediaViewer
media={{
id: videoPreview,
media_type: 2,
filename: "video_preview",
}}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsVideoPreviewOpen(false)}>
Закрыть
</Button>
</DialogActions>
</Dialog>
)}
</Paper>
);
});

View File

@@ -6,37 +6,71 @@ import {
MenuItem,
FormControl,
InputLabel,
// Typography,
Typography,
Box,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { LanguageSwitcher } from "@widgets";
import { MediaViewer } from "@widgets";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react";
import { ArrowLeft, Copy, Save, Plus } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { carrierStore } from "../../../shared/store/CarrierStore";
import { articlesStore } from "../../../shared/store/ArticlesStore";
import { routeStore } from "../../../shared/store/RouteStore";
import {
routeStore,
languageStore,
SelectArticleModal,
SelectMediaDialog,
} from "@shared";
import { toast } from "react-toastify";
import { languageStore } from "@shared";
import { stationsStore } from "@shared";
import { LinkedItems } from "../LinekedStations";
export const RouteEditPage = observer(() => {
const navigate = useNavigate();
const { id } = useParams();
const { editRouteData } = routeStore;
const { editRouteData, copyRouteAction } = routeStore;
const [isLoading, setIsLoading] = useState(false);
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
useState(false);
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const { language } = languageStore;
const [coordinates, setCoordinates] = useState<string>("");
useEffect(() => {
const fetchData = async () => {
// Устанавливаем русский язык при загрузке страницы
const response = await routeStore.getRoute(Number(id));
routeStore.setEditRouteData(response);
languageStore.setLanguage("ru");
};
fetchData();
}, []);
useEffect(() => {
const fetchData = async () => {
carrierStore.getCarriers(language);
stationsStore.getStations();
articlesStore.getArticleList();
};
fetchData();
}, [id, language]);
useEffect(() => {
if (editRouteData.path && editRouteData.path.length > 0) {
const formattedPath = editRouteData.path
.map((coords) => coords.join(" "))
.join("\n");
setCoordinates(formattedPath);
}
}, [editRouteData.path]);
const handleSave = async () => {
setIsLoading(true);
await routeStore.editRoute(Number(id));
@@ -44,9 +78,80 @@ export const RouteEditPage = observer(() => {
setIsLoading(false);
};
const validateCoordinates = (value: string) => {
try {
const lines = value.trim().split("\n");
const coordinates = lines.map((line) => {
const [lat, lon] = line
.trim()
.split(/[\s,]+/)
.map(Number);
return [lat, lon];
});
if (coordinates.length === 0) {
return "Введите хотя бы одну пару координат";
}
if (
!coordinates.every(
(point) => Array.isArray(point) && point.length === 2
)
) {
return "Каждая строка должна содержать две координаты";
}
if (
!coordinates.every((point) =>
point.every((coord) => !isNaN(coord) && typeof coord === "number")
)
) {
return "Координаты должны быть числами";
}
return true;
} catch {
return "Неверный формат координат";
}
};
const handleCopy = async () => {
await copyRouteAction(Number(id));
toast.success("Маршрут успешно скопирован");
};
const handleArticleSelect = (articleId: number) => {
routeStore.setEditRouteData({
governor_appeal: articleId,
});
setIsSelectArticleDialogOpen(false);
};
const handleVideoSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
routeStore.setEditRouteData({
video_preview: media.id,
});
setIsSelectVideoDialogOpen(false);
};
const handleVideoPreviewClick = () => {
if (editRouteData.video_preview && editRouteData.video_preview !== "") {
setIsVideoPreviewOpen(true);
}
};
// Получаем название выбранной статьи для отображения
const selectedArticle = articlesStore.articleList.ru.data.find(
(article) => article.id === editRouteData.governor_appeal
);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
@@ -105,15 +210,62 @@ export const RouteEditPage = observer(() => {
className="w-full"
label="Координаты маршрута"
multiline
minRows={3}
value={editRouteData.path.map((p) => p.join(" ")).join("\n") || ""}
onChange={(e) =>
routeStore.setEditRouteData({
path: e.target.value
.split("\n")
.map((line) => line.split(" ").map(Number)),
})
minRows={2}
maxRows={10}
value={coordinates}
onChange={(e) => {
const newValue = e.target.value;
setCoordinates(newValue);
const validationResult = validateCoordinates(newValue);
if (validationResult === true) {
const lines = newValue.trim().split("\n");
const path = lines.map((line) => {
const [lat, lon] = line
.trim()
.split(/[\s,]+/)
.map(Number);
return [lat, lon];
});
routeStore.setEditRouteData({ path });
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
const lines = coordinates.split("\n");
const lastLine = lines[lines.length - 1];
// Если мы на последней строке и она не пустая
if (lastLine && lastLine.trim()) {
e.preventDefault();
const newValue = coordinates + "\n";
setCoordinates(newValue);
}
}
}}
error={validateCoordinates(coordinates) !== true}
helperText={
typeof validateCoordinates(coordinates) === "string"
? validateCoordinates(coordinates)
: "Формат: широта долгота"
}
placeholder="55.7558 37.6173&#10;55.7539 37.6208"
sx={{
"& .MuiInputBase-root": {
maxHeight: "500px",
overflow: "auto",
},
"& .MuiInputBase-input": {
fontFamily: "monospace",
fontSize: "0.8rem",
lineHeight: "1.2",
padding: "8px 12px",
},
"& .MuiFormHelperText-root": {
fontSize: "0.75rem",
marginTop: "2px",
},
}}
/>
<TextField
className="w-full"
@@ -126,28 +278,111 @@ export const RouteEditPage = observer(() => {
})
}
/>
<FormControl fullWidth required>
<InputLabel>Обращение губернатора</InputLabel>
<Select
value={editRouteData.governor_appeal || ""}
label="Обращение губернатора"
onChange={(e) =>
routeStore.setEditRouteData({
governor_appeal: Number(e.target.value),
})
}
disabled={articlesStore.articleList.ru.data.length === 0}
{/* Заменяем 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 }}
>
<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>
)
Выбрать
</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>
)}
</Select>
</FormControl>
</Box>
</Box>
<Button
variant="outlined"
onClick={() => setIsSelectVideoDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
</Box>
<FormControl fullWidth required>
<InputLabel>Прямой/обратный маршрут</InputLabel>
<Select
@@ -166,55 +401,86 @@ export const RouteEditPage = observer(() => {
<TextField
className="w-full"
label="Масштаб (мин)"
value={editRouteData.scale_min || ""}
value={editRouteData.scale_min ?? ""}
onChange={(e) =>
routeStore.setEditRouteData({
scale_min: Number(e.target.value),
scale_min:
e.target.value === "" ? null : parseFloat(e.target.value),
})
}
/>
<TextField
className="w-full"
label="Масштаб (макс)"
value={editRouteData.scale_max || ""}
value={editRouteData.scale_max ?? ""}
onChange={(e) =>
routeStore.setEditRouteData({
scale_max: Number(e.target.value),
scale_max:
e.target.value === "" ? null : parseFloat(e.target.value),
})
}
/>
<TextField
className="w-full"
label="Поворот"
value={editRouteData.rotate || ""}
value={editRouteData.rotate ?? ""}
onChange={(e) =>
routeStore.setEditRouteData({
rotate: Number(e.target.value),
rotate:
e.target.value === "" ? null : parseFloat(e.target.value),
})
}
/>
<TextField
className="w-full"
label="Центр. широта"
value={editRouteData.center_latitude || ""}
value={editRouteData.center_latitude ?? ""}
type="text"
onChange={(e) =>
routeStore.setEditRouteData({
center_latitude: Number(e.target.value),
center_latitude: e.target.value,
})
}
/>
<TextField
className="w-full"
label="Центр. долгота"
value={editRouteData.center_longitude || ""}
value={editRouteData.center_longitude ?? ""}
type="text"
onChange={(e) =>
routeStore.setEditRouteData({
center_longitude: Number(e.target.value),
center_longitude: e.target.value,
})
}
/>
</Box>
<div className="flex w-full justify-end">
<LinkedItems
parentId={id || ""}
type="edit"
dragAllowed={true}
fields={[
{ label: "Название", data: "name" },
{ label: "Описание", data: "description" },
]}
onUpdate={() => {
routeStore.getRoute(Number(id));
}}
routeDirection={editRouteData.route_direction}
/>
<div className="flex w-full justify-between">
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Copy size={20} />}
onClick={handleCopy}
disabled={isLoading}
>
Скопировать
</Button>
<Button
variant="contained"
color="primary"
@@ -227,6 +493,45 @@ export const RouteEditPage = observer(() => {
</Button>
</div>
</div>
{/* Модальное окно выбора статьи */}
<SelectArticleModal
open={isSelectArticleDialogOpen}
onClose={() => setIsSelectArticleDialogOpen(false)}
onSelectArticle={handleArticleSelect}
/>
{/* Модальное окно выбора видео */}
<SelectMediaDialog
open={isSelectVideoDialogOpen}
onClose={() => setIsSelectVideoDialogOpen(false)}
onSelectMedia={handleVideoSelect}
mediaType={2}
/>
{/* Модальное окно предпросмотра видео */}
<Dialog
open={isVideoPreviewOpen}
onClose={() => setIsVideoPreviewOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>Предпросмотр видео</DialogTitle>
<DialogContent>
<Box className="flex justify-center items-center p-4">
<MediaViewer
media={{
id: editRouteData.video_preview,
media_type: 2,
filename: "video_preview",
}}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsVideoPreviewOpen(false)}>Закрыть</Button>
</DialogActions>
</Dialog>
</Paper>
);
});

View File

@@ -1,35 +1,48 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, routeStore } from "@shared";
import { ruRU } from "@mui/x-data-grid/locales";
import { carrierStore, languageStore, routeStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Map, Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal } from "@widgets";
import { LanguageSwitcher } from "@widgets";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const RouteListPage = observer(() => {
const { routes, getRoutes, deleteRoute } = routeStore;
const { carriers, getCarriers } = carrierStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
useEffect(() => {
getRoutes();
const fetchData = async () => {
setIsLoading(true);
await getCarriers("ru");
await getCarriers("en");
await getCarriers("zh");
await getRoutes();
setIsLoading(false);
};
fetchData();
}, [language]);
const columns: GridColDef[] = [
{
field: "carrier",
field: "carrier_id",
headerName: "Перевозчик",
width: 250,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
carriers[language].data.find(
(carrier) => carrier.id == params.value
)?.short_name
) : (
<Minus size={20} className="text-red-500" />
)}
@@ -77,6 +90,7 @@ export const RouteListPage = observer(() => {
width: 250,
align: "center",
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
@@ -105,7 +119,7 @@ export const RouteListPage = observer(() => {
const rows = routes.data.map((route) => ({
id: route.id,
carrier: route.carrier,
carrier_id: route.carrier_id,
route_number: route.route_number,
route_direction: route.route_direction ? "Прямой" : "Обратный",
}));
@@ -136,12 +150,20 @@ export const RouteListPage = observer(() => {
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
checkboxSelection
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter
checkboxSelection
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет маршрутов"}
</Box>
),
}}
/>
</div>

View File

@@ -1,8 +1,8 @@
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;

View File

@@ -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 (

View File

@@ -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} />
<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>
При поддержке Правительства
</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>
);
}
});

View File

@@ -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,42 +184,135 @@ 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;
return prev.map((station) => {
if (station.station_id === stationId) {
return found;
}
return station;
});
} else {
const foundStation = stationData.ru?.find(
const currentStation = stationData.ru?.find(
(station) => station.id === stationId
);
if (foundStation) {
if (
currentStation &&
Math.abs(currentStation.offset_x - x) < 0.01 &&
Math.abs(currentStation.offset_y - y) < 0.01
) {
return;
}
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,
transfers: foundStation.transfers,
align: originalStation?.align ?? 1,
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, 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 originalStation = originalStationData?.find(
(s) => s.id === stationId
);
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: "",
},
},
];
}
});
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;
});
}
@@ -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,
]
);

View File

@@ -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(() => {
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]);
}
}, [
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("Ошибка при сохранении изменений");
}
}}
>
Сохранить изменения

View File

@@ -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>
);
}
};

View 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>
);
}

View File

@@ -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();
const coordinates = coordinatesToLocal(
station.latitude,
station.longitude
// Основной фон с градиентом
g.roundRect(
-controlWidth / 2,
0,
controlWidth,
controlHeight,
borderRadius
);
g.circle(
coordinates.x * UP_SCALE,
coordinates.y * UP_SCALE,
STATION_RADIUS
g.fill({ color: "#1a1a1a" }); // Темный фон как у УрФУ
// Тонкая рамка
g.roundRect(
-controlWidth / 2,
0,
controlWidth,
controlHeight,
borderRadius
);
g.fill({ color: PATH_COLOR });
g.stroke({ color: BACKGROUND_COLOR, width: STATION_OUTLINE_WIDTH });
}, []);
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>
<pixiGraphics draw={draw} />
<StationLabel station={station} ruLabel={ruLabel} />
<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>
);
}
);
};
export const StationLabel = observer(
({ station, ruLabel }: Readonly<StationProps>) => {
// =========================================================================
// Компонент: Метка Станции (с логикой)
// =========================================================================
const StationLabel = observer(
({
station,
ruLabel,
labelAlign: labelAlignProp = "center",
onLabelAlignChange,
onTextHover,
}: Readonly<StationLabelProps>) => {
const { language } = languageStore;
const { rotation, scale } = useTransform();
const { setStationOffset } = useMapData();
const { setStationOffset, setStationAlign } = useMapData();
const [position, setPosition] = useState({
x: station.offset_x,
y: station.offset_y,
});
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
const [startMousePosition, setStartMousePosition] = useState({
x: 0,
y: 0,
});
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);
if (!station) {
console.error("station is null");
return null;
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) => {
setIsDragging(true);
setStartPosition({
x: position.x,
y: position.y,
});
setStartMousePosition({
x: e.globalX,
y: e.globalY,
});
setIsPointerDown(true);
setIsDragging(false);
dragStartPos.current = { ...position };
mouseStartPos.current = { x: e.global.x, y: e.global.y };
e.stopPropagation();
};
const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isDragging) return;
const dx = e.globalX - startMousePosition.x;
const dy = e.globalY - startMousePosition.y;
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: startPosition.x + dx,
y: startPosition.y + dy,
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) => {
setIsDragging(false);
setIsPointerDown(false);
setTimeout(() => setIsDragging(false), 50);
e.stopPropagation();
};
const coordinates = coordinatesToLocal(station.latitude, station.longitude);
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
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}
zIndex={isHovered || isControlHovered ? 1000 : 0}
eventMode="static"
interactive
cursor={isDragging ? "grabbing" : "grab"}
onPointerOver={handlePointerEnter}
onPointerOut={handlePointerLeave}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerUpOutside={handlePointerUp}
onGlobalPointerMove={handlePointerMove}
>
<pixiText
anchor={{ x: 1, y: 0.5 }}
text={station.name}
<pixiContainer
position={{
x: position.x / scale + 24,
y: position.y / scale,
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: 26,
fontSize: compensatedRuFontSize,
fontWeight: "bold",
fill: "#ffffff",
}}
/>
{ruLabel && (
)}
{station.name && language !== "ru" && ruLabel && (
<pixiText
anchor={{ x: 1, y: -1 }}
text={ruLabel}
text={station.name}
position={{
x: position.x / scale + 24,
y: position.y / scale,
x: getSecondLabelPosition(),
y: compensatedRuFontSize * 1.1,
}}
anchor={{ x: getSecondLabelAnchor(), y: 0.5 }}
style={{
fontSize: 16,
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
);
const radius = STATION_RADIUS;
const strokeWidth = STATION_OUTLINE_WIDTH;
g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius);
// 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]
);
return (
<pixiContainer zIndex={isTextHovered ? 1000 : 0}>
<pixiGraphics draw={draw} />
<StationLabel
station={station}
ruLabel={ruLabel}
labelAlign={labelAlign}
onLabelAlignChange={onLabelAlignChange}
onTextHover={setIsTextHovered}
/>
</pixiContainer>
);
};

View File

@@ -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,
]
);

View File

@@ -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" }}>
Погода
{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>
);

View File

@@ -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>

View File

@@ -12,6 +12,7 @@ export interface RouteData {
route_sys_number: string;
scale_max: number;
scale_min: number;
thumbnail?: string; // uuid логотипа маршрута
}
export interface StationTransferData {
@@ -38,12 +39,14 @@ export interface StationData {
offset_y: number;
system_name: string;
transfers: StationTransferData;
align: number;
}
export interface StationPatchData {
station_id: number;
offset_x: number;
offset_y: number;
align: number;
transfers: StationTransferData;
}

View File

@@ -1,22 +1,38 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, sightsStore } from "@shared";
import { useEffect, useState } from "react";
import { ruRU } from "@mui/x-data-grid/locales";
import {
cityStore,
languageStore,
sightsStore,
selectedCityStore,
} from "@shared";
import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const SightListPage = observer(() => {
const { sights, getSights, deleteListSight } = sightsStore;
const { cities, getCities } = cityStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
useEffect(() => {
getSights();
const fetchSights = async () => {
setIsLoading(true);
await getCities(language);
await getSights();
setIsLoading(false);
};
fetchSights();
}, [language]);
const columns: GridColDef[] = [
@@ -37,14 +53,14 @@ export const SightListPage = observer(() => {
},
},
{
field: "city",
field: "city_id",
headerName: "Город",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
cities[language].data.find((el) => el.id == params.value)?.name
) : (
<Minus size={20} className="text-red-500" />
)}
@@ -57,6 +73,7 @@ export const SightListPage = observer(() => {
headerName: "Действия",
align: "center",
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
@@ -81,10 +98,19 @@ export const SightListPage = observer(() => {
},
];
const rows = sights.map((sight) => ({
// Фильтрация достопримечательностей по выбранному городу
const filteredSights = useMemo(() => {
const { selectedCityId } = selectedCityStore;
if (!selectedCityId) {
return sights;
}
return sights.filter((sight: any) => sight.city_id === selectedCityId);
}, [sights, selectedCityStore.selectedCityId]);
const rows = filteredSights.map((sight) => ({
id: sight.id,
name: sight.name,
city: sight.city,
city_id: sight.city_id,
}));
return (
@@ -116,12 +142,24 @@ export const SightListPage = observer(() => {
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
checkboxSelection
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter
checkboxSelection
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? (
<CircularProgress size={20} />
) : (
"Нет достопримечательностей"
)}
</Box>
),
}}
/>
</div>

View File

@@ -1,26 +1,20 @@
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="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"
@@ -51,11 +45,15 @@ export const SnapshotCreatePage = observer(() => {
await createSnapshot(name);
setIsLoading(false);
toast.success("Снапшот успешно создан");
navigate(-1);
} catch (error) {
console.error(error);
toast.error("Ошибка при создании снапшота");
} finally {
setIsLoading(false);
}
}}
disabled={isLoading}
disabled={isLoading || !name.trim()}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
@@ -64,6 +62,7 @@ export const SnapshotCreatePage = observer(() => {
)}
</Button>
</div>
</Paper>
</div>
</div>
);
});

View File

@@ -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);

View File

@@ -1,3 +1,2 @@
export * from "./SnapshotListPage";
export * from "./SnapshotCreatePage";

View 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;

View File

@@ -8,33 +8,107 @@ import {
InputLabel,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { stationsStore } from "@shared";
import { useState } from "react";
import {
stationsStore,
languageStore,
cityStore,
useSelectedCity,
} from "@shared";
import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets";
import { SaveWithoutCityAgree } from "@widgets";
export const StationCreatePage = observer(() => {
const navigate = useNavigate();
const [name, setName] = useState("");
const [systemName, setSystemName] = useState("");
const [direction, setDirection] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
const {
createStationData,
setCreateCommonData,
createStation,
setLanguageCreateStationData,
} = stationsStore;
const { cities, getCities } = cityStore;
const { selectedCityId, selectedCity } = useSelectedCity();
const [coordinates, setCoordinates] = useState<string>("");
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
const handleCreate = async () => {
useEffect(() => {
if (
createStationData.common.latitude !== 0 ||
createStationData.common.longitude !== 0
) {
setCoordinates(
`${createStationData.common.latitude}, ${createStationData.common.longitude}`
);
}
}, [createStationData.common.latitude, createStationData.common.longitude]);
// НОВАЯ ФУНКЦИЯ: Фактическое создание (вызывается после подтверждения)
const executeCreate = async () => {
try {
setIsLoading(true);
await stationsStore.createStation(name, systemName, direction);
toast.success("Станция успешно создана");
await createStation();
toast.success("Остановка успешно создана");
navigate("/station");
} catch (error) {
console.error("Error creating station:", error);
toast.error("Ошибка при создании станции");
} finally {
setIsLoading(false);
}
};
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или создания
const handleCreate = async () => {
const isCityMissing = !createStationData.common.city_id;
// Проверяем названия на всех языках
const isNameMissing = !createStationData.ru.name || !createStationData.en.name || !createStationData.zh.name;
if (isCityMissing || isNameMissing) {
setIsSaveWarningOpen(true);
return;
}
await executeCreate();
};
// Обработчик "Да" в предупреждающем окне
const handleConfirmCreate = async () => {
setIsSaveWarningOpen(false);
await executeCreate();
};
// Обработчик "Нет" в предупреждающем окне
const handleCancelCreate = () => {
setIsSaveWarningOpen(false);
};
useEffect(() => {
const fetchCities = async () => {
await getCities("ru");
await getCities("en");
await getCities("zh");
};
fetchCities();
}, []);
// Автоматически устанавливаем выбранный город при загрузке страницы
useEffect(() => {
if (selectedCityId && selectedCity && !createStationData.common.city_id) {
setCreateCommonData({
city_id: selectedCityId,
city: selectedCity.name,
});
}
}, [selectedCityId, selectedCity, createStationData.common.city_id]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />
@@ -47,44 +121,123 @@ export const StationCreatePage = observer(() => {
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
<h1 className="text-3xl break-words">{name}</h1>
<h1 className="text-3xl break-words">Создание остановки</h1>
</div>
<TextField
className="w-full"
fullWidth
label="Название"
value={createStationData[language].name || ""}
required
value={name}
onChange={(e) => setName(e.target.value)}
/>
<TextField
className="w-full"
label="Системное название"
required
value={systemName}
onChange={(e) => setSystemName(e.target.value)}
onChange={(e) =>
setLanguageCreateStationData(language, {
name: e.target.value,
})
}
/>
<FormControl fullWidth>
<InputLabel>Направление</InputLabel>
<InputLabel id="direction-label">Прямой/обратный маршрут</InputLabel>
<Select
value={direction}
label="Направление"
onChange={(e) => setDirection(e.target.value)}
required
labelId="direction-label"
value={createStationData.common.direction ? "Прямой" : "Обратный"}
label="Прямой/обратный маршрут"
onChange={(e) =>
setCreateCommonData({
direction: e.target.value === "Прямой",
})
}
>
<MenuItem value="forward">Прямое</MenuItem>
<MenuItem value="backward">Обратное</MenuItem>
<MenuItem value="Прямой">Прямой</MenuItem>
<MenuItem value="Обратный">Обратный</MenuItem>
</Select>
</FormControl>
<TextField
fullWidth
label="Описание"
value={createStationData.common.description || ""}
onChange={(e) =>
setCreateCommonData({
description: e.target.value,
})
}
/>
{/* <TextField
fullWidth
label="Адрес"
value={createStationData[language].address || ""}
onChange={(e) =>
setLanguageCreateStationData(language, {
address: e.target.value,
})
}
/> */}
<TextField
fullWidth
label="Координаты"
value={coordinates}
onChange={(e) => {
const newValue = e.target.value;
setCoordinates(newValue);
const input = newValue.replace(/,/g, " ").trim();
const [latStr, lonStr] = input.split(/\s+/);
const lat = parseFloat(latStr);
const lon = parseFloat(lonStr);
const isValidLat = !isNaN(lat);
const isValidLon = !isNaN(lon);
if (isValidLat && isValidLon) {
setCreateCommonData({
latitude: lat,
longitude: lon,
});
} else {
setCreateCommonData({
latitude: 0,
longitude: 0,
});
}
}}
placeholder="Введите координаты в формате: широта долгота (можно использовать запятые или пробелы)"
/>
<FormControl fullWidth>
<InputLabel>Город</InputLabel>
<Select
value={createStationData.common.city_id || ""}
label="Город"
onChange={(e) => {
const selectedCity = cities["ru"].data.find(
(city) => city.id === e.target.value
);
setCreateCommonData({
city_id: e.target.value as number,
city: selectedCity?.name || "",
});
}}
>
{cities["ru"].data.map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={isLoading || !name || !systemName || !direction}
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleCreate
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
@@ -93,6 +246,16 @@ export const StationCreatePage = observer(() => {
)}
</Button>
</div>
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
{isSaveWarningOpen && (
<SaveWithoutCityAgree
blocker={{
proceed: handleConfirmCreate,
reset: handleCancelCreate,
}}
/>
)}
</Paper>
);
});

View File

@@ -15,6 +15,8 @@ import { toast } from "react-toastify";
import { stationsStore, languageStore, cityStore } from "@shared";
import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets";
import { LinkedSights } from "../LinkedSights";
import { SaveWithoutCityAgree } from "@widgets";
export const StationEditPage = observer(() => {
const navigate = useNavigate();
@@ -29,12 +31,31 @@ export const StationEditPage = observer(() => {
setLanguageEditStationData,
} = stationsStore;
const { cities, getCities } = cityStore;
const [coordinates, setCoordinates] = useState<string>("");
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
const handleEdit = async () => {
useEffect(() => {
languageStore.setLanguage("ru");
}, []);
useEffect(() => {
if (
editStationData.common.latitude !== 0 ||
editStationData.common.longitude !== 0
) {
setCoordinates(
`${editStationData.common.latitude}, ${editStationData.common.longitude}`
);
}
}, [editStationData.common.latitude, editStationData.common.longitude]);
// НОВАЯ ФУНКЦИЯ: Фактическое редактирование (вызывается после подтверждения)
const executeEdit = async () => {
try {
setIsLoading(true);
await editStation(Number(id));
toast.success("Станция успешно обновлена");
toast.success("Остановка успешно обновлена");
} catch (error) {
console.error("Error updating station:", error);
toast.error("Ошибка при обновлении станции");
@@ -43,6 +64,34 @@ export const StationEditPage = observer(() => {
}
};
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или редактирования
const handleEdit = async () => {
const isCityMissing = !editStationData.common.city_id;
// Проверяем названия на всех языках
const isNameMissing =
!editStationData.ru.name ||
!editStationData.en.name ||
!editStationData.zh.name;
if (isCityMissing || isNameMissing) {
setIsSaveWarningOpen(true);
return;
}
await executeEdit();
};
// Обработчик "Да" в предупреждающем окне
const handleConfirmEdit = async () => {
setIsSaveWarningOpen(false);
await executeEdit();
};
// Обработчик "Нет" в предупреждающем окне
const handleCancelEdit = () => {
setIsSaveWarningOpen(false);
};
useEffect(() => {
const fetchAndSetStationData = async () => {
if (!id) return;
@@ -60,6 +109,7 @@ export const StationEditPage = observer(() => {
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
@@ -71,7 +121,7 @@ export const StationEditPage = observer(() => {
</div>
<div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
<h1 className="text-3xl break-words">{editStationData.ru.name}</h1>
</div>
<TextField
@@ -106,15 +156,15 @@ export const StationEditPage = observer(() => {
<TextField
fullWidth
label="Описание"
value={editStationData[language].description || ""}
value={editStationData.common.description || ""}
onChange={(e) =>
setLanguageEditStationData(language, {
setEditCommonData({
description: e.target.value,
})
}
/>
<TextField
{/* <TextField
fullWidth
label="Адрес"
value={editStationData[language].address || ""}
@@ -123,21 +173,38 @@ export const StationEditPage = observer(() => {
address: e.target.value,
})
}
/>
/> */}
<TextField
fullWidth
label="Координаты"
value={`${editStationData.common.latitude} ${editStationData.common.longitude}`}
value={coordinates}
onChange={(e) => {
const [latitude, longitude] = e.target.value.split(" ").map(Number);
if (!isNaN(latitude) && !isNaN(longitude)) {
const newValue = e.target.value;
setCoordinates(newValue);
const input = newValue.replace(/,/g, " ").trim();
const [latStr, lonStr] = input.split(/\s+/);
const lat = parseFloat(latStr);
const lon = parseFloat(lonStr);
const isValidLat = !isNaN(lat);
const isValidLon = !isNaN(lon);
if (isValidLat && isValidLon) {
setEditCommonData({
latitude: latitude,
longitude: longitude,
latitude: lat,
longitude: lon,
});
} else {
setEditCommonData({
latitude: 0,
longitude: 0,
});
}
}}
placeholder="Введите координаты в формате: широта долгота (можно использовать запятые или пробелы)"
/>
<FormControl fullWidth>
@@ -146,7 +213,7 @@ export const StationEditPage = observer(() => {
value={editStationData.common.city_id || ""}
label="Город"
onChange={(e) => {
const selectedCity = cities[language].data.find(
const selectedCity = cities["ru"].data.find(
(city) => city.id === e.target.value
);
setEditCommonData({
@@ -155,7 +222,7 @@ export const StationEditPage = observer(() => {
});
}}
>
{cities[language].data.map((city) => (
{cities["ru"].data.map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>
@@ -163,20 +230,38 @@ export const StationEditPage = observer(() => {
</Select>
</FormControl>
{id && (
<LinkedSights
parentId={Number(id)}
fields={[{ label: "Название", data: "name" }]}
type="edit"
/>
)}
<Button
variant="contained"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleEdit}
disabled={isLoading || !editStationData[language]?.name}
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleEdit
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Обновить"
"Сохранить"
)}
</Button>
</div>
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
{isSaveWarningOpen && (
<SaveWithoutCityAgree
blocker={{
proceed: handleConfirmEdit,
reset: handleCancelEdit,
}}
/>
)}
</Paper>
);
});

View File

@@ -1,10 +1,17 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, stationsStore } from "@shared";
import { ruRU } from "@mui/x-data-grid/locales";
import {
languageStore,
stationsStore,
selectedCityStore,
cityStore,
} from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const StationListPage = observer(() => {
const { stationLists, getStationList, deleteStation } = stationsStore;
@@ -13,10 +20,17 @@ export const StationListPage = observer(() => {
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
useEffect(() => {
getStationList();
const fetchStations = async () => {
setIsLoading(true);
await cityStore.getCities(language);
await getStationList();
setIsLoading(false);
};
fetchStations();
}, [language]);
const columns: GridColDef[] = [
@@ -77,6 +91,7 @@ export const StationListPage = observer(() => {
width: 140,
align: "center",
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
@@ -101,7 +116,18 @@ export const StationListPage = observer(() => {
},
];
const rows = stationLists[language].data.map((station: any) => ({
// Фильтрация станций по выбранному городу
const filteredStations = () => {
const { selectedCityId } = selectedCityStore;
if (!selectedCityId) {
return stationLists[language].data;
}
return stationLists[language].data.filter(
(station: any) => station.city_id === selectedCityId
);
};
const rows = filteredStations().map((station: any) => ({
id: station.id,
name: station.name,
system_name: station.system_name,
@@ -115,7 +141,7 @@ export const StationListPage = observer(() => {
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Станции</h1>
<CreateButton label="Создать станцию" path="/station/create" />
<CreateButton label="Создать остановки" path="/station/create" />
</div>
<div
@@ -136,10 +162,19 @@ export const StationListPage = observer(() => {
columns={columns}
hideFooterPagination
checkboxSelection
loading={isLoading}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет станций"}
</Box>
),
}}
/>
</div>

View File

@@ -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>
);

View File

@@ -2,3 +2,4 @@ export * from "./StationListPage";
export * from "./StationCreatePage";
export * from "./StationPreviewPage";
export * from "./StationEditPage";
export * from "./LinkedSights";

View File

@@ -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>

View File

@@ -1,11 +1,12 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { userStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const UserListPage = observer(() => {
const { users, getUsers, deleteUser } = userStore;
@@ -14,9 +15,15 @@ export const UserListPage = observer(() => {
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
getUsers();
const fetchUsers = async () => {
setIsLoading(true);
await getUsers();
setIsLoading(false);
};
fetchUsers();
}, []);
const columns: GridColDef[] = [
@@ -76,6 +83,7 @@ export const UserListPage = observer(() => {
flex: 1,
align: "center",
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
@@ -136,10 +144,23 @@ export const UserListPage = observer(() => {
columns={columns}
hideFooterPagination
checkboxSelection
loading={isLoading}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? (
<CircularProgress size={20} />
) : (
"Нет пользователей"
)}
</Box>
),
}}
/>
</div>

View File

@@ -31,6 +31,12 @@ export const VehicleEditPage = observer(() => {
} = vehicleStore;
const { getCarriers } = carrierStore;
const { language } = languageStore;
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
useEffect(() => {
(async () => {
await getVehicle(Number(id));

View File

@@ -1,4 +1,5 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { carrierStore, languageStore, vehicleStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
@@ -6,6 +7,7 @@ import { Eye, Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal } from "@widgets";
import { VEHICLE_TYPES } from "@shared";
import { Box, CircularProgress } from "@mui/material";
export const VehicleListPage = observer(() => {
const { vehicles, getVehicles, deleteVehicle } = vehicleStore;
@@ -15,11 +17,17 @@ export const VehicleListPage = observer(() => {
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
useEffect(() => {
getVehicles();
getCarriers(language);
const fetchData = async () => {
setIsLoading(true);
await getVehicles();
await getCarriers(language);
setIsLoading(false);
};
fetchData();
}, [language]);
const columns: GridColDef[] = [
@@ -94,6 +102,7 @@ export const VehicleListPage = observer(() => {
width: 200,
align: "center",
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
@@ -157,10 +166,23 @@ export const VehicleListPage = observer(() => {
columns={columns}
hideFooterPagination
checkboxSelection
loading={isLoading}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? (
<CircularProgress size={20} />
) : (
"Нет транспортных средств"
)}
</Box>
),
}}
/>
</div>

View File

@@ -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")}`;

View File

@@ -1,62 +0,0 @@
export const CarrierSvg = () => {
return (
<svg
fill="#000000"
height="26px"
width="26px"
version="1.1"
id="Capa_1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
viewBox="0 0 489.785 489.785"
>
<g id="XMLID_196_">
<path
id="XMLID_203_"
d="M409.772,379.327l-81.359-124.975c-5.884-9.054-15.925-13.119-25.987-13.119
c-2.082,0-6.392,0.05-11.051,0.115c-0.363-0.61-0.742-1.215-1.355-1.627l-20.492-13.609c-2.364-1.569-5.434-1.486-7.701,0.182
l-16.948,12.508l-16.959-12.508c-2.285-1.668-5.337-1.751-7.72-0.182l-20.455,13.609c-0.578,0.377-0.945,0.907-1.282,1.461
c-4.828,0.031-9.327,0.057-11.222,0.057c-10.016,0-20.011,4.119-25.859,13.113L80.022,379.327
c-8.65,13.267-5.149,31.008,7.896,39.992l18.06,12.449c10.887-25.926,28.868-48.094,51.45-64.279l4.657-7.162v3.861
c16.364-10.811,34.941-18.477,54.885-22.234c-5.926-13.152-10.899-28.819-14.546-43.586c-4.249-17.232-6.741-33.201-6.741-42.245
c0-3.351,0.433-6.579,1.09-9.727l14.8,48.975c0.766,2.565,2.984,4.417,5.641,4.73c0.268,0.03,0.529,0.046,0.784,0.046
c2.365,0,4.602-1.25,5.818-3.34l11.538-19.873l3.246,3.235c-7.768,10.276-10.82,39.199-12.005,60.314
c5.994-0.734,12.066-1.222,18.254-1.222c6.201,0,12.292,0.497,18.304,1.23c-1.186-21.114-4.237-50.037-12.024-60.322l3.246-3.255
l11.574,19.892c1.216,2.09,3.422,3.34,5.805,3.34c0.255,0,0.522-0.016,0.779-0.046c2.655-0.314,4.874-2.166,5.659-4.73
l14.791-48.872c0.634,3.116,1.051,6.313,1.051,9.624c0,16.806-8.425,57.342-21.276,85.831
c19.981,3.768,38.588,11.453,54.953,22.291v-3.899l4.735,7.256c22.504,16.193,40.436,38.324,51.293,64.206l18.139-12.488
C414.919,410.335,418.403,392.594,409.772,379.327z M219.962,276.685l-8.613-28.53l12.388-8.24l12.322,9.088L219.962,276.685z
M269.783,276.685l-16.079-27.683l12.31-9.088l12.401,8.24L269.783,276.685z"
/>
<path
id="XMLID_202_"
d="M202.716,424.721l14.705,19.349c8.151-4.914,17.598-7.607,27.427-7.607c9.848,0,19.313,2.692,27.464,7.615
l14.705-19.363c-11.465-10.799-26.346-16.721-42.15-16.721C229.055,407.994,214.156,413.925,202.716,424.721z"
/>
<path
id="XMLID_201_"
d="M176.693,160.576c0.499,25.456,14.96,47.266,36.03,58.591c9.622,5.18,20.473,8.384,32.174,8.384
c11.683,0,22.503-3.198,32.114-8.368c21.063-11.311,35.579-33.117,36.06-58.582c-17.379,12.075-41.896,19.923-68.174,19.923
S194.096,172.676,176.693,160.576z"
/>
<path
id="XMLID_200_"
d="M174.741,100.132l-0.225,20.205c0.037,15.991,31.524,36.82,70.38,36.82
c38.855,0,70.314-20.829,70.331-36.82l-0.207-20.195c10.224-2.662,18.158-6.617,23.239-12.301
c3.981-4.434,6.267-9.902,6.267-16.783C344.528,39.883,299.879,0,244.897,0c-55.031,0-99.631,39.883-99.631,71.058
c0,6.881,2.273,12.34,6.236,16.783C156.585,93.524,164.529,97.479,174.741,100.132z"
/>
<path
id="XMLID_197_"
d="M244.848,356.925c-73.255,0-132.858,59.605-132.858,132.86h33.47c0-0.048,0-0.114,0-0.161v-0.031
c1.088-6.557,6.711-11.334,13.313-11.334c0.115,0,0.243,0.01,0.37,0.01l51.707,1.341c-0.973,3.247-1.648,6.619-1.648,10.176h71.322
c0-3.557-0.669-6.929-1.66-10.176l51.724-1.341c0.109,0,0.219-0.01,0.353-0.01c6.595,0,12.243,4.777,13.324,11.334v0.031
c0,0.047,0,0.113,0,0.161h33.44C377.706,416.53,318.122,356.925,244.848,356.925z M302.201,433.91l-27.562,36.317
c-6.389-9.687-17.325-16.104-29.792-16.104c-12.437,0-23.385,6.411-29.762,16.098l-27.555-36.3
c-4.699-6.194-4.11-14.923,1.392-20.424c15.452-15.443,35.689-23.166,55.943-23.166c20.249,0,40.484,7.723,55.961,23.179
C306.322,419.007,306.901,427.719,302.201,433.91z"
/>
</g>
</svg>
);
};

View 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

View File

@@ -10,14 +10,14 @@ import {
GitBranch,
// Car,
Table,
Notebook,
Split,
Newspaper,
// Newspaper,
PersonStanding,
Cpu,
BookImage,
// BookImage,
} from "lucide-react";
import { CarrierSvg } from "./CarrierSvg";
import carrierIcon from "./carrier.svg";
export const DRAWER_WIDTH = 300;
@@ -26,6 +26,7 @@ interface NavigationItem {
label: string;
icon?: LucideIcon | React.ReactNode;
path?: string;
for_admin?: boolean;
onClick?: () => void;
nestedItems?: NavigationItem[];
isActive?: boolean;
@@ -36,23 +37,56 @@ export const NAVIGATION_ITEMS: {
secondary: NavigationItem[];
} = {
primary: [
{
id: "snapshots",
label: "Снапшоты",
icon: GitBranch,
path: "/snapshot",
for_admin: true,
},
{
id: "map",
label: "Карта",
icon: Map,
path: "/map",
},
{
id: "devices",
label: "Устройства",
icon: Cpu,
path: "/devices",
for_admin: true,
},
// {
// id: "vehicles",
// label: "Транспорт",
// icon: Car,
// path: "/vehicle",
// },
{
id: "users",
label: "Пользователи",
icon: Users,
path: "/user",
for_admin: true,
},
{
id: "all",
label: "Все сущности",
label: "Справочник",
icon: Table,
nestedItems: [
{
id: "media",
label: "Медиа",
icon: BookImage,
path: "/media",
},
{
id: "articles",
label: "Статьи",
icon: Newspaper,
path: "/article",
},
// {
// id: "media",
// label: "Медиа",
// icon: BookImage,
// path: "/media",
// },
// {
// id: "articles",
// label: "Статьи",
// icon: Newspaper,
// path: "/article",
// },
{
id: "attractions",
label: "Достопримечательности",
@@ -71,65 +105,32 @@ export const NAVIGATION_ITEMS: {
icon: Split,
path: "/route",
},
{
id: "reference",
label: "Справочник",
icon: Notebook,
nestedItems: [
{
id: "countries",
label: "Страны",
icon: Earth,
path: "/country",
for_admin: true,
},
{
id: "cities",
label: "Города",
icon: Building2,
path: "/city",
for_admin: true,
},
{
id: "carriers",
label: "Перевозчики",
// @ts-ignore
icon: CarrierSvg,
icon: () => <img src={carrierIcon} alt="Перевозчики"/>,
path: "/carrier",
for_admin: true,
},
],
},
],
},
{
id: "snapshots",
label: "Снапшоты",
icon: GitBranch,
path: "/snapshot",
},
{
id: "map",
label: "Карта",
icon: Map,
path: "/map",
},
{
id: "devices",
label: "Устройства",
icon: Cpu,
path: "/devices",
},
// {
// id: "vehicles",
// label: "Транспорт",
// icon: Car,
// path: "/vehicle",
// },
{
id: "users",
label: "Пользователи",
icon: Users,
path: "/user",
},
],
secondary: [
{
id: "logout",

View File

@@ -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");

View 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 };
};

View File

@@ -0,0 +1 @@
export * from "./useSelectedCity";

View File

@@ -0,0 +1,12 @@
import { selectedCityStore } from "@shared";
export const useSelectedCity = () => {
const { selectedCity, selectedCityId, selectedCityName } = selectedCityStore;
return {
selectedCity,
selectedCityId,
selectedCityName,
hasSelectedCity: !!selectedCity,
};
};

View File

@@ -5,3 +5,4 @@ export * from "./store";
export * from "./const";
export * from "./api";
export * from "./modals";
export * from "./hooks";

View 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();
}
};

View File

@@ -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}_Медиа`;
};

View File

@@ -75,6 +75,7 @@ export const PreviewMediaDialog = observer(
setError(err instanceof Error ? err.message : "Failed to save media");
} finally {
setIsLoading(false);
onClose();
}
};
@@ -96,7 +97,6 @@ export const PreviewMediaDialog = observer(
className="flex gap-4"
dividers
sx={{
height: "600px",
display: "flex",
flexDirection: "column",
gap: 2,
@@ -132,7 +132,7 @@ export const PreviewMediaDialog = observer(
sx={{ width: "50%" }}
/>
<Box className="flex gap-4">
<Box className="flex gap-4 h-[40vh]">
<Paper
elevation={2}
sx={{
@@ -142,7 +142,6 @@ export const PreviewMediaDialog = observer(
alignItems: "center",
justifyContent: "center",
}}
className="max-h-[40vh]"
>
<MediaViewer
media={{
@@ -150,6 +149,7 @@ export const PreviewMediaDialog = observer(
media_type: media.media_type,
filename: media.filename,
}}
className="h-full w-full object-contain"
fullHeight
/>
</Paper>

View File

@@ -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татьи"}

View File

@@ -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>
)
)

View File

@@ -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
);
}
}, [fileToUpload]);
setMediaName(defaultName);
}
}
}, [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);
}
}, [mediaFile]);
const newBlobUrl = URL.createObjectURL(mediaFile as Blob);
setMediaUrl(newBlobUrl);
previousMediaUrlRef.current = newBlobUrl; // Сохраняем новый URL в ref
setIsPreviewLoaded(false); // Сбрасываем состояние загрузки при смене файла
}
}, [mediaFile]); // Убираем mediaUrl из зависимостей чтобы избежать зацикливания
// const fileFormat = useEffect(() => {
// const handleKeyPress = (event: KeyboardEvent) => {
@@ -120,6 +285,10 @@ export const UploadMediaDialog = observer(
}
}
setSuccess(true);
// Закрываем модальное окно после успешного сохранения
setTimeout(() => {
handleClose();
}, 1000); // Небольшая задержка, чтобы пользователь увидел сообщение об успехе
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save media");
} finally {
@@ -128,8 +297,20 @@ export const UploadMediaDialog = observer(
};
const handleClose = () => {
// Очищаем blob URL и кеш GLTF при закрытии диалога
if (
previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:")
) {
clearBlobAndGLTFCache(previousMediaUrlRef.current);
}
setError(null);
setSuccess(false);
setMediaUrl(null);
setMediaFile(null);
setIsPreviewLoaded(false);
previousMediaUrlRef.current = null; // Очищаем ref
onClose();
};
@@ -141,7 +322,6 @@ export const UploadMediaDialog = observer(
className="flex gap-4"
dividers
sx={{
height: "600px",
display: "flex",
flexDirection: "column",
gap: 2,
@@ -198,27 +378,51 @@ export const UploadMediaDialog = observer(
alignItems: "center",
justifyContent: "center",
height: "100%",
position: "relative",
}}
>
{/* <MediaViewer
media={{
id: "",
media_type: mediaType,
filename: mediaFilename,
{!isPreviewLoaded && mediaUrl && (
<Box
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: 1,
}}
/> */}
>
<CircularProgress />
</Box>
)}
{mediaType == 2 && mediaUrl && (
<video
src={mediaUrl}
autoPlay
muted
loop
controls
style={{ maxWidth: "100%", maxHeight: "100%" }}
onLoadedData={() => setIsPreviewLoaded(true)}
onError={() => setIsPreviewLoaded(true)}
/>
)}
{mediaType === 6 && mediaUrl && (
<ModelViewer3D fileUrl={mediaUrl} height="100%" />
<ModelViewer3D
fileUrl={mediaUrl}
height="100%"
onLoad={() => setIsPreviewLoaded(true)}
/>
)}
{mediaType !== 6 && mediaType !== 2 && mediaUrl && (
<img
src={mediaUrl ?? ""}
alt="Uploaded media"
style={{
maxWidth: "100%",
maxHeight: "100%",
height: "100%",
objectFit: "contain",
}}
onLoad={() => setIsPreviewLoaded(true)}
onError={() => setIsPreviewLoaded(true)}
/>
)}
</Paper>
@@ -226,18 +430,31 @@ export const UploadMediaDialog = observer(
<Box className="flex flex-col gap-2 self-end">
<Button
variant="contained"
color="success"
sx={{
backgroundColor: isLoading ? "#9e9e9e" : "#4caf50",
"&:hover": {
backgroundColor: isLoading ? "#9e9e9e" : "#45a049",
},
}}
startIcon={
isLoading ? (
<CircularProgress size={16} />
<CircularProgress size={16} color="inherit" />
) : (
<Save size={16} />
)
}
onClick={handleSave}
disabled={isLoading || (!mediaName && !mediaFilename)}
disabled={
isLoading ||
(!mediaName && !mediaFilename) ||
!isPreviewLoaded
}
>
Сохранить
{isLoading
? "Сохранение..."
: !isPreviewLoaded
? "Загрузка превью..."
: "Сохранить"}
</Button>
</Box>
</Box>

View File

@@ -109,7 +109,8 @@ class ArticlesStore {
getArticles = async (language: Language) => {
this.articleLoading = true;
const response = await authInstance.get("/article");
const response = await languageInstance(language).get("/article");
runInAction(() => {
this.articles[language] = response.data;
@@ -119,8 +120,9 @@ class ArticlesStore {
getArticle = async (id: number, language?: Language) => {
this.articleLoading = true;
let response: any;
if (language) {
const response = await languageInstance(language).get(`/article/${id}`);
response = await languageInstance(language).get(`/article/${id}`);
runInAction(() => {
if (!this.articleData) {
this.articleData = { id, heading: "", body: "", service_name: "" };
@@ -131,11 +133,12 @@ class ArticlesStore {
};
});
} else {
const response = await authInstance.get(`/article/${id}`);
response = await authInstance.get(`/article/${id}`);
runInAction(() => {
this.articleData = response.data;
});
}
return response;
this.articleLoading = false;
};

View File

@@ -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) {

View File

@@ -64,7 +64,7 @@ class CarrierStore {
getCarriers = async (language: Language) => {
if (this.carriers[language as keyof Carriers].loaded) return;
const response = await authInstance.get("/carrier");
const response = await languageInstance(language).get("/carrier");
runInAction(() => {
this.carriers[language as keyof Carriers].data = response.data;
@@ -108,46 +108,114 @@ class CarrierStore {
return this.carrier[id];
};
createCarrier = async (
createCarrierData = {
city_id: 0,
logo: "",
ru: {
full_name: "",
short_name: "",
slogan: "",
},
en: {
full_name: "",
short_name: "",
slogan: "",
},
zh: {
full_name: "",
short_name: "",
slogan: "",
},
};
setCreateCarrierData = (
fullName: string,
shortName: string,
cityId: number,
slogan: string,
logoId: string
logoId: string,
language: Language
) => {
const { language } = languageStore;
const cityName =
cityStore.cities[language].data.find((city) => city.id === cityId)
?.name || "";
const response = await languageInstance(language).post("/carrier", {
this.createCarrierData.city_id = cityId;
this.createCarrierData.logo = logoId;
this.createCarrierData[language] = {
full_name: fullName,
short_name: shortName,
slogan: slogan,
};
};
createCarrier = async () => {
const { language } = languageStore;
const cityName =
cityStore.cities[language].data.find(
(city) => city.id === this.createCarrierData.city_id
)?.name || "";
const payload = {
full_name: this.createCarrierData[language].full_name,
short_name: this.createCarrierData[language].short_name,
city: cityName,
city_id: cityId,
slogan,
logo: logoId,
});
city_id: this.createCarrierData.city_id,
slogan: this.createCarrierData[language].slogan,
...(this.createCarrierData.logo
? { logo: this.createCarrierData.logo }
: {}),
};
const response = await languageInstance(language).post("/carrier", payload);
const carrierId = response.data.id;
runInAction(() => {
this.carriers[language].data.push(response.data);
});
// Create translations for other languages
for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) {
await languageInstance(lang as Language).patch(`/carrier/${carrierId}`, {
full_name: fullName,
short_name: shortName,
const patchPayload = {
// @ts-ignore
full_name: this.createCarrierData[lang as any].full_name as string,
// @ts-ignore
short_name: this.createCarrierData[lang as any].short_name as string,
city: cityName,
city_id: cityId,
slogan,
logo: logoId,
city_id: this.createCarrierData.city_id,
// @ts-ignore
slogan: this.createCarrierData[lang as any].slogan as string,
...(this.createCarrierData.logo
? { logo: this.createCarrierData.logo }
: {}),
};
const response = await languageInstance(lang as Language).patch(
`/carrier/${carrierId}`,
patchPayload
);
runInAction(() => {
this.carriers[lang as keyof Carriers].data.push(response.data);
});
}
runInAction(() => {
for (const language of ["ru", "en", "zh"] as const) {
this.carriers[language].data.push(response.data);
}
});
this.createCarrierData = {
city_id: 0,
logo: "",
ru: {
full_name: "",
short_name: "",
slogan: "",
},
en: {
full_name: "",
short_name: "",
slogan: "",
},
zh: {
full_name: "",
short_name: "",
slogan: "",
},
};
};
editCarrierData = {
@@ -206,31 +274,31 @@ class CarrierStore {
};
editCarrier = async (id: number) => {
const { language } = languageStore;
const cityName =
cityStore.cities[languageStore.language].data.find(
cityStore.cities[language].data.find(
(city) => city.id === this.editCarrierData.city_id
)?.name || "";
for (const language of ["ru", "en", "zh"] as const) {
const response = await languageInstance(language).patch(
`/carrier/${id}`,
{
...this.editCarrierData[language],
for (const lang of ["ru", "en", "zh"] as const) {
const response = await languageInstance(lang).patch(`/carrier/${id}`, {
...this.editCarrierData[lang],
city: cityName,
logo: this.editCarrierData.logo,
}
);
city_id: this.editCarrierData.city_id,
...(this.editCarrierData.logo
? { logo: this.editCarrierData.logo }
: {}),
});
runInAction(() => {
if (this.carrier[id]) {
this.carrier[id][language] = response.data;
this.carrier[id][lang] = response.data;
}
for (const language of ["ru", "en", "zh"] as const) {
this.carriers[language].data = this.carriers[language].data.map(
this.carriers[lang].data = this.carriers[lang].data.map(
(carrier: Carrier) =>
carrier.id === id ? { ...carrier, ...response.data } : carrier
);
}
});
}
};

View File

@@ -85,7 +85,7 @@ class CityStore {
return;
}
const response = await authInstance.get(`/city`);
const response = await languageInstance(language).get(`/city`);
runInAction(() => {
this.cities[language].data = response.data;
@@ -98,7 +98,7 @@ class CityStore {
return;
}
const response = await authInstance.get(`/city/${code}`);
const response = await languageInstance(language).get(`/city/${code}`);
runInAction(() => {
if (!this.city[code]) {
@@ -170,15 +170,20 @@ class CityStore {
try {
// Create city in primary language
const cityResponse = await languageInstance(language).post("/city", {
const cityPayload = {
name,
country:
countryStore.countries[language as keyof CashedCountries]?.data.find(
(c) => c.code === country_code
)?.name || "",
country_code,
arms: arms || "",
});
...(arms ? { arms } : {}),
};
const cityResponse = await languageInstance(language).post(
"/city",
cityPayload
);
const cityId = cityResponse.data.id;
@@ -194,14 +199,16 @@ class CityStore {
(c) => c.code === country_code
)?.name || "";
const patchResponse = await languageInstance(secondaryLanguage).patch(
`/city/${cityId}`,
{
const patchPayload = {
name: secondaryName || "",
country: countryName,
country_code: country_code || "",
arms: arms || "",
}
...(arms ? { arms } : {}),
};
const patchResponse = await languageInstance(secondaryLanguage).patch(
`/city/${cityId}`,
patchPayload
);
runInAction(() => {
@@ -277,7 +284,6 @@ class CityStore {
(country) => country.code === country_code
);
if (name) {
await languageInstance(language as Language).patch(`/city/${code}`, {
name,
country: country?.name || "",
@@ -312,7 +318,6 @@ class CityStore {
}
});
}
}
};
}

View File

@@ -68,14 +68,7 @@ class CountryStore {
};
getCountry = async (code: string, language: keyof CashedCountries) => {
if (
this.country[code]?.[language] &&
this.country[code][language] !== null
) {
return;
}
const response = await authInstance.get(`/country/${code}`);
const response = await languageInstance(language).get(`/country/${code}`);
runInAction(() => {
if (!this.country[code]) {
@@ -91,14 +84,16 @@ class CountryStore {
return response.data;
};
deleteCountry = async (code: string, language: keyof CashedCountries) => {
deleteCountry = async (code: string) => {
await authInstance.delete(`/country/${code}`);
runInAction(() => {
this.countries[language].data = this.countries[language].data.filter(
(country) => country.code !== code
);
this.countries[language].loaded = true;
for (const lang of ["ru", "en", "zh"]) {
this.countries[lang as keyof CashedCountries].data = this.countries[
lang as keyof CashedCountries
].data.filter((country) => country.code !== code);
}
this.country[code] = {
ru: null,
en: null,

View File

@@ -1,5 +1,11 @@
// @shared/stores/createSightStore.ts
import { Language, authInstance, languageInstance, mediaStore } from "@shared";
import {
articlesStore,
Language,
authInstance,
languageInstance,
mediaStore,
} from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
type MediaItem = {
@@ -162,6 +168,8 @@ class CreateSightStore {
media: mediaData,
});
});
return articleId; // Return the linked article ID
} catch (error) {
console.error("Error linking existing right article:", error);
throw error;
@@ -315,7 +323,18 @@ class CreateSightStore {
deleteLeftArticle = async (articleId: number) => {
/* ... your existing logic ... */
await authInstance.delete(`/article/${articleId}`);
// articlesStore.getArticles(languageStore.language); // If still needed
// articlesStore.getArticles(languageStore.language); // If still neede
runInAction(() => {
articlesStore.articles.ru = articlesStore.articles.ru.filter(
(article) => article.id !== articleId
);
articlesStore.articles.en = articlesStore.articles.en.filter(
(article) => article.id !== articleId
);
articlesStore.articles.zh = articlesStore.articles.zh.filter(
(article) => article.id !== articleId
);
});
this.unlinkLeftArticle();
};
@@ -352,6 +371,25 @@ class CreateSightStore {
body: "填写内容",
media: [],
};
articlesStore.articles.ru.push({
id: newLeftArticleId,
heading: "Новая левая статья",
body: "Заполните контентом",
service_name: "Новая левая статья",
});
articlesStore.articles.en.push({
id: newLeftArticleId,
heading: "New Left Article",
body: "Fill with content",
service_name: "New Left Article",
});
articlesStore.articles.zh.push({
id: newLeftArticleId,
heading: "新的左侧文章",
body: "填写内容",
service_name: "新的左侧文章",
});
});
return newLeftArticleId;
};
@@ -513,8 +551,8 @@ class CreateSightStore {
});
}
console.log("Sight created with ID:", newSightId);
// Optionally: this.clearCreateSight(); // To reset form after successful creation
this.needLeaveAgree = false;
return newSightId;
};

View File

@@ -12,6 +12,7 @@ class DevicesStore {
getDevices = async () => {
const response = await authInstance.get(`${API_URL}/devices/connected`);
runInAction(() => {
this.devices = response.data;
});

View File

@@ -1,5 +1,11 @@
// @shared/stores/editSightStore.ts
import { authInstance, Language, languageInstance, mediaStore } from "@shared";
import {
articlesStore,
authInstance,
Language,
languageInstance,
mediaStore,
} from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
export type SightLanguageInfo = {
@@ -82,11 +88,7 @@ class EditSightStore {
hasLoadedCommon = false;
getSightInfo = async (id: number, language: Language) => {
if (this.sight[language].id === id) {
return;
}
const response = await authInstance.get(`/sight/${id}`);
const response = await languageInstance(language).get(`/sight/${id}`);
const data = response.data;
if (data.left_article != 0 && data.left_article != null) {
@@ -346,6 +348,8 @@ class EditSightStore {
// body: this.sight.zh.left.body,
// }
// );
this.needLeaveAgree = false;
};
getLeftArticle = async (id: number) => {
@@ -374,6 +378,18 @@ class EditSightStore {
deleteLeftArticle = async (id: number) => {
await authInstance.delete(`/article/${id}`);
runInAction(() => {
articlesStore.articles.ru = articlesStore.articles.ru.filter(
(article) => article.id !== id
);
articlesStore.articles.en = articlesStore.articles.en.filter(
(article) => article.id !== id
);
articlesStore.articles.zh = articlesStore.articles.zh.filter(
(article) => article.id !== id
);
});
this.sight.common.left_article = 0;
this.sight.ru.left.heading = "";
this.sight.en.left.heading = "";
@@ -481,9 +497,7 @@ class EditSightStore {
media_name: media_name,
media_type: type,
};
} catch (error) {
console.log(error);
}
} catch (error) {}
};
createLinkWithArticle = async (media: {
@@ -554,6 +568,8 @@ class EditSightStore {
media: mediaIds.data,
});
});
return article_id; // Return the linked article ID
};
deleteRightArticleMedia = async (article_id: number, media_id: string) => {
@@ -637,6 +653,29 @@ class EditSightStore {
body: articleZhData.body,
media: [],
});
runInAction(() => {
articlesStore.articles.ru.push({
id: id,
heading: articleRuData.heading,
body: articleRuData.body,
service_name: articleRuData.heading,
});
articlesStore.articles.en.push({
id: id,
heading: articleEnData.heading,
body: articleEnData.body,
service_name: articleEnData.heading,
});
articlesStore.articles.zh.push({
id: id,
heading: articleZhData.heading,
body: articleZhData.body,
service_name: articleZhData.heading,
});
});
return id; // Return the ID of the newly created article
};
createLinkWithRightArticle = async (

View File

@@ -0,0 +1,15 @@
import { makeAutoObservable } from "mobx";
class MenuStore {
isOpen: boolean = true;
constructor() {
makeAutoObservable(this);
}
setIsMenuOpen = (isOpen: boolean) => {
this.isOpen = isOpen;
};
}
export const menuStore = new MenuStore();

View File

@@ -0,0 +1,101 @@
import { makeAutoObservable } from "mobx";
export interface ModelLoadingState {
isLoading: boolean;
progress: number;
modelId: string | null;
error?: string;
startTime?: number;
}
class ModelLoadingStore {
private loadingStates: Map<string, ModelLoadingState> = new Map();
constructor() {
makeAutoObservable(this);
}
// Начать отслеживание загрузки модели
startLoading(modelId: string) {
this.loadingStates.set(modelId, {
isLoading: true,
progress: 0,
modelId,
startTime: Date.now(),
});
}
// Обновить прогресс загрузки
updateProgress(modelId: string, progress: number) {
const state = this.loadingStates.get(modelId);
if (state) {
state.progress = Math.min(100, Math.max(0, progress));
}
}
// Завершить загрузку модели
finishLoading(modelId: string) {
const state = this.loadingStates.get(modelId);
if (state) {
state.isLoading = false;
state.progress = 100;
}
}
// Остановить загрузку (в случае ошибки)
stopLoading(modelId: string) {
this.loadingStates.delete(modelId);
}
// Обработать ошибку загрузки
handleError(modelId: string, error?: string) {
const state = this.loadingStates.get(modelId);
if (state) {
state.isLoading = false;
state.error = error || "Ошибка загрузки модели";
}
}
// Получить состояние загрузки для конкретной модели
getLoadingState(modelId: string): ModelLoadingState | undefined {
return this.loadingStates.get(modelId);
}
// Проверить, загружается ли какая-либо модель
get isAnyModelLoading(): boolean {
return Array.from(this.loadingStates.values()).some(
(state) => state.isLoading
);
}
// Получить все загружающиеся модели
get loadingModels(): ModelLoadingState[] {
return Array.from(this.loadingStates.values()).filter(
(state) => state.isLoading
);
}
// Получить общий прогресс всех загружающихся моделей
get overallProgress(): number {
const loadingModels = this.loadingModels;
if (loadingModels.length === 0) return 100;
const totalProgress = loadingModels.reduce(
(sum, model) => sum + model.progress,
0
);
return Math.round(totalProgress / loadingModels.length);
}
// Проверить, заблокировано ли сохранение (есть ли загружающиеся модели)
get isSaveBlocked(): boolean {
return this.isAnyModelLoading;
}
// Очистить все состояния загрузки
clearAll() {
this.loadingStates.clear();
}
}
export const modelLoadingStore = new ModelLoadingStore();

View File

@@ -66,22 +66,41 @@ class RouteStore {
});
};
routeStations: Record<string, any[]> = {};
getRoute = async (id: number) => {
if (this.route[id]) return this.route[id];
const response = await authInstance.get(`/route/${id}`);
const stations = await authInstance.get(`/route/${id}/station`);
runInAction(() => {
this.route[id] = response.data;
this.routeStations[id] = stations.data;
});
return response.data;
};
setRouteStations = (routeId: number, stationId: number, data: any) => {
this.routeStations[routeId] = this.routeStations[routeId]?.map((station) =>
station.id === stationId ? { ...station, ...data } : station
);
};
saveRouteStations = async (routeId: number, stationId: number) => {
await authInstance.patch(`/route/${routeId}/station`, {
...this.routeStations[routeId]?.find(
(station) => station.id === stationId
),
station_id: stationId,
});
};
editRouteData = {
carrier: "",
carrier_id: 0,
center_latitude: 0,
center_longitude: 0,
center_latitude: "",
center_longitude: "",
governor_appeal: 0,
id: 0,
path: [] as number[][],
@@ -99,10 +118,11 @@ class RouteStore {
};
editRoute = async (id: number) => {
const response = await authInstance.patch(
`/route/${id}`,
this.editRouteData
);
const response = await authInstance.patch(`/route/${id}`, {
...this.editRouteData,
center_latitude: parseFloat(this.editRouteData.center_latitude),
center_longitude: parseFloat(this.editRouteData.center_longitude),
});
runInAction(() => {
this.route[id] = response.data;
@@ -111,6 +131,20 @@ class RouteStore {
);
});
};
copyRouteAction = async (id: number) => {
const response = await authInstance.post(`/route/${id}/copy`);
runInAction(() => {
this.routes.data = [...this.routes.data, response.data];
});
};
selectedStationId = 0;
setSelectedStationId = (id: number) => {
this.selectedStationId = id;
};
}
export const routeStore = new RouteStore();

View File

@@ -0,0 +1,48 @@
import { makeAutoObservable, runInAction } from "mobx";
import { City } from "../CityStore";
class SelectedCityStore {
selectedCity: City | null = null;
constructor() {
makeAutoObservable(this);
this.initialize();
}
private initialize() {
const storedCity = localStorage.getItem("selectedCity");
if (storedCity) {
try {
this.selectedCity = JSON.parse(storedCity);
} catch (error) {
console.error("Error parsing stored city:", error);
localStorage.removeItem("selectedCity");
}
}
}
setSelectedCity = (city: City | null) => {
runInAction(() => {
this.selectedCity = city;
if (city) {
localStorage.setItem("selectedCity", JSON.stringify(city));
} else {
localStorage.removeItem("selectedCity");
}
});
};
clearSelectedCity = () => {
this.setSelectedCity(null);
};
get selectedCityId() {
return this.selectedCity?.id || null;
}
get selectedCityName() {
return this.selectedCity?.name || null;
}
}
export const selectedCityStore = new SelectedCityStore();

Some files were not shown because too many files have changed in this diff Show More