54 Commits

Author SHA1 Message Date
39e11ad5ca feat: add deleting vehicles in device page 2025-12-10 15:20:53 +03:00
7e068e49f5 feat: update transfers 2025-12-07 19:36:49 +03:00
79539d0583 fix: add anchor for station 2025-11-28 02:36:03 +03:00
c5c5f835bc feat: update map anchor for station and desciprtion search 2025-11-27 20:17:23 +03:00
5481d264e0 feat: update center route-preview and align buttons 2025-11-27 10:58:51 +03:00
d6772b1e3a feat: add loader for saving route and description for stations 2025-11-26 21:45:59 +03:00
11133b6839 fix: update preview-page bug fix sights 2025-11-26 19:37:26 +03:00
aaeaed3fa5 fix: delete try catch in try catch... 2025-11-26 13:33:55 +03:00
95fe297aae feat: update route center calculating 2025-11-26 04:29:19 +03:00
04a9ac452e feat: snapshot realization update for polling usage 2025-11-24 16:16:57 +03:00
85c71563c1 feat: Improving page loading 2025-11-20 20:17:52 +03:00
6f32c6e671 feat: Update left sidebar and fetch queries 2025-11-12 03:14:03 +03:00
0a6192c7da feat: Корректировки 07.11.25 2025-11-11 09:13:58 +03:00
b1ba3b4cd5 feat: Update 2025-11-11 03:33:26 +03:00
1917b2cf5a fix: Delete ai comments 2025-11-06 00:58:10 +03:00
5298fb9f60 feat: Add description for stations in sightbar 2025-11-06 00:32:19 +03:00
c95a6517e9 fix: 01.11.25 MapPage update + sight/station relation + preview base 2025-11-06 00:21:45 +03:00
79f523e9cb hotfix-chunks (#17)
Co-authored-by: Микаэл Оганесян <mikaeloganesan@MacBook-Pro-Mikael.local>
Reviewed-on: #17
Reviewed-by: Илья Куприец <kkzemeow@gmail.com>
2025-10-31 14:25:46 +00:00
90f3d66b22 #14 Перепись редактирования и создания маршрута (#16)
Добавлено новое поле route_name:

Текстовые поля на двух страницах
Поле в списке маршрутов

Добавлено выбор видео на двух страниц вместе с редактором статей в виде модального окна

Модальное окно позволяет создать статью, выбрать готовую, отредактировать выбранную сразу на трех языках

Микаэл:

Пожалуйста, перепроверь код, вдруг чего найдешь как улучшить

+

захости локально и потыкай пж:

создай с 0 маршрут и прикрепи к нему созданную / какую-нибудь статью с видео, можешь попробовать загрузить либо взять готовое

после того как создашь, попробуй потыкать и поменять чего-нибудь

(проще обьясню: представь, что ты Руслан)

Reviewed-on: #16
Reviewed-by: Микаэл Оганесян <15lu.akari@unprism.ru>
Co-authored-by: fisenko <kkzemeow@gmail.com>
Co-committed-by: fisenko <kkzemeow@gmail.com>
2025-10-31 11:13:08 +00:00
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
2117a6836e feat: Add carriers translation on 3 languages 2025-06-13 11:17:18 +03:00
f49caf3ec8 fix: Map page finish 2025-06-13 09:17:24 +03:00
300ff262ce fix: Fix Map page 2025-06-12 22:50:43 +03:00
161 changed files with 22654 additions and 6384 deletions

4
.env
View File

@@ -1,2 +1,4 @@
VITE_REACT_APP ='https://wn.krbl.ru/' VITE_API_URL='https://wn.krbl.ru'
VITE_REACT_APP ='https://wn.krbl.ru'
VITE_KRBL_MEDIA='https://wn.krbl.ru/media/' VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
VITE_NEED_AUTH='true'

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"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon_ship.png" /> <link rel="icon" type="image/svg" href="/favicon_ship.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Белые ночи</title> <title>Белые ночи</title>
</head> </head>

3486
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"license": "UNLICENSED",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
@@ -23,6 +24,7 @@
"@tailwindcss/vite": "^4.1.8", "@tailwindcss/vite": "^4.1.8",
"axios": "^1.9.0", "axios": "^1.9.0",
"easymde": "^2.20.0", "easymde": "^2.20.0",
"i18n-iso-countries": "^7.14.0",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"mobx": "^6.13.7", "mobx": "^6.13.7",
"mobx-react-lite": "^4.1.0", "mobx-react-lite": "^4.1.0",
@@ -30,17 +32,17 @@
"path": "^0.12.7", "path": "^0.12.7",
"pixi.js": "^8.10.1", "pixi.js": "^8.10.1",
"react": "^19.1.0", "react": "^19.1.0",
"react-colorful": "^5.6.1",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-dropzone": "^14.3.8",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-photo-sphere-viewer": "^6.2.3", "react-photo-sphere-viewer": "^6.2.3",
"react-router": "^7.9.4",
"react-router-dom": "^7.6.1", "react-router-dom": "^7.6.1",
"react-simplemde-editor": "^5.2.0", "react-simplemde-editor": "^5.2.0",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"tailwindcss": "^4.1.8", "tailwindcss": "^4.1.8",
"three": "^0.177.0" "three": "^0.177.0",
"uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",
@@ -53,8 +55,11 @@
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19", "eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0", "globals": "^16.0.0",
"rollup-plugin-visualizer": "^6.0.5",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.30.1", "typescript-eslint": "^8.30.1",
"vite": "^6.3.5" "vite": "^6.3.5",
} "vite-plugin-svgr": "^4.5.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
} }

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 { CustomTheme } from "@shared";
import { ThemeProvider } from "@mui/material/styles"; import { ThemeProvider } from "@mui/material/styles";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { GlobalErrorBoundary } from "./GlobalErrorBoundary";
export const App: React.FC = () => ( export const App: React.FC = () => (
<GlobalErrorBoundary>
<ThemeProvider theme={CustomTheme.Light}> <ThemeProvider theme={CustomTheme.Light}>
<ToastContainer /> <ToastContainer />
<Router /> <Router />
</ThemeProvider> </ThemeProvider>
</GlobalErrorBoundary>
); );

View File

@@ -16,12 +16,7 @@ import {
SnapshotListPage, SnapshotListPage,
CarrierListPage, CarrierListPage,
StationListPage, StationListPage,
VehicleListPage,
ArticleListPage, ArticleListPage,
CityPreviewPage,
CountryPreviewPage,
VehiclePreviewPage,
CarrierPreviewPage,
SnapshotCreatePage, SnapshotCreatePage,
CountryCreatePage, CountryCreatePage,
CityCreatePage, CityCreatePage,
@@ -31,7 +26,6 @@ import {
CityEditPage, CityEditPage,
UserCreatePage, UserCreatePage,
UserEditPage, UserEditPage,
VehicleEditPage,
CarrierEditPage, CarrierEditPage,
StationCreatePage, StationCreatePage,
StationPreviewPage, StationPreviewPage,
@@ -39,6 +33,8 @@ import {
RouteCreatePage, RouteCreatePage,
RoutePreview, RoutePreview,
RouteEditPage, RouteEditPage,
ArticlePreviewPage,
CountryAddPage,
} from "@pages"; } from "@pages";
import { authStore, createSightStore, editSightStore } from "@shared"; import { authStore, createSightStore, editSightStore } from "@shared";
import { Layout } from "@widgets"; import { Layout } from "@widgets";
@@ -55,25 +51,31 @@ import {
const PublicRoute = ({ children }: { children: React.ReactNode }) => { const PublicRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated } = authStore; const { isAuthenticated } = authStore;
if (isAuthenticated) { const need_auth = import.meta.env.VITE_NEED_AUTH == "true";
return <Navigate to="/sight" replace />;
if (isAuthenticated || !need_auth) {
return <Navigate to="/map" replace />;
} }
return <>{children}</>; return <>{children}</>;
}; };
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated } = authStore; const { isAuthenticated } = authStore;
const need_auth = import.meta.env.VITE_NEED_AUTH == "true";
const location = useLocation(); const location = useLocation();
if (!isAuthenticated) {
if (!isAuthenticated && need_auth) {
return <Navigate to="/login" replace />; return <Navigate to="/login" replace />;
} }
if (location.pathname === "/") { if (location.pathname === "/") {
return <Navigate to="/sight" replace />; return <Navigate to="/map" replace />;
} }
return <>{children}</>; return <>{children}</>;
}; };
// Чтобы очистка сторов происходила при смене локации
const ClearStoresWrapper: React.FC<{ children: React.ReactNode }> = ({ const ClearStoresWrapper: React.FC<{ children: React.ReactNode }> = ({
children, children,
}) => { }) => {
@@ -114,64 +116,45 @@ const router = createBrowserRouter([
children: [ children: [
{ index: true, element: <MainPage /> }, { index: true, element: <MainPage /> },
// Sight
{ path: "sight", element: <SightListPage /> }, { path: "sight", element: <SightListPage /> },
{ path: "sight/create", element: <CreateSightPage /> }, { path: "sight/create", element: <CreateSightPage /> },
{ path: "sight/:id/edit", element: <EditSightPage /> }, { path: "sight/:id/edit", element: <EditSightPage /> },
// Device
{ path: "devices", element: <DevicesPage /> }, { path: "devices", element: <DevicesPage /> },
// Map
{ path: "map", element: <MapPage /> }, { path: "map", element: <MapPage /> },
// Media
{ path: "media", element: <MediaListPage /> }, { path: "media", element: <MediaListPage /> },
{ path: "media/:id", element: <MediaPreviewPage /> }, { path: "media/:id", element: <MediaPreviewPage /> },
{ path: "media/:id/edit", element: <MediaEditPage /> }, { path: "media/:id/edit", element: <MediaEditPage /> },
// Country
{ path: "country", element: <CountryListPage /> }, { path: "country", element: <CountryListPage /> },
{ path: "country/create", element: <CountryCreatePage /> }, { path: "country/create", element: <CountryCreatePage /> },
{ path: "country/:id", element: <CountryPreviewPage /> }, { path: "country/add", element: <CountryAddPage /> },
{ path: "country/:id/edit", element: <CountryEditPage /> }, { path: "country/:id/edit", element: <CountryEditPage /> },
// City
{ path: "city", element: <CityListPage /> }, { path: "city", element: <CityListPage /> },
{ path: "city/create", element: <CityCreatePage /> }, { path: "city/create", element: <CityCreatePage /> },
{ path: "city/:id", element: <CityPreviewPage /> },
{ path: "city/:id/edit", element: <CityEditPage /> }, { path: "city/:id/edit", element: <CityEditPage /> },
// Route
{ path: "route", element: <RouteListPage /> }, { path: "route", element: <RouteListPage /> },
{ path: "route/create", element: <RouteCreatePage /> }, { path: "route/create", element: <RouteCreatePage /> },
{ path: "route/:id/edit", element: <RouteEditPage /> }, { path: "route/:id/edit", element: <RouteEditPage /> },
// User
{ path: "user", element: <UserListPage /> }, { path: "user", element: <UserListPage /> },
{ path: "user/create", element: <UserCreatePage /> }, { path: "user/create", element: <UserCreatePage /> },
{ path: "user/:id/edit", element: <UserEditPage /> }, { path: "user/:id/edit", element: <UserEditPage /> },
// Snapshot
{ path: "snapshot", element: <SnapshotListPage /> }, { path: "snapshot", element: <SnapshotListPage /> },
{ path: "snapshot/create", element: <SnapshotCreatePage /> }, { path: "snapshot/create", element: <SnapshotCreatePage /> },
// Carrier
{ path: "carrier", element: <CarrierListPage /> }, { path: "carrier", element: <CarrierListPage /> },
{ path: "carrier/create", element: <CarrierCreatePage /> }, { path: "carrier/create", element: <CarrierCreatePage /> },
{ path: "carrier/:id", element: <CarrierPreviewPage /> },
{ path: "carrier/:id/edit", element: <CarrierEditPage /> }, { path: "carrier/:id/edit", element: <CarrierEditPage /> },
// Station
{ path: "station", element: <StationListPage /> }, { path: "station", element: <StationListPage /> },
{ path: "station/create", element: <StationCreatePage /> }, { path: "station/create", element: <StationCreatePage /> },
{ path: "station/:id", element: <StationPreviewPage /> }, { path: "station/:id", element: <StationPreviewPage /> },
{ path: "station/:id/edit", element: <StationEditPage /> }, { path: "station/:id/edit", element: <StationEditPage /> },
// Vehicle
{ path: "vehicle", element: <VehicleListPage /> },
{ path: "vehicle/create", element: <VehicleCreatePage /> }, { path: "vehicle/create", element: <VehicleCreatePage /> },
{ path: "vehicle/:id", element: <VehiclePreviewPage /> },
{ path: "vehicle/:id/edit", element: <VehicleEditPage /> },
// Article
{ path: "article", element: <ArticleListPage /> }, { path: "article", element: <ArticleListPage /> },
// { path: "article/:id", element: <ArticlePreviewPage /> }, { path: "article/:id", element: <ArticlePreviewPage /> },
// { path: "media/create", element: <CreateMediaPage /> },
], ],
}, },
]); ]);

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; label: string;
icon: LucideIcon; icon: LucideIcon;
path?: string; path?: string;
for_admin?: boolean;
onClick?: () => void; onClick?: () => void;
nestedItems?: NavigationItem[]; nestedItems?: NavigationItem[];
} }

View File

@@ -9,12 +9,15 @@ import ExpandLessIcon from "@mui/icons-material/ExpandLess";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import type { NavigationItem } from "../model"; import type { NavigationItem } from "../model";
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import { Plus } from "lucide-react";
import { authStore } from "@shared";
interface NavigationItemProps { interface NavigationItemProps {
item: NavigationItem; item: NavigationItem;
open: boolean; open: boolean;
onClick?: () => void; onClick?: () => void;
isNested?: boolean; isNested?: boolean;
onDrawerOpen?: () => void;
} }
export const NavigationItemComponent: React.FC<NavigationItemProps> = ({ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
@@ -22,15 +25,33 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
open, open,
onClick, onClick,
isNested = false, isNested = false,
onDrawerOpen,
}) => { }) => {
const Icon = item.icon; const Icon = item.icon;
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [isExpanded, setIsExpanded] = React.useState(false); const [isExpanded, setIsExpanded] = React.useState(false);
const { payload } = authStore;
const need_auth = import.meta.env.VITE_NEED_AUTH == "true";
// @ts-ignore
const isAdmin = payload?.is_admin || false || !need_auth;
const isActive = item.path ? location.pathname.startsWith(item.path) : false; const isActive = item.path ? location.pathname.startsWith(item.path) : false;
const filteredNestedItems = item.nestedItems?.filter((nestedItem) => {
if (nestedItem.for_admin) {
return isAdmin;
}
return true;
});
const handleClick = () => { const handleClick = () => {
if (item.id === "all" && !open) {
onDrawerOpen?.();
}
if (item.nestedItems) { if (item.nestedItems) {
setIsExpanded(!isExpanded); setIsExpanded(!isExpanded);
} else if (onClick) { } else if (onClick) {
@@ -58,7 +79,7 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
justifyContent: "center", justifyContent: "center",
}, },
isNested && { isNested && {
pl: 4, pl: open ? 4 : 2.5,
}, },
isActive && { isActive && {
backgroundColor: "rgba(0, 0, 0, 0.08)", backgroundColor: "rgba(0, 0, 0, 0.08)",
@@ -84,7 +105,7 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
}, },
]} ]}
> >
<Icon /> {Icon ? <Icon /> : <Plus />}
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText
primary={item.label} primary={item.label}
@@ -102,15 +123,16 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
}, },
]} ]}
/> />
{item.nestedItems && {filteredNestedItems &&
filteredNestedItems.length > 0 &&
open && open &&
(isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />)} (isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />)}
</ListItemButton> </ListItemButton>
</ListItem> </ListItem>
{item.nestedItems && ( {filteredNestedItems && filteredNestedItems.length > 0 && (
<Collapse in={isExpanded && open} timeout="auto" unmountOnExit> <Collapse in={isExpanded} timeout="auto" unmountOnExit>
<List component="div" disablePadding> <List component="div" disablePadding>
{item.nestedItems.map((nestedItem) => ( {filteredNestedItems.map((nestedItem) => (
<NavigationItemComponent <NavigationItemComponent
key={nestedItem.id} key={nestedItem.id}
item={nestedItem} item={nestedItem}

View File

@@ -1,11 +1,36 @@
import List from "@mui/material/List"; import List from "@mui/material/List";
import Divider from "@mui/material/Divider"; import Divider from "@mui/material/Divider";
import { NAVIGATION_ITEMS } from "@shared"; import { authStore, NAVIGATION_ITEMS } from "@shared";
import { NavigationItem, NavigationItemComponent } from "@entities"; import { NavigationItem, NavigationItemComponent } from "@entities";
import { observer } from "mobx-react-lite";
export const NavigationList = ({ open }: { open: boolean }) => { interface NavigationListProps {
const primaryItems = NAVIGATION_ITEMS.primary; open: boolean;
const secondaryItems = NAVIGATION_ITEMS.secondary; onDrawerOpen?: () => void;
}
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 ( return (
<> <>
@@ -15,20 +40,23 @@ export const NavigationList = ({ open }: { open: boolean }) => {
key={item.id} key={item.id}
item={item as NavigationItem} item={item as NavigationItem}
open={open} open={open}
onDrawerOpen={onDrawerOpen}
/> />
))} ))}
</List> </List>
<Divider /> <Divider />
<List> <List>
{secondaryItems.map((item) => ( {NAVIGATION_ITEMS.secondary.map((item) => (
<NavigationItemComponent <NavigationItemComponent
key={item.id} key={item.id}
item={item as NavigationItem} item={item as NavigationItem}
open={open} open={open}
onClick={item.onClick ? item.onClick : undefined} onClick={item.onClick ? item.onClick : undefined}
onDrawerOpen={onDrawerOpen}
/> />
))} ))}
</List> </List>
</> </>
); );
}; }
);

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
import React, { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { LanguageSwitcher } from "@widgets";
import { articlesStore, languageStore } from "@shared";
import { observer } from "mobx-react-lite";
const ArticleEditPage: React.FC = observer(() => {
const navigate = useNavigate();
const { id } = useParams();
const { articleData, getArticle } = articlesStore;
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
useEffect(() => {
if (id) {
// Fetch data for all languages
getArticle(parseInt(id), "ru");
getArticle(parseInt(id), "en");
getArticle(parseInt(id), "zh");
}
}, [id]);
return (
<div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
<h1 className="text-3xl break-words">
{articleData?.ru?.heading || "Редактирование статьи"}
</h1>
</div>
<LanguageSwitcher />
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
</div>
);
});
export default ArticleEditPage;

View File

@@ -1,20 +1,30 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { articlesStore, languageStore } from "@shared"; import { articlesStore, languageStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Trash2, Eye } from "lucide-react"; import { Trash2, Eye, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { DeleteModal, LanguageSwitcher } from "@widgets"; import { DeleteModal, LanguageSwitcher } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const ArticleListPage = observer(() => { export const ArticleListPage = observer(() => {
const { articleList, getArticleList } = articlesStore; const { articleList, getArticleList, deleteArticles } = articlesStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null); // Lifted state const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null);
const { language } = languageStore; const { language } = languageStore;
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => { useEffect(() => {
getArticleList(); const fetchArticles = async () => {
setIsLoading(true);
await getArticleList();
setIsLoading(false);
};
fetchArticles();
}, [language]); }, [language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
@@ -22,11 +32,21 @@ export const ArticleListPage = observer(() => {
field: "heading", field: "heading",
headerName: "Название", headerName: "Название",
flex: 1, flex: 1,
renderCell: (params: GridRenderCellParams) => {
return params.value ? (
params.value
) : (
<div className="flex h-full gap-7 items-center">
<Minus size={20} className="text-red-500" />
</div>
);
},
}, },
{ {
field: "actions", field: "actions",
headerName: "Действия", headerName: "Действия",
sortable: false,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
return ( return (
@@ -58,19 +78,54 @@ export const ArticleListPage = observer(() => {
<> <>
<LanguageSwitcher /> <LanguageSwitcher />
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Статьи</h1>
</div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<div className="w-full"> <div className="w-full">
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooterPagination hideFooterPagination
checkboxSelection
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter hideFooter
slots={{
noRowsOverlay: () => (
<Box
sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}
>
{isLoading ? <CircularProgress size={20} /> : "Нет статей"}
</Box>
),
}}
/> />
</div> </div>
</div>
<DeleteModal <DeleteModal
open={isDeleteModalOpen} open={isDeleteModalOpen}
onDelete={async () => { onDelete={async () => {
if (rowId) { if (rowId) {
await deleteArticles([parseInt(rowId)]);
getArticleList(); getArticleList();
} }
setIsDeleteModalOpen(false); setIsDeleteModalOpen(false);
@@ -81,6 +136,19 @@ export const ArticleListPage = observer(() => {
setRowId(null); setRowId(null);
}} }}
/> />
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await deleteArticles(ids);
getArticleList();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</> </>
); );
}); });

View File

@@ -0,0 +1,85 @@
import { Paper, Box, Typography } from "@mui/material";
import { MediaViewer, ReactMarkdownComponent } from "@widgets";
import { articlesStore, languageStore } from "@shared";
import { observer } from "mobx-react-lite";
export const PreviewLeftWidget = observer(() => {
const { articleMedia, articleData } = articlesStore;
const { language } = languageStore;
return (
<Paper
elevation={3}
sx={{
width: "100%",
minWidth: 320,
background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
overflowY: "auto",
display: "flex",
flexDirection: "column",
borderRadius: "10px",
}}
>
<Box
sx={{
overflow: "hidden",
width: "100%",
minHeight: 100,
padding: "3px",
display: "flex",
alignItems: "center",
justifyContent: "center",
"& img": {
borderTopLeftRadius: "10px",
borderTopRightRadius: "10px",
width: "100%",
height: "auto",
objectFit: "contain",
},
}}
>
{articleMedia && <MediaViewer media={articleMedia} fullWidth />}
</Box>
<Box
sx={{
background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
color: "white",
margin: "5px 0px 5px 0px",
display: "flex",
flexDirection: "column",
gap: 1,
padding: 1,
}}
>
<Typography
variant="h5"
component="h2"
sx={{
wordBreak: "break-word",
fontSize: "24px",
fontWeight: 700,
lineHeight: "120%",
}}
>
{articleData?.[language]?.heading || "Название информации"}
</Typography>
</Box>
{articleData?.[language]?.body && (
<Box
sx={{
padding: 1,
maxHeight: "300px",
overflowY: "scroll",
background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
flexGrow: 1,
}}
>
<ReactMarkdownComponent value={articleData?.[language]?.body} />
</Box>
)}
</Paper>
);
});

View File

@@ -0,0 +1,139 @@
import { Paper, Box, Typography } from "@mui/material";
import { MediaViewer, ReactMarkdownComponent } from "@widgets";
import { articlesStore, languageStore } from "@shared";
import { observer } from "mobx-react-lite";
import { ImagePlus } from "lucide-react";
export const PreviewRightWidget = observer(() => {
const { articleData, articleMedia } = articlesStore;
const { language } = languageStore;
const article = articleData?.[language];
if (!article) return null;
return (
<Paper
className="flex-1 flex flex-col max-w-[500px]"
sx={{
borderRadius: "10px",
overflow: "hidden",
}}
elevation={2}
>
<Box
className="overflow-hidden"
sx={{
width: "100%",
background: "#877361",
borderColor: "grey.300",
display: "flex",
flexDirection: "column",
}}
>
{articleMedia ? (
<Box
sx={{
overflow: "hidden",
width: "100%",
padding: "2px 2px 0px 2px",
"& img": {
borderTopLeftRadius: "10px",
borderTopRightRadius: "10px",
width: "100%",
height: "auto",
objectFit: "contain",
},
}}
>
<MediaViewer media={articleMedia} fullWidth fullHeight />
</Box>
) : (
<Box
sx={{
width: "100%",
height: 200,
flexShrink: 0,
backgroundColor: "rgba(0,0,0,0.1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<ImagePlus size={48} color="white" />
</Box>
)}
<Box
sx={{
p: 1,
wordBreak: "break-word",
fontSize: "24px",
fontWeight: 700,
lineHeight: "120%",
backdropFilter: "blur(12px)",
borderBottom: "1px solid #A89F90",
boxShadow: "inset 4px 4px 12px 0 rgba(255,255,255,0.12)",
background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
}}
>
<Typography variant="h6" color="white">
{article.heading || "Выберите статью"}
</Typography>
</Box>
<Box
sx={{
padding: 1,
minHeight: "200px",
maxHeight: "300px",
overflowY: "scroll",
background:
"rgba(179, 165, 152, 0.4), linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 100%)",
flexGrow: 1,
}}
>
{article.body ? (
<ReactMarkdownComponent value={article.body} />
) : (
<Typography
color="rgba(255,255,255,0.7)"
sx={{ textAlign: "center", mt: 4 }}
>
Предпросмотр статьи появится здесь
</Typography>
)}
</Box>
{/* @ts-ignore */}
{articleData?.right && articleData?.right.length > 1 && (
<Box
sx={{
p: 2,
display: "flex",
justifyContent: "space-between",
fontSize: "24px",
fontWeight: 700,
lineHeight: "120%",
flexWrap: "wrap",
gap: 1,
backdropFilter: "blur(12px)",
boxShadow: "inset 4px 4px 12px 0 rgba(255,255,255,0.12)",
background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
}}
>
{/* @ts-ignore */}
{articleData.right.map((a, idx) => (
<button
key={idx}
className="inline-block text-left text-xs text-white"
>
{a.heading}
</button>
))}
</Box>
)}
</Box>
</Paper>
);
});

View File

@@ -0,0 +1,80 @@
import { useNavigate, useParams } from "react-router-dom";
import { useEffect, useState } from "react";
import { Box } from "@mui/material";
import { PreviewLeftWidget } from "./PreviewLeftWidget";
import { PreviewRightWidget } from "./PreviewRightWidget";
import { articlesStore, languageStore, LoadingSpinner } from "@shared";
import { ArrowLeft } from "lucide-react";
export const ArticlePreviewPage = () => {
const navigate = useNavigate();
const { id } = useParams();
const { getArticle, getArticleMedia, getArticlePreview } = articlesStore;
const { language } = languageStore;
const [isLoadingData, setIsLoadingData] = useState(true);
useEffect(() => {
const fetchData = async () => {
if (id) {
setIsLoadingData(true);
try {
await getArticle(Number(id), language);
await getArticleMedia(Number(id));
await getArticlePreview(Number(id));
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
}
};
fetchData();
}, [id, language]);
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных статьи..." />
</Box>
);
}
return (
<>
<div className="flex items-center gap-4 mb-10">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<Box
sx={{
display: "flex",
gap: 2,
p: 2,
justifyContent: "center",
margin: "0 auto",
}}
>
<Box sx={{ width: "320px" }}>
<PreviewLeftWidget />
</Box>
<Box sx={{ width: "500px" }}>
<PreviewRightWidget />
</Box>
</Box>
</>
);
};

View File

@@ -1 +1,2 @@
export * from "./ArticleListPage"; export * from "./ArticleListPage";
export * from "./ArticlePreviewPage";

View File

@@ -12,41 +12,60 @@ import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { carrierStore, cityStore, mediaStore } from "@shared"; import {
carrierStore,
cityStore,
mediaStore,
languageStore,
useSelectedCity,
} from "@shared";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { MediaViewer } from "@widgets"; import { ImageUploadCard, LanguageSwitcher } from "@widgets";
import {
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared";
export const CarrierCreatePage = observer(() => { export const CarrierCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const [fullName, setFullName] = useState(""); const { createCarrierData, setCreateCarrierData } = carrierStore;
const [shortName, setShortName] = useState(""); const { language } = languageStore;
const [cityId, setCityId] = useState<number | null>(null); const { selectedCityId } = useSelectedCity();
const [main_color, setMainColor] = useState("#000000");
const [left_color, setLeftColor] = useState("#ffffff");
const [right_color, setRightColor] = useState("#ff0000");
const [slogan, setSlogan] = useState("");
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null); const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
useEffect(() => { useEffect(() => {
cityStore.getCities("ru"); cityStore.getCities("ru");
mediaStore.getMedia(); mediaStore.getMedia();
languageStore.setLanguage("ru");
}, []); }, []);
useEffect(() => {
if (selectedCityId && !createCarrierData.city_id) {
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
selectedCityId,
createCarrierData[language].slogan,
selectedMediaId || "",
language
);
}
}, [selectedCityId, createCarrierData.city_id]);
const handleCreate = async () => { const handleCreate = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
await carrierStore.createCarrier( await carrierStore.createCarrier();
fullName,
shortName,
cityStore.cities.ru.find((c) => c.id === cityId)?.name!,
cityId!,
main_color,
left_color,
right_color,
slogan,
selectedMediaId!
);
toast.success("Перевозчик успешно создан"); toast.success("Перевозчик успешно создан");
navigate("/carrier"); navigate("/carrier");
} catch (error) { } catch (error) {
@@ -56,8 +75,30 @@ export const CarrierCreatePage = observer(() => {
} }
}; };
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setSelectedMediaId(media.id);
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
createCarrierData[language].slogan,
media.id,
language
);
};
const selectedMedia = selectedMediaId
? mediaStore.media.find((m) => m.id === selectedMediaId)
: null;
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button <button
className="flex items-center gap-2" className="flex items-center gap-2"
@@ -69,15 +110,28 @@ export const CarrierCreatePage = observer(() => {
</div> </div>
<div className="flex flex-col gap-10 w-full items-end"> <div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
<h1 className="text-3xl break-words">Создание перевозчика</h1>
</div>
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel>Город</InputLabel> <InputLabel>Город</InputLabel>
<Select <Select
value={cityId || ""} value={createCarrierData.city_id || ""}
label="Город" label="Город"
required required
onChange={(e) => setCityId(e.target.value as number)} onChange={(e) =>
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
e.target.value as number,
createCarrierData[language].slogan,
selectedMediaId || "",
language
)
}
> >
{cityStore.cities.ru.map((city) => ( {cityStore.cities["ru"].data.map((city) => (
<MenuItem key={city.id} value={city.id}> <MenuItem key={city.id} value={city.id}>
{city.name} {city.name}
</MenuItem> </MenuItem>
@@ -88,100 +142,83 @@ export const CarrierCreatePage = observer(() => {
<TextField <TextField
fullWidth fullWidth
label="Полное название" label="Полное название"
value={fullName} value={createCarrierData[language].full_name}
required required
onChange={(e) => setFullName(e.target.value)} onChange={(e) =>
setCreateCarrierData(
e.target.value,
createCarrierData[language].short_name,
createCarrierData.city_id,
createCarrierData[language].slogan,
selectedMediaId || "",
language
)
}
/> />
<TextField <TextField
fullWidth fullWidth
label="Короткое название" label="Короткое название"
value={shortName} value={createCarrierData[language].short_name}
required required
onChange={(e) => setShortName(e.target.value)} onChange={(e) =>
setCreateCarrierData(
createCarrierData[language].full_name,
e.target.value,
createCarrierData.city_id,
createCarrierData[language].slogan,
selectedMediaId || "",
language
)
}
/> />
<div className="flex gap-4 w-full ">
<TextField
fullWidth
label="Основной цвет"
value={main_color}
className="flex-1 w-full"
onChange={(e) => setMainColor(e.target.value)}
type="color"
sx={{
"& input": {
height: "50px",
paddingBlock: "14px",
paddingInline: "14px",
cursor: "pointer",
},
}}
/>
<TextField
fullWidth
label="Цвет левого виджета"
value={left_color}
className="flex-1 w-full"
onChange={(e) => setLeftColor(e.target.value)}
type="color"
sx={{
"& input": {
height: "50px",
paddingBlock: "14px",
paddingInline: "14px",
cursor: "pointer",
},
}}
/>
<TextField
fullWidth
label="Цвет правого виджета"
value={right_color}
className="flex-1 w-full"
onChange={(e) => setRightColor(e.target.value)}
type="color"
sx={{
"& input": {
height: "50px",
paddingBlock: "14px",
paddingInline: "14px",
cursor: "pointer",
},
}}
/>
</div>
<TextField <TextField
fullWidth fullWidth
label="Слоган" label="Слоган"
value={slogan} value={createCarrierData[language].slogan}
onChange={(e) => setSlogan(e.target.value)} onChange={(e) =>
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
e.target.value,
selectedMediaId || "",
language
)
}
/> />
<div className="w-full flex flex-col gap-4"> <div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<FormControl fullWidth> <ImageUploadCard
<InputLabel>Логотип</InputLabel> title="Логотип перевозчика"
<Select imageKey="thumbnail"
value={selectedMediaId || ""} imageUrl={selectedMedia?.id}
label="Логотип" onImageClick={() => {
required setIsPreviewMediaOpen(true);
onChange={(e) => setSelectedMediaId(e.target.value as string)} setMediaId(selectedMedia?.id ?? "");
> }}
{mediaStore.media onDeleteImageClick={() => {
.filter((media) => media.media_type === 3) setSelectedMediaId(null);
.map((media) => ( setActiveMenuType(null);
<MenuItem key={media.id} value={media.id}> setCreateCarrierData(
{media.media_name || media.filename} createCarrierData[language].full_name,
</MenuItem> createCarrierData[language].short_name,
))} createCarrierData.city_id,
</Select> createCarrierData[language].slogan,
</FormControl> "",
{selectedMediaId && ( language
<div className="w-32 h-32"> );
<MediaViewer media={{ id: selectedMediaId, media_type: 1 }} /> }}
</div> onSelectFileClick={() => {
)} setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("image");
}}
/>
</div> </div>
<Button <Button
@@ -190,7 +227,10 @@ export const CarrierCreatePage = observer(() => {
startIcon={<Save size={20} />} startIcon={<Save size={20} />}
onClick={handleCreate} onClick={handleCreate}
disabled={ disabled={
isLoading || !fullName || !shortName || !cityId || !selectedMediaId isLoading ||
!createCarrierData[language].full_name ||
!createCarrierData[language].short_name ||
!createCarrierData.city_id
} }
> >
{isLoading ? ( {isLoading ? (
@@ -200,6 +240,28 @@ export const CarrierCreatePage = observer(() => {
)} )}
</Button> </Button>
</div> </div>
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={createCarrierData[language].full_name}
contextType="carrier"
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
</Paper> </Paper>
); );
}); });

View File

@@ -6,41 +6,92 @@ import {
MenuItem, MenuItem,
FormControl, FormControl,
InputLabel, InputLabel,
Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { carrierStore, cityStore, mediaStore } from "@shared"; import {
carrierStore,
cityStore,
mediaStore,
languageStore,
LoadingSpinner,
} from "@shared";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { MediaViewer } from "@widgets"; import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets";
import {
SelectMediaDialog,
PreviewMediaDialog,
UploadMediaDialog,
} from "@shared";
export const CarrierEditPage = observer(() => { export const CarrierEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams(); const { id } = useParams();
const { carrier, getCarrier, setEditCarrierData, editCarrierData } = const { getCarrier, setEditCarrierData, editCarrierData } = carrierStore;
carrierStore; const { language } = languageStore;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [isDeleteLogoModalOpen, setIsDeleteLogoModalOpen] = useState(false);
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
await getCarrier(Number(id)); if (!id) {
setIsLoadingData(false);
return;
}
setIsLoadingData(true);
try {
await cityStore.getCities("ru");
await cityStore.getCities("en");
await cityStore.getCities("zh");
const carrierData = await getCarrier(Number(id));
if (carrierData) {
setEditCarrierData( setEditCarrierData(
carrier?.[Number(id)]?.full_name as string, carrierData.ru?.full_name || "",
carrier?.[Number(id)]?.short_name as string, carrierData.ru?.short_name || "",
carrier?.[Number(id)]?.city as string, carrierData.ru?.city_id || 0,
carrier?.[Number(id)]?.city_id as number, carrierData.ru?.slogan || "",
carrier?.[Number(id)]?.main_color as string, carrierData.ru?.logo || "",
carrier?.[Number(id)]?.left_color as string, "ru"
carrier?.[Number(id)]?.right_color as string,
carrier?.[Number(id)]?.slogan as string,
carrier?.[Number(id)]?.logo as string
); );
cityStore.getCities("ru"); setEditCarrierData(
mediaStore.getMedia(); carrierData.en?.full_name || "",
carrierData.en?.short_name || "",
carrierData.en?.city_id || 0,
carrierData.en?.slogan || "",
carrierData.en?.logo || "",
"en"
);
setEditCarrierData(
carrierData.zh?.full_name || "",
carrierData.zh?.short_name || "",
carrierData.zh?.city_id || 0,
carrierData.zh?.slogan || "",
carrierData.zh?.logo || "",
"zh"
);
}
await mediaStore.getMedia();
} finally {
setIsLoadingData(false);
}
})(); })();
languageStore.setLanguage("ru");
}, [id]); }, [id]);
const handleEdit = async () => { const handleEdit = async () => {
@@ -56,8 +107,44 @@ export const CarrierEditPage = observer(() => {
} }
}; };
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
media.id,
language
);
};
const selectedMedia = editCarrierData.logo
? mediaStore.media.find((m) => m.id === editCarrierData.logo)
: null;
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных перевозчика..." />
</Box>
);
}
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button <button
className="flex items-center gap-2" className="flex items-center gap-2"
@@ -68,6 +155,9 @@ export const CarrierEditPage = observer(() => {
</button> </button>
</div> </div>
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
<h1 className="text-3xl break-words">{editCarrierData.ru.full_name}</h1>
</div>
<div className="flex flex-col gap-10 w-full items-end"> <div className="flex flex-col gap-10 w-full items-end">
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel>Город</InputLabel> <InputLabel>Город</InputLabel>
@@ -77,19 +167,16 @@ export const CarrierEditPage = observer(() => {
required required
onChange={(e) => onChange={(e) =>
setEditCarrierData( setEditCarrierData(
editCarrierData.full_name, editCarrierData[language].full_name,
editCarrierData.short_name, editCarrierData[language].short_name,
editCarrierData.city,
Number(e.target.value), Number(e.target.value),
editCarrierData.main_color, editCarrierData[language].slogan,
editCarrierData.left_color, editCarrierData.logo,
editCarrierData.right_color, language
editCarrierData.slogan,
editCarrierData.logo
) )
} }
> >
{cityStore.cities.ru.map((city) => ( {cityStore.cities["ru"].data?.map((city) => (
<MenuItem key={city.id} value={city.id}> <MenuItem key={city.id} value={city.id}>
{city.name} {city.name}
</MenuItem> </MenuItem>
@@ -100,19 +187,16 @@ export const CarrierEditPage = observer(() => {
<TextField <TextField
fullWidth fullWidth
label="Полное название" label="Полное название"
value={editCarrierData.full_name} value={editCarrierData[language].full_name}
required required
onChange={(e) => onChange={(e) =>
setEditCarrierData( setEditCarrierData(
e.target.value, e.target.value,
editCarrierData.short_name, editCarrierData[language].short_name,
editCarrierData.city,
editCarrierData.city_id, editCarrierData.city_id,
editCarrierData.main_color, editCarrierData[language].slogan,
editCarrierData.left_color, editCarrierData.logo,
editCarrierData.right_color, language
editCarrierData.slogan,
editCarrierData.logo
) )
} }
/> />
@@ -120,167 +204,58 @@ export const CarrierEditPage = observer(() => {
<TextField <TextField
fullWidth fullWidth
label="Короткое название" label="Короткое название"
value={editCarrierData.short_name} value={editCarrierData[language].short_name}
required required
onChange={(e) => onChange={(e) =>
setEditCarrierData( setEditCarrierData(
editCarrierData.full_name, editCarrierData[language].full_name,
e.target.value, e.target.value,
editCarrierData.city,
editCarrierData.city_id, editCarrierData.city_id,
editCarrierData.main_color, editCarrierData[language].slogan,
editCarrierData.left_color, editCarrierData.logo,
editCarrierData.right_color, language
editCarrierData.slogan,
editCarrierData.logo
) )
} }
/> />
<div className="flex gap-4 w-full">
<TextField
fullWidth
label="Основной цвет"
value={editCarrierData.main_color}
className="flex-1 w-full"
onChange={(e) =>
setEditCarrierData(
editCarrierData.full_name,
editCarrierData.short_name,
editCarrierData.city,
editCarrierData.city_id,
e.target.value,
editCarrierData.left_color,
editCarrierData.right_color,
editCarrierData.slogan,
editCarrierData.logo
)
}
type="color"
sx={{
"& input": {
height: "50px",
paddingBlock: "14px",
paddingInline: "14px",
cursor: "pointer",
},
}}
/>
<TextField
fullWidth
label="Цвет левого виджета"
value={editCarrierData.left_color}
className="flex-1 w-full"
onChange={(e) =>
setEditCarrierData(
editCarrierData.full_name,
editCarrierData.short_name,
editCarrierData.city,
editCarrierData.city_id,
editCarrierData.main_color,
e.target.value,
editCarrierData.right_color,
editCarrierData.slogan,
editCarrierData.logo
)
}
type="color"
sx={{
"& input": {
height: "50px",
paddingBlock: "14px",
paddingInline: "14px",
cursor: "pointer",
},
}}
/>
<TextField
fullWidth
label="Цвет правого виджета"
value={editCarrierData.right_color}
className="flex-1 w-full"
onChange={(e) =>
setEditCarrierData(
editCarrierData.full_name,
editCarrierData.short_name,
editCarrierData.city,
editCarrierData.city_id,
editCarrierData.main_color,
editCarrierData.left_color,
e.target.value,
editCarrierData.slogan,
editCarrierData.logo
)
}
type="color"
sx={{
"& input": {
height: "50px",
paddingBlock: "14px",
paddingInline: "14px",
cursor: "pointer",
},
}}
/>
</div>
<TextField <TextField
fullWidth fullWidth
label="Слоган" label="Слоган"
value={editCarrierData.slogan} value={editCarrierData[language].slogan}
onChange={(e) => onChange={(e) =>
setEditCarrierData( setEditCarrierData(
editCarrierData.full_name, editCarrierData[language].full_name,
editCarrierData.short_name, editCarrierData[language].short_name,
editCarrierData.city,
editCarrierData.city_id, editCarrierData.city_id,
editCarrierData.main_color,
editCarrierData.left_color,
editCarrierData.right_color,
e.target.value, e.target.value,
editCarrierData.logo editCarrierData.logo,
language
) )
} }
/> />
<div className="w-full flex flex-col gap-4"> <div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<FormControl fullWidth> <ImageUploadCard
<InputLabel>Логотип</InputLabel> title="Логотип перевозчика"
<Select imageKey="thumbnail"
value={editCarrierData.logo || ""} imageUrl={selectedMedia?.id}
label="Логотип" onImageClick={() => {
required setIsPreviewMediaOpen(true);
onChange={(e) => setMediaId(selectedMedia?.id ?? "");
setEditCarrierData( }}
editCarrierData.full_name, onDeleteImageClick={() => {
editCarrierData.short_name, setIsDeleteLogoModalOpen(true);
editCarrierData.city, }}
editCarrierData.city_id, onSelectFileClick={() => {
editCarrierData.main_color, setActiveMenuType("image");
editCarrierData.left_color, setIsSelectMediaOpen(true);
editCarrierData.right_color, }}
editCarrierData.slogan, setUploadMediaOpen={() => {
e.target.value as string setIsUploadMediaOpen(true);
) setActiveMenuType("image");
} }}
>
{mediaStore.media
.filter((media) => media.media_type === 3)
.map((media) => (
<MenuItem key={media.id} value={media.id}>
{media.media_name || media.filename}
</MenuItem>
))}
</Select>
</FormControl>
{editCarrierData.logo && (
<div className="w-32 h-32">
<MediaViewer
media={{ id: editCarrierData.logo, media_type: 1 }}
/> />
</div> </div>
)}
</div>
<Button <Button
variant="contained" variant="contained"
@@ -289,19 +264,56 @@ export const CarrierEditPage = observer(() => {
onClick={handleEdit} onClick={handleEdit}
disabled={ disabled={
isLoading || isLoading ||
!editCarrierData.full_name || !editCarrierData[language].full_name ||
!editCarrierData.short_name || !editCarrierData.city_id
!editCarrierData.city_id ||
!editCarrierData.logo
} }
> >
{isLoading ? ( {isLoading ? (
<Loader2 size={20} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />
) : ( ) : (
"Обновить" "Сохранить"
)} )}
</Button> </Button>
</div> </div>
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={editCarrierData[language].full_name}
contextType="carrier"
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
<DeleteModal
open={isDeleteLogoModalOpen}
onDelete={() => {
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
"",
language
);
setIsDeleteLogoModalOpen(false);
}}
onCancel={() => setIsDeleteLogoModalOpen(false)}
edit
/>
</Paper> </Paper>
); );
}); });

View File

@@ -1,43 +1,94 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { carrierStore } from "@shared"; import { ruRU } from "@mui/x-data-grid/locales";
import { carrierStore, cityStore, languageStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Eye, Pencil, Trash2 } from "lucide-react"; import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal } from "@widgets"; import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const CarrierListPage = observer(() => { export const CarrierListPage = observer(() => {
const { carriers, getCarriers, deleteCarrier } = carrierStore; const { carriers, getCarriers, deleteCarrier } = carrierStore;
const { getCities, cities } = cityStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null); // Lifted state const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
useEffect(() => { useEffect(() => {
getCarriers(); const fetchData = async () => {
}, []); setIsLoading(true);
await getCities("ru");
await getCities("en");
await getCities("zh");
await getCarriers(language);
setIsLoading(false);
};
fetchData();
}, [language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
field: "full_name", field: "full_name",
headerName: "Полное имя", headerName: "Полное имя",
width: 300, width: 300,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
}, },
{ {
field: "short_name", field: "short_name",
headerName: "Короткое имя", headerName: "Короткое имя",
width: 200, width: 200,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
}, },
{ {
field: "city", field: "city_id",
headerName: "Город", headerName: "Город",
flex: 1, flex: 1,
renderCell: (params: GridRenderCellParams) => {
const city = cities[language]?.data.find(
(city) => city.id == params.value
);
return (
<div className="w-full h-full flex items-center">
{city && city.name ? (
city.name
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
}, },
{ {
field: "actions", field: "actions",
headerName: "Действия", headerName: "Действия",
headerAlign: "center", headerAlign: "center",
width: 200, width: 200,
sortable: false,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
return ( return (
@@ -45,9 +96,9 @@ export const CarrierListPage = observer(() => {
<button onClick={() => navigate(`/carrier/${params.row.id}/edit`)}> <button onClick={() => navigate(`/carrier/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" /> <Pencil size={20} className="text-blue-500" />
</button> </button>
<button onClick={() => navigate(`/carrier/${params.row.id}`)}> {/* <button onClick={() => navigate(`/carrier/${params.row.id}`)}>
<Eye size={20} className="text-green-500" /> <Eye size={20} className="text-green-500" />
</button> </button> */}
<button <button
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
@@ -62,25 +113,56 @@ export const CarrierListPage = observer(() => {
}, },
]; ];
const rows = carriers.data?.map((carrier) => ({ const rows = carriers[language].data?.map((carrier) => ({
id: carrier.id, id: carrier.id,
full_name: carrier.full_name, full_name: carrier.full_name,
short_name: carrier.short_name, short_name: carrier.short_name,
city: carrier.city, city_id: carrier.city_id,
})); }));
return ( return (
<> <>
<LanguageSwitcher />
<div className="w-full"> <div className="w-full">
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Перевозчики</h1> <h1 className="text-2xl">Перевозчики</h1>
<CreateButton label="Создать перевозчика" path="/carrier/create" /> <CreateButton label="Создать перевозчика" path="/carrier/create" />
</div> </div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooterPagination
hideFooter hideFooter
checkboxSelection
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? (
<CircularProgress size={20} />
) : (
"Нет перевозчиков"
)}
</Box>
),
}}
/> />
</div> </div>
@@ -98,6 +180,19 @@ export const CarrierListPage = observer(() => {
setRowId(null); setRowId(null);
}} }}
/> />
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteCarrier(id)));
await getCarriers(language);
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</> </>
); );
}); });

View File

@@ -1,114 +0,0 @@
import { Paper } from "@mui/material";
import { carrierStore, mediaStore } from "@shared";
import { MediaViewer } from "@widgets";
import { observer } from "mobx-react-lite";
import { ArrowLeft } from "lucide-react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
export const CarrierPreviewPage = observer(() => {
const { id } = useParams();
const { getCarrier, carrier, setEditCarrierData } = carrierStore;
const { oneMedia, getOneMedia } = mediaStore;
const navigate = useNavigate();
useEffect(() => {
(async () => {
const carrierResponse = await getCarrier(Number(id));
setEditCarrierData(
carrierResponse?.full_name as string,
carrierResponse?.short_name as string,
carrierResponse?.city as string,
carrierResponse?.city_id as number,
carrierResponse?.main_color as string,
carrierResponse?.left_color as string,
carrierResponse?.right_color as string,
carrierResponse?.slogan as string,
carrierResponse?.logo as string
);
console.log(carrierResponse);
await getOneMedia(carrierResponse?.logo as string);
})();
}, [id]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
{carrier && (
<>
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Полное имя</h1>
<p>{carrier[Number(id)]?.full_name}</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Полное имя</h1>
<p>{carrier[Number(id)]?.full_name}</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Город</h1>
<p>{carrier[Number(id)]?.city}</p>
</div>
<div className="flex flex-col gap-2 ">
<h1 className="text-lg font-bold">Основной цвет</h1>
<div
className="w-min"
style={{
backgroundColor: `${carrier[Number(id)]?.main_color}90`,
}}
>
{carrier[Number(id)]?.main_color}
</div>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Цвет левого виджета</h1>
<div
className="w-min"
style={{
backgroundColor: `${carrier[Number(id)]?.left_color}90`,
}}
>
{carrier[Number(id)]?.left_color}
</div>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Цвет правого виджета</h1>
<div
className="w-min"
style={{
backgroundColor: `${carrier[Number(id)]?.right_color}90`,
}}
>
{carrier[Number(id)]?.right_color}
</div>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Краткое имя</h1>
<p>{carrier[Number(id)]?.short_name}</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Логотип</h1>
<MediaViewer
media={{
id: oneMedia?.id as string,
media_type: oneMedia?.media_type as number,
filename: oneMedia?.filename,
}}
/>
</div>
</div>
</>
)}
</Paper>
);
});

View File

@@ -1,4 +1,4 @@
export * from "./CarrierListPage"; export * from "./CarrierListPage";
export * from "./CarrierPreviewPage";
export * from "./CarrierCreatePage"; export * from "./CarrierCreatePage";
export * from "./CarrierEditPage"; export * from "./CarrierEditPage";

View File

@@ -6,17 +6,20 @@ import {
MenuItem, MenuItem,
FormControl, FormControl,
InputLabel, InputLabel,
Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save, ImagePlus } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { cityStore, countryStore, languageStore, mediaStore } from "@shared"; import { cityStore, countryStore, languageStore, mediaStore } from "@shared";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { LanguageSwitcher, MediaViewer } from "@widgets"; import { LanguageSwitcher, ImageUploadCard } from "@widgets";
import { SelectMediaDialog } from "@shared"; import {
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared";
export const CityCreatePage = observer(() => { export const CityCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -24,12 +27,20 @@ export const CityCreatePage = observer(() => {
const { createCityData, setCreateCityData } = cityStore; const { createCityData, setCreateCityData } = cityStore;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
const { getCountries } = countryStore; const { getCountries } = countryStore;
const { getMedia } = mediaStore; const { getMedia } = mediaStore;
useEffect(() => { useEffect(() => {
(async () => { (async () => {
await getCountries(language); await getCountries("ru");
await getCountries("en");
await getCountries("zh");
await getMedia(); await getMedia();
})(); })();
}, [language]); }, [language]);
@@ -55,7 +66,6 @@ export const CityCreatePage = observer(() => {
}) => { }) => {
setCreateCityData( setCreateCityData(
createCityData[language].name, createCityData[language].name,
createCityData.country,
createCityData.country_code, createCityData.country_code,
media.id, media.id,
language language
@@ -80,6 +90,9 @@ export const CityCreatePage = observer(() => {
</div> </div>
<div className="flex flex-col gap-10 w-full items-end"> <div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
<h1 className="text-3xl break-words">{createCityData.ru.name}</h1>
</div>
<TextField <TextField
fullWidth fullWidth
label="Название города" label="Название города"
@@ -88,7 +101,6 @@ export const CityCreatePage = observer(() => {
onChange={(e) => onChange={(e) =>
setCreateCityData( setCreateCityData(
e.target.value, e.target.value,
createCityData.country,
createCityData.country_code, createCityData.country_code,
createCityData.arms, createCityData.arms,
language language
@@ -103,19 +115,15 @@ export const CityCreatePage = observer(() => {
label="Страна" label="Страна"
required required
onChange={(e) => { onChange={(e) => {
const selectedCountry = countryStore.countries[language]?.find(
(country) => country.code === e.target.value
);
setCreateCityData( setCreateCityData(
createCityData[language].name, createCityData[language].name,
selectedCountry?.name || "",
e.target.value, e.target.value,
createCityData.arms, createCityData.arms,
language language
); );
}} }}
> >
{countryStore.countries[language].map((country) => ( {countryStore.countries["ru"]?.data?.map((country) => (
<MenuItem key={country.code} value={country.code}> <MenuItem key={country.code} value={country.code}>
{country.name} {country.name}
</MenuItem> </MenuItem>
@@ -123,44 +131,36 @@ export const CityCreatePage = observer(() => {
</Select> </Select>
</FormControl> </FormControl>
<div className="w-full flex flex-col gap-4"> <div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<label className="text-sm text-gray-600">Герб города</label> <ImageUploadCard
<div className="flex items-center gap-4"> title="Герб города"
<Button imageKey="image"
variant="outlined" imageUrl={selectedMedia?.id}
onClick={() => setIsSelectMediaOpen(true)} onImageClick={() => {
startIcon={<ImagePlus size={20} />} setIsPreviewMediaOpen(true);
> setMediaId(selectedMedia?.id ?? "");
Выбрать герб
</Button>
{selectedMedia && (
<span className="text-sm text-gray-600">
{selectedMedia.media_name || selectedMedia.filename}
</span>
)}
</div>
{selectedMedia && (
<Box
sx={{
width: "200px",
height: "200px",
border: "1px solid #e0e0e0",
borderRadius: "8px",
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
}} }}
> onDeleteImageClick={() => {
<MediaViewer setCreateCityData(
media={{ createCityData[language].name,
id: selectedMedia.id, createCityData.country_code,
media_type: selectedMedia.media_type, "",
filename: selectedMedia.filename, language
);
setActiveMenuType(null);
}}
onSelectFileClick={() => {
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("image");
}}
setHardcodeType={() => {
setActiveMenuType("image");
}} }}
/> />
</Box>
)}
</div> </div>
<Button <Button
@@ -182,7 +182,24 @@ export const CityCreatePage = observer(() => {
open={isSelectMediaOpen} open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)} onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect} onSelectMedia={handleMediaSelect}
mediaType={3} // Тип медиа для иконок mediaType={1} // Тип медиа для иконок
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={createCityData[language]?.name}
contextType="city"
afterUpload={handleMediaSelect}
hardcodeType={
activeMenuType as "thumbnail" | "watermark_lu" | "watermark_rd" | null
}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/> />
</Paper> </Paper>
); );

View File

@@ -9,7 +9,7 @@ import {
Box, Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save, ImagePlus } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@@ -19,21 +19,37 @@ import {
languageStore, languageStore,
mediaStore, mediaStore,
CashedCities, CashedCities,
LoadingSpinner,
} from "@shared"; } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LanguageSwitcher, MediaViewer } from "@widgets"; import { LanguageSwitcher, ImageUploadCard } from "@widgets";
import { SelectMediaDialog } from "@shared"; import {
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared";
export const CityEditPage = observer(() => { export const CityEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
const { language } = languageStore; const { language } = languageStore;
const { id } = useParams(); const { id } = useParams();
const { editCityData, editCity, getCity, setEditCityData } = cityStore; const { editCityData, editCity, getCity, setEditCityData } = cityStore;
const { getCountries } = countryStore; const { getCountries } = countryStore;
const { getMedia, getOneMedia } = mediaStore; const { getMedia, getOneMedia } = mediaStore;
useEffect(() => {
languageStore.setLanguage("ru");
}, []);
const handleEdit = async () => { const handleEdit = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
@@ -49,20 +65,29 @@ export const CityEditPage = observer(() => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
const data = await getCity(id as string, language); setIsLoadingData(true);
setEditCityData( try {
data.name, await getCountries("ru");
data.country,
data.country_code, const ruData = await getCity(id as string, "ru");
data.arms, const enData = await getCity(id as string, "en");
language const zhData = await getCity(id as string, "zh");
);
await getOneMedia(data.arms as string); setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
await getCountries(language); setEditCityData(enData.name, enData.country_code, enData.arms, "en");
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
await getOneMedia(ruData.arms as string);
await getMedia(); await getMedia();
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
} }
})(); })();
}, [id, language]); }, [id]);
const handleMediaSelect = (media: { const handleMediaSelect = (media: {
id: string; id: string;
@@ -72,7 +97,6 @@ export const CityEditPage = observer(() => {
}) => { }) => {
setEditCityData( setEditCityData(
editCityData[language].name, editCityData[language].name,
editCityData.country,
editCityData.country_code, editCityData.country_code,
media.id, media.id,
language language
@@ -83,6 +107,21 @@ export const CityEditPage = observer(() => {
? mediaStore.media.find((m) => m.id === editCityData.arms) ? mediaStore.media.find((m) => m.id === editCityData.arms)
: null; : null;
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных города..." />
</Box>
);
}
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher /> <LanguageSwitcher />
@@ -97,6 +136,9 @@ export const CityEditPage = observer(() => {
</div> </div>
<div className="flex flex-col gap-10 w-full items-end"> <div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start ">
<h1 className="text-3xl break-words">{editCityData.ru.name}</h1>
</div>
<TextField <TextField
fullWidth fullWidth
label="Название" label="Название"
@@ -105,7 +147,6 @@ export const CityEditPage = observer(() => {
onChange={(e) => onChange={(e) =>
setEditCityData( setEditCityData(
e.target.value, e.target.value,
editCityData.country,
editCityData.country_code, editCityData.country_code,
editCityData.arms, editCityData.arms,
language language
@@ -120,19 +161,15 @@ export const CityEditPage = observer(() => {
label="Страна" label="Страна"
required required
onChange={(e) => { onChange={(e) => {
const selectedCountry = countryStore.countries[language]?.find(
(country) => country.code === e.target.value
);
setEditCityData( setEditCityData(
editCityData[language as keyof CashedCities]?.name || "", editCityData[language].name,
selectedCountry?.name || "",
e.target.value, e.target.value,
editCityData.arms, editCityData.arms,
language language
); );
}} }}
> >
{countryStore.countries[language].map((country) => ( {countryStore.countries.ru.data.map((country) => (
<MenuItem key={country.code} value={country.code}> <MenuItem key={country.code} value={country.code}>
{country.name} {country.name}
</MenuItem> </MenuItem>
@@ -140,44 +177,36 @@ export const CityEditPage = observer(() => {
</Select> </Select>
</FormControl> </FormControl>
<div className="w-full flex flex-col gap-4"> <div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<label className="text-sm text-gray-600">Герб города</label> <ImageUploadCard
<div className="flex items-center gap-4"> title="Герб города"
<Button imageKey="image"
variant="outlined" imageUrl={selectedMedia?.id}
onClick={() => setIsSelectMediaOpen(true)} onImageClick={() => {
startIcon={<ImagePlus size={20} />} setIsPreviewMediaOpen(true);
> setMediaId(selectedMedia?.id ?? "");
Выбрать герб
</Button>
{selectedMedia && (
<span className="text-sm text-gray-600">
{selectedMedia.media_name || selectedMedia.filename}
</span>
)}
</div>
{selectedMedia && (
<Box
sx={{
width: "200px",
height: "200px",
border: "1px solid #e0e0e0",
borderRadius: "8px",
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
}} }}
> onDeleteImageClick={() => {
<MediaViewer setEditCityData(
media={{ editCityData[language].name,
id: selectedMedia.id, editCityData.country_code,
media_type: selectedMedia.media_type, "",
filename: selectedMedia.filename, language
);
setActiveMenuType(null);
}}
onSelectFileClick={() => {
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("image");
}}
setHardcodeType={() => {
setActiveMenuType("image");
}} }}
/> />
</Box>
)}
</div> </div>
<Button <Button
@@ -192,7 +221,7 @@ export const CityEditPage = observer(() => {
{isLoading ? ( {isLoading ? (
<Loader2 size={20} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />
) : ( ) : (
"Обновить" "Сохранить"
)} )}
</Button> </Button>
</div> </div>
@@ -201,6 +230,29 @@ export const CityEditPage = observer(() => {
open={isSelectMediaOpen} open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)} onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect} onSelectMedia={handleMediaSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={editCityData[language].name}
contextType="city"
afterUpload={handleMediaSelect}
hardcodeType={
activeMenuType as
| "thumbnail"
| "watermark_lu"
| "watermark_rd"
| "image"
| null
}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/> />
</Paper> </Paper>
); );

View File

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

View File

@@ -16,18 +16,18 @@ export const CityPreviewPage = observer(() => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
const cityResponse = await getCity(id as string, language); const ruData = await getCity(id as string, "ru");
setEditCityData( const enData = await getCity(id as string, "en");
cityResponse.name, const zhData = await getCity(id as string, "zh");
cityResponse.country,
cityResponse.country_code, setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
cityResponse.arms, setEditCityData(enData.name, enData.country_code, enData.arms, "en");
language setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
);
await getOneMedia(cityResponse.arms as string); await getOneMedia(ruData.arms as string);
} }
})(); })();
}, [id, language]); }, [id]);
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">

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

@@ -41,6 +41,9 @@ export const CountryCreatePage = observer(() => {
</div> </div>
<div className="flex flex-col gap-10 w-full items-end"> <div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
<h1 className="text-3xl break-words">{createCountryData.ru.name}</h1>
</div>
<TextField <TextField
fullWidth fullWidth
label="Код страны" label="Код страны"

View File

@@ -1,21 +1,26 @@
import { Button, Paper, TextField } from "@mui/material"; import { Button, Paper, TextField, Box } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { countryStore, languageStore } from "@shared"; import { countryStore, languageStore, LoadingSpinner } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets"; import { LanguageSwitcher } from "@widgets";
export const CountryEditPage = observer(() => { export const CountryEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const { language } = languageStore; const { language } = languageStore;
const { id } = useParams(); const { id } = useParams();
const { editCountryData, editCountry, getCountry, setEditCountryData } = const { editCountryData, editCountry, getCountry, setEditCountryData } =
countryStore; countryStore;
useEffect(() => {
languageStore.setLanguage("ru");
}, []);
const handleEdit = async () => { const handleEdit = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
@@ -31,11 +36,38 @@ export const CountryEditPage = observer(() => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
const data = await getCountry(id as string, language); setIsLoadingData(true);
setEditCountryData(data.name, language); try {
const ruData = await getCountry(id as string, "ru");
const enData = await getCountry(id as string, "en");
const zhData = await getCountry(id as string, "zh");
setEditCountryData(ruData.name, "ru");
setEditCountryData(enData.name, "en");
setEditCountryData(zhData.name, "zh");
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
} }
})(); })();
}, [id, language]); }, [id]);
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных страны..." />
</Box>
);
}
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
@@ -51,6 +83,9 @@ export const CountryEditPage = observer(() => {
</div> </div>
<div className="flex flex-col gap-10 w-full items-end"> <div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
<h1 className="text-3xl break-words t">{editCountryData.ru.name}</h1>
</div>
<TextField <TextField
fullWidth fullWidth
label="Код страны" label="Код страны"
@@ -78,7 +113,7 @@ export const CountryEditPage = observer(() => {
{isLoading ? ( {isLoading ? (
<Loader2 size={20} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />
) : ( ) : (
"Обновить" "Сохранить"
)} )}
</Button> </Button>
</div> </div>

View File

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

View File

@@ -15,11 +15,16 @@ export const CountryPreviewPage = observer(() => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
const data = await getCountry(id as string, language); const ruData = await getCountry(id as string, "ru");
setEditCountryData(data.name, language); const enData = await getCountry(id as string, "en");
const zhData = await getCountry(id as string, "zh");
setEditCountryData(ruData.name, "ru");
setEditCountryData(enData.name, "en");
setEditCountryData(zhData.name, "zh");
} }
})(); })();
}, [id, language]); }, [id]);
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
@@ -55,7 +60,7 @@ export const CountryPreviewPage = observer(() => {
<div className="flex flex-col gap-10 w-full"> <div className="flex flex-col gap-10 w-full">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Название</h1> <h1 className="text-lg font-bold">Название</h1>
<p>{country[id!]?.[language]?.name}</p> <p>{country[id!]?.ru?.name}</p>
</div> </div>
</div> </div>
)} )}

View File

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

View File

@@ -7,7 +7,7 @@ import {
articlesStore, articlesStore,
cityStore, cityStore,
editSightStore, editSightStore,
languageStore, LoadingSpinner,
} from "@shared"; } from "@shared";
import { useBlocker, useParams } from "react-router-dom"; import { useBlocker, useParams } from "react-router-dom";
@@ -20,11 +20,12 @@ function a11yProps(index: number) {
export const EditSightPage = observer(() => { export const EditSightPage = observer(() => {
const [value, setValue] = useState(0); const [value, setValue] = useState(0);
const { sight, getSightInfo, needLeaveAgree } = editSightStore; const [isLoadingData, setIsLoadingData] = useState(true);
const { sight, getSightInfo, needLeaveAgree, getRightArticles } = editSightStore;
const { getArticles } = articlesStore; const { getArticles } = articlesStore;
const { language } = languageStore;
const { id } = useParams(); const { id } = useParams();
const { getRuCities } = cityStore; const { getCities } = cityStore;
let blocker = useBlocker( let blocker = useBlocker(
({ currentLocation, nextLocation }) => ({ currentLocation, nextLocation }) =>
@@ -38,13 +39,26 @@ export const EditSightPage = observer(() => {
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
if (id) { if (id) {
await getSightInfo(+id, language); setIsLoadingData(true);
await getArticles(language); try {
await getRuCities(); await getCities("ru");
await getSightInfo(+id, "ru");
await getSightInfo(+id, "en");
await getSightInfo(+id, "zh");
await getArticles("ru");
await getArticles("en");
await getArticles("zh");
// Загружаем данные правого виджета перед завершением загрузки
await getRightArticles(+id);
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
} }
}; };
fetchData(); fetchData();
}, [id, language]); }, [id]);
return ( return (
<Box <Box
@@ -80,12 +94,25 @@ export const EditSightPage = observer(() => {
</Tabs> </Tabs>
</Box> </Box>
{sight.common.id !== 0 && ( {isLoadingData ? (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных достопримечательности..." />
</Box>
) : (
sight.common.id !== 0 && (
<div className="flex-1"> <div className="flex-1">
<InformationTab value={value} index={0} /> <InformationTab value={value} index={0} />
<LeftWidgetTab value={value} index={1} /> <LeftWidgetTab value={value} index={1} />
<RightWidgetTab value={value} index={2} /> <RightWidgetTab value={value} index={2} />
</div> </div>
)
)} )}
{blocker.state === "blocked" ? <LeaveAgree blocker={blocker} /> : null} {blocker.state === "blocked" ? <LeaveAgree blocker={blocker} /> : null}

View File

@@ -5,9 +5,12 @@ import {
Typography, Typography,
Alert, Alert,
CircularProgress, CircularProgress,
FormControlLabel,
Checkbox,
Paper,
} from "@mui/material"; } from "@mui/material";
import { authStore } from "@shared"; import { authStore, userStore } from "@shared";
import { useState } from "react"; import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@@ -15,9 +18,20 @@ export const LoginPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { login } = authStore; const { login } = authStore;
const { getUsers } = userStore;
useEffect(() => {
const savedEmail = localStorage.getItem("rememberedEmail");
const savedPassword = localStorage.getItem("rememberedPassword");
if (savedEmail && savedPassword) {
setEmail(savedEmail);
setPassword(savedPassword);
setRememberMe(true);
}
}, []);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -26,7 +40,22 @@ export const LoginPage = () => {
try { try {
await login(email, password); await login(email, password);
navigate("/sight");
if (rememberMe) {
localStorage.setItem("rememberedEmail", email);
localStorage.setItem("rememberedPassword", password);
} else {
localStorage.removeItem("rememberedEmail");
localStorage.removeItem("rememberedPassword");
}
navigate("/map");
try {
await getUsers();
} catch (err) {
console.error(err);
}
toast.success("Вход в систему выполнен успешно"); toast.success("Вход в систему выполнен успешно");
} catch (err) { } catch (err) {
setError( setError(
@@ -47,12 +76,31 @@ export const LoginPage = () => {
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
minHeight: "100vh", width: "100vw",
height: "100vh",
gap: 3, gap: 3,
p: 3, p: 3,
backgroundImage: "url('/login-bg.png')",
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}} }}
> >
<Typography variant="h4" component="h1" gutterBottom> <Paper
elevation={3}
sx={{
p: 4,
borderRadius: 2,
backgroundColor: "white",
width: "100%",
maxWidth: "400px",
}}
>
<Typography
variant="h4"
component="h1"
className="text-center pb-[50px]"
>
Вход в систему Вход в систему
</Typography> </Typography>
<Box <Box
@@ -63,7 +111,6 @@ export const LoginPage = () => {
flexDirection: "column", flexDirection: "column",
gap: 2, gap: 2,
width: "100%", width: "100%",
maxWidth: "400px",
}} }}
> >
{error && ( {error && (
@@ -95,6 +142,16 @@ export const LoginPage = () => {
disabled={isLoading} disabled={isLoading}
error={!!error} error={!!error}
/> />
<FormControlLabel
control={
<Checkbox
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
disabled={isLoading}
/>
}
label="Запомнить пароль"
/>
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"
@@ -114,6 +171,7 @@ export const LoginPage = () => {
{isLoading ? <CircularProgress size={24} sx={{}} /> : "Войти"} {isLoading ? <CircularProgress size={24} sx={{}} /> : "Войти"}
</Button> </Button>
</Box> </Box>
</Paper>
</Box> </Box>
); );
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -22,10 +22,8 @@ interface ApiSight {
longitude: number; longitude: number;
} }
// Допуск для сравнения координат, чтобы избежать ошибок с точностью чисел.
const COORDINATE_PRECISION_TOLERANCE = 1e-9; const COORDINATE_PRECISION_TOLERANCE = 1e-9;
// Вспомогательная функция, обновленная для сравнения с допуском.
const arePathsEqual = ( const arePathsEqual = (
path1: [number, number][], path1: [number, number][],
path2: [number, number][] path2: [number, number][]
@@ -54,20 +52,22 @@ class MapStore {
sights: ApiSight[] = []; sights: ApiSight[] = [];
getRoutes = async () => { getRoutes = async () => {
const routes = await languageInstance("ru").get("/route"); const response = await languageInstance("ru").get("/route");
const routedIds = routes.data.map((route: any) => route.id);
const mappedRoutes: ApiRoute[] = []; const routesIds = response.data.map((route: any) => route.id);
for (const routeId of routedIds) { for (const id of routesIds) {
const responseSoloRoute = await languageInstance("ru").get( const route = await languageInstance("ru").get(`/route/${id}`);
`/route/${routeId}` this.routes.push({
); id: route.data.id,
const route = responseSoloRoute.data; route_number: route.data.route_number,
mappedRoutes.push({ path: route.data.path,
});
}
const mappedRoutes: ApiRoute[] = response.data.map((route: any) => ({
id: route.id, id: route.id,
route_number: route.route_number, route_number: route.route_number,
path: route.path, path: route.path,
}); }));
}
this.routes = mappedRoutes.sort((a, b) => this.routes = mappedRoutes.sort((a, b) =>
a.route_number.localeCompare(b.route_number) a.route_number.localeCompare(b.route_number)
); );
@@ -114,7 +114,6 @@ class MapStore {
const updatedStations: any[] = []; const updatedStations: any[] = [];
const parsedJSON = JSON.parse(json); const parsedJSON = JSON.parse(json);
console.log("Данные для сохранения (GeoJSON):", parsedJSON);
for (const feature of parsedJSON.features) { for (const feature of parsedJSON.features) {
const { geometry, properties, id } = feature; const { geometry, properties, id } = feature;
@@ -135,7 +134,6 @@ class MapStore {
longitude: geometry.coordinates[0], longitude: geometry.coordinates[0],
}; };
// ИЗМЕНЕНИЕ: Сравнение координат с допуском
if ( if (
originalStation.name !== currentStation.name || originalStation.name !== currentStation.name ||
Math.abs(originalStation.latitude - currentStation.latitude) > Math.abs(originalStation.latitude - currentStation.latitude) >
@@ -154,7 +152,6 @@ class MapStore {
path: geometry.coordinates, path: geometry.coordinates,
}; };
// ИЗМЕНЕНИЕ: Используется новая функция arePathsEqual с допуском
if ( if (
originalRoute.route_number !== currentRoute.route_number || originalRoute.route_number !== currentRoute.route_number ||
!arePathsEqual(originalRoute.path, currentRoute.path) !arePathsEqual(originalRoute.path, currentRoute.path)
@@ -172,7 +169,6 @@ class MapStore {
longitude: geometry.coordinates[0], longitude: geometry.coordinates[0],
}; };
// ИЗМЕНЕНИЕ: Сравнение координат с допуском
if ( if (
originalSight.name !== currentSight.name || originalSight.name !== currentSight.name ||
originalSight.description !== currentSight.description || originalSight.description !== currentSight.description ||
@@ -209,13 +205,6 @@ class MapStore {
const requests: Promise<any>[] = []; const requests: Promise<any>[] = [];
console.log(
`К созданию: ${newStations.length} станций, ${newRoutes.length} маршрутов, ${newSights.length} достопримечательностей.`
);
console.log(
`К обновлению: ${updatedStations.length} станций, ${updatedRoutes.length} маршрутов, ${updatedSights.length} достопримечательностей.`
);
newStations.forEach((data) => newStations.forEach((data) =>
requests.push(languageInstance("ru").post("/station", data)) requests.push(languageInstance("ru").post("/station", data))
); );
@@ -237,13 +226,11 @@ class MapStore {
); );
if (requests.length === 0) { if (requests.length === 0) {
console.log("Нет изменений для сохранения.");
return; return;
} }
try { try {
await Promise.all(requests); await Promise.all(requests);
console.log("Все изменения успешно сохранены!");
await Promise.all([ await Promise.all([
this.getRoutes(), this.getRoutes(),

View File

@@ -16,7 +16,13 @@ import {
Snackbar, Snackbar,
} from "@mui/material"; } from "@mui/material";
import { Save, ArrowLeft } from "lucide-react"; import { Save, ArrowLeft } from "lucide-react";
import { authInstance, mediaStore, MEDIA_TYPE_LABELS } from "@shared"; import {
authInstance,
mediaStore,
MEDIA_TYPE_LABELS,
languageStore,
LoadingSpinner,
} from "@shared";
import { MediaViewer } from "@widgets"; import { MediaViewer } from "@widgets";
export const MediaEditPage = observer(() => { export const MediaEditPage = observer(() => {
@@ -28,7 +34,7 @@ export const MediaEditPage = observer(() => {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [newFile, setNewFile] = useState<File | null>(null); const [newFile, setNewFile] = useState<File | null>(null);
const [uploadDialogOpen, setUploadDialogOpen] = useState(false); // State for the upload dialog const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
const media = id ? mediaStore.media.find((m) => m.id === id) : null; const media = id ? mediaStore.media.find((m) => m.id === id) : null;
const [mediaName, setMediaName] = useState(media?.media_name ?? ""); const [mediaName, setMediaName] = useState(media?.media_name ?? "");
@@ -40,51 +46,36 @@ export const MediaEditPage = observer(() => {
if (id) { if (id) {
mediaStore.getOneMedia(id); mediaStore.getOneMedia(id);
} }
console.log(newFile);
console.log(uploadDialogOpen);
}, [id]); }, [id]);
useEffect(() => {}, [newFile, uploadDialogOpen]);
useEffect(() => { useEffect(() => {
if (media) { if (media) {
setMediaName(media.media_name); setMediaName(media.media_name);
setMediaFilename(media.filename); setMediaFilename(media.filename);
setMediaType(media.media_type); setMediaType(media.media_type);
// Set available media types based on current file extension
const extension = media.filename.split(".").pop()?.toLowerCase(); const extension = media.filename.split(".").pop()?.toLowerCase();
if (extension) { if (extension) {
if (["glb", "gltf"].includes(extension)) { if (["glb", "gltf"].includes(extension)) {
setAvailableMediaTypes([6]); // 3D model setAvailableMediaTypes([6]);
} else if (["jpg", "jpeg", "png", "gif"].includes(extension)) { } else if (
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama ["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
extension
)
) {
setAvailableMediaTypes([1, 3, 4, 5]);
} else if (["mp4", "webm", "mov"].includes(extension)) { } else if (["mp4", "webm", "mov"].includes(extension)) {
setAvailableMediaTypes([2]); // Video setAvailableMediaTypes([2]);
} }
} }
} }
}, [media]); }, [media]);
// const handleDrop = (e: DragEvent<HTMLDivElement>) => { useEffect(() => {
// e.preventDefault(); languageStore.setLanguage("ru");
// e.stopPropagation(); }, []);
// setIsDragging(false);
// const files = Array.from(e.dataTransfer.files);
// if (files.length > 0) {
// setNewFile(files[0]);
// setMediaFilename(files[0].name);
// setUploadDialogOpen(true); // Open dialog on file drop
// }
// };
// const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault();
// setIsDragging(true);
// };
// const handleDragLeave = () => {
// setIsDragging(false);
// };
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files; const files = e.target.files;
@@ -93,22 +84,25 @@ export const MediaEditPage = observer(() => {
setNewFile(file); setNewFile(file);
setMediaFilename(file.name); setMediaFilename(file.name);
// Determine media type based on file extension
const extension = file.name.split(".").pop()?.toLowerCase(); const extension = file.name.split(".").pop()?.toLowerCase();
if (extension) { if (extension) {
if (["glb", "gltf"].includes(extension)) { if (["glb", "gltf"].includes(extension)) {
setAvailableMediaTypes([6]); // 3D model setAvailableMediaTypes([6]);
setMediaType(6); setMediaType(6);
} else if (["jpg", "jpeg", "png", "gif"].includes(extension)) { } else if (
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama ["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
setMediaType(1); // Default to Photo extension
)
) {
setAvailableMediaTypes([1, 3, 4, 5]);
setMediaType(1);
} else if (["mp4", "webm", "mov"].includes(extension)) { } else if (["mp4", "webm", "mov"].includes(extension)) {
setAvailableMediaTypes([2]); // Video setAvailableMediaTypes([2]);
setMediaType(2); setMediaType(2);
} }
} }
setUploadDialogOpen(true); // Open dialog on file selection setUploadDialogOpen(true);
} }
}; };
@@ -125,11 +119,6 @@ export const MediaEditPage = observer(() => {
type: mediaType, type: mediaType,
}); });
// If a new file was selected, the actual file upload will happen
// via the UploadMediaDialog. We just need to make sure the metadata
// is updated correctly before or after.
// Since the dialog handles the actual upload, we don't call updateMediaFile here.
setSuccess(true); setSuccess(true);
handleUploadSuccess(); handleUploadSuccess();
} catch (err) { } catch (err) {
@@ -140,20 +129,25 @@ export const MediaEditPage = observer(() => {
}; };
const handleUploadSuccess = () => { const handleUploadSuccess = () => {
// After successful upload in the dialog, refresh media data if needed
if (id) { if (id) {
mediaStore.getOneMedia(id); mediaStore.getOneMedia(id);
} }
setNewFile(null); // Clear the new file state after successful upload setNewFile(null);
setUploadDialogOpen(false); setUploadDialogOpen(false);
setSuccess(true); setSuccess(true);
}; };
if (!media && id) { if (!media && id) {
// Only show loading if an ID is present and media is not yet loaded
return ( return (
<Box className="flex justify-center items-center h-screen"> <Box
<CircularProgress /> sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных медиа..." />
</Box> </Box>
); );
} }

View File

@@ -1,20 +1,30 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared"; import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Eye, Trash2 } from "lucide-react"; import { Eye, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal } from "@widgets"; import { DeleteModal } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const MediaListPage = observer(() => { export const MediaListPage = observer(() => {
const { media, getMedia, deleteMedia } = mediaStore; const { media, getMedia, deleteMedia } = mediaStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null); // Lifted state const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null);
const [ids, setIds] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
getMedia(); const fetchMedia = async () => {
setIsLoading(true);
await getMedia();
setIsLoading(false);
};
fetchMedia();
}, [language]); }, [language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
@@ -22,6 +32,17 @@ export const MediaListPage = observer(() => {
field: "media_name", field: "media_name",
headerName: "Название", headerName: "Название",
flex: 1, flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
}, },
{ {
field: "media_type", field: "media_type",
@@ -30,13 +51,15 @@ export const MediaListPage = observer(() => {
flex: 1, flex: 1,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
return ( return (
<p> <div className="w-full h-full flex items-center">
{ {params.value ? (
MEDIA_TYPE_LABELS[ MEDIA_TYPE_LABELS[
params.row.media_type as keyof typeof MEDIA_TYPE_LABELS params.row.media_type as keyof typeof MEDIA_TYPE_LABELS
] ]
} ) : (
</p> <Minus size={20} className="text-red-500" />
)}
</div>
); );
}, },
}, },
@@ -46,6 +69,7 @@ export const MediaListPage = observer(() => {
width: 200, width: 200,
align: "center", align: "center",
headerAlign: "center", headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
return ( return (
@@ -76,15 +100,37 @@ export const MediaListPage = observer(() => {
return ( return (
<> <>
<div className="w-full"> <div className="w-full">
<div className="flex justify-between items-center mb-10"> <div
<h1 className="text-2xl">Медиа</h1> className="flex justify-end mb-5 duration-300"
<CreateButton label="Создать медиа" path="/media/create" /> style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div> </div>
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooterPagination hideFooterPagination
checkboxSelection
loading={isLoading}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as string[]);
}}
hideFooter hideFooter
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет медиафайлов"}
</Box>
),
}}
/> />
</div> </div>
@@ -103,6 +149,19 @@ export const MediaListPage = observer(() => {
setRowId(null); setRowId(null);
}} }}
/> />
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteMedia(id)));
getMedia();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</> </>
); );
}); });

View File

@@ -15,13 +15,14 @@ export const MediaPreviewPage = observer(() => {
}, []); }, []);
return ( return (
<div className="w-full h-[80vh] flex flex-col justify-center items-center gap-4"> <div className="w-full flex flex-col justify-center items-center gap-4">
<div className="w-full h-full flex justify-center items-center"> <div className="w-full flex flex-col justify-center items-center gap-4">
<MediaViewer className="w-full h-full" media={oneMedia!} /> <div className="flex justify-center items-center max-w-[60%]">
<MediaViewer media={oneMedia!} />
</div> </div>
{oneMedia && ( {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 className="text-white text-center">
Чтобы скачать файл, нажмите на кнопку ниже Чтобы скачать файл, нажмите на кнопку ниже
</p> </p>
@@ -40,5 +41,6 @@ export const MediaPreviewPage = observer(() => {
</div> </div>
)} )}
</div> </div>
</div>
); );
}); });

View File

@@ -0,0 +1,691 @@
import { useState, useEffect } from "react";
import {
Stack,
Typography,
FormControl,
Accordion,
AccordionSummary,
AccordionDetails,
useTheme,
TextField,
Autocomplete,
TableCell,
TableContainer,
Table,
TableHead,
TableRow,
Paper,
TableBody,
IconButton,
Checkbox,
FormControlLabel,
Tabs,
Tab,
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
} from "@hello-pangea/dnd";
import {
AnimatedCircleButton,
authInstance,
languageStore,
routeStore,
selectedCityStore,
stationsStore,
} from "@shared";
import { EditStationModal } from "../../widgets/modals/EditStationModal";
function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] {
const index = pos - 1;
const result = [...arr];
if (index >= result.length) {
result.push(value);
} else {
result.splice(index, 0, value);
}
return result;
}
const reorder = <T,>(list: T[], startIndex: number, endIndex: number): T[] => {
const result = Array.from(list);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
};
type Field<T> = {
label: string;
data: keyof T;
render?: (value: any) => React.ReactNode;
};
type LinkedItemsProps<T> = {
parentId: string | number;
fields: Field<T>[];
setItemsParent?: (items: T[]) => void;
type: "show" | "edit";
dragAllowed?: boolean;
onUpdate?: () => void;
dontRecurse?: boolean;
disableCreation?: boolean;
updatedLinkedItems?: T[];
refresh?: number;
routeDirection?: boolean;
};
export const LinkedItems = <
T extends { id: number; name: string; [key: string]: any }
>(
props: LinkedItemsProps<T>
) => {
const theme = useTheme();
return (
<>
<Accordion sx={{ width: "100%" }}>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
background: theme.palette.background.paper,
borderBottom: `1px solid ${theme.palette.divider}`,
width: "100%",
}}
>
<Typography variant="subtitle1" fontWeight="bold">
Привязанные станции
</Typography>
</AccordionSummary>
<AccordionDetails
sx={{ background: theme.palette.background.paper, width: "100%" }}
>
<Stack gap={2} width="100%">
<LinkedItemsContents {...props} />
</Stack>
</AccordionDetails>
</Accordion>
</>
);
};
const LinkedItemsContentsInner = <
T extends { id: number; name: string; [key: string]: any }
>({
parentId,
setItemsParent,
fields,
dragAllowed = false,
type,
onUpdate,
disableCreation = false,
updatedLinkedItems,
refresh,
routeDirection,
}: LinkedItemsProps<T>) => {
const { language } = languageStore;
const [position, setPosition] = useState<number>(1);
const [allItems, setAllItems] = useState<T[]>([]);
const [linkedItems, setLinkedItems] = useState<T[]>([]);
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [activeTab, setActiveTab] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [isLinkingSingle, setIsLinkingSingle] = useState(false);
const [isLinkingBulk, setIsLinkingBulk] = useState(false);
const [detachingIds, setDetachingIds] = useState<Set<number>>(new Set());
useEffect(() => {}, [error]);
const parentResource = "route";
const childResource = "station";
const availableItems = allItems
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
.filter((item) => {
if (routeDirection === undefined) return true;
return item.direction === routeDirection;
})
.filter((item) => {
const selectedCityId = selectedCityStore.selectedCityId;
if (selectedCityId && "city_id" in item) {
return item.city_id === selectedCityId;
}
return true;
})
.sort((a, b) => a.name.localeCompare(b.name));
const filteredAvailableItems = availableItems.filter((item) => {
if (!searchQuery.trim()) return true;
return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());
});
useEffect(() => {
if (updatedLinkedItems) {
setLinkedItems(updatedLinkedItems);
}
}, [updatedLinkedItems]);
useEffect(() => {
setItemsParent?.(linkedItems);
}, [linkedItems, setItemsParent]);
useEffect(() => {
setPosition(linkedItems.length + 1);
}, [linkedItems.length]);
const getStationTransfers = (stationId: number, fallbackTransfers?: any) => {
const { stationLists } = stationsStore;
for (const lang of ["ru", "en", "zh"] as const) {
const station = stationLists[lang].data.find(
(s: any) => s.id === stationId
);
if (station?.transfers) {
return station.transfers;
}
}
return fallbackTransfers;
};
const onDragEnd = (result: DropResult) => {
if (!result.destination) return;
const reorderedItems = reorder(
linkedItems,
result.source.index,
result.destination.index
);
setLinkedItems(reorderedItems);
authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, {
stations: reorderedItems.map((item) => {
const stationData: any = { id: item.id };
const transfers = getStationTransfers(item.id, item.transfers);
if (transfers) {
stationData.transfers = transfers;
}
return stationData;
}),
})
.catch((error) => {
console.error("Error updating station order:", error);
setError("Failed to update station order");
});
};
useEffect(() => {
if (parentId) {
setIsLoading(true);
setError(null);
authInstance
.get(`/${parentResource}/${parentId}/${childResource}`)
.then((response) => {
setLinkedItems(response?.data || []);
})
.catch((error) => {
console.error("Error fetching linked items:", error);
setError("Failed to load linked stations");
setLinkedItems([]);
})
.finally(() => {
setIsLoading(false);
});
}
}, [parentId, language, refresh]);
useEffect(() => {
if (type === "edit") {
setError(null);
authInstance
.get(`/${childResource}`)
.then((response) => {
setAllItems(response?.data || []);
})
.catch((error) => {
console.error("Error fetching all items:", error);
setError("Failed to load available stations");
setAllItems([]);
});
}
}, [type]);
const linkItem = () => {
if (selectedItemId !== null) {
setError(null);
const selectedItem = allItems.find((item) => item.id === selectedItemId);
const requestData = {
stations: insertAtPosition(
linkedItems.map((item) => {
const stationData: any = { id: item.id };
const transfers = getStationTransfers(item.id, item.transfers);
if (transfers) {
stationData.transfers = transfers;
}
return stationData;
}),
position,
(() => {
const newStationData: any = { id: selectedItemId };
const transfers = getStationTransfers(
selectedItemId,
selectedItem?.transfers
);
if (transfers) {
newStationData.transfers = transfers;
}
return newStationData;
})()
),
};
setIsLinkingSingle(true);
authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
.then(() => {
const newItem = allItems.find((item) => item.id === selectedItemId);
if (newItem) {
const updatedList = insertAtPosition(
[...linkedItems],
position,
newItem
);
setLinkedItems(updatedList);
}
setSelectedItemId(null);
onUpdate?.();
})
.catch((error) => {
console.error("Error linking item:", error);
setError("Failed to link station");
})
.finally(() => {
setIsLinkingSingle(false);
});
}
};
const deleteItem = (itemId: number) => {
setError(null);
setDetachingIds((prev) => {
const next = new Set(prev);
next.add(itemId);
return next;
});
authInstance
.delete(`/${parentResource}/${parentId}/${childResource}`, {
data: { [`${childResource}_id`]: itemId },
})
.then(() => {
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
onUpdate?.();
})
.catch((error) => {
console.error("Error deleting item:", error);
setError("Failed to delete station");
})
.finally(() => {
setDetachingIds((prev) => {
const next = new Set(prev);
next.delete(itemId);
return next;
});
});
};
const handleStationClick = (item: T) => {
routeStore.setSelectedStationId(item.id);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
};
const handleCheckboxChange = (itemId: number) => {
const newSelected = new Set(selectedItems);
if (newSelected.has(itemId)) {
newSelected.delete(itemId);
} else {
newSelected.add(itemId);
}
setSelectedItems(newSelected);
};
const handleBulkLink = () => {
if (selectedItems.size === 0) return;
setError(null);
setIsLinkingBulk(true);
const selectedStations = Array.from(selectedItems).map((id) => {
const item = allItems.find((item) => item.id === id);
const stationData: any = { id };
const transfers = getStationTransfers(id, item?.transfers);
if (transfers) {
stationData.transfers = transfers;
}
return stationData;
});
const requestData = {
stations: [
...linkedItems.map((item) => {
const stationData: any = { id: item.id };
const transfers = getStationTransfers(item.id, item.transfers);
if (transfers) {
stationData.transfers = transfers;
}
return stationData;
}),
...selectedStations,
],
};
authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
.then(() => {
const newItems = allItems.filter((item) => selectedItems.has(item.id));
setLinkedItems([...linkedItems, ...newItems]);
setSelectedItems(new Set());
onUpdate?.();
})
.catch((error) => {
console.error("Error linking items:", error);
setError("Failed to link stations");
})
.finally(() => {
setIsLinkingBulk(false);
});
};
return (
<>
{linkedItems?.length > 0 && (
<DragDropContext onDragEnd={onDragEnd}>
<TableContainer component={Paper} sx={{ width: "100%" }}>
<Table sx={{ width: "100%" }}>
<TableHead>
<TableRow>
{type === "edit" && dragAllowed && (
<TableCell width="40px"></TableCell>
)}
<TableCell key="id" width="60px">
</TableCell>
{fields.map((field) => (
<TableCell key={String(field.data)}>
{field.label}
</TableCell>
))}
{type === "edit" && (
<TableCell width="120px">Действие</TableCell>
)}
</TableRow>
</TableHead>
<Droppable
droppableId="droppable-stations"
isDropDisabled={type !== "edit" || !dragAllowed}
>
{(provided) => (
<TableBody
ref={provided.innerRef}
{...provided.droppableProps}
>
{linkedItems.map((item, index) => (
<Draggable
key={item.id}
draggableId={"station-" + String(item.id)}
index={index}
isDragDisabled={type !== "edit" || !dragAllowed}
>
{(provided) => (
<TableRow
sx={{ cursor: "pointer" }}
ref={provided.innerRef}
{...provided.draggableProps}
hover
onClick={() => handleStationClick(item)}
>
{type === "edit" && dragAllowed && (
<TableCell {...provided.dragHandleProps}>
<IconButton size="small">
<DragIndicatorIcon />
</IconButton>
</TableCell>
)}
<TableCell>{index + 1}</TableCell>
{fields.map((field, idx) => (
<TableCell key={String(field.data) + String(idx)}>
{field.render
? field.render(item[field.data])
: item[field.data]}
</TableCell>
))}
{type === "edit" && (
<TableCell>
<AnimatedCircleButton
variant="outlined"
color="error"
size="small"
onClick={(e) => {
e.stopPropagation();
deleteItem(item.id);
}}
disabled={detachingIds.has(item.id)}
loading={detachingIds.has(item.id)}
>
Отвязать
</AnimatedCircleButton>
</TableCell>
)}
</TableRow>
)}
</Draggable>
))}
{provided.placeholder}
</TableBody>
)}
</Droppable>
</Table>
</TableContainer>
</DragDropContext>
)}
{linkedItems.length === 0 && !isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
Станции не найдены
</Typography>
)}
{type === "edit" && !disableCreation && (
<Stack gap={2} mt={2}>
<Typography variant="subtitle1">Добавить остановки</Typography>
{routeDirection !== undefined && (
<Typography variant="body2" color="textSecondary">
Показываются только остановки для{" "}
{routeDirection ? "прямого" : "обратного"} направления
</Typography>
)}
<Tabs
value={activeTab}
onChange={(_, newValue) => setActiveTab(newValue)}
>
<Tab label="По одной" />
<Tab label="Массово" />
</Tabs>
<Box sx={{ mt: 2 }}>
{activeTab === 0 && (
<Stack gap={2}>
<Autocomplete
fullWidth
value={
availableItems?.find(
(item) => item.id === selectedItemId
) || null
}
onChange={(_, newValue) =>
setSelectedItemId(newValue?.id || null)
}
options={availableItems}
getOptionLabel={(item) => String(item.name)}
renderInput={(params) => (
<TextField
{...params}
label="Выберите остановку"
fullWidth
/>
)}
isOptionEqualToValue={(option, value) =>
option.id === value?.id
}
filterOptions={(options, { inputValue }) => {
const searchWords = inputValue
.toLowerCase()
.split(" ")
.filter(Boolean);
return options.filter((option) => {
const optionWords = String(option.name)
.toLowerCase()
.split(" ");
return searchWords.every((searchWord) =>
optionWords.some((word) => word.startsWith(searchWord))
);
});
}}
renderOption={(props, option) => (
<li {...props} key={option.id}>
<div className="flex justify-between items-center w-full">
<p>{String(option.name)}</p>
<p className="text-xs text-gray-500 max-w-[300px] mr-4 truncate">
{String(option.description)}
</p>
</div>
</li>
)}
/>
<FormControl fullWidth>
<TextField
type="number"
label="Позиция добавляемой остановки"
value={position}
onChange={(e) => {
const newValue = Math.max(1, Number(e.target.value));
setPosition(
newValue > linkedItems.length + 1
? linkedItems.length + 1
: newValue
);
}}
InputProps={{
inputProps: { min: 1, max: linkedItems.length + 1 },
}}
fullWidth
/>
</FormControl>
<AnimatedCircleButton
variant="contained"
onClick={linkItem}
disabled={!selectedItemId || isLinkingSingle}
loading={isLinkingSingle}
sx={{ alignSelf: "flex-start" }}
>
Добавить
</AnimatedCircleButton>
</Stack>
)}
{activeTab === 1 && (
<Stack gap={2}>
{/* Поле поиска */}
<TextField
fullWidth
label="Поиск остановок"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Введите название остановки..."
size="small"
sx={{ mb: 1 }}
/>
{/* Список доступных остановок с чекбоксами */}
<Paper sx={{ maxHeight: 300, overflow: "auto", p: 2 }}>
<Stack gap={1}>
{filteredAvailableItems.map((item) => (
<FormControlLabel
key={item.id}
control={
<Checkbox
checked={selectedItems.has(item.id)}
onChange={() => handleCheckboxChange(item.id)}
size="small"
/>
}
label={
<div className="flex justify-between items-center w-full gap-10">
<p>{String(item.name)}</p>
<p className="text-xs text-gray-500 max-w-[300px] truncate text-ellipsis">
{String(item.description)}
</p>
</div>
}
sx={{
margin: 0,
"& .MuiFormControlLabel-label": {
fontSize: "0.875rem",
},
}}
/>
))}
{filteredAvailableItems.length === 0 && (
<Typography
color="textSecondary"
textAlign="center"
py={1}
>
{searchQuery.trim()
? "Остановки не найдены"
: "Нет доступных остановок"}
</Typography>
)}
</Stack>
</Paper>
<AnimatedCircleButton
variant="contained"
onClick={handleBulkLink}
disabled={selectedItems.size === 0 || isLinkingBulk}
loading={isLinkingBulk}
sx={{ alignSelf: "flex-start" }}
>
Добавить выбранные ({selectedItems.size})
</AnimatedCircleButton>
</Stack>
)}
</Box>
</Stack>
)}
<EditStationModal open={isModalOpen} onClose={handleCloseModal} />
</>
);
};
export const LinkedItemsContents = observer(
LinkedItemsContentsInner
) as typeof LinkedItemsContentsInner;

View File

@@ -8,16 +8,27 @@ import {
InputLabel, InputLabel,
Typography, Typography,
Box, Box,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material"; } from "@mui/material";
import { MediaViewer, VideoPreviewCard } from "@widgets";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react"; import { ArrowLeft, Loader2, Save, Plus, X } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState, useMemo } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { carrierStore } from "../../../shared/store/CarrierStore"; import { carrierStore } from "../../../shared/store/CarrierStore";
import { articlesStore } from "../../../shared/store/ArticlesStore"; import { articlesStore } from "../../../shared/store/ArticlesStore";
import { Route, routeStore } from "../../../shared/store/RouteStore"; import { Route, routeStore } from "../../../shared/store/RouteStore";
import {
languageStore,
ArticleSelectOrCreateDialog,
SelectMediaDialog,
selectedCityStore,
UploadMediaDialog,
} from "@shared";
export const RouteCreatePage = observer(() => { export const RouteCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -27,59 +38,219 @@ export const RouteCreatePage = observer(() => {
const [govRouteNumber, setGovRouteNumber] = useState(""); const [govRouteNumber, setGovRouteNumber] = useState("");
const [governorAppeal, setGovernorAppeal] = useState<string>(""); const [governorAppeal, setGovernorAppeal] = useState<string>("");
const [direction, setDirection] = useState("backward"); const [direction, setDirection] = useState("backward");
const [scaleMin, setScaleMin] = useState(""); const [scaleMin, setScaleMin] = useState("10");
const [scaleMax, setScaleMax] = useState(""); const [scaleMax, setScaleMax] = useState("100");
const [routeName, setRouteName] = useState("");
const [turn, setTurn] = useState(""); const [turn, setTurn] = useState("");
const [centerLat, setCenterLat] = useState(""); const [centerLat, setCenterLat] = useState("");
const [centerLng, setCenterLng] = useState(""); const [centerLng, setCenterLng] = useState("");
const [videoPreview, setVideoPreview] = useState<string>("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
useState(false);
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
const { language } = languageStore;
useEffect(() => { useEffect(() => {
carrierStore.getCarriers(); carrierStore.getCarriers(language);
articlesStore.getArticleList(); articlesStore.getArticleList();
}, []); }, [language]);
const filteredCarriers = useMemo(() => {
const carriers =
carrierStore.carriers[language as keyof typeof carrierStore.carriers]
.data || [];
if (!selectedCityStore.selectedCityId) {
return carriers;
}
return carriers.filter(
(carrier: any) => carrier.city_id === selectedCityStore.selectedCityId
);
}, [carrierStore.carriers, language, selectedCityStore.selectedCityId]);
const validateCoordinates = (value: string) => {
try {
const lines = value.trim().split("\n");
const coordinates = lines.map((line) => {
const [lat, lon] = line
.trim()
.split(/[\s,]+/)
.map(Number);
return [lat, lon];
});
if (coordinates.length === 0) {
return "Введите хотя бы одну пару координат";
}
if (
!coordinates.every(
(point) => Array.isArray(point) && point.length === 2
)
) {
return "Каждая строка должна содержать две координаты";
}
if (
!coordinates.every((point) =>
point.every((coord) => !isNaN(coord) && typeof coord === "number")
)
) {
return "Координаты должны быть числами";
}
return true;
} catch {
return "Неверный формат координат";
}
};
const handleArticleSelect = (articleId: number) => {
setGovernorAppeal(articleId.toString());
setIsSelectArticleDialogOpen(false);
articlesStore.getArticleList();
};
const handleVideoSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setVideoPreview(media.id);
setIsSelectVideoDialogOpen(false);
};
const handleVideoFileSelect = (file?: File) => {
if (file) {
setFileToUpload(file);
setIsUploadVideoDialogOpen(true);
} else {
setIsSelectVideoDialogOpen(true);
}
};
const handleVideoUpload = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setVideoPreview(media.id);
setIsUploadVideoDialogOpen(false);
setFileToUpload(null);
};
const handleVideoPreviewClick = () => {
setIsVideoPreviewOpen(true);
};
const handleCreateRoute = async () => { const handleCreateRoute = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
// Преобразуем значения в нужные типы
if (!routeName.trim()) {
toast.error("Заполните название маршрута");
setIsLoading(false);
return;
}
if (!carrier) {
toast.error("Выберите перевозчика");
setIsLoading(false);
return;
}
if (!routeNumber.trim()) {
toast.error("Заполните номер маршрута");
setIsLoading(false);
return;
}
if (!govRouteNumber.trim()) {
toast.error("Заполните номер маршрута в Говорящем Городе");
setIsLoading(false);
return;
}
const validationResult = validateCoordinates(routeCoords);
if (validationResult !== true) {
toast.error(validationResult);
setIsLoading(false);
return;
}
const scale_min = scaleMin ? Number(scaleMin) : null;
const scale_max = scaleMax ? Number(scaleMax) : null;
if (
scale_min === 0 ||
scale_max === 0 ||
scale_min === null ||
scale_max === null
) {
toast.error("Масштабы не могут быть равны 0");
setIsLoading(false);
return;
}
if (
scale_min !== null &&
scale_max !== null &&
scale_max !== undefined &&
scale_min > scale_max
) {
toast.error("Максимальный масштаб не может быть меньше минимального");
setIsLoading(false);
return;
}
const carrier_id = Number(carrier); const carrier_id = Number(carrier);
const governor_appeal = Number(governorAppeal); const governor_appeal = governorAppeal
const scale_min = scaleMin ? Number(scaleMin) : undefined; ? Number(governorAppeal)
const scale_max = scaleMax ? Number(scaleMax) : undefined; : undefined;
const rotate = turn ? Number(turn) : undefined; const rotate = turn ? Number(turn) : undefined;
const center_latitude = centerLat ? Number(centerLat) : undefined; const center_latitude = centerLat ? Number(centerLat) : undefined;
const center_longitude = centerLng ? Number(centerLng) : undefined; const center_longitude = centerLng ? Number(centerLng) : undefined;
const route_direction = direction === "forward"; const route_direction = direction === "forward";
// Координаты маршрута как массив массивов чисел
const path = routeCoords
.split("\n")
.map((line) =>
line
.split(" ")
.map((coord) => Number(coord.trim()))
.filter((n) => !isNaN(n))
)
.filter((arr) => arr.length === 2);
// Собираем объект маршрута const path = routeCoords
.trim()
.split("\n")
.map((line) => {
const [lat, lon] = line
.trim()
.split(/[\s,]+/)
.map(Number);
return [lat, lon];
});
const newRoute: Partial<Route> = { const newRoute: Partial<Route> = {
carrier: carrier:
carrierStore.carriers.data.find((c: any) => c.id === carrier_id) carrierStore.carriers[
?.full_name || "", language as keyof typeof carrierStore.carriers
].data?.find((c: any) => c.id === carrier_id)?.full_name || "",
carrier_id, carrier_id,
route_number: routeNumber, route_number: routeNumber,
route_sys_number: govRouteNumber, route_sys_number: govRouteNumber,
governor_appeal, route_name: routeName,
route_direction, route_direction,
scale_min, scale_min: scale_min !== null ? scale_min : 0,
scale_max, scale_max: scale_max !== null ? scale_max : 0,
rotate, rotate,
center_latitude, center_latitude,
center_longitude, center_longitude,
path, path,
video_preview:
videoPreview && videoPreview !== "" ? videoPreview : undefined,
}; };
if (governor_appeal !== undefined) {
newRoute.governor_appeal = governor_appeal;
}
await routeStore.createRoute(newRoute); await routeStore.createRoute(newRoute);
toast.success("Маршрут успешно создан"); toast.success("Маршрут успешно создан");
navigate(-1); navigate(-1);
@@ -91,37 +262,45 @@ export const RouteCreatePage = observer(() => {
} }
}; };
const selectedArticle = articlesStore.articleList.ru.data.find(
(article) => article.id === Number(governorAppeal)
);
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center"> <div className="flex items-center gap-4">
<button <button
className="flex items-center gap-2" className="flex items-center gap-2"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
> >
<ArrowLeft size={20} /> <ArrowLeft size={20} />
Маршруты / Создать Назад
</button> </button>
</div> </div>
<Typography variant="h5" fontWeight={700}>
Создать маршрут <div className="flex flex-col gap-10 w-full items-end">
</Typography>
<Box className="flex flex-col gap-6 w-full"> <Box className="flex flex-col gap-6 w-full">
<TextField
className="w-full"
label="Название маршрута"
required
value={routeName}
onChange={(e) => setRouteName(e.target.value)}
/>
<FormControl fullWidth required> <FormControl fullWidth required>
<InputLabel>Выберите перевозчика</InputLabel> <InputLabel>Выберите перевозчика</InputLabel>
<Select <Select
value={carrier} value={carrier}
label="Выберите перевозчика" label="Выберите перевозчика"
onChange={(e) => setCarrier(e.target.value as string)} onChange={(e) => setCarrier(e.target.value as string)}
disabled={carrierStore.carriers.data.length === 0} disabled={filteredCarriers.length === 0}
> >
<MenuItem value="">Не выбрано</MenuItem> <MenuItem value="">Не выбрано</MenuItem>
{carrierStore.carriers.data.map( {filteredCarriers.map((carrier: any) => (
(c: (typeof carrierStore.carriers.data)[number]) => ( <MenuItem key={carrier.id} value={carrier.id}>
<MenuItem key={c.id} value={c.id}> {carrier.full_name}
{c.full_name}
</MenuItem> </MenuItem>
) ))}
)}
</Select> </Select>
</FormControl> </FormControl>
<TextField <TextField
@@ -135,10 +314,50 @@ export const RouteCreatePage = observer(() => {
className="w-full" className="w-full"
label="Координаты маршрута" label="Координаты маршрута"
multiline multiline
minRows={3} minRows={2}
maxRows={10}
value={routeCoords} 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 <TextField
className="w-full" className="w-full"
label="Номер маршрута в Говорящем Городе" label="Номер маршрута в Говорящем Городе"
@@ -146,24 +365,55 @@ export const RouteCreatePage = observer(() => {
value={govRouteNumber} value={govRouteNumber}
onChange={(e) => setGovRouteNumber(e.target.value)} onChange={(e) => setGovRouteNumber(e.target.value)}
/> />
<FormControl fullWidth required>
<InputLabel>Обращение губернатора</InputLabel> <Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
<Select Обращение к пассажирам
value={governorAppeal} </Typography>
label="Обращение губернатора" <Box className="flex gap-2">
onChange={(e) => setGovernorAppeal(e.target.value as string)} <TextField
disabled={articlesStore.articleList.ru.data.length === 0} className="flex-1"
value={selectedArticle?.heading || "Статья не выбрана"}
placeholder="Выберите статью"
disabled
fullWidth
sx={{
"& .MuiInputBase-input": {
color: selectedArticle ? "inherit" : "#999",
},
}}
/>
{selectedArticle && (
<Button
variant="outlined"
color="error"
onClick={() => setGovernorAppeal("")}
startIcon={<X size={16} />}
sx={{ minWidth: "auto", px: 2 }}
> >
<MenuItem value="">Не выбрано</MenuItem> Сбросить
{articlesStore.articleList.ru.data.map( </Button>
(a: (typeof articlesStore.articleList.ru.data)[number]) => (
<MenuItem key={a.id} value={a.id}>
{a.heading}
</MenuItem>
)
)} )}
</Select> <Button
</FormControl> variant="outlined"
onClick={() => setIsSelectArticleDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
<VideoPreviewCard
title="Видеозаставка"
videoId={videoPreview}
onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => {
setVideoPreview("");
}}
onSelectVideoClick={handleVideoFileSelect}
className="w-full"
/>
<FormControl fullWidth required> <FormControl fullWidth required>
<InputLabel>Прямой/обратный маршрут</InputLabel> <InputLabel>Прямой/обратный маршрут</InputLabel>
<Select <Select
@@ -178,15 +428,53 @@ export const RouteCreatePage = observer(() => {
<TextField <TextField
className="w-full" className="w-full"
label="Масштаб (мин)" label="Масштаб (мин)"
type="number"
value={scaleMin} value={scaleMin}
onChange={(e) => setScaleMin(e.target.value)} onChange={(e) => {
let value = e.target.value;
if (Number(value) > 297) {
value = "297";
}
if (Number(value) < 10) {
value = "10";
}
setScaleMin(value);
if (value && scaleMax && Number(value) > Number(scaleMax)) {
setScaleMax(value);
}
}}
error={
scaleMin !== "" &&
scaleMax !== "" &&
Number(scaleMin) > Number(scaleMax)
}
required
helperText={
scaleMin !== "" &&
scaleMax !== "" &&
Number(scaleMin) > Number(scaleMax)
? "Минимальный масштаб не может быть больше максимального"
: ""
}
/> />
<TextField <TextField
className="w-full" className="w-full"
label="Масштаб (макс)" label="Масштаб (макс)"
type="number"
value={scaleMax} value={scaleMax}
onChange={(e) => setScaleMax(e.target.value)} required
onChange={(e) => {
if (Number(e.target.value) > 300) {
e.target.value = "300";
}
const value = e.target.value;
setScaleMax(value);
}}
/> />
<TextField <TextField
className="w-full" className="w-full"
label="Поворот" label="Поворот"
@@ -222,6 +510,56 @@ export const RouteCreatePage = observer(() => {
)} )}
</Button> </Button>
</div> </div>
</div>
<ArticleSelectOrCreateDialog
open={isSelectArticleDialogOpen}
onClose={() => setIsSelectArticleDialogOpen(false)}
onSelectArticle={handleArticleSelect}
/>
<SelectMediaDialog
open={isSelectVideoDialogOpen}
onClose={() => setIsSelectVideoDialogOpen(false)}
onSelectMedia={handleVideoSelect}
mediaType={2}
/>
{videoPreview && videoPreview !== "" && (
<Dialog
open={isVideoPreviewOpen}
onClose={() => setIsVideoPreviewOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>Предпросмотр видео</DialogTitle>
<DialogContent>
<Box className="flex justify-center items-center p-4">
<MediaViewer
media={{
id: videoPreview,
media_type: 2,
filename: "video_preview",
}}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsVideoPreviewOpen(false)}>
Закрыть
</Button>
</DialogActions>
</Dialog>
)}
<UploadMediaDialog
open={isUploadVideoDialogOpen}
onClose={() => {
setIsUploadVideoDialogOpen(false);
setFileToUpload(null);
}}
hardcodeType="video_preview"
contextObjectName={routeName || "Маршрут"}
contextType="sight"
initialFile={fileToUpload || undefined}
afterUpload={handleVideoUpload}
/>
</Paper> </Paper>
); );
}); });

View File

@@ -8,56 +8,278 @@ import {
InputLabel, InputLabel,
Typography, Typography,
Box, Box,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material"; } from "@mui/material";
import { MediaViewer, VideoPreviewCard } from "@widgets";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Copy, Save, Plus, X } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { carrierStore } from "../../../shared/store/CarrierStore"; import { carrierStore } from "../../../shared/store/CarrierStore";
import { articlesStore } from "../../../shared/store/ArticlesStore"; import { articlesStore } from "../../../shared/store/ArticlesStore";
import { routeStore } from "../../../shared/store/RouteStore"; import {
routeStore,
languageStore,
ArticleSelectOrCreateDialog,
SelectMediaDialog,
UploadMediaDialog,
LoadingSpinner,
} from "@shared";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { stationsStore } from "@shared";
import { LinkedItems } from "../LinekedStations";
export const RouteEditPage = observer(() => { export const RouteEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams(); const { id } = useParams();
const { editRouteData } = routeStore; const { editRouteData, copyRouteAction } = routeStore;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
useState(false);
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
const { language } = languageStore;
const [coordinates, setCoordinates] = useState<string>("");
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
if (!id) {
setIsLoadingData(false);
return;
}
setIsLoadingData(true);
try {
const response = await routeStore.getRoute(Number(id)); const response = await routeStore.getRoute(Number(id));
routeStore.setEditRouteData(response); routeStore.setEditRouteData(response);
carrierStore.getCarriers(); languageStore.setLanguage("ru");
articlesStore.getArticleList(); } finally {
setIsLoadingData(false);
}
}; };
fetchData(); fetchData();
}, [id]); }, [id]);
useEffect(() => {
const fetchData = async () => {
await carrierStore.getCarriers(language);
await stationsStore.getStations();
await articlesStore.getArticleList();
};
fetchData();
}, [id, language]);
useEffect(() => {
if (editRouteData.path && editRouteData.path.length > 0) {
const formattedPath = editRouteData.path
.map((coords) => coords.join(" "))
.join("\n");
setCoordinates(formattedPath);
}
}, [editRouteData.path]);
const handleSave = async () => { const handleSave = async () => {
// Валидация обязательных полей
if (!editRouteData.route_name?.trim()) {
toast.error("Заполните название маршрута");
return;
}
if (!editRouteData.carrier_id) {
toast.error("Выберите перевозчика");
return;
}
if (!editRouteData.route_number?.trim()) {
toast.error("Заполните номер маршрута");
return;
}
if (!editRouteData.route_sys_number?.trim()) {
toast.error("Заполните номер маршрута в Говорящем Городе");
return;
}
const validationResult = validateCoordinates(coordinates);
if (validationResult !== true) {
toast.error(validationResult);
return;
}
// Валидация масштабов
if (
editRouteData.scale_min !== null &&
editRouteData.scale_min !== undefined &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
editRouteData.scale_min > editRouteData.scale_max
) {
toast.error("Максимальный масштаб не может быть меньше минимального");
return;
}
if (
editRouteData.scale_min === 0 ||
editRouteData.scale_max === 0 ||
editRouteData.scale_min === null ||
editRouteData.scale_max === null
) {
toast.error("Масштабы не могут быть равны 0");
setIsLoading(false);
return;
}
setIsLoading(true); setIsLoading(true);
try {
await routeStore.editRoute(Number(id)); await routeStore.editRoute(Number(id));
toast.success("Маршрут успешно сохранен"); toast.success("Маршрут успешно сохранен");
} catch (error) {
console.error(error);
toast.error("Произошла ошибка при сохранении маршрута");
} finally {
setIsLoading(false); setIsLoading(false);
}
}; };
const validateCoordinates = (value: string) => {
try {
const lines = value.trim().split("\n");
const coordinates = lines.map((line) => {
const [lat, lon] = line
.trim()
.split(/[\s,]+/)
.map(Number);
return [lat, lon];
});
if (coordinates.length === 0) {
return "Введите хотя бы одну пару координат";
}
if (
!coordinates.every(
(point) => Array.isArray(point) && point.length === 2
)
) {
return "Каждая строка должна содержать две координаты";
}
if (
!coordinates.every((point) =>
point.every((coord) => !isNaN(coord) && typeof coord === "number")
)
) {
return "Координаты должны быть числами";
}
return true;
} catch {
return "Неверный формат координат";
}
};
const handleCopy = async () => {
await copyRouteAction(Number(id));
toast.success("Маршрут успешно скопирован");
};
const handleArticleSelect = (articleId: number) => {
routeStore.setEditRouteData({
governor_appeal: articleId,
});
setIsSelectArticleDialogOpen(false);
// Обновляем список статей после создания новой
articlesStore.getArticleList();
};
const handleVideoSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
routeStore.setEditRouteData({
video_preview: media.id,
});
setIsSelectVideoDialogOpen(false);
};
const handleVideoFileSelect = (file?: File) => {
if (file) {
setFileToUpload(file);
setIsUploadVideoDialogOpen(true);
} else {
setIsSelectVideoDialogOpen(true);
}
};
const handleVideoUpload = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
routeStore.setEditRouteData({
video_preview: media.id,
});
setIsUploadVideoDialogOpen(false);
setFileToUpload(null);
};
const handleVideoPreviewClick = () => {
if (editRouteData.video_preview && editRouteData.video_preview !== "") {
setIsVideoPreviewOpen(true);
}
};
// Получаем название выбранной статьи для отображения
const selectedArticle = articlesStore.articleList.ru.data.find(
(article) => article.id === editRouteData.governor_appeal
);
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных маршрута..." />
</Box>
);
}
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center"> <div className="flex items-center gap-4">
<button <button
className="flex items-center gap-2" className="flex items-center gap-2"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
> >
<ArrowLeft size={20} /> <ArrowLeft size={20} />
Маршруты / Редактировать Назад
</button> </button>
</div> </div>
<Typography variant="h5" fontWeight={700}>
Редактировать маршрут <div className="flex flex-col gap-10 w-full items-end">
</Typography>
<Box className="flex flex-col gap-6 w-full"> <Box className="flex flex-col gap-6 w-full">
<TextField
className="w-full"
label="Название маршрута"
required
value={editRouteData.route_name || ""}
onChange={(e) =>
routeStore.setEditRouteData({
route_name: e.target.value,
})
}
/>
<FormControl fullWidth required> <FormControl fullWidth required>
<InputLabel>Выберите перевозчика</InputLabel> <InputLabel>Выберите перевозчика</InputLabel>
<Select <Select
@@ -67,21 +289,26 @@ export const RouteEditPage = observer(() => {
routeStore.setEditRouteData({ routeStore.setEditRouteData({
carrier_id: Number(e.target.value), carrier_id: Number(e.target.value),
carrier: carrier:
carrierStore.carriers.data.find( carrierStore.carriers[
(c) => c.id === Number(e.target.value) language as keyof typeof carrierStore.carriers
)?.full_name || "", ].data?.find((c) => c.id === Number(e.target.value))
?.full_name || "",
}) })
} }
disabled={carrierStore.carriers.data.length === 0} disabled={
carrierStore.carriers[
language as keyof typeof carrierStore.carriers
].data?.length === 0
}
> >
<MenuItem value="">Не выбрано</MenuItem> <MenuItem value="">Не выбрано</MenuItem>
{carrierStore.carriers.data.map( {carrierStore.carriers[
(c: (typeof carrierStore.carriers.data)[number]) => ( language as keyof typeof carrierStore.carriers
<MenuItem key={c.id} value={c.id}> ].data?.map((carrier) => (
{c.full_name} <MenuItem key={carrier.id} value={carrier.id}>
{carrier.full_name}
</MenuItem> </MenuItem>
) ))}
)}
</Select> </Select>
</FormControl> </FormControl>
<TextField <TextField
@@ -99,15 +326,61 @@ export const RouteEditPage = observer(() => {
className="w-full" className="w-full"
label="Координаты маршрута" label="Координаты маршрута"
multiline multiline
minRows={3} minRows={2}
value={editRouteData.path.map((p) => p.join(" ")).join("\n") || ""} maxRows={10}
onChange={(e) => value={coordinates}
routeStore.setEditRouteData({ onChange={(e) => {
path: e.target.value const newValue = e.target.value;
.split("\n") setCoordinates(newValue);
.map((line) => line.split(" ").map(Number)),
}) 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 <TextField
className="w-full" className="w-full"
@@ -120,28 +393,7 @@ export const RouteEditPage = observer(() => {
}) })
} }
/> />
<FormControl fullWidth required>
<InputLabel>Обращение губернатора</InputLabel>
<Select
value={editRouteData.governor_appeal || ""}
label="Обращение губернатора"
onChange={(e) =>
routeStore.setEditRouteData({
governor_appeal: Number(e.target.value),
})
}
disabled={articlesStore.articleList.ru.data.length === 0}
>
<MenuItem value="">Не выбрано</MenuItem>
{articlesStore.articleList.ru.data.map(
(a: (typeof articlesStore.articleList.ru.data)[number]) => (
<MenuItem key={a.id} value={a.id}>
{a.heading}
</MenuItem>
)
)}
</Select>
</FormControl>
<FormControl fullWidth required> <FormControl fullWidth required>
<InputLabel>Прямой/обратный маршрут</InputLabel> <InputLabel>Прямой/обратный маршрут</InputLabel>
<Select <Select
@@ -160,55 +412,184 @@ export const RouteEditPage = observer(() => {
<TextField <TextField
className="w-full" className="w-full"
label="Масштаб (мин)" label="Масштаб (мин)"
value={editRouteData.scale_min || ""} type="number"
onChange={(e) => value={editRouteData.scale_min ?? ""}
routeStore.setEditRouteData({ onChange={(e) => {
scale_min: Number(e.target.value), let value = e.target.value === "" ? null : e.target.value;
})
if (value && Number(value) > 297) {
value = "297";
} }
if (value && Number(value) < 10) {
value = "10";
}
routeStore.setEditRouteData({
scale_min: value ? Number(value) : null,
});
// Если максимальный масштаб стал меньше минимального, обновляем его
if (
value !== null &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
value &&
Number(value) > (editRouteData.scale_max ?? 0)
) {
routeStore.setEditRouteData({
scale_max: value ? Number(value) : null,
});
}
}}
required
/> />
<TextField <TextField
className="w-full" className="w-full"
required
label="Масштаб (макс)" label="Масштаб (макс)"
value={editRouteData.scale_max || ""} type="number"
onChange={(e) => value={editRouteData.scale_max ?? ""}
onChange={(e) => {
let value = e.target.value;
if (Number(value) > 300) {
value = "300";
}
routeStore.setEditRouteData({ routeStore.setEditRouteData({
scale_max: Number(e.target.value), scale_max: value === "" ? null : parseFloat(value),
}) });
}}
error={
editRouteData.scale_min !== null &&
editRouteData.scale_min !== undefined &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
editRouteData.scale_max < editRouteData.scale_min
}
helperText={
editRouteData.scale_min !== null &&
editRouteData.scale_min !== undefined &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
editRouteData.scale_max < editRouteData.scale_min
? "Максимальный масштаб не может быть меньше минимального"
: ""
} }
/> />
<TextField <TextField
className="w-full" className="w-full"
label="Поворот" label="Поворот"
value={editRouteData.rotate || ""} value={editRouteData.rotate ?? ""}
onChange={(e) => onChange={(e) =>
routeStore.setEditRouteData({ routeStore.setEditRouteData({
rotate: Number(e.target.value), rotate:
e.target.value === "" ? null : parseFloat(e.target.value),
}) })
} }
/> />
<TextField <TextField
className="w-full" className="w-full"
label="Центр. широта" label="Центр. широта"
value={editRouteData.center_latitude || ""} value={editRouteData.center_latitude ?? ""}
type="text"
onChange={(e) => onChange={(e) =>
routeStore.setEditRouteData({ routeStore.setEditRouteData({
center_latitude: Number(e.target.value), center_latitude: e.target.value,
}) })
} }
/> />
<TextField <TextField
className="w-full" className="w-full"
label="Центр. долгота" label="Центр. долгота"
value={editRouteData.center_longitude || ""} value={editRouteData.center_longitude ?? ""}
type="text"
onChange={(e) => onChange={(e) =>
routeStore.setEditRouteData({ routeStore.setEditRouteData({
center_longitude: Number(e.target.value), center_longitude: e.target.value,
}) })
} }
/> />
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Обращение к пассажирам
</Typography>
<Box className="flex gap-2">
<TextField
className="flex-1"
value={selectedArticle?.heading || "Статья не выбрана"}
placeholder="Выберите статью"
disabled
fullWidth
sx={{
"& .MuiInputBase-input": {
color: selectedArticle ? "inherit" : "#999",
},
}}
/>
{selectedArticle && (
<Button
variant="outlined"
color="error"
onClick={() =>
routeStore.setEditRouteData({
governor_appeal: 0,
})
}
startIcon={<X size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Сбросить
</Button>
)}
<Button
variant="outlined"
onClick={() => setIsSelectArticleDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box> </Box>
<div className="flex w-full justify-end">
<VideoPreviewCard
title="Видеозаставка"
videoId={editRouteData.video_preview}
onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => {
routeStore.setEditRouteData({ video_preview: "" });
}}
onSelectVideoClick={handleVideoFileSelect}
className="w-full"
/>
</Box>
<LinkedItems
parentId={id || ""}
type="edit"
dragAllowed={true}
fields={[
{ label: "Название", data: "name" },
{ label: "Описание", data: "description" },
]}
onUpdate={() => {
routeStore.getRoute(Number(id));
}}
routeDirection={editRouteData.route_direction}
/>
<div className="flex w-full justify-between">
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Copy size={20} />}
onClick={handleCopy}
disabled={isLoading}
>
Скопировать
</Button>
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"
@@ -220,6 +601,54 @@ export const RouteEditPage = observer(() => {
Сохранить Сохранить
</Button> </Button>
</div> </div>
</div>
<ArticleSelectOrCreateDialog
open={isSelectArticleDialogOpen}
onClose={() => setIsSelectArticleDialogOpen(false)}
onSelectArticle={handleArticleSelect}
/>
<SelectMediaDialog
open={isSelectVideoDialogOpen}
onClose={() => setIsSelectVideoDialogOpen(false)}
onSelectMedia={handleVideoSelect}
mediaType={2}
/>
<Dialog
open={isVideoPreviewOpen}
onClose={() => setIsVideoPreviewOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>Предпросмотр видео</DialogTitle>
<DialogContent>
<Box className="flex justify-center items-center p-4">
{editRouteData.video_preview && (
<MediaViewer
media={{
id: editRouteData.video_preview,
media_type: 2,
filename: "video_preview",
}}
/>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsVideoPreviewOpen(false)}>Закрыть</Button>
</DialogActions>
</Dialog>
<UploadMediaDialog
open={isUploadVideoDialogOpen}
onClose={() => {
setIsUploadVideoDialogOpen(false);
setFileToUpload(null);
}}
hardcodeType="video_preview"
contextObjectName={editRouteData.route_name || "Маршрут"}
contextType="sight"
initialFile={fileToUpload || undefined}
afterUpload={handleVideoUpload}
/>
</Paper> </Paper>
); );
}); });

View File

@@ -1,32 +1,86 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, routeStore } from "@shared"; import { ruRU } from "@mui/x-data-grid/locales";
import { carrierStore, languageStore, routeStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Map, Pencil, Trash2 } from "lucide-react"; import { Map, Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal } from "@widgets"; import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const RouteListPage = observer(() => { export const RouteListPage = observer(() => {
const { routes, getRoutes, deleteRoute } = routeStore; const { routes, getRoutes, deleteRoute } = routeStore;
const { carriers, getCarriers } = carrierStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null); // Lifted state const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
getRoutes(); const fetchData = async () => {
setIsLoading(true);
await getCarriers("ru");
await getCarriers("en");
await getCarriers("zh");
await getRoutes();
setIsLoading(false);
};
fetchData();
}, [language]); }, [language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
field: "carrier", field: "carrier_id",
headerName: "Перевозчик", headerName: "Перевозчик",
width: 250, width: 250,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
carriers[language].data.find(
(carrier) => carrier.id == params.value
)?.short_name
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "route_name",
headerName: "Название маршрута",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
}, },
{ {
field: "route_number", field: "route_number",
headerName: "Номер маршрута", headerName: "Номер маршрута",
flex: 1, flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
}, },
{ {
field: "route_direction", field: "route_direction",
@@ -52,6 +106,7 @@ export const RouteListPage = observer(() => {
width: 250, width: 250,
align: "center", align: "center",
headerAlign: "center", headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
return ( return (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
@@ -61,9 +116,7 @@ export const RouteListPage = observer(() => {
<button onClick={() => navigate(`/route-preview/${params.row.id}`)}> <button onClick={() => navigate(`/route-preview/${params.row.id}`)}>
<Map size={20} className="text-purple-500" /> <Map size={20} className="text-purple-500" />
</button> </button>
{/* <button onClick={() => navigate(`/route/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button> */}
<button <button
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
@@ -80,23 +133,52 @@ export const RouteListPage = observer(() => {
const rows = routes.data.map((route) => ({ const rows = routes.data.map((route) => ({
id: route.id, id: route.id,
carrier: route.carrier, carrier_id: route.carrier_id,
route_number: route.route_number, route_number: route.route_number,
route_direction: route.route_direction ? "Прямой" : "Обратный", route_direction: route.route_direction ? "Прямой" : "Обратный",
route_name: route.route_name,
})); }));
return ( return (
<> <>
<LanguageSwitcher />
<div style={{ width: "100%" }}> <div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Маршруты</h1> <h1 className="text-2xl">Маршруты</h1>
<CreateButton label="Создать маршрут" path="/route/create" /> <CreateButton label="Создать маршрут" path="/route/create" />
</div> </div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooterPagination
hideFooter hideFooter
checkboxSelection
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет маршрутов"}
</Box>
),
}}
/> />
</div> </div>
@@ -114,6 +196,19 @@ export const RouteListPage = observer(() => {
setRowId(null); setRowId(null);
}} }}
/> />
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteRoute(id)));
getRoutes();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</> </>
); );
}); });

View File

@@ -1,9 +1,9 @@
export const UP_SCALE = 30000; export const UP_SCALE = 10000;
export const PATH_WIDTH = 15; export const PATH_WIDTH = 5;
export const STATION_RADIUS = 20; export const STATION_RADIUS = 8;
export const STATION_OUTLINE_WIDTH = 10; export const STATION_OUTLINE_WIDTH = 4;
export const SIGHT_SIZE = 60; export const SIGHT_SIZE = 40;
export const SCALE_FACTOR = 50; export const SCALE_FACTOR = 50;
export const BACKGROUND_COLOR = 0x111111; export const BACKGROUND_COLOR = 0x000;
export const PATH_COLOR = 0xff4d4d; export const PATH_COLOR = 0xff4d4d;

View File

@@ -37,7 +37,7 @@ export function InfiniteCanvas({
setScreenCenter, setScreenCenter,
screenCenter, screenCenter,
} = useTransform(); } = useTransform();
const { routeData, originalRouteData } = useMapData(); const { routeData, originalRouteData, setSelectedSight } = useMapData();
const applicationRef = useApplication(); const applicationRef = useApplication();
@@ -45,28 +45,28 @@ export function InfiniteCanvas({
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 }); const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
const [startRotation, setStartRotation] = useState(0); const [startRotation, setStartRotation] = useState(0);
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 }); const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
const [isPointerDown, setIsPointerDown] = useState(false);
// Флаг для предотвращения конфликта между пользовательским вводом и данными маршрута
const [isUserInteracting, setIsUserInteracting] = useState(false); const [isUserInteracting, setIsUserInteracting] = useState(false);
// Реф для отслеживания последнего значения originalRouteData?.rotate
const lastOriginalRotation = useRef<number | undefined>(undefined); const lastOriginalRotation = useRef<number | undefined>(undefined);
useEffect(() => { useEffect(() => {
const canvas = applicationRef?.app.canvas; if (!applicationRef?.app?.canvas) return;
if (!canvas) return;
const canvas = applicationRef.app.canvas;
const canvasRect = canvas.getBoundingClientRect(); const canvasRect = canvas.getBoundingClientRect();
const canvasLeft = canvasRect.left; const canvasLeft = canvasRect.left;
const canvasTop = canvasRect.top; const canvasTop = canvasRect.top;
const centerX = window.innerWidth / 2 - canvasLeft; const centerX = window.innerWidth / 2 - canvasLeft;
const centerY = window.innerHeight / 2 - canvasTop; const centerY = window.innerHeight / 2 - canvasTop;
setScreenCenter({ x: centerX, y: centerY }); setScreenCenter({ x: centerX, y: centerY });
}, [applicationRef?.app.canvas, setScreenCenter]); }, [applicationRef?.app, setScreenCenter]);
const handlePointerDown = (e: FederatedMouseEvent) => { const handlePointerDown = (e: FederatedMouseEvent) => {
setIsDragging(true); setIsPointerDown(true);
setIsUserInteracting(true); // Устанавливаем флаг взаимодействия пользователя setIsDragging(false);
setIsUserInteracting(true);
setStartPosition({ setStartPosition({
x: position.x, x: position.x,
y: position.y, y: position.y,
@@ -79,13 +79,9 @@ export function InfiniteCanvas({
e.stopPropagation(); e.stopPropagation();
}; };
// Устанавливаем rotation только при изменении originalRouteData и отсутствии взаимодействия пользователя
useEffect(() => { useEffect(() => {
const newRotation = originalRouteData?.rotate ?? 0; const newRotation = originalRouteData?.rotate ?? 0;
// Обновляем rotation только если:
// 1. Пользователь не взаимодействует с канвасом
// 2. Значение действительно изменилось
if (!isUserInteracting && lastOriginalRotation.current !== newRotation) { if (!isUserInteracting && lastOriginalRotation.current !== newRotation) {
setRotation((newRotation * Math.PI) / 180); setRotation((newRotation * Math.PI) / 180);
lastOriginalRotation.current = newRotation; lastOriginalRotation.current = newRotation;
@@ -93,7 +89,17 @@ export function InfiniteCanvas({
}, [originalRouteData?.rotate, isUserInteracting, setRotation]); }, [originalRouteData?.rotate, isUserInteracting, setRotation]);
const handlePointerMove = (e: FederatedMouseEvent) => { const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isDragging) return; if (!isPointerDown) return;
if (!isDragging) {
const dx = e.globalX - startMousePosition.x;
const dy = e.globalY - startMousePosition.y;
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
setIsDragging(true);
} else {
return;
}
}
if (e.shiftKey) { if (e.shiftKey) {
const center = screenCenter ?? { x: 0, y: 0 }; const center = screenCenter ?? { x: 0, y: 0 };
@@ -106,10 +112,8 @@ export function InfiniteCanvas({
e.globalX - center.x e.globalX - center.x
); );
// Calculate rotation difference in radians
const rotationDiff = currentAngle - startAngle; const rotationDiff = currentAngle - startAngle;
// Update rotation
setRotation(startRotation + rotationDiff); setRotation(startRotation + rotationDiff);
const cosDelta = Math.cos(rotationDiff); const cosDelta = Math.cos(rotationDiff);
@@ -136,9 +140,13 @@ export function InfiniteCanvas({
}; };
const handlePointerUp = (e: FederatedMouseEvent) => { const handlePointerUp = (e: FederatedMouseEvent) => {
if (!isDragging) {
setSelectedSight(undefined);
}
setIsPointerDown(false);
setIsDragging(false); setIsDragging(false);
// Сбрасываем флаг взаимодействия через небольшую задержку
// чтобы избежать немедленного срабатывания useEffect
setTimeout(() => { setTimeout(() => {
setIsUserInteracting(false); setIsUserInteracting(false);
}, 100); }, 100);
@@ -147,29 +155,25 @@ export function InfiniteCanvas({
const handleWheel = (e: FederatedWheelEvent) => { const handleWheel = (e: FederatedWheelEvent) => {
e.stopPropagation(); e.stopPropagation();
setIsUserInteracting(true); // Устанавливаем флаг при зуме setIsUserInteracting(true);
// Get mouse position relative to canvas
const mouseX = e.globalX - position.x; const mouseX = e.globalX - position.x;
const mouseY = e.globalY - position.y; const mouseY = e.globalY - position.y;
// Calculate new scale
const scaleMin = (routeData?.scale_min ?? 10) / SCALE_FACTOR; const scaleMin = (routeData?.scale_min ?? 10) / SCALE_FACTOR;
const scaleMax = (routeData?.scale_max ?? 20) / SCALE_FACTOR; const scaleMax = (routeData?.scale_max ?? 20) / SCALE_FACTOR;
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; // Zoom out/in const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor)); const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor));
const actualZoomFactor = newScale / scale; const actualZoomFactor = newScale / scale;
if (scale === newScale) { if (scale === newScale) {
// Сбрасываем флаг, если зум не изменился
setTimeout(() => { setTimeout(() => {
setIsUserInteracting(false); setIsUserInteracting(false);
}, 100); }, 100);
return; return;
} }
// Update position to zoom towards mouse cursor
setPosition({ setPosition({
x: position.x + mouseX * (1 - actualZoomFactor), x: position.x + mouseX * (1 - actualZoomFactor),
y: position.y + mouseY * (1 - actualZoomFactor), y: position.y + mouseY * (1 - actualZoomFactor),
@@ -177,7 +181,6 @@ export function InfiniteCanvas({
setScale(newScale); setScale(newScale);
// Сбрасываем флаг взаимодействия через задержку
setTimeout(() => { setTimeout(() => {
setIsUserInteracting(false); setIsUserInteracting(false);
}, 100); }, 100);
@@ -185,7 +188,6 @@ export function InfiniteCanvas({
useEffect(() => { useEffect(() => {
applicationRef?.app.render(); applicationRef?.app.render();
console.log(position, scale, rotation);
}, [position, scale, rotation]); }, [position, scale, rotation]);
return ( return (

View File

@@ -1,10 +1,37 @@
import { Stack, Typography, Button } from "@mui/material"; import { Box, Stack, Typography, Button } from "@mui/material";
import { useNavigate, useNavigationType } from "react-router"; import { useNavigate, useNavigationType } from "react-router";
import { MediaViewer } from "@widgets";
import { useMapData } from "./MapDataContext";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { authInstance } from "@shared";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import LanguageSelector from "./web-gl/LanguageSelector";
export function LeftSidebar() { type LeftSidebarProps = {
open: boolean;
onToggle: () => void;
};
export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const navigationType = useNavigationType(); // PUSH, POP, REPLACE const navigationType = useNavigationType(); // PUSH, POP, REPLACE
const { routeData } = useMapData();
const [carrierThumbnail, setCarrierThumbnail] = useState<string | null>(null);
const [carrierLogo, setCarrierLogo] = useState<string | null>(null);
useEffect(() => {
async function fetchCarrierThumbnail() {
if (routeData?.carrier_id) {
const { city_id, logo } = (
await authInstance.get(`/carrier/${routeData.carrier_id}`)
).data;
const { arms } = (await authInstance.get(`/city/${city_id}`)).data;
setCarrierThumbnail(arms);
setCarrierLogo(logo);
}
}
fetchCarrierThumbnail();
}, [routeData?.carrier_id]);
const handleBack = () => { const handleBack = () => {
if (navigationType === "PUSH") { if (navigationType === "PUSH") {
@@ -15,75 +42,131 @@ export function LeftSidebar() {
}; };
return ( return (
<Stack direction="column" width="300px" p={2} bgcolor="primary.main"> <Box
<button sx={{
onClick={handleBack} position: "relative",
type="button" height: "100%",
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: 10,
color: "#fff", color: "#fff",
backgroundColor: "#222", transition: "padding 0.3s ease",
borderRadius: 10, p: open ? 2 : 0,
width: "100%", display: "flex",
border: "none", flexDirection: "column",
cursor: "pointer", alignItems: "stretch",
justifyContent: "flex-start",
}} }}
> >
<p>Назад</p>
</button>
<Stack <Stack
direction="column" direction="column"
alignItems="center" height="100%"
justifyContent="center" width="100%"
my={10} spacing={4}
alignItems="stretch"
justifyContent="space-between"
sx={{
opacity: open ? 1 : 0,
transition: "opacity 0.25s ease",
pointerEvents: open ? "auto" : "none",
display: open ? "flex" : "none",
}}
> >
<img src={"/Emblem.svg"} alt="logo" width={100} height={100} /> <div>
<Typography sx={{ mb: 2, color: "#fff" }} textAlign="center"> <Button
При поддержке Правительства Санкт-Петербурга onClick={handleBack}
</Typography> variant="contained"
</Stack> color="primary"
sx={{
<Stack backgroundColor: "#222",
direction="column" color: "#fff",
alignItems="center" borderRadius: 1.5,
justifyContent="center" px: 2,
my={10} py: 1,
spacing={2} marginBottom: 10,
"&:hover": {
backgroundColor: "#2d2d2d",
},
}}
fullWidth
startIcon={<ArrowBackIcon />}
> >
<Button variant="outlined" color="warning" fullWidth> Назад
Достопримечательности
</Button> </Button>
<Button variant="outlined" color="warning" fullWidth>
Остановки
</Button>
</Stack>
<Stack <Stack
direction="column" direction="column"
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
my={10} spacing={3}
> >
<img <div
src={"/GET.png"} style={{
alt="logo" maxWidth: 150,
width="80%" display: "flex",
style={{ margin: "0 auto" }} flexDirection: "column",
alignItems: "center",
gap: 10,
}}
>
{carrierThumbnail && (
<MediaViewer
media={{
id: carrierThumbnail,
media_type: 1, // Тип "Фото" для логотипа
filename: "route_thumbnail",
}}
fullWidth
fullHeight
/> />
)}
<Typography sx={{ color: "#fff" }} textAlign="center">
При поддержке Правительства
</Typography>
</div>
</Stack>
<div className="flex flex-col items-center justify-center gap-2 mt-10">
<button className="bg-[#fcd500] text-black px-4 py-2 rounded-md w-full font-medium my-10">
Обращение губернатора
</button>
<button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
Достопримечательности
</button>
<button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
Остановки
</button>
</div>
</div>
<Stack
direction="column"
alignItems="center"
maxHeight={150}
justifyContent="center"
flexGrow={1}
>
{carrierLogo && (
<MediaViewer
media={{
id: carrierLogo,
media_type: 1, // Тип "Фото" для логотипа
filename: "route_thumbnail_logo",
}}
fullHeight
/>
)}
</Stack> </Stack>
<Typography <Typography
variant="h6" variant="h6"
textAlign="center" textAlign="center"
mt="auto" sx={{ color: "#fff", marginTop: "auto" }}
sx={{ color: "#fff" }}
> >
#ВсемПоПути #ВсемПоПути
</Typography> </Typography>
</Stack> </Stack>
<div className="absolute bottom-[20px] -right-[520px] z-10">
<LanguageSelector onBack={onToggle} isSidebarOpen={open} />
</div>
</Box>
); );
} });

View File

@@ -29,15 +29,20 @@ const MapDataContext = createContext<{
isRouteLoading: boolean; isRouteLoading: boolean;
isStationLoading: boolean; isStationLoading: boolean;
isSightLoading: boolean; isSightLoading: boolean;
selectedSight?: SightData;
setSelectedSight: (sight?: SightData) => void;
setScaleRange: (min: number, max: number) => void; setScaleRange: (min: number, max: number) => void;
setMapRotation: (rotation: number) => void; setMapRotation: (rotation: number) => void;
setMapCenter: (x: number, y: number) => void; setMapCenter: (x: number, y: number) => void;
setStationOffset: (stationId: number, x: number, y: number) => void; setStationOffset: (stationId: number, x: number, y: number) => void;
setStationAlign: (stationId: number, align: number) => void;
setSightCoordinates: ( setSightCoordinates: (
sightId: number, sightId: number,
latitude: number, latitude: number,
longitude: number longitude: number
) => void; ) => void;
setIconSize: (size: number) => void;
setFontSize: (size: number) => void;
saveChanges: () => void; saveChanges: () => void;
}>({ }>({
originalRouteData: undefined, originalRouteData: undefined,
@@ -50,11 +55,16 @@ const MapDataContext = createContext<{
isRouteLoading: true, isRouteLoading: true,
isStationLoading: true, isStationLoading: true,
isSightLoading: true, isSightLoading: true,
selectedSight: undefined,
setSelectedSight: () => {},
setScaleRange: () => {}, setScaleRange: () => {},
setMapRotation: () => {}, setMapRotation: () => {},
setMapCenter: () => {}, setMapCenter: () => {},
setStationOffset: () => {}, setStationOffset: () => {},
setStationAlign: () => {},
setSightCoordinates: () => {}, setSightCoordinates: () => {},
setIconSize: () => {},
setFontSize: () => {},
saveChanges: () => {}, saveChanges: () => {},
}); });
@@ -87,6 +97,7 @@ export const MapDataProvider = observer(
const [isRouteLoading, setIsRouteLoading] = useState(true); const [isRouteLoading, setIsRouteLoading] = useState(true);
const [isStationLoading, setIsStationLoading] = useState(true); const [isStationLoading, setIsStationLoading] = useState(true);
const [isSightLoading, setIsSightLoading] = useState(true); const [isSightLoading, setIsSightLoading] = useState(true);
const [selectedSight, setSelectedSight] = useState<SightData>();
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
@@ -106,17 +117,18 @@ export const MapDataProvider = observer(
languageInstance("ru").get(`/route/${routeId}/station`), languageInstance("ru").get(`/route/${routeId}/station`),
languageInstance("en").get(`/route/${routeId}/station`), languageInstance("en").get(`/route/${routeId}/station`),
languageInstance("zh").get(`/route/${routeId}/station`), languageInstance("zh").get(`/route/${routeId}/station`),
authInstance.get(`/route/${routeId}/sight`), languageInstance("ru").get(`/route/${routeId}/sight`),
]); ]);
setOriginalRouteData(routeResponse.data as RouteData); const routeData = routeResponse.data as RouteData;
setOriginalRouteData(routeData);
setOriginalStationData(ruStationResponse.data as StationData[]); setOriginalStationData(ruStationResponse.data as StationData[]);
setStationData({ setStationData({
ru: ruStationResponse.data as StationData[], ru: ruStationResponse.data as StationData[],
en: enStationResponse.data as StationData[], en: enStationResponse.data as StationData[],
zh: zhStationResponse.data as StationData[], zh: zhStationResponse.data as StationData[],
}); });
setOriginalSightData(sightResponse as unknown as SightData[]); setOriginalSightData(sightResponse.data as SightData[]);
setIsRouteLoading(false); setIsRouteLoading(false);
setIsStationLoading(false); setIsStationLoading(false);
@@ -133,7 +145,6 @@ export const MapDataProvider = observer(
}, [routeId]); }, [routeId]);
useEffect(() => { useEffect(() => {
// combine changes with original data
if (originalRouteData) if (originalRouteData)
setRouteData({ ...originalRouteData, ...routeChanges }); setRouteData({ ...originalRouteData, ...routeChanges });
if (originalSightData) setSightData(originalSightData); if (originalSightData) setSightData(originalSightData);
@@ -157,9 +168,57 @@ export const MapDataProvider = observer(
}); });
} }
function setMapCenter(x: number, y: number) { function setIconSize(size: number) {
const clamped = Math.max(50, Math.min(300, size));
setRouteChanges((prev) => { setRouteChanges((prev) => {
return { ...prev, center_latitude: x, center_longitude: y }; if (prev.icon_size === clamped) {
return prev;
}
return { ...prev, icon_size: clamped };
});
}
function setFontSize(size: number) {
const clamped = Math.max(50, Math.min(300, size));
setRouteChanges((prev) => {
if (prev.font_size === clamped) {
return prev;
}
return { ...prev, font_size: clamped };
});
}
function setMapCenter(latitude: number, longitude: number) {
const epsilon = 1e-6;
setRouteChanges((prev) => {
const prevLat = prev.center_latitude;
const prevLon = prev.center_longitude;
if (
prevLat !== undefined &&
prevLon !== undefined &&
Math.abs(prevLat - latitude) < epsilon &&
Math.abs(prevLon - longitude) < epsilon
) {
return prev;
}
return {
...prev,
center_latitude: latitude,
center_longitude: longitude,
};
});
setRouteData((routePrev) => {
if (!routePrev) return routePrev;
return {
...routePrev,
center_latitude: latitude,
center_longitude: longitude,
};
}); });
} }
@@ -172,46 +231,169 @@ export const MapDataProvider = observer(
async function saveStationChanges() { async function saveStationChanges() {
for (const station of stationChanges) { for (const station of stationChanges) {
await authInstance.patch(`/route/${routeId}/station`, station); await authInstance.patch(`/route/${routeId}/station`, station);
setStationData((prev) => {
const updated = { ...prev };
Object.keys(updated).forEach((lang) => {
updated[lang] = updated[lang].map((s) =>
s.id === station.station_id
? {
...s,
offset_x: station.offset_x,
offset_y: station.offset_y,
}
: s
);
});
return updated;
});
} }
} }
async function saveSightChanges() { async function saveSightChanges() {
console.log("sightChanges", sightChanges);
for (const sight of sightChanges) { for (const sight of sightChanges) {
await authInstance.patch(`/route/${routeId}/sight`, sight); await authInstance.patch(`/route/${routeId}/sight`, sight);
setSightData((prev) =>
prev
? prev.map((s) =>
s.id === sight.sight_id
? {
...s,
latitude: sight.latitude,
longitude: sight.longitude,
}
: s
)
: prev
);
} }
} }
function setStationOffset(stationId: number, x: number, y: number) { function setStationOffset(stationId: number, x: number, y: number) {
setStationChanges((prev) => { const currentStation = stationData.ru?.find(
let found = prev.find((station) => station.station_id === stationId);
if (found) {
found.offset_x = x;
found.offset_y = y;
return prev.map((station) => {
if (station.station_id === stationId) {
return found;
}
return station;
});
} else {
const foundStation = stationData.ru?.find(
(station) => station.id === stationId (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 [ return [
...prev, ...prev,
{ {
station_id: stationId, station_id: stationId,
offset_x: x, offset_x: x,
offset_y: y, 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;
}); });
} }
@@ -220,15 +402,27 @@ export const MapDataProvider = observer(
latitude: number, latitude: number,
longitude: number longitude: number
) { ) {
setSightChanges((prev) => { setSightData((prev) =>
let found = prev.find((sight) => sight.sight_id === sightId); prev
if (found) { ? prev.map((sight) =>
found.latitude = latitude; sight.id === sightId ? { ...sight, latitude, longitude } : sight
found.longitude = longitude; )
: prev
);
return prev.map((sight) => { setSightChanges((prev) => {
if (sight.sight_id === sightId) { const existingIndex = prev.findIndex(
return found; (sight) => sight.sight_id === sightId
);
if (existingIndex !== -1) {
return prev.map((sight, index) => {
if (index === existingIndex) {
return {
...sight,
latitude,
longitude,
};
} }
return sight; return sight;
}); });
@@ -249,9 +443,7 @@ export const MapDataProvider = observer(
}); });
} }
useEffect(() => { useEffect(() => {}, [sightChanges]);
console.log("sightChanges", sightChanges);
}, [sightChanges]);
const value = useMemo( const value = useMemo(
() => ({ () => ({
@@ -264,12 +456,17 @@ export const MapDataProvider = observer(
isRouteLoading, isRouteLoading,
isStationLoading, isStationLoading,
isSightLoading, isSightLoading,
selectedSight,
setSelectedSight,
setScaleRange, setScaleRange,
setMapRotation, setMapRotation,
setMapCenter, setMapCenter,
saveChanges, saveChanges,
setStationOffset, setStationOffset,
setStationAlign,
setSightCoordinates, setSightCoordinates,
setIconSize,
setFontSize,
}), }),
[ [
originalRouteData, originalRouteData,
@@ -281,6 +478,9 @@ export const MapDataProvider = observer(
isRouteLoading, isRouteLoading,
isStationLoading, isStationLoading,
isSightLoading, isSightLoading,
selectedSight,
setIconSize,
setFontSize,
] ]
); );

View File

@@ -1,8 +1,17 @@
import { Button, Stack, TextField, Typography } from "@mui/material"; import {
Button,
Stack,
TextField,
Typography,
Slider,
CircularProgress,
Box,
} from "@mui/material";
import { useMapData } from "./MapDataContext"; import { useMapData } from "./MapDataContext";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTransform } from "./TransformContext"; import { useTransform } from "./TransformContext";
import { coordinatesToLocal, localToCoordinates } from "./utils"; import { SCALE_FACTOR } from "./Constants";
import { toast } from "react-toastify";
export function RightSidebar() { export function RightSidebar() {
const { const {
@@ -12,32 +21,40 @@ export function RightSidebar() {
originalRouteData, originalRouteData,
setMapRotation, setMapRotation,
setMapCenter, setMapCenter,
setIconSize: updateIconSize,
setFontSize: updateFontSize,
} = useMapData(); } = useMapData();
const { const { rotation, rotateToAngle, scale, setScaleAtCenter } = useTransform();
rotation,
position,
screenToLocal,
screenCenter,
rotateToAngle,
setTransform,
} = useTransform();
const [minScale, setMinScale] = useState<number>(1); const [minScale, setMinScale] = useState<number>(1);
const [maxScale, setMaxScale] = useState<number>(10); const [maxScale, setMaxScale] = useState<number>(5);
const [localCenter, setLocalCenter] = useState<{ x: number; y: number }>({ const [localCenter, setLocalCenter] = useState<{ x: number; y: number }>({
x: 0, x: 0,
y: 0, y: 0,
}); });
const [rotationDegrees, setRotationDegrees] = useState<number>(0); const [rotationDegrees, setRotationDegrees] = useState<number>(0);
const [isUserEditing, setIsUserEditing] = useState<boolean>(false);
const [iconSize, setIconSize] = useState<number>(100);
const [fontSize, setFontSize] = useState<number>(100);
const [isSaving, setIsSaving] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
if (originalRouteData) { if (originalRouteData) {
setMinScale(originalRouteData.scale_min ?? 1); const originalMinScale = originalRouteData.scale_min ?? 1;
setMaxScale(originalRouteData.scale_max ?? 10); const resetMinScale = originalMinScale < 1 ? 1 : originalMinScale;
const originalMaxScale = originalRouteData.scale_max ?? 5;
const resetMaxScale = originalMaxScale < 3 ? 3 : originalMaxScale;
setMinScale(resetMinScale);
setMaxScale(resetMaxScale);
setRotationDegrees(originalRouteData.rotate ?? 0); setRotationDegrees(originalRouteData.rotate ?? 0);
setLocalCenter({ setLocalCenter({
x: originalRouteData.center_latitude ?? 0, x: originalRouteData.center_latitude ?? 0,
y: originalRouteData.center_longitude ?? 0, y: originalRouteData.center_longitude ?? 0,
}); });
setIconSize(originalRouteData.icon_size ?? 100);
setFontSize(originalRouteData.font_size ?? 100);
} }
}, [originalRouteData]); }, [originalRouteData]);
@@ -52,32 +69,63 @@ export function RightSidebar() {
((Math.round((rotation * 180) / Math.PI) % 360) + 360) % 360 ((Math.round((rotation * 180) / Math.PI) % 360) + 360) % 360
); );
}, [rotation]); }, [rotation]);
useEffect(() => { useEffect(() => {
setMapRotation(rotationDegrees); setMapRotation(rotationDegrees);
}, [rotationDegrees]); }, [rotationDegrees]);
useEffect(() => { useEffect(() => {
const center = screenCenter ?? { x: 0, y: 0 }; if (isUserEditing) {
const localCenter = screenToLocal(center.x, center.y); return;
const coordinates = localToCoordinates(localCenter.x, localCenter.y); }
setLocalCenter({ x: coordinates.latitude, y: coordinates.longitude });
}, [position]);
useEffect(() => { const latitude = routeData?.center_latitude ?? 0;
setMapCenter(localCenter.x, localCenter.y); const longitude = routeData?.center_longitude ?? 0;
}, [localCenter]);
setLocalCenter((prev) => {
if (
Math.abs(prev.x - latitude) < 1e-6 &&
Math.abs(prev.y - longitude) < 1e-6
) {
return prev;
}
return { x: latitude, y: longitude };
});
}, [isUserEditing, routeData?.center_latitude, routeData?.center_longitude]);
function setRotationFromDegrees(degrees: number) { function setRotationFromDegrees(degrees: number) {
rotateToAngle((degrees * Math.PI) / 180); rotateToAngle((degrees * Math.PI) / 180);
} }
function pan({ x, y }: { x: number; y: number }) { const handleIconSizeChange = (value: number) => {
const coordinates = coordinatesToLocal(x, y); if (!Number.isFinite(value)) {
setTransform(coordinates.x, coordinates.y); return;
} }
const clamped = Math.max(50, Math.min(300, Math.round(value)));
setIconSize(clamped);
updateIconSize(clamped);
};
const handleFontSizeChange = (value: number) => {
if (!Number.isFinite(value)) {
return;
}
const clamped = Math.max(50, Math.min(300, Math.round(value)));
setFontSize(clamped);
updateFontSize(clamped);
};
useEffect(() => {
const next = routeData?.icon_size ?? originalRouteData?.icon_size ?? 100;
setIconSize((prev) => (Math.abs(prev - next) > 0.5 ? next : prev));
}, [routeData?.icon_size, originalRouteData?.icon_size]);
useEffect(() => {
const next = routeData?.font_size ?? originalRouteData?.font_size ?? 100;
setFontSize((prev) => (Math.abs(prev - next) > 0.5 ? next : prev));
}, [routeData?.font_size, originalRouteData?.font_size]);
if (!routeData) { if (!routeData) {
console.error("routeData is null");
return null; return null;
} }
@@ -95,7 +143,7 @@ export function RightSidebar() {
borderRadius={2} borderRadius={2}
> >
<Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center"> <Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center">
Детали о достопримечательностях Настройка маршрута
</Typography> </Typography>
<Stack spacing={2} direction="row" alignItems="center"> <Stack spacing={2} direction="row" alignItems="center">
@@ -104,7 +152,37 @@ export function RightSidebar() {
label="Минимальный масштаб" label="Минимальный масштаб"
variant="filled" variant="filled"
value={minScale} value={minScale}
onChange={(e) => setMinScale(Number(e.target.value))} onChange={(e) => {
let newMinScale = Number(e.target.value);
if (newMinScale < 10) {
newMinScale = 10;
}
if (newMinScale > 300) {
newMinScale = 297;
}
setMinScale(newMinScale);
if (maxScale - newMinScale < 2) {
let newMaxScale = newMinScale + 2;
if (newMaxScale > 300) {
newMaxScale = 300;
}
if (newMaxScale < 3) {
newMaxScale = 3;
setMinScale(1);
}
setMaxScale(newMaxScale);
}
if (newMinScale > scale * SCALE_FACTOR) {
setScaleAtCenter(newMinScale / SCALE_FACTOR);
}
}}
style={{ backgroundColor: "#222", borderRadius: 4 }} style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{ sx={{
"& .MuiInputLabel-root": { "& .MuiInputLabel-root": {
@@ -116,7 +194,8 @@ export function RightSidebar() {
}} }}
slotProps={{ slotProps={{
input: { input: {
min: 0.1, min: 1,
max: 10,
}, },
}} }}
/> />
@@ -125,7 +204,33 @@ export function RightSidebar() {
label="Максимальный масштаб" label="Максимальный масштаб"
variant="filled" variant="filled"
value={maxScale} value={maxScale}
onChange={(e) => setMaxScale(Number(e.target.value))} onChange={(e) => {
let newMaxScale = Number(e.target.value);
if (newMaxScale < 13) {
newMaxScale = 13;
}
if (newMaxScale > 300) {
newMaxScale = 300;
}
setMaxScale(newMaxScale);
if (newMaxScale - minScale < 2) {
let newMinScale = newMaxScale - 2;
if (newMinScale < 1) {
newMinScale = 1;
setMaxScale(3);
}
setMinScale(newMinScale);
}
if (newMaxScale < scale * SCALE_FACTOR) {
setScaleAtCenter(newMaxScale / SCALE_FACTOR);
}
}}
style={{ backgroundColor: "#222", borderRadius: 4, color: "#fff" }} style={{ backgroundColor: "#222", borderRadius: 4, color: "#fff" }}
sx={{ sx={{
"& .MuiInputLabel-root": { "& .MuiInputLabel-root": {
@@ -137,12 +242,127 @@ export function RightSidebar() {
}} }}
slotProps={{ slotProps={{
input: { input: {
min: 0.1, min: 3,
max: 300,
}, },
}} }}
/> />
</Stack> </Stack>
<Typography variant="body2" sx={{ color: "#fff", textAlign: "center" }}>
Текущий масштаб: {Math.round(scale * SCALE_FACTOR * 100) / 100}
</Typography>
<Slider
value={scale * SCALE_FACTOR}
onChange={(_, newValue) => {
if (typeof newValue === "number") {
setScaleAtCenter(newValue / SCALE_FACTOR);
}
}}
min={minScale}
max={maxScale}
step={0.1}
sx={{
color: "#fff",
"& .MuiSlider-thumb": {
backgroundColor: "#fff",
},
"& .MuiSlider-track": {
backgroundColor: "#fff",
},
"& .MuiSlider-rail": {
backgroundColor: "#666",
},
}}
/>
<TextField
type="number"
label="Текущий масштаб"
variant="filled"
value={Math.round(scale * SCALE_FACTOR * 100) / 100}
onChange={(e) => {
const newScale = Number(e.target.value);
if (
!isNaN(newScale) &&
newScale >= minScale &&
newScale <= maxScale
) {
setScaleAtCenter(newScale / SCALE_FACTOR);
}
}}
style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{
"& .MuiInputLabel-root": {
color: "#fff",
},
"& .MuiInputBase-input": {
color: "#fff",
},
}}
inputProps={{
min: minScale,
max: maxScale,
}}
/>
<Typography variant="body2" sx={{ color: "#fff", textAlign: "center" }}>
Размер иконок: {iconSize}%
</Typography>
<Slider
value={iconSize}
onChange={(_, value) => {
if (typeof value === "number") {
handleIconSizeChange(value);
}
}}
min={50}
max={300}
step={1}
sx={{
color: "#fff",
"& .MuiSlider-thumb": {
backgroundColor: "#fff",
},
"& .MuiSlider-track": {
backgroundColor: "#fff",
},
"& .MuiSlider-rail": {
backgroundColor: "#666",
},
}}
/>
<Typography variant="body2" sx={{ color: "#fff", textAlign: "center" }}>
Размер шрифта: {fontSize}%
</Typography>
<Slider
value={fontSize}
onChange={(_, value) => {
if (typeof value === "number") {
handleFontSizeChange(value);
}
}}
min={50}
max={300}
step={1}
sx={{
color: "#fff",
"& .MuiSlider-thumb": {
backgroundColor: "#fff",
},
"& .MuiSlider-track": {
backgroundColor: "#fff",
},
"& .MuiSlider-rail": {
backgroundColor: "#666",
},
}}
/>
<TextField <TextField
type="number" type="number"
label="Поворот (в градусах)" label="Поворот (в градусах)"
@@ -181,10 +401,17 @@ export function RightSidebar() {
type="number" type="number"
label="Центр карты, широта" label="Центр карты, широта"
variant="filled" variant="filled"
value={Math.round(localCenter.x * 100000) / 100000} value={Math.round(localCenter.x * 1000) / 1000}
onChange={(e) => { onChange={(e) => {
setLocalCenter((prev) => ({ ...prev, x: Number(e.target.value) })); setIsUserEditing(true);
pan({ x: Number(e.target.value), y: localCenter.y }); const newValue = Number(e.target.value);
setLocalCenter((prev) => ({ ...prev, x: newValue }));
if (!isNaN(newValue) && localCenter.y !== undefined) {
setMapCenter(newValue, localCenter.y);
}
}}
onBlur={() => {
setIsUserEditing(false);
}} }}
style={{ backgroundColor: "#222", borderRadius: 4 }} style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{ sx={{
@@ -195,15 +422,25 @@ export function RightSidebar() {
color: "#fff", color: "#fff",
}, },
}} }}
inputProps={{
step: 0.001,
}}
/> />
<TextField <TextField
type="number" type="number"
label="Центр карты, высота" label="Центр карты, долгота"
variant="filled" variant="filled"
value={Math.round(localCenter.y * 100000) / 100000} value={Math.round(localCenter.y * 1000) / 1000}
onChange={(e) => { onChange={(e) => {
setLocalCenter((prev) => ({ ...prev, y: Number(e.target.value) })); setIsUserEditing(true);
pan({ x: localCenter.x, y: Number(e.target.value) }); const newValue = Number(e.target.value);
setLocalCenter((prev) => ({ ...prev, y: newValue }));
if (!isNaN(newValue) && localCenter.x !== undefined) {
setMapCenter(localCenter.x, newValue);
}
}}
onBlur={() => {
setIsUserEditing(false);
}} }}
style={{ backgroundColor: "#222", borderRadius: 4 }} style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{ sx={{
@@ -214,19 +451,60 @@ export function RightSidebar() {
color: "#fff", color: "#fff",
}, },
}} }}
inputProps={{
step: 0.001,
}}
/> />
</Stack> </Stack>
<Button <Button
variant="contained" variant="contained"
color="secondary" color="secondary"
sx={{ mt: 2 }} sx={{ mt: 2, position: "relative" }}
onClick={() => { disabled={isSaving}
saveChanges(); onClick={async () => {
setIsSaving(true);
try {
await saveChanges();
toast.success("Изменения сохранены");
} catch (error) {
console.error(error);
toast.error("Ошибка при сохранении изменений");
} finally {
setIsSaving(false);
}
}} }}
> >
Сохранить изменения {isSaving ? (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
}}
>
<CircularProgress size={20} sx={{ color: "inherit" }} />
<span>Сохранение...</span>
</Box>
) : (
"Сохранить изменения"
)}
</Button> </Button>
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 48 48"
fill="none"
style={{ cursor: "pointer" }}
className="absolute bottom-5 left-[-68px] z-100"
>
<path
d="M24.0013 0C23.3513 0 22.7013 0.03 22.0413 0.08C10.4213 1 1.01127 10.41 0.0812683 22.03C-0.428732 28.39 1.55127 34.28 5.14127 38.83C5.60127 39.42 5.76127 40.21 5.46127 40.89C4.76127 42.43 3.63127 43.64 3.05127 44.23C2.50127 44.78 1.97127 45.27 1.38127 45.7C0.791268 46.13 0.841268 47.03 1.45127 47.42C2.08127 47.82 3.01127 47.99 4.12127 47.99C6.84127 47.99 10.5813 46.99 13.3013 46.06C13.5013 45.99 13.7013 45.96 13.9113 45.96C14.1813 45.96 14.4613 46.02 14.7113 46.13C17.5713 47.33 20.7113 48 24.0013 48C24.6513 48 25.3213 47.97 25.9813 47.92C37.6313 46.98 47.0613 37.51 47.9313 25.85C48.9913 11.76 37.8713 0 24.0013 0ZM29.5113 37.71C29.4813 37.82 29.3413 37.94 29.2313 37.98C27.7413 38.48 26.2713 39.12 24.7313 39.42C22.9513 39.77 21.1413 39.68 19.5513 38.58C18.2213 37.66 17.7313 36.36 17.8113 34.8C17.9013 32.91 18.5113 31.13 19.0013 29.33C19.5213 27.42 20.1113 25.53 20.4613 23.59C20.9413 20.94 19.7813 20.48 17.3913 20.74C16.8013 20.8 16.2313 21.04 15.5813 21.22C15.7213 20.62 15.8313 20.08 15.9913 19.55C16.0213 19.45 19.6313 17.94 21.4413 17.78C23.3513 17.61 25.2013 17.8 26.6013 19.32C27.3913 20.17 27.6113 21.21 27.5913 22.33C27.5413 24.8 26.5813 27.07 25.9813 29.42C25.6113 30.86 25.2513 32.3 24.9313 33.75C24.8413 34.15 24.8413 34.59 24.8813 35C24.9613 35.97 25.4413 36.39 26.4313 36.57C27.6213 36.78 28.7213 36.45 29.9313 36.04C29.7813 36.66 29.6613 37.19 29.5213 37.7L29.5113 37.71ZM26.8513 15.21C26.6513 15.23 26.4613 15.23 26.2013 15.25C24.4013 15.27 22.7313 14.15 22.2013 12.52C21.5913 10.65 22.5613 8.71 24.5313 7.86C26.7913 6.88 29.5813 8.07 30.2713 10.33C31.0513 12.87 29.0613 14.95 26.8613 15.21H26.8513Z"
fill="white"
/>
</svg>
</Stack> </Stack>
); );
} }

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTransform } from "./TransformContext"; import { useTransform } from "./TransformContext";
import { SightData } from "./types"; import { SightData } from "./types";
import { Assets, FederatedMouseEvent, Graphics, Texture } from "pixi.js"; import { Assets, FederatedMouseEvent, Texture } from "pixi.js";
import { SIGHT_SIZE, UP_SCALE } from "./Constants"; import { SIGHT_SIZE, UP_SCALE } from "./Constants";
import { coordinatesToLocal, localToCoordinates } from "./utils"; import { coordinatesToLocal, localToCoordinates } from "./utils";
@@ -12,19 +12,21 @@ interface SightProps {
id: number; id: number;
} }
export function Sight({ sight, id }: Readonly<SightProps>) { export const Sight = ({ sight, id }: Readonly<SightProps>) => {
const { rotation, scale } = useTransform(); const { rotation, scale } = useTransform();
const { setSightCoordinates } = useMapData(); const { setSightCoordinates, setSelectedSight } = useMapData();
const [position, setPosition] = useState( const [position, setPosition] = useState(
coordinatesToLocal(sight.latitude, sight.longitude) coordinatesToLocal(sight.latitude, sight.longitude)
); );
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isPointerDown, setIsPointerDown] = useState(false);
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 }); const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 }); const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
const handlePointerDown = (e: FederatedMouseEvent) => { const handlePointerDown = (e: FederatedMouseEvent) => {
setIsDragging(true); setIsPointerDown(true);
setIsDragging(false);
setStartPosition({ setStartPosition({
x: position.x, x: position.x,
y: position.y, y: position.y,
@@ -37,7 +39,18 @@ export function Sight({ sight, id }: Readonly<SightProps>) {
e.stopPropagation(); e.stopPropagation();
}; };
const handlePointerMove = (e: FederatedMouseEvent) => { const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isDragging) return; if (!isPointerDown) return;
if (!isDragging) {
const dx = e.globalX - startMousePosition.x;
const dy = e.globalY - startMousePosition.y;
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
setIsDragging(true);
} else {
return;
}
}
const dx = (e.globalX - startMousePosition.x) / scale / UP_SCALE; const dx = (e.globalX - startMousePosition.x) / scale / UP_SCALE;
const dy = (e.globalY - startMousePosition.y) / scale / UP_SCALE; const dy = (e.globalY - startMousePosition.y) / scale / UP_SCALE;
const cos = Math.cos(rotation); const cos = Math.cos(rotation);
@@ -53,30 +66,33 @@ export function Sight({ sight, id }: Readonly<SightProps>) {
}; };
const handlePointerUp = (e: FederatedMouseEvent) => { const handlePointerUp = (e: FederatedMouseEvent) => {
setIsPointerDown(false);
// Если не было перетаскивания, то это клик
if (!isDragging) {
setSelectedSight(sight);
}
setIsDragging(false); setIsDragging(false);
e.stopPropagation(); e.stopPropagation();
}; };
const [texture, setTexture] = useState(Texture.EMPTY); const [texture, setTexture] = useState(Texture.EMPTY);
useEffect(() => { useEffect(() => {
if (texture === Texture.EMPTY) { Assets.load("/sight_icon.svg").then(setTexture);
Assets.load("/SightIcon.png").then((result) => { }, []);
setTexture(result);
});
}
}, [texture]);
function draw(g: Graphics) { useEffect(() => {}, [id, sight.latitude, sight.longitude]);
g.clear();
g.circle(0, 0, 20);
g.fill({ color: "#000" }); // Fill circle with primary color
}
if (!sight) { if (!sight) {
console.error("sight is null"); console.error("sight is null");
return null; return null;
} }
// Компенсируем масштаб для сохранения постоянного размера
const compensatedSize = SIGHT_SIZE / scale;
const compensatedFontSize = 24 / scale;
return ( return (
<pixiContainer <pixiContainer
rotation={-rotation} rotation={-rotation}
@@ -86,22 +102,34 @@ export function Sight({ sight, id }: Readonly<SightProps>) {
onGlobalPointerMove={handlePointerMove} onGlobalPointerMove={handlePointerMove}
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
onPointerUpOutside={handlePointerUp} onPointerUpOutside={handlePointerUp}
x={position.x * UP_SCALE - SIGHT_SIZE / 2} // Offset by half width to center x={position.x * UP_SCALE - SIGHT_SIZE / 2}
y={position.y * UP_SCALE - SIGHT_SIZE / 2} // Offset by half height to center y={position.y * UP_SCALE - SIGHT_SIZE / 2}
> >
<pixiSprite texture={texture} width={SIGHT_SIZE} height={SIGHT_SIZE} /> <pixiSprite
<pixiGraphics draw={draw} x={SIGHT_SIZE} y={0} /> texture={texture}
width={compensatedSize}
height={compensatedSize}
/>
<pixiGraphics
draw={(g) => {
g.clear();
g.circle(0, 0, 20 / scale);
g.fill({ color: "#000" });
}}
x={compensatedSize}
y={0}
/>
<pixiText <pixiText
text={`${id + 1}`} text={`${id + 1}`}
x={SIGHT_SIZE + 1} x={compensatedSize + 1 / scale}
y={0} y={0}
anchor={0.5} anchor={0.5}
style={{ style={{
fontSize: 24, fontSize: compensatedFontSize,
fontWeight: "bold", fontWeight: "bold",
fill: "#ffffff", fill: "#ffffff",
}} }}
/> />
</pixiContainer> </pixiContainer>
); );
} };

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,7 @@
import { FederatedMouseEvent, Graphics } from "pixi.js"; import { FederatedMouseEvent, Graphics } from "pixi.js";
import { useCallback, useState, useEffect, useRef, FC, useMemo } from "react";
import { observer } from "mobx-react-lite";
import { import {
BACKGROUND_COLOR, BACKGROUND_COLOR,
PATH_COLOR, PATH_COLOR,
@@ -7,140 +10,530 @@ import {
UP_SCALE, UP_SCALE,
} from "./Constants"; } from "./Constants";
import { useTransform } from "./TransformContext"; import { useTransform } from "./TransformContext";
import { useCallback, useState } from "react";
import { StationData } from "./types"; import { StationData } from "./types";
import { useMapData } from "./MapDataContext"; import { useMapData } from "./MapDataContext";
import { coordinatesToLocal } from "./utils"; import { coordinatesToLocal } from "./utils";
import { observer } from "mobx-react-lite"; import { languageStore } from "@shared";
declare const pixiContainer: any;
declare const pixiGraphics: any;
declare const pixiText: any;
type HorizontalAlign = "left" | "center" | "right";
type VerticalAlign = "top" | "center" | "bottom";
type TextAlign = HorizontalAlign | `${HorizontalAlign} ${VerticalAlign}`;
type LabelAlign = "left" | "center" | "right";
/**
* Преобразует текстовое позиционирование в anchor координаты.
*/
/**
* Получает координату anchor.x из типа выравнивания.
*/
interface StationProps { interface StationProps {
station: StationData; station: StationData;
ruLabel: string | null; ruLabel: string | null;
anchorPoint?: { x: number; y: number };
/** Anchor для всего блока с текстом. По умолчанию: `"right center"` */
labelBlockAnchor?: TextAlign | { x: number; y: number };
/** Внутреннее выравнивание текста в блоке. По умолчанию: `"left"` */
labelAlign?: LabelAlign;
/** Callback для изменения внутреннего выравнивания */
onLabelAlignChange?: (align: LabelAlign) => void;
/** Callback для отслеживания наведения на текст */
onTextHover?: (isHovered: boolean) => void;
} }
export const Station = observer( interface LabelAlignmentControlProps {
({ station, ruLabel }: Readonly<StationProps>) => { scale: number;
const draw = useCallback((g: Graphics) => { currentAlign: LabelAlign;
onAlignChange: (align: LabelAlign) => void;
onPointerOver: () => void;
onPointerOut: () => void;
onControlPointerEnter: () => void;
onControlPointerLeave: () => void;
}
interface StationLabelProps
extends Omit<StationProps, "ruLabelAnchor" | "nameLabelAnchor"> {}
const getAnchorFromOffset = (
offsetX: number,
offsetY: number
): { x: number; y: number } => {
if (offsetX === 0 && offsetY === 0) {
return { x: 0.5, y: 0.5 };
}
const length = Math.hypot(offsetX, offsetY);
const nx = offsetX / length;
const ny = offsetY / length;
return { x: (1 - nx) / 2, y: (1 - ny) / 2 };
};
const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
scale,
currentAlign,
onAlignChange,
onControlPointerEnter,
onControlPointerLeave,
}) => {
const controlHeight = 50 / scale;
const controlWidth = 200 / scale;
const fontSize = 18 / scale;
const borderRadius = 8 / scale;
const compensatedRuFontSize = (26 * 0.75) / scale;
const buttonWidth = controlWidth / 3;
const strokeWidth = 2 / scale;
const drawBg = useCallback(
(g: Graphics) => {
g.clear(); g.clear();
const coordinates = coordinatesToLocal(
station.latitude, g.roundRect(
station.longitude -controlWidth / 2,
0,
controlWidth,
controlHeight,
borderRadius
); );
g.circle( g.fill({ color: "#1a1a1a" });
coordinates.x * UP_SCALE,
coordinates.y * UP_SCALE, g.roundRect(
STATION_RADIUS -controlWidth / 2,
0,
controlWidth,
controlHeight,
borderRadius
); );
g.fill({ color: PATH_COLOR }); g.stroke({ color: "#333333", width: strokeWidth });
g.stroke({ color: BACKGROUND_COLOR, width: STATION_OUTLINE_WIDTH });
}, []); 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 ( return (
<pixiContainer> <pixiContainer
<pixiGraphics draw={draw} /> position={{ x: 0, y: compensatedRuFontSize * 1.1 + 15 / scale }}
<StationLabel station={station} ruLabel={ruLabel} /> 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> </pixiContainer>
); );
} };
);
export const StationLabel = observer( const StationLabel = observer(
({ station, ruLabel }: Readonly<StationProps>) => { ({
station,
ruLabel,
labelAlign: labelAlignProp = "center",
onLabelAlignChange,
onTextHover,
}: Readonly<StationLabelProps>) => {
const { language } = languageStore;
const { rotation, scale } = useTransform(); const { rotation, scale } = useTransform();
const { setStationOffset } = useMapData(); const { setStationOffset, setStationAlign } = useMapData();
const [position, setPosition] = useState({ const [position, setPosition] = useState({ x: 0, y: 0 });
x: station.offset_x,
y: station.offset_y,
});
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 }); const [isPointerDown, setIsPointerDown] = useState(false);
const [startMousePosition, setStartMousePosition] = useState({ const [isHovered, setIsHovered] = useState(false);
x: 0, const [isControlHovered, setIsControlHovered] = useState(false);
y: 0, const [currentLabelAlign, setCurrentLabelAlign] = useState(labelAlignProp);
}); const [ruLabelWidth, setRuLabelWidth] = useState(0);
if (!station) { const dragStartPos = useRef({ x: 0, y: 0 });
console.error("station is null"); const mouseStartPos = useRef({ x: 0, y: 0 });
return null; const hideTimer = useRef<NodeJS.Timeout | null>(null);
const ruLabelRef = useRef<any>(null);
useEffect(() => {
return () => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
} }
};
}, []);
const handlePointerEnter = () => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
setIsHovered(true);
onTextHover?.(true);
};
const handleControlPointerEnter = () => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
setIsControlHovered(true);
setIsHovered(true);
onTextHover?.(true);
};
const handleControlPointerLeave = () => {
setIsControlHovered(false);
if (!isHovered) {
hideTimer.current = setTimeout(() => {
setIsHovered(false);
onTextHover?.(false);
}, 0);
}
};
const handlePointerLeave = () => {
hideTimer.current = setTimeout(() => {
setIsHovered(false);
if (!isControlHovered) {
setIsControlHovered(false);
}
onTextHover?.(false);
}, 100);
};
useEffect(() => {
setPosition({ x: station.offset_x ?? 0, y: station.offset_y ?? 0 });
}, [station.offset_x, station.offset_y, station.id]);
const convertNumericAlign = (align: number): LabelAlign => {
switch (align) {
case 0:
return "left";
case 1:
return "center";
case 2:
return "right";
default:
return "center";
}
};
const convertStringAlign = (align: LabelAlign): number => {
switch (align) {
case "left":
return 0;
case "center":
return 1;
case "right":
return 2;
default:
return 1;
}
};
useEffect(() => {
setCurrentLabelAlign(convertNumericAlign(station.align ?? 1));
}, [station.align]);
if (!station) return null;
const coordinates = coordinatesToLocal(station.latitude, station.longitude);
const compensatedRuFontSize = (26 * 0.75) / scale;
const compensatedNameFontSize = (16 * 0.75) / scale;
useEffect(() => {
if (ruLabelRef.current && ruLabel) {
setRuLabelWidth(ruLabelRef.current.width);
}
}, [ruLabel, compensatedRuFontSize]);
const handlePointerDown = (e: FederatedMouseEvent) => { const handlePointerDown = (e: FederatedMouseEvent) => {
setIsDragging(true); setIsPointerDown(true);
setStartPosition({ setIsDragging(false);
x: position.x, dragStartPos.current = { ...position };
y: position.y, mouseStartPos.current = { x: e.global.x, y: e.global.y };
});
setStartMousePosition({
x: e.globalX,
y: e.globalY,
});
e.stopPropagation(); e.stopPropagation();
}; };
const handlePointerMove = (e: FederatedMouseEvent) => { const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isDragging) return; if (!isPointerDown) return;
const dx = e.globalX - startMousePosition.x;
const dy = e.globalY - startMousePosition.y; 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 = { const newPosition = {
x: startPosition.x + dx, x: dragStartPos.current.x + dx_screen,
y: startPosition.y + dy, 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); setPosition(newPosition);
setStationOffset(station.id, newPosition.x, newPosition.y); setStationOffset(station.id, newPosition.x, newPosition.y);
}
e.stopPropagation(); e.stopPropagation();
}; };
const handlePointerUp = (e: FederatedMouseEvent) => { const handlePointerUp = (e: FederatedMouseEvent) => {
setIsDragging(false); setIsPointerDown(false);
setTimeout(() => setIsDragging(false), 50);
e.stopPropagation(); 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;
}
};
const getSecondLabelAnchor = (): number => {
switch (currentLabelAlign) {
case "left":
return 0;
case "center":
return 0.5;
case "right":
return 1;
default:
return 0.5;
}
};
return ( return (
<pixiContainer <pixiContainer
eventMode="static"
interactive
onPointerDown={handlePointerDown}
onGlobalPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerUpOutside={handlePointerUp}
width={48}
height={48}
x={coordinates.x * UP_SCALE} x={coordinates.x * UP_SCALE}
y={coordinates.y * UP_SCALE} y={coordinates.y * UP_SCALE}
rotation={-rotation} 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 <pixiContainer
anchor={{ x: 1, y: 0.5 }}
text={station.name}
position={{ position={{
x: position.x / scale + 24, x:
y: position.y / scale, (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={{ style={{
fontSize: 26, fontSize: compensatedRuFontSize,
fontWeight: "bold", fontWeight: "bold",
fill: "#ffffff", fill: "#ffffff",
}} }}
/> />
)}
{ruLabel && ( {station.name && language !== "ru" && ruLabel && (
<pixiText <pixiText
anchor={{ x: 1, y: -1 }} text={station.name}
text={ruLabel}
position={{ position={{
x: position.x / scale + 24, x: getSecondLabelPosition(),
y: position.y / scale, y: compensatedRuFontSize * 1.1,
}} }}
anchor={{ x: getSecondLabelAnchor(), y: 0.5 }}
style={{ style={{
fontSize: 16, fontSize: compensatedNameFontSize,
fontWeight: "bold", fontWeight: "bold",
fill: "#CCCCCC", fill: "#CCCCCC",
}} }}
/> />
)} )}
{(isHovered || isControlHovered) && !isDragging && (
<LabelAlignmentControl
scale={scale}
currentAlign={currentLabelAlign}
onAlignChange={handleAlignChange}
onPointerOver={handlePointerEnter}
onPointerOut={handlePointerLeave}
onControlPointerEnter={handleControlPointerEnter}
onControlPointerLeave={handleControlPointerLeave}
/>
)}
</pixiContainer>
</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);
if (isTextHovered) {
g.fill({ color: 0x00aaff });
g.stroke({ color: 0xffffff, width: strokeWidth + 1 });
} 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, rotationDegrees?: number,
scale?: number scale?: number
) => void; ) => void;
setScaleOnly: (newScale: number) => void;
setScaleWithoutMovingCenter: (newScale: number) => void;
setScreenCenter: React.Dispatch< setScreenCenter: React.Dispatch<
React.SetStateAction<{ x: number; y: number } | undefined> React.SetStateAction<{ x: number; y: number } | undefined>
>; >;
setScaleAtCenter: (newScale: number) => void;
}>({ }>({
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
scale: 1, scale: 1,
@@ -41,10 +44,12 @@ const TransformContext = createContext<{
localToScreen: () => ({ x: 0, y: 0 }), localToScreen: () => ({ x: 0, y: 0 }),
rotateToAngle: () => {}, rotateToAngle: () => {},
setTransform: () => {}, setTransform: () => {},
setScaleOnly: () => {},
setScaleWithoutMovingCenter: () => {},
setScreenCenter: () => {}, setScreenCenter: () => {},
setScaleAtCenter: () => {},
}); });
// Provider component
export const TransformProvider = ({ children }: { children: ReactNode }) => { export const TransformProvider = ({ children }: { children: ReactNode }) => {
const [position, setPosition] = useState({ x: 0, y: 0 }); const [position, setPosition] = useState({ x: 0, y: 0 });
const [scale, setScale] = useState(1); const [scale, setScale] = useState(1);
@@ -53,12 +58,10 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
const screenToLocal = useCallback( const screenToLocal = useCallback(
(screenX: number, screenY: number) => { (screenX: number, screenY: number) => {
// Translate point relative to current pan position
const translatedX = (screenX - position.x) / scale; const translatedX = (screenX - position.x) / scale;
const translatedY = (screenY - position.y) / scale; const translatedY = (screenY - position.y) / scale;
// Rotate point around center const cosRotation = Math.cos(-rotation);
const cosRotation = Math.cos(-rotation); // Negative rotation to reverse transform
const sinRotation = Math.sin(-rotation); const sinRotation = Math.sin(-rotation);
const rotatedX = translatedX * cosRotation - translatedY * sinRotation; const rotatedX = translatedX * cosRotation - translatedY * sinRotation;
const rotatedY = translatedX * sinRotation + translatedY * cosRotation; const rotatedY = translatedX * sinRotation + translatedY * cosRotation;
@@ -71,7 +74,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
[position.x, position.y, scale, rotation] [position.x, position.y, scale, rotation]
); );
// Inverse of screenToLocal
const localToScreen = useCallback( const localToScreen = useCallback(
(localX: number, localY: number) => { (localX: number, localY: number) => {
const upscaledX = localX * UP_SCALE; const upscaledX = localX * UP_SCALE;
@@ -114,7 +116,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
(currentFromPosition.x - center.x) * sinDelta, (currentFromPosition.x - center.x) * sinDelta,
}; };
// Update both rotation and position in a single batch to avoid stale closure
setRotation(to); setRotation(to);
setPosition(newPosition); setPosition(newPosition);
}, },
@@ -136,8 +137,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
useScale !== undefined ? useScale / SCALE_FACTOR : scale; useScale !== undefined ? useScale / SCALE_FACTOR : scale;
const center = screenCenter ?? { x: 0, y: 0 }; const center = screenCenter ?? { x: 0, y: 0 };
console.log("center", center.x, center.y);
const newPosition = { const newPosition = {
x: -latitude * UP_SCALE * selectedScale, x: -latitude * UP_SCALE * selectedScale,
y: -longitude * UP_SCALE * selectedScale, y: -longitude * UP_SCALE * selectedScale,
@@ -146,13 +145,11 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
const cosRot = Math.cos(selectedRotation); const cosRot = Math.cos(selectedRotation);
const sinRot = Math.sin(selectedRotation); const sinRot = Math.sin(selectedRotation);
// Translate point relative to center, rotate, then translate back
const dx = newPosition.x; const dx = newPosition.x;
const dy = newPosition.y; const dy = newPosition.y;
newPosition.x = dx * cosRot - dy * sinRot + center.x; newPosition.x = dx * cosRot - dy * sinRot + center.x;
newPosition.y = dx * sinRot + dy * cosRot + center.y; newPosition.y = dx * sinRot + dy * cosRot + center.y;
// Batch state updates to avoid intermediate renders
setPosition(newPosition); setPosition(newPosition);
setRotation(selectedRotation); setRotation(selectedRotation);
setScale(selectedScale); setScale(selectedScale);
@@ -160,6 +157,36 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
[rotation, scale, screenCenter] [rotation, scale, screenCenter]
); );
const setScaleAtCenter = useCallback(
(newScale: number) => {
if (scale === newScale) return;
const center = screenCenter ?? { x: 0, y: 0 };
const actualZoomFactor = newScale / scale;
const newPosition = {
x: position.x + (center.x - position.x) * (1 - actualZoomFactor),
y: position.y + (center.y - position.y) * (1 - actualZoomFactor),
};
setPosition(newPosition);
setScale(newScale);
},
[position, scale, screenCenter]
);
const setScaleOnly = useCallback((newScale: number) => {
setScale(newScale);
}, []);
const setScaleWithoutMovingCenter = useCallback(
(newScale: number) => {
setScale(newScale);
},
[setScale]
);
const value = useMemo( const value = useMemo(
() => ({ () => ({
position, position,
@@ -173,17 +200,25 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
screenToLocal, screenToLocal,
localToScreen, localToScreen,
setTransform, setTransform,
setScaleOnly,
setScaleWithoutMovingCenter,
setScreenCenter, setScreenCenter,
setScaleAtCenter,
}), }),
[ [
position, position,
scale, scale,
rotation, rotation,
screenCenter, screenCenter,
setScale,
rotateToAngle, rotateToAngle,
screenToLocal, screenToLocal,
localToScreen, localToScreen,
setTransform, setTransform,
setScaleOnly,
setScaleWithoutMovingCenter,
setScreenCenter,
setScaleAtCenter,
] ]
); );
@@ -194,7 +229,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
); );
}; };
// Custom hook for easy access to transform values
export const useTransform = () => { export const useTransform = () => {
const context = useContext(TransformContext); const context = useContext(TransformContext);
if (!context) { if (!context) {

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() { export function Widgets() {
const { selectedSight, setSelectedSight } = useMapData();
return ( return (
<Stack <Stack
direction="column" direction="column"
@@ -24,6 +29,8 @@ export function Widgets() {
Станция Станция
</Typography> </Typography>
</Stack> </Stack>
{/* Виджет выбранной достопримечательности (заменяет виджет погоды) */}
<Stack <Stack
bgcolor="primary.main" bgcolor="primary.main"
width={223} width={223}
@@ -31,12 +38,102 @@ export function Widgets() {
p={2} p={2}
m={2} m={2}
borderRadius={2} borderRadius={2}
alignItems="center" sx={{
justifyContent="center" pointerEvents: "auto",
position: "relative",
overflow: "hidden",
}}
> >
<Typography variant="h6" sx={{ color: "#fff" }}> {selectedSight ? (
Погода <Box
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> </Typography>
</Box>
<IconButton
size="small"
onClick={() => setSelectedSight(undefined)}
sx={{
color: "#fff",
p: 0,
minWidth: 20,
width: 20,
height: 20,
"&:hover": { backgroundColor: "rgba(255, 255, 255, 0.1)" },
}}
>
<Close fontSize="small" />
</IconButton>
</Box>
{/* Описание достопримечательности */}
{selectedSight.address && (
<Typography
variant="caption"
sx={{
color: "#fff",
mb: 1,
opacity: 0.9,
lineHeight: 1.3,
overflow: "hidden",
textOverflow: "ellipsis",
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
}}
>
{selectedSight.address}
</Typography>
)}
{/* Город */}
{selectedSight.city && (
<Typography
variant="caption"
sx={{
color: "#fff",
opacity: 0.7,
mt: "auto",
}}
>
Город: {selectedSight.city}
</Typography>
)}
</Box>
) : (
<Box
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 5,
justifyContent: "center",
textAlign: "center",
}}
>
<Landmark size={32} />
<Typography variant="body2" sx={{ color: "#fff", opacity: 0.8 }}>
Выберите достопримечательность
</Typography>
</Box>
)}
</Stack> </Stack>
</Stack> </Stack>
); );

View File

@@ -1,6 +1,6 @@
import { useRef, useEffect, useState } from "react"; import { useRef, useEffect, useState } from "react";
import { Widgets } from "./Widgets";
import { Application, extend } from "@pixi/react"; import { extend } from "@pixi/react";
import { import {
Container, Container,
Graphics, Graphics,
@@ -9,21 +9,18 @@ import {
TilingSprite, TilingSprite,
Text, Text,
} from "pixi.js"; } from "pixi.js";
import { Stack } from "@mui/material"; import { Box, Stack } from "@mui/material";
import { MapDataProvider, useMapData } from "./MapDataContext"; import { MapDataProvider, useMapData } from "./MapDataContext";
import { TransformProvider, useTransform } from "./TransformContext"; import { TransformProvider, useTransform } from "./TransformContext";
import { InfiniteCanvas } from "./InfiniteCanvas";
import { UP_SCALE } from "./Constants";
import { Station } from "./Station";
import { TravelPath } from "./TravelPath";
import { LeftSidebar } from "./LeftSidebar"; import { LeftSidebar } from "./LeftSidebar";
import { RightSidebar } from "./RightSidebar"; import { RightSidebar } from "./RightSidebar";
import { Widgets } from "./Widgets";
import { coordinatesToLocal } from "./utils"; import { coordinatesToLocal } from "./utils";
import { LanguageSwitcher } from "@widgets";
import { languageStore } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { UP_SCALE } from "./Constants";
import { WebGLRouteMapPrototype } from "./webgl-prototype/WebGLRouteMapPrototype";
import { CircularProgress } from "@mui/material";
extend({ extend({
Container, Container,
@@ -34,17 +31,50 @@ extend({
Text, Text,
}); });
const Loading = () => {
const { isRouteLoading, isStationLoading, isSightLoading } = useMapData();
if (isRouteLoading || isStationLoading || isSightLoading) {
return (
<div className="fixed flex z-1000000000 items-center justify-center h-screen w-screen bg-[#111]">
<CircularProgress />
</div>
);
}
return null;
};
export const RoutePreview = () => { export const RoutePreview = () => {
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(true);
return ( return (
<MapDataProvider> <MapDataProvider>
<TransformProvider> <TransformProvider>
<Stack direction="row" height="100vh" width="100vw" overflow="hidden"> <Stack direction="row" height="100vh" width="100vw" overflow="hidden">
<LanguageSwitcher /> <Loading />
<Box
<LeftSidebar /> sx={{
position: "relative",
width: isLeftSidebarOpen ? 300 : 0,
transition: "width 0.3s ease",
overflow: "visible",
height: "100%",
bgcolor: "primary.main",
borderRight: isLeftSidebarOpen
? "1px solid rgba(255,255,255,0.08)"
: "none",
display: "flex",
justifyContent: "flex-start",
flexShrink: 0,
}}
>
<LeftSidebar
open={isLeftSidebarOpen}
onToggle={() => setIsLeftSidebarOpen((prev) => !prev)}
/>
</Box>
<Stack direction="row" flex={1} position="relative" height="100%"> <Stack direction="row" flex={1} position="relative" height="100%">
<Widgets />
<RouteMap /> <RouteMap />
<Widgets />
<RightSidebar /> <RightSidebar />
</Stack> </Stack>
</Stack> </Stack>
@@ -54,16 +84,21 @@ export const RoutePreview = () => {
}; };
export const RouteMap = observer(() => { export const RouteMap = observer(() => {
const { language } = languageStore; const { setPosition, setTransform, screenCenter } = useTransform();
const { setPosition, screenToLocal, setTransform, screenCenter } =
useTransform();
const { routeData, stationData, sightData, originalRouteData } = useMapData(); const { routeData, stationData, sightData, originalRouteData } = useMapData();
console.log(stationData);
const [points, setPoints] = useState<{ x: number; y: number }[]>([]); const [points, setPoints] = useState<{ x: number; y: number }[]>([]);
const [isSetup, setIsSetup] = useState(false); const [isSetup, setIsSetup] = useState(false);
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "auto";
};
}, []);
useEffect(() => { useEffect(() => {
if (originalRouteData) { if (originalRouteData) {
const path = originalRouteData?.path; const path = originalRouteData?.path;
@@ -131,13 +166,12 @@ export const RouteMap = observer(() => {
]); ]);
if (!routeData || !stationData || !sightData) { if (!routeData || !stationData || !sightData) {
console.error("routeData, stationData or sightData is null"); return null;
return <div>Loading...</div>;
} }
return ( return (
<div style={{ width: "100%", height: "100%" }} ref={parentRef}> <div style={{ width: "100%", height: "100%" }} ref={parentRef}>
<Application resizeTo={parentRef} background="#fff"> {/* <Application resizeTo={parentRef} background="#000" preference="webgl">
<InfiniteCanvas> <InfiniteCanvas>
<TravelPath points={points} /> <TravelPath points={points} />
{stationData[language].map((obj, index) => ( {stationData[language].map((obj, index) => (
@@ -146,22 +180,17 @@ export const RouteMap = observer(() => {
key={obj.id} key={obj.id}
ruLabel={ ruLabel={
language === "ru" language === "ru"
? stationData.en[index].name ? stationData.ru[index].name
: stationData.ru[index].name : stationData.ru[index].name
} }
/> />
))} ))}
{originalSightData?.map((sight: SightData, index: number) => {
<pixiGraphics return <Sight sight={sight} id={index} key={sight.id} />;
draw={(g) => { })}
g.clear();
const localCenter = screenToLocal(0, 0);
g.circle(localCenter.x, localCenter.y, 10);
g.fill("#fff");
}}
/>
</InfiniteCanvas> </InfiniteCanvas>
</Application> </Application> */}
<WebGLRouteMapPrototype />
</div> </div>
); );
}); });

View File

@@ -3,6 +3,8 @@ export interface RouteData {
carrier_id: number; carrier_id: number;
center_latitude: number; center_latitude: number;
center_longitude: number; center_longitude: number;
icon_size: number;
font_size: number;
governor_appeal: number; governor_appeal: number;
id: number; id: number;
path: [number, number][]; path: [number, number][];
@@ -12,6 +14,7 @@ export interface RouteData {
route_sys_number: string; route_sys_number: string;
scale_max: number; scale_max: number;
scale_min: number; scale_min: number;
thumbnail?: string; // uuid логотипа маршрута
} }
export interface StationTransferData { export interface StationTransferData {
@@ -38,12 +41,14 @@ export interface StationData {
offset_y: number; offset_y: number;
system_name: string; system_name: string;
transfers: StationTransferData; transfers: StationTransferData;
align: number;
} }
export interface StationPatchData { export interface StationPatchData {
station_id: number; station_id: number;
offset_x: number; offset_x: number;
offset_y: number; offset_y: number;
align: number;
transfers: StationTransferData; transfers: StationTransferData;
} }

View File

@@ -0,0 +1,203 @@
import { useEffect, useRef, useState, type ReactElement } from "react";
import { observer } from "mobx-react-lite";
import { languageStore } from "@shared";
const LANGUAGES = ["ru", "zh", "en"] as const;
type Language = (typeof LANGUAGES)[number];
type LanguageSelectorProps = {
onBack?: () => void;
isSidebarOpen?: boolean;
};
const renderLanguageIcon = (lang: Language): ReactElement => {
switch (lang) {
case "ru":
return (
<svg
className="h-12 w-12 cursor-pointer"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="3" y="3" width="42" height="42" rx="21" fill="black" />
<path
d="M24 0C10.75 0 0 10.75 0 24C0 37.25 10.75 48 24 48C37.25 48 48 37.25 48 24C48 10.75 37.25 0 24 0ZM24.2 33.55H19.92L16.29 26.46H13.11V33.55H9.12V14.18H16.32C18.61 14.18 20.37 14.69 21.62 15.71C22.87 16.73 23.48 18.17 23.48 20.03C23.48 21.35 23.19 22.45 22.62 23.34C22.05 24.22 21.18 24.93 20.02 25.45L24.21 33.37V33.56L24.2 33.55ZM40.3 26.94C40.3 29.06 39.64 30.74 38.31 31.97C36.98 33.2 35.17 33.82 32.87 33.82C30.57 33.82 28.81 33.22 27.48 32.02C26.15 30.82 25.47 29.18 25.44 27.08V14.18H29.43V26.97C29.43 28.24 29.73 29.16 30.34 29.74C30.95 30.32 31.79 30.61 32.86 30.61C35.1 30.61 36.24 29.43 36.28 27.07V14.18H40.28V26.94H40.3Z"
fill="white"
/>
<path
d="M16.3086 17.4099H13.0986V23.2199H16.3186C17.3186 23.2199 18.0986 22.9599 18.6486 22.4499C19.1986 21.9399 19.4686 21.2399 19.4686 20.3399C19.4686 19.4399 19.2086 18.7099 18.6886 18.1799C18.1686 17.6499 17.3686 17.3899 16.2986 17.3899L16.3086 17.4099Z"
fill="white"
/>
</svg>
);
case "zh":
return (
<svg
className="h-12 w-12 cursor-pointer"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="3" y="3" width="42" height="42" rx="21" fill="black" />
<path d="M10.287 20.382H6.291V24.147H10.287V20.382Z" fill={"white"} />
<path
d="M13.704 24.147H17.721V20.382H13.704V24.147Z"
fill={"white"}
/>
<path
d="M36.1254 20.046H29.8575C30.6606 21.9406 31.7187 23.6442 33.0513 25.1217C34.3105 23.6927 35.3315 22.0126 36.1254 20.046Z"
fill={"white"}
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M24 48C37.2548 48 48 37.2548 48 24C48 10.7452 37.2548 0 24 0C10.7452 0 0 10.7452 0 24C0 37.2548 10.7452 48 24 48ZM10.287 13.5H13.704V17.1541H21.117V28.446H17.721V27.375H13.704V33.969H10.287V27.375H6.291V28.5511H3V17.1541H10.287V13.5ZM31.35 13.5H34.704V16.8181H43.083V20.046H39.8887C38.804 22.9506 37.3746 25.3834 35.581 27.4065C37.6488 28.9237 40.1651 30.0542 43.1682 30.7291L43.8469 30.8817L43.3465 31.3649C42.7753 31.9162 41.9777 33.0771 41.5886 33.7939L41.4484 34.0521L41.1642 33.9778C37.8385 33.1088 35.1249 31.7521 32.8974 29.9253C30.6296 31.6954 27.9389 33.0335 24.802 34.015L24.4889 34.1129L24.3502 33.8156C24.0724 33.2203 23.2933 32.029 22.8051 31.439L22.4307 30.9868L22.9986 30.8373C25.936 30.0648 28.4025 28.9702 30.4373 27.4935C28.6775 25.4061 27.319 22.9142 26.2412 20.046H23.097V16.8181H31.35V13.5Z"
fill={"white"}
/>
</svg>
);
case "en":
default:
return (
<svg
className="h-12 w-12 cursor-pointer"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="3" y="3" width="42" height="42" rx="21" fill="black" />
<path
d="M24 0C10.75 0 0 10.75 0 24C0 37.25 10.75 48 24 48C37.25 48 48 37.25 48 24C48 10.75 37.25 0 24 0ZM21.57 33.79H8.41V14.15H21.55V17.43H12.45V22.11H20.22V25.28H12.45V30.54H21.57V33.79ZM39.54 33.79H35.49L27.61 20.87V33.79H23.56V14.15H27.61L35.5 27.1V14.15H39.53V33.79H39.54Z"
fill="white"
/>
</svg>
);
}
};
const CollapsedIcon = () => (
<svg
className="h-12 w-12 cursor-pointer"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="4" y="3" width="39" height="42" rx="19.5" fill="black" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 24C0 10.75 10.75 0 24 0C37.25 0 48 10.75 48 24C48 37.25 37.25 48 24 48C10.75 48 0 37.25 0 24ZM36.05 19.41L38.27 25.47L40.47 19.39L39.17 19.77C39.16 19.75 39.16 19.35 39.16 19.32C38.58 12.41 32.99 7.15 25.94 7.15C25.42 7.15 24.99 7.58 24.99 8.1C24.99 8.62 25.42 9.05 25.94 9.05C31.92 9.05 36.68 13.47 37.26 19.31C37.26 19.325 37.2625 19.435 37.265 19.545C37.2675 19.655 37.27 19.765 37.27 19.78L36.05 19.41ZM8.90375 27.5369C11.3535 26.9568 13.6332 25.8438 15.5701 24.2838L15.5709 24.2846C17.2124 25.8526 19.2497 26.9784 21.4812 27.5521C21.5875 27.5649 21.6945 27.5649 21.8007 27.5521C22.3194 27.6074 22.8298 27.3911 23.1385 26.9848C23.448 26.5786 23.5086 26.0441 23.2986 25.5826C23.0879 25.1211 22.6389 24.803 22.1202 24.7477C20.4804 24.3182 18.9808 23.4937 17.7634 22.3495C20.2489 20.0131 22.1036 17.1245 23.1659 13.9363C23.2729 13.5077 23.165 13.0566 22.8754 12.716C22.6016 12.3627 22.1709 12.1552 21.7136 12.1552H16.6307V10.402C16.6307 9.90121 16.3535 9.43889 15.9045 9.1881C15.4556 8.9373 14.9012 8.9373 14.4522 9.1881C14.0033 9.43809 13.7261 9.90121 13.7261 10.402V12.1824H8.64317C8.12367 12.1824 7.64484 12.45 7.38509 12.8835C7.12534 13.317 7.12534 13.8522 7.38509 14.2857C7.64484 14.7192 8.1245 14.9868 8.64317 14.9868H19.6655C18.6804 16.9971 17.3443 18.828 15.7153 20.3993C14.8373 19.3977 14.0688 18.3128 13.4207 17.1598C13.2747 16.7896 12.9734 16.4972 12.5909 16.3529C12.2083 16.2087 11.7809 16.2279 11.4141 16.405C11.0473 16.5821 10.7751 16.901 10.6647 17.2824C10.5544 17.6638 10.6166 18.0724 10.8357 18.4074C11.6058 19.7423 12.5153 20.997 13.551 22.1516C12.0207 23.3728 10.2307 24.2533 8.30873 24.7317C7.92284 24.7709 7.56932 24.956 7.32617 25.2469C7.08219 25.5369 6.96767 25.9095 7.00833 26.2813C7.04899 26.6539 7.24069 26.9952 7.54193 27.23C7.84235 27.4656 8.22824 27.5761 8.61329 27.5369C8.70956 27.5513 8.80748 27.5513 8.90375 27.5369ZM34.9002 38.8803C35.2512 39.0301 35.6487 39.0397 36.0072 38.9067C36.3815 38.7865 36.6886 38.5237 36.8587 38.18C37.0288 37.8362 37.0462 37.4404 36.9077 37.0839L30.3434 20.7767C30.238 20.5131 30.0529 20.2863 29.8115 20.1261C29.57 19.9658 29.2845 19.8793 28.9924 19.8785C28.7019 19.8785 28.4173 19.9626 28.1766 20.1196C27.9359 20.2775 27.7501 20.501 27.6422 20.7623L21.136 36.523C20.9443 36.9885 21.024 37.5181 21.346 37.9116C21.668 38.305 22.1825 38.5029 22.6962 38.4308C23.2107 38.3579 23.6455 38.0269 23.8372 37.5606L25.4057 33.6489H32.3185L34.1342 38.1079C34.2737 38.4524 34.5492 38.7304 34.9002 38.8803ZM28.9052 25.1652L31.1857 30.8445H31.1849H26.5667L28.9052 25.1652Z"
fill="#FFFFFF"
/>
</svg>
);
const ArrowIcon = ({ rotation }: { rotation: number }) => (
<svg
style={{
transform: `rotate(${rotation}deg)`,
transition: "transform 0.15s ease",
}}
className="h-12 w-12"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="8" y="7" width="31" height="33" fill="black" />
<path
d="M24.0001 0C10.7501 0 0.00012207 10.75 0.00012207 24C0.00012207 37.25 10.7501 48 24.0001 48C37.2501 48 48.0001 37.25 48.0001 24C48.0001 10.75 37.2501 0 24.0001 0ZM37.5401 25.84C37.5401 26.4 37.0901 26.85 36.5301 26.85H20.5901C20.1401 26.85 19.9201 27.39 20.2301 27.71L27.6801 35.16C28.0801 35.56 28.0801 36.2 27.6801 36.59L25.0801 39.19C24.6801 39.59 24.0401 39.59 23.6501 39.19L12.4901 28.03L9.17012 24.71C8.77012 24.31 8.77012 23.67 9.17012 23.28L12.4901 19.96L23.6501 8.8C24.0501 8.4 24.6901 8.4 25.0801 8.8L27.6801 11.4C28.0801 11.8 28.0801 12.44 27.6801 12.83L20.2301 20.28C19.9101 20.6 20.1401 21.14 20.5901 21.14H36.5301C37.0901 21.14 37.5401 21.59 37.5401 22.15V25.82V25.84Z"
fill="white"
/>
</svg>
);
const LanguageSelector = observer(
({ onBack, isSidebarOpen = true }: LanguageSelectorProps) => {
const { setLanguage } = languageStore;
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!isOpen) {
return;
}
const handleOutside = (event: PointerEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("pointerdown", handleOutside);
return () => {
document.removeEventListener("pointerdown", handleOutside);
};
}, [isOpen]);
const handleSelect = (code: Language) => {
setLanguage(code);
setIsOpen(false);
};
const toggle = () => setIsOpen((prev) => !prev);
return (
<div
ref={containerRef}
className="pointer-events-auto"
style={{
width: "500px",
transition: "width 0.25s ease",
flex: "0 0 auto",
}}
>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 ">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onBack?.();
}}
className="flex h-12 w-12 items-center justify-center"
aria-label={
isOpen ? "Скрыть выбор языка" : "Показать выбор языка"
}
>
<ArrowIcon rotation={isSidebarOpen ? 0 : 180} />
</button>
{isOpen ? (
LANGUAGES.map((lang) => (
<button
key={lang}
type="button"
onClick={(event) => {
event.stopPropagation();
handleSelect(lang);
}}
className="flex h-12 w-12 items-center justify-center"
aria-label={`Переключить язык на ${lang.toUpperCase()}`}
>
{renderLanguageIcon(lang)}
</button>
))
) : (
<div
className="flex h-12 w-12 items-center justify-center"
onClick={toggle}
>
<CollapsedIcon />
</div>
)}
</div>
</div>
</div>
);
}
);
export default LanguageSelector;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,619 @@
import { useState, useEffect } from "react";
import {
Stack,
Typography,
Accordion,
AccordionSummary,
AccordionDetails,
useTheme,
TextField,
Autocomplete,
TableCell,
TableContainer,
Table,
TableHead,
TableRow,
Paper,
TableBody,
Checkbox,
FormControlLabel,
Tabs,
Tab,
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import {
AnimatedCircleButton,
authInstance,
languageStore,
selectedCityStore,
} from "@shared";
type Field<T> = {
label: string;
data: keyof T;
render?: (value: any) => React.ReactNode;
};
type LinkedStationsProps<T> = {
parentId: string | number;
fields: Field<T>[];
setItemsParent?: (items: T[]) => void;
type: "show" | "edit";
onUpdate?: () => void;
disableCreation?: boolean;
updatedLinkedItems?: T[];
refresh?: number;
};
export const LinkedStations = <
T extends { id: number; name: string; [key: string]: any }
>(
props: LinkedStationsProps<T>
) => {
const theme = useTheme();
return (
<>
<Accordion sx={{ width: "100%" }}>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
background: theme.palette.background.paper,
borderBottom: `1px solid ${theme.palette.divider}`,
width: "100%",
}}
>
<Typography variant="subtitle1" fontWeight="bold">
Привязанные остановки
</Typography>
</AccordionSummary>
<AccordionDetails
sx={{ background: theme.palette.background.paper, width: "100%" }}
>
<Stack gap={2} width="100%">
<LinkedStationsContents {...props} />
</Stack>
</AccordionDetails>
</Accordion>
</>
);
};
const LinkedStationsContentsInner = <
T extends { id: number; name: string; [key: string]: any }
>({
parentId,
setItemsParent,
fields,
type,
onUpdate,
disableCreation = false,
updatedLinkedItems,
refresh,
}: LinkedStationsProps<T>) => {
const { language } = languageStore;
const [allItems, setAllItems] = useState<T[]>([]);
const [linkedItems, setLinkedItems] = useState<T[]>([]);
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState(0);
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [selectedToDetach, setSelectedToDetach] = useState<Set<number>>(
new Set()
);
const [isLinkingSingle, setIsLinkingSingle] = useState(false);
const [isLinkingBulk, setIsLinkingBulk] = useState(false);
const [isBulkDetaching, setIsBulkDetaching] = useState(false);
const [detachingIds, setDetachingIds] = useState<Set<number>>(new Set());
useEffect(() => {}, [error]);
const parentResource = "sight";
const childResource = "station";
const buildPayload = (ids: number[]) => ({
[`${childResource}_ids`]: ids,
});
const availableItems = allItems
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
.filter((item) => {
const selectedCityId = selectedCityStore.selectedCityId;
if (selectedCityId && "city_id" in item) {
return item.city_id === selectedCityId;
}
return true;
})
.sort((a, b) => a.name.localeCompare(b.name));
const filteredAvailableItems = availableItems.filter((item) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
const name = String(item.name || "").toLowerCase();
const description = String(item.description || "").toLowerCase();
return name.includes(query) || description.includes(query);
});
useEffect(() => {
if (updatedLinkedItems) {
setLinkedItems(updatedLinkedItems);
}
}, [updatedLinkedItems]);
useEffect(() => {
setItemsParent?.(linkedItems);
}, [linkedItems, setItemsParent]);
useEffect(() => {
setSelectedToDetach((prev) => {
const updated = new Set<number>();
linkedItems.forEach((item) => {
if (prev.has(item.id)) {
updated.add(item.id);
}
});
return updated;
});
}, [linkedItems]);
const linkItem = () => {
if (selectedItemId !== null) {
setError(null);
const requestData = buildPayload([selectedItemId]);
setIsLinkingSingle(true);
authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
.then(() => {
const newItem = allItems.find((item) => item.id === selectedItemId);
if (newItem) {
setLinkedItems([...linkedItems, newItem]);
}
setSelectedItemId(null);
onUpdate?.();
})
.catch((error) => {
console.error("Error linking station:", error);
setError("Failed to link station");
})
.finally(() => {
setIsLinkingSingle(false);
});
}
};
const deleteItem = (itemId: number) => {
setError(null);
setDetachingIds((prev) => {
const next = new Set(prev);
next.add(itemId);
return next;
});
authInstance
.delete(`/${parentResource}/${parentId}/${childResource}`, {
data: buildPayload([itemId]),
})
.then(() => {
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
onUpdate?.();
})
.catch((error) => {
console.error("Error deleting station:", error);
setError("Failed to delete station");
})
.finally(() => {
setDetachingIds((prev) => {
const next = new Set(prev);
next.delete(itemId);
return next;
});
});
};
const handleCheckboxChange = (itemId: number) => {
const updated = new Set(selectedItems);
if (updated.has(itemId)) {
updated.delete(itemId);
} else {
updated.add(itemId);
}
setSelectedItems(updated);
};
const handleBulkLink = async () => {
if (selectedItems.size === 0) return;
setError(null);
setIsLinkingBulk(true);
const idsToLink = Array.from(selectedItems);
try {
await authInstance.post(
`/${parentResource}/${parentId}/${childResource}`,
buildPayload(idsToLink)
);
const newItems = allItems.filter((item) => idsToLink.includes(item.id));
setLinkedItems((prev) => {
const existingIds = new Set(prev.map((item) => item.id));
const additions = newItems.filter((item) => !existingIds.has(item.id));
return [...prev, ...additions];
});
setSelectedItems((prev) => {
const remaining = new Set(prev);
idsToLink.forEach((id) => remaining.delete(id));
return remaining;
});
onUpdate?.();
} catch (error) {
console.error("Error linking stations:", error);
setError("Failed to link stations");
}
setIsLinkingBulk(false);
};
const toggleDetachSelection = (itemId: number) => {
const updated = new Set(selectedToDetach);
if (updated.has(itemId)) {
updated.delete(itemId);
} else {
updated.add(itemId);
}
setSelectedToDetach(updated);
};
const handleToggleAllDetach = (checked: boolean) => {
if (!checked) {
setSelectedToDetach(new Set());
return;
}
setSelectedToDetach(new Set(linkedItems.map((item) => item.id)));
};
const handleBulkDetach = async () => {
const idsToDetach = Array.from(selectedToDetach);
if (idsToDetach.length === 0) return;
setError(null);
setIsBulkDetaching(true);
setDetachingIds((prev) => {
const next = new Set(prev);
idsToDetach.forEach((id) => next.add(id));
return next;
});
try {
await authInstance.delete(
`/${parentResource}/${parentId}/${childResource}`,
{
data: buildPayload(idsToDetach),
}
);
setLinkedItems((prev) =>
prev.filter((item) => !idsToDetach.includes(item.id))
);
setSelectedToDetach((prev) => {
const remaining = new Set(prev);
idsToDetach.forEach((id) => remaining.delete(id));
return remaining;
});
onUpdate?.();
} catch (error) {
console.error("Error deleting stations:", error);
setError("Failed to delete stations");
}
setDetachingIds((prev) => {
const next = new Set(prev);
idsToDetach.forEach((id) => next.delete(id));
return next;
});
setIsBulkDetaching(false);
};
const allSelectedForDetach =
linkedItems.length > 0 &&
linkedItems.every((item) => selectedToDetach.has(item.id));
const isIndeterminateDetach =
selectedToDetach.size > 0 && !allSelectedForDetach;
useEffect(() => {
if (parentId) {
setIsLoading(true);
setError(null);
authInstance
.get(`/${parentResource}/${parentId}/${childResource}`)
.then((response) => {
setLinkedItems(response?.data || []);
})
.catch((error) => {
console.error("Error fetching linked stations:", error);
setError("Failed to load linked stations");
setLinkedItems([]);
})
.finally(() => {
setIsLoading(false);
});
}
}, [parentId, language, refresh]);
useEffect(() => {
if (type === "edit") {
setError(null);
authInstance
.get(`/${childResource}`)
.then((response) => {
setAllItems(response?.data || []);
})
.catch((error) => {
console.error("Error fetching all stations:", error);
setError("Failed to load available stations");
setAllItems([]);
});
}
}, [type]);
return (
<>
{linkedItems?.length > 0 && (
<TableContainer component={Paper} sx={{ width: "100%" }}>
<Table sx={{ width: "100%" }}>
<TableHead>
<TableRow>
{type === "edit" && (
<TableCell width="50px">
<Checkbox
size="small"
checked={allSelectedForDetach}
indeterminate={isIndeterminateDetach}
onChange={(e) => handleToggleAllDetach(e.target.checked)}
/>
</TableCell>
)}
<TableCell key="id" width="60px">
</TableCell>
{fields.map((field) => (
<TableCell key={String(field.data)}>{field.label}</TableCell>
))}
{type === "edit" && (
<TableCell width="120px">Действие</TableCell>
)}
</TableRow>
</TableHead>
<TableBody>
{linkedItems.map((item, index) => (
<TableRow key={item.id} hover>
{type === "edit" && (
<TableCell>
<Checkbox
size="small"
checked={selectedToDetach.has(item.id)}
onChange={() => toggleDetachSelection(item.id)}
/>
</TableCell>
)}
<TableCell>{index + 1}</TableCell>
{fields.map((field, idx) => (
<TableCell key={String(field.data) + String(idx)}>
{field.render
? field.render(item[field.data])
: item[field.data]}
</TableCell>
))}
{type === "edit" && (
<TableCell>
<AnimatedCircleButton
variant="outlined"
color="error"
size="small"
onClick={(e) => {
e.stopPropagation();
deleteItem(item.id);
}}
disabled={detachingIds.has(item.id)}
loading={detachingIds.has(item.id)}
>
Отвязать
</AnimatedCircleButton>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
{type === "edit" && linkedItems.length > 0 && (
<Stack direction="row" gap={2} mt={2}>
<AnimatedCircleButton
variant="outlined"
color="error"
onClick={handleBulkDetach}
disabled={selectedToDetach.size === 0 || isBulkDetaching}
loading={isBulkDetaching}
>
Отвязать выбранные ({selectedToDetach.size})
</AnimatedCircleButton>
</Stack>
)}
{linkedItems.length === 0 && !isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
Остановки не найдены
</Typography>
)}
{type === "edit" && !disableCreation && (
<Stack gap={2} mt={2}>
<Typography variant="subtitle1">Добавить остановки</Typography>
<Tabs
value={activeTab}
onChange={(_, value) => setActiveTab(value)}
variant="fullWidth"
>
<Tab label="По одной" />
<Tab label="Массово" />
</Tabs>
<Box sx={{ mt: 1 }}>
{activeTab === 0 && (
<Stack gap={2}>
<Autocomplete
fullWidth
value={
availableItems?.find(
(item) => item.id === selectedItemId
) || null
}
onChange={(_, newValue) =>
setSelectedItemId(newValue?.id || null)
}
options={availableItems}
getOptionLabel={(item) => String(item.name)}
renderInput={(params) => (
<TextField
{...params}
label="Выберите остановку"
placeholder="Введите название или описание остановки..."
fullWidth
/>
)}
isOptionEqualToValue={(option, value) =>
option.id === value?.id
}
filterOptions={(options, { inputValue }) => {
if (!inputValue.trim()) return options;
const query = inputValue.toLowerCase();
return options.filter((option) => {
const name = String(option.name || "").toLowerCase();
const description = String(
option.description || ""
).toLowerCase();
return (
name.includes(query) || description.includes(query)
);
});
}}
renderOption={(props, option) => (
<li {...props} key={option.id}>
<div className="flex justify-between items-center w-full">
<p>{String(option.name)}</p>
<p className="text-xs text-gray-500 max-w-[300px] mr-4 truncate">
{String(option.description)}
</p>
</div>
</li>
)}
/>
<AnimatedCircleButton
variant="contained"
onClick={linkItem}
disabled={!selectedItemId || isLinkingSingle}
loading={isLinkingSingle}
sx={{ alignSelf: "flex-start" }}
>
Добавить
</AnimatedCircleButton>
</Stack>
)}
{activeTab === 1 && (
<Stack gap={2}>
<TextField
fullWidth
label="Поиск остановок"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Введите название или описание остановки..."
size="small"
/>
<Paper sx={{ maxHeight: 300, overflow: "auto", p: 2 }}>
<Stack gap={1}>
{filteredAvailableItems.map((item) => (
<FormControlLabel
key={item.id}
control={
<Checkbox
checked={selectedItems.has(item.id)}
onChange={() => handleCheckboxChange(item.id)}
size="small"
/>
}
label={
<div className="flex justify-between items-center w-full gap-10">
<p>{String(item.name)}</p>
<p className="text-xs text-gray-500 max-w-[300px] truncate text-ellipsis">
{String(item.description)}
</p>
</div>
}
sx={{
margin: 0,
"& .MuiFormControlLabel-label": {
fontSize: "0.9rem",
width: "100%",
},
}}
/>
))}
{filteredAvailableItems.length === 0 && (
<Typography
color="textSecondary"
textAlign="center"
py={1}
>
{searchQuery.trim()
? "Остановки не найдены"
: "Нет доступных остановок"}
</Typography>
)}
</Stack>
</Paper>
<AnimatedCircleButton
variant="contained"
onClick={handleBulkLink}
disabled={selectedItems.size === 0 || isLinkingBulk}
loading={isLinkingBulk}
sx={{ alignSelf: "flex-start" }}
>
Добавить выбранные ({selectedItems.size})
</AnimatedCircleButton>
</Stack>
)}
</Box>
</Stack>
)}
{isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
Загрузка...
</Typography>
)}
{error && (
<Typography color="error" textAlign="center" py={2}>
{error}
</Typography>
)}
</>
);
};
export const LinkedStationsContents = observer(
LinkedStationsContentsInner
) as typeof LinkedStationsContentsInner;

View File

@@ -1,20 +1,38 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, sightsStore } from "@shared"; import { ruRU } from "@mui/x-data-grid/locales";
import { useEffect, useState } from "react"; import {
cityStore,
languageStore,
sightsStore,
selectedCityStore,
} from "@shared";
import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Pencil, Trash2 } from "lucide-react"; import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const SightListPage = observer(() => { export const SightListPage = observer(() => {
const { sights, getSights, deleteListSight } = sightsStore; const { sights, getSights, deleteListSight } = sightsStore;
const { cities, getCities } = cityStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null); // Lifted state const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
getSights(); const fetchSights = async () => {
setIsLoading(true);
await getCities(language);
await getSights();
setIsLoading(false);
};
fetchSights();
}, [language]); }, [language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
@@ -22,18 +40,40 @@ export const SightListPage = observer(() => {
field: "name", field: "name",
headerName: "Имя", headerName: "Имя",
flex: 1, flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
}, },
{ {
field: "city", field: "city_id",
headerName: "Город", headerName: "Город",
flex: 1, flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
cities[language].data.find((el) => el.id == params.value)?.name
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
}, },
{ {
field: "actions", field: "actions",
headerName: "Действия", headerName: "Действия",
align: "center", align: "center",
headerAlign: "center", headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
return ( return (
@@ -58,10 +98,19 @@ export const SightListPage = observer(() => {
}, },
]; ];
const rows = sights.map((sight) => ({ // Фильтрация достопримечательностей по выбранному городу
const filteredSights = useMemo(() => {
const { selectedCityId } = selectedCityStore;
if (!selectedCityId) {
return sights;
}
return sights.filter((sight: any) => sight.city_id === selectedCityId);
}, [sights, selectedCityStore.selectedCityId]);
const rows = filteredSights.map((sight) => ({
id: sight.id, id: sight.id,
name: sight.name, name: sight.name,
city: sight.city, city_id: sight.city_id,
})); }));
return ( return (
@@ -76,11 +125,41 @@ export const SightListPage = observer(() => {
path="/sight/create" path="/sight/create"
/> />
</div> </div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooterPagination
hideFooter hideFooter
checkboxSelection
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? (
<CircularProgress size={20} />
) : (
"Нет достопримечательностей"
)}
</Box>
),
}}
/> />
</div> </div>
@@ -98,6 +177,19 @@ export const SightListPage = observer(() => {
setRowId(null); setRowId(null);
}} }}
/> />
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteListSight(id)));
getSights();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</> </>
); );
}); });

View File

@@ -1 +1,2 @@
export * from "./SightListPage"; export * from "./SightListPage";
export { LinkedStations } from "./LinkedStations";

View File

@@ -1,26 +1,21 @@
import { Button, Paper, TextField } from "@mui/material"; import { Button, TextField } from "@mui/material";
import { snapshotStore } from "@shared"; import { snapshotStore } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react"; import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useEffect, useState } from "react"; import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { runInAction } from "mobx";
export const SnapshotCreatePage = observer(() => { export const SnapshotCreatePage = observer(() => {
const { id } = useParams(); const { createSnapshot, getSnapshotStatus, snapshotStatus } = snapshotStore;
const { getSnapshot, createSnapshot } = snapshotStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
(async () => {
await getSnapshot(id as string);
})();
}, [id]);
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <div className="w-full h-[400px] flex justify-center items-center">
<div className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<button <button
className="flex items-center gap-2" className="flex items-center gap-2"
@@ -48,22 +43,49 @@ export const SnapshotCreatePage = observer(() => {
onClick={async () => { onClick={async () => {
try { try {
setIsLoading(true); setIsLoading(true);
await createSnapshot(name); const id = await createSnapshot(name);
setIsLoading(false);
await getSnapshotStatus(id);
while (snapshotStore.snapshotStatus?.Status != "done") {
await new Promise((resolve) => setTimeout(resolve, 1000));
await getSnapshotStatus(id);
}
if (snapshotStore.snapshotStatus?.Status === "done") {
toast.success("Снапшот успешно создан"); toast.success("Снапшот успешно создан");
runInAction(() => {
snapshotStore.snapshotStatus = null;
});
navigate(-1);
}
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.error("Ошибка при создании снапшота");
} finally {
setIsLoading(false);
} }
}} }}
disabled={isLoading} disabled={isLoading || !name.trim()}
> >
{isLoading ? ( {isLoading ? (
<div className="flex items-center gap-2">
<Loader2 size={20} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />
<span>
{snapshotStatus?.Progress
? (snapshotStatus.Progress * 100).toFixed(2)
: 0}
%
</span>
</div>
) : ( ) : (
"Сохранить" "Сохранить"
)} )}
</Button> </Button>
</div> </div>
</Paper> </div>
</div>
); );
}); });

View File

@@ -1,10 +1,11 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { languageStore, snapshotStore } from "@shared"; import { languageStore, snapshotStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { DatabaseBackup, Trash2 } from "lucide-react"; import { DatabaseBackup, Trash2 } from "lucide-react";
import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets"; import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const SnapshotListPage = observer(() => { export const SnapshotListPage = observer(() => {
const { snapshots, getSnapshots, deleteSnapshot, restoreSnapshot } = const { snapshots, getSnapshots, deleteSnapshot, restoreSnapshot } =
@@ -14,9 +15,15 @@ export const SnapshotListPage = observer(() => {
const [rowId, setRowId] = useState<string | null>(null); // Lifted state const [rowId, setRowId] = useState<string | null>(null); // Lifted state
const { language } = languageStore; const { language } = languageStore;
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false); const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => { useEffect(() => {
getSnapshots(); const fetchSnapshots = async () => {
setIsLoading(true);
await getSnapshots();
setIsLoading(false);
};
fetchSnapshots();
}, [language]); }, [language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
@@ -36,6 +43,7 @@ export const SnapshotListPage = observer(() => {
headerName: "Действия", headerName: "Действия",
width: 300, width: 300,
headerAlign: "center", headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
return ( return (
@@ -81,6 +89,15 @@ export const SnapshotListPage = observer(() => {
columns={columns} columns={columns}
hideFooterPagination hideFooterPagination
hideFooter hideFooter
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет снапшотов"}
</Box>
),
}}
/> />
</div> </div>
@@ -101,12 +118,15 @@ export const SnapshotListPage = observer(() => {
<SnapshotRestore <SnapshotRestore
open={isRestoreModalOpen} open={isRestoreModalOpen}
loading={isLoading}
onDelete={async () => { onDelete={async () => {
setIsLoading(true);
if (rowId) { if (rowId) {
await restoreSnapshot(rowId); await restoreSnapshot(rowId);
} }
setIsRestoreModalOpen(false); setIsRestoreModalOpen(false);
setRowId(null); setRowId(null);
setIsLoading(false);
}} }}
onCancel={() => { onCancel={() => {
setIsRestoreModalOpen(false); setIsRestoreModalOpen(false);

View File

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

View File

@@ -0,0 +1,606 @@
import { useState, useEffect } from "react";
import {
Stack,
Typography,
Accordion,
AccordionSummary,
AccordionDetails,
useTheme,
TextField,
Autocomplete,
TableCell,
TableContainer,
Table,
TableHead,
TableRow,
Paper,
TableBody,
Checkbox,
FormControlLabel,
Tabs,
Tab,
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import {
AnimatedCircleButton,
authInstance,
languageStore,
selectedCityStore,
} from "@shared";
type Field<T> = {
label: string;
data: keyof T;
render?: (value: any) => React.ReactNode;
};
type LinkedSightsProps<T> = {
parentId: string | number;
fields: Field<T>[];
setItemsParent?: (items: T[]) => void;
type: "show" | "edit";
onUpdate?: () => void;
disableCreation?: boolean;
updatedLinkedItems?: T[];
refresh?: number;
};
export const LinkedSights = <
T extends { id: number; name: string; [key: string]: any }
>(
props: LinkedSightsProps<T>
) => {
const theme = useTheme();
return (
<>
<Accordion sx={{ width: "100%" }}>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
background: theme.palette.background.paper,
borderBottom: `1px solid ${theme.palette.divider}`,
width: "100%",
}}
>
<Typography variant="subtitle1" fontWeight="bold">
Привязанные достопримечательности
</Typography>
</AccordionSummary>
<AccordionDetails
sx={{ background: theme.palette.background.paper, width: "100%" }}
>
<Stack gap={2} width="100%">
<LinkedSightsContents {...props} />
</Stack>
</AccordionDetails>
</Accordion>
</>
);
};
const LinkedSightsContentsInner = <
T extends { id: number; name: string; [key: string]: any }
>({
parentId,
setItemsParent,
fields,
type,
onUpdate,
disableCreation = false,
updatedLinkedItems,
refresh,
}: LinkedSightsProps<T>) => {
const { language } = languageStore;
const [allItems, setAllItems] = useState<T[]>([]);
const [linkedItems, setLinkedItems] = useState<T[]>([]);
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState(0);
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [selectedToDetach, setSelectedToDetach] = useState<Set<number>>(
new Set()
);
const [isLinkingSingle, setIsLinkingSingle] = useState(false);
const [isLinkingBulk, setIsLinkingBulk] = useState(false);
const [isBulkDetaching, setIsBulkDetaching] = useState(false);
const [detachingIds, setDetachingIds] = useState<Set<number>>(new Set());
useEffect(() => {}, [error]);
const parentResource = "station";
const childResource = "sight";
const buildPayload = (ids: number[]) => ({
[`${childResource}_ids`]: ids,
});
const availableItems = allItems
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
.filter((item) => {
// Фильтруем по городу из навбара
const selectedCityId = selectedCityStore.selectedCityId;
if (selectedCityId && "city_id" in item) {
return item.city_id === selectedCityId;
}
return true;
})
.sort((a, b) => a.name.localeCompare(b.name));
const filteredAvailableItems = availableItems.filter((item) => {
if (!searchQuery.trim()) return true;
return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());
});
useEffect(() => {
if (updatedLinkedItems) {
setLinkedItems(updatedLinkedItems);
}
}, [updatedLinkedItems]);
useEffect(() => {
setItemsParent?.(linkedItems);
}, [linkedItems, setItemsParent]);
useEffect(() => {
setSelectedToDetach((prev) => {
const updated = new Set<number>();
linkedItems.forEach((item) => {
if (prev.has(item.id)) {
updated.add(item.id);
}
});
return updated;
});
}, [linkedItems]);
const linkItem = () => {
if (selectedItemId !== null) {
setError(null);
const requestData = buildPayload([selectedItemId]);
setIsLinkingSingle(true);
authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
.then(() => {
const newItem = allItems.find((item) => item.id === selectedItemId);
if (newItem) {
setLinkedItems([...linkedItems, newItem]);
}
setSelectedItemId(null);
onUpdate?.();
})
.catch((error) => {
console.error("Error linking sight:", error);
setError("Failed to link sight");
})
.finally(() => {
setIsLinkingSingle(false);
});
}
};
const deleteItem = (itemId: number) => {
setError(null);
setDetachingIds((prev) => {
const next = new Set(prev);
next.add(itemId);
return next;
});
authInstance
.delete(`/${parentResource}/${parentId}/${childResource}`, {
data: buildPayload([itemId]),
})
.then(() => {
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
onUpdate?.();
})
.catch((error) => {
console.error("Error deleting sight:", error);
setError("Failed to delete sight");
})
.finally(() => {
setDetachingIds((prev) => {
const next = new Set(prev);
next.delete(itemId);
return next;
});
});
};
const handleCheckboxChange = (itemId: number) => {
const updated = new Set(selectedItems);
if (updated.has(itemId)) {
updated.delete(itemId);
} else {
updated.add(itemId);
}
setSelectedItems(updated);
};
const handleBulkLink = async () => {
if (selectedItems.size === 0) return;
setError(null);
setIsLinkingBulk(true);
const idsToLink = Array.from(selectedItems);
try {
await authInstance.post(
`/${parentResource}/${parentId}/${childResource}`,
buildPayload(idsToLink)
);
const newItems = allItems.filter((item) => idsToLink.includes(item.id));
setLinkedItems((prev) => {
const existingIds = new Set(prev.map((item) => item.id));
const additions = newItems.filter((item) => !existingIds.has(item.id));
return [...prev, ...additions];
});
setSelectedItems((prev) => {
const remaining = new Set(prev);
idsToLink.forEach((id) => remaining.delete(id));
return remaining;
});
onUpdate?.();
} catch (error) {
console.error("Error linking sights:", error);
setError("Failed to link sights");
}
setIsLinkingBulk(false);
};
const toggleDetachSelection = (itemId: number) => {
const updated = new Set(selectedToDetach);
if (updated.has(itemId)) {
updated.delete(itemId);
} else {
updated.add(itemId);
}
setSelectedToDetach(updated);
};
const handleToggleAllDetach = (checked: boolean) => {
if (!checked) {
setSelectedToDetach(new Set());
return;
}
setSelectedToDetach(new Set(linkedItems.map((item) => item.id)));
};
const handleBulkDetach = async () => {
const idsToDetach = Array.from(selectedToDetach);
if (idsToDetach.length === 0) return;
setError(null);
setIsBulkDetaching(true);
setDetachingIds((prev) => {
const next = new Set(prev);
idsToDetach.forEach((id) => next.add(id));
return next;
});
try {
await authInstance.delete(
`/${parentResource}/${parentId}/${childResource}`,
{
data: buildPayload(idsToDetach),
}
);
setLinkedItems((prev) =>
prev.filter((item) => !idsToDetach.includes(item.id))
);
setSelectedToDetach((prev) => {
const remaining = new Set(prev);
idsToDetach.forEach((id) => remaining.delete(id));
return remaining;
});
onUpdate?.();
} catch (error) {
console.error("Error deleting sights:", error);
setError("Failed to delete sights");
}
setDetachingIds((prev) => {
const next = new Set(prev);
idsToDetach.forEach((id) => next.delete(id));
return next;
});
setIsBulkDetaching(false);
};
const allSelectedForDetach =
linkedItems.length > 0 &&
linkedItems.every((item) => selectedToDetach.has(item.id));
const isIndeterminateDetach =
selectedToDetach.size > 0 && !allSelectedForDetach;
useEffect(() => {
if (parentId) {
setIsLoading(true);
setError(null);
authInstance
.get(`/${parentResource}/${parentId}/${childResource}`)
.then((response) => {
setLinkedItems(response?.data || []);
})
.catch((error) => {
console.error("Error fetching linked sights:", error);
setError("Failed to load linked sights");
setLinkedItems([]);
})
.finally(() => {
setIsLoading(false);
});
}
}, [parentId, language, refresh]);
useEffect(() => {
if (type === "edit") {
setError(null);
authInstance
.get(`/${childResource}`)
.then((response) => {
setAllItems(response?.data || []);
})
.catch((error) => {
console.error("Error fetching all sights:", error);
setError("Failed to load available sights");
setAllItems([]);
});
}
}, [type]);
return (
<>
{linkedItems?.length > 0 && (
<TableContainer component={Paper} sx={{ width: "100%" }}>
<Table sx={{ width: "100%" }}>
<TableHead>
<TableRow>
{type === "edit" && (
<TableCell width="50px">
<Checkbox
size="small"
checked={allSelectedForDetach}
indeterminate={isIndeterminateDetach}
onChange={(e) => handleToggleAllDetach(e.target.checked)}
/>
</TableCell>
)}
<TableCell key="id" width="60px">
</TableCell>
{fields.map((field) => (
<TableCell key={String(field.data)}>{field.label}</TableCell>
))}
{type === "edit" && (
<TableCell width="120px">Действие</TableCell>
)}
</TableRow>
</TableHead>
<TableBody>
{linkedItems.map((item, index) => (
<TableRow key={item.id} hover>
{type === "edit" && (
<TableCell>
<Checkbox
size="small"
checked={selectedToDetach.has(item.id)}
onChange={() => toggleDetachSelection(item.id)}
/>
</TableCell>
)}
<TableCell>{index + 1}</TableCell>
{fields.map((field, idx) => (
<TableCell key={String(field.data) + String(idx)}>
{field.render
? field.render(item[field.data])
: item[field.data]}
</TableCell>
))}
{type === "edit" && (
<TableCell>
<AnimatedCircleButton
variant="outlined"
color="error"
size="small"
onClick={(e) => {
e.stopPropagation();
deleteItem(item.id);
}}
disabled={detachingIds.has(item.id)}
loading={detachingIds.has(item.id)}
>
Отвязать
</AnimatedCircleButton>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
{type === "edit" && linkedItems.length > 0 && (
<Stack direction="row" gap={2} mt={2}>
<AnimatedCircleButton
variant="outlined"
color="error"
onClick={handleBulkDetach}
disabled={selectedToDetach.size === 0 || isBulkDetaching}
loading={isBulkDetaching}
>
Отвязать выбранные ({selectedToDetach.size})
</AnimatedCircleButton>
</Stack>
)}
{linkedItems.length === 0 && !isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
Достопримечательности не найдены
</Typography>
)}
{type === "edit" && !disableCreation && (
<Stack gap={2} mt={2}>
<Typography variant="subtitle1">
Добавить достопримечательности
</Typography>
<Tabs
value={activeTab}
onChange={(_, value) => setActiveTab(value)}
variant="fullWidth"
>
<Tab label="По одной" />
<Tab label="Массово" />
</Tabs>
<Box sx={{ mt: 1 }}>
{activeTab === 0 && (
<Stack gap={2}>
<Autocomplete
fullWidth
value={
availableItems?.find(
(item) => item.id === selectedItemId
) || null
}
onChange={(_, newValue) =>
setSelectedItemId(newValue?.id || null)
}
options={availableItems}
getOptionLabel={(item) => String(item.name)}
renderInput={(params) => (
<TextField
{...params}
label="Выберите достопримечательность"
fullWidth
/>
)}
isOptionEqualToValue={(option, value) =>
option.id === value?.id
}
filterOptions={(options, { inputValue }) => {
const searchWords = inputValue
.toLowerCase()
.split(" ")
.filter(Boolean);
return options.filter((option) => {
const optionWords = String(option.name)
.toLowerCase()
.split(" ");
return searchWords.every((searchWord) =>
optionWords.some((word) => word.startsWith(searchWord))
);
});
}}
renderOption={(props, option) => (
<li {...props} key={option.id}>
{String(option.name)}
</li>
)}
/>
<AnimatedCircleButton
variant="contained"
onClick={linkItem}
disabled={!selectedItemId || isLinkingSingle}
loading={isLinkingSingle}
sx={{ alignSelf: "flex-start" }}
>
Добавить
</AnimatedCircleButton>
</Stack>
)}
{activeTab === 1 && (
<Stack gap={2}>
<TextField
fullWidth
label="Поиск достопримечательностей"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Введите название..."
size="small"
/>
<Paper sx={{ maxHeight: 300, overflow: "auto", p: 2 }}>
<Stack gap={1}>
{filteredAvailableItems.map((item) => (
<FormControlLabel
key={item.id}
control={
<Checkbox
checked={selectedItems.has(item.id)}
onChange={() => handleCheckboxChange(item.id)}
size="small"
/>
}
label={String(item.name)}
sx={{
margin: 0,
"& .MuiFormControlLabel-label": {
fontSize: "0.9rem",
},
}}
/>
))}
{filteredAvailableItems.length === 0 && (
<Typography
color="textSecondary"
textAlign="center"
py={1}
>
{searchQuery.trim()
? "Достопримечательности не найдены"
: "Нет доступных достопримечательностей"}
</Typography>
)}
</Stack>
</Paper>
<AnimatedCircleButton
variant="contained"
onClick={handleBulkLink}
disabled={selectedItems.size === 0 || isLinkingBulk}
loading={isLinkingBulk}
sx={{ alignSelf: "flex-start" }}
>
Добавить выбранные ({selectedItems.size})
</AnimatedCircleButton>
</Stack>
)}
</Box>
</Stack>
)}
{isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
Загрузка...
</Typography>
)}
{error && (
<Typography color="error" textAlign="center" py={2}>
{error}
</Typography>
)}
</>
);
};
export const LinkedSightsContents = observer(
LinkedSightsContentsInner
) as typeof LinkedSightsContentsInner;

View File

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

View File

@@ -6,19 +6,28 @@ import {
MenuItem, MenuItem,
FormControl, FormControl,
InputLabel, InputLabel,
Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { stationsStore, languageStore, cityStore } from "@shared"; import {
stationsStore,
languageStore,
cityStore,
LoadingSpinner,
} from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets"; import { LanguageSwitcher } from "@widgets";
import { LinkedSights } from "../LinkedSights";
import { SaveWithoutCityAgree } from "@widgets";
export const StationEditPage = observer(() => { export const StationEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const { language } = languageStore; const { language } = languageStore;
const { id } = useParams(); const { id } = useParams();
const { const {
@@ -29,12 +38,30 @@ export const StationEditPage = observer(() => {
setLanguageEditStationData, setLanguageEditStationData,
} = stationsStore; } = stationsStore;
const { cities, getCities } = cityStore; const { cities, getCities } = cityStore;
const [coordinates, setCoordinates] = useState<string>("");
const handleEdit = async () => { const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => {
languageStore.setLanguage("ru");
}, []);
useEffect(() => {
if (
editStationData.common.latitude !== 0 ||
editStationData.common.longitude !== 0
) {
setCoordinates(
`${editStationData.common.latitude}, ${editStationData.common.longitude}`
);
}
}, [editStationData.common.latitude, editStationData.common.longitude]);
const executeEdit = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
await editStation(Number(id)); await editStation(Number(id));
toast.success("Станция успешно обновлена"); toast.success("Остановка успешно обновлена");
} catch (error) { } catch (error) {
console.error("Error updating station:", error); console.error("Error updating station:", error);
toast.error("Ошибка при обновлении станции"); toast.error("Ошибка при обновлении станции");
@@ -43,21 +70,72 @@ export const StationEditPage = observer(() => {
} }
}; };
const handleEdit = async () => {
const isCityMissing = !editStationData.common.city_id;
const isNameMissing =
!editStationData.ru.name ||
!editStationData.en.name ||
!editStationData.zh.name;
if (isCityMissing || isNameMissing) {
setIsSaveWarningOpen(true);
return;
}
await executeEdit();
};
const handleConfirmEdit = async () => {
setIsSaveWarningOpen(false);
await executeEdit();
};
const handleCancelEdit = () => {
setIsSaveWarningOpen(false);
};
useEffect(() => { useEffect(() => {
const fetchAndSetStationData = async () => { const fetchAndSetStationData = async () => {
if (!id) return; if (!id) {
setIsLoadingData(false);
return;
}
setIsLoadingData(true);
try {
const stationId = Number(id); const stationId = Number(id);
await getEditStation(stationId); await getEditStation(stationId);
await getCities(language); await getCities("ru");
await getCities("en");
await getCities("zh");
} finally {
setIsLoadingData(false);
}
}; };
fetchAndSetStationData(); fetchAndSetStationData();
}, [id, language]); }, [id]);
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных станции..." />
</Box>
);
}
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher /> <LanguageSwitcher />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button <button
className="flex items-center gap-2" className="flex items-center gap-2"
@@ -69,6 +147,9 @@ export const StationEditPage = observer(() => {
</div> </div>
<div className="flex flex-col gap-10 w-full items-end"> <div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
<h1 className="text-3xl break-words">{editStationData.ru.name}</h1>
</div>
<TextField <TextField
fullWidth fullWidth
label="Название" label="Название"
@@ -101,15 +182,15 @@ export const StationEditPage = observer(() => {
<TextField <TextField
fullWidth fullWidth
label="Описание" label="Описание"
value={editStationData[language].description || ""} value={editStationData.common.description || ""}
onChange={(e) => onChange={(e) =>
setLanguageEditStationData(language, { setEditCommonData({
description: e.target.value, description: e.target.value,
}) })
} }
/> />
<TextField {/* <TextField
fullWidth fullWidth
label="Адрес" label="Адрес"
value={editStationData[language].address || ""} value={editStationData[language].address || ""}
@@ -118,21 +199,38 @@ export const StationEditPage = observer(() => {
address: e.target.value, address: e.target.value,
}) })
} }
/> /> */}
<TextField <TextField
fullWidth fullWidth
label="Координаты" label="Координаты"
value={`${editStationData.common.latitude} ${editStationData.common.longitude}`} value={coordinates}
onChange={(e) => { onChange={(e) => {
const [latitude, longitude] = e.target.value.split(" ").map(Number); const newValue = e.target.value;
if (!isNaN(latitude) && !isNaN(longitude)) { setCoordinates(newValue);
const input = newValue.replace(/,/g, " ").trim();
const [latStr, lonStr] = input.split(/\s+/);
const lat = parseFloat(latStr);
const lon = parseFloat(lonStr);
const isValidLat = !isNaN(lat);
const isValidLon = !isNaN(lon);
if (isValidLat && isValidLon) {
setEditCommonData({ setEditCommonData({
latitude: latitude, latitude: lat,
longitude: longitude, longitude: lon,
});
} else {
setEditCommonData({
latitude: 0,
longitude: 0,
}); });
} }
}} }}
placeholder="Введите координаты в формате: широта долгота (можно использовать запятые или пробелы)"
/> />
<FormControl fullWidth> <FormControl fullWidth>
@@ -141,7 +239,7 @@ export const StationEditPage = observer(() => {
value={editStationData.common.city_id || ""} value={editStationData.common.city_id || ""}
label="Город" label="Город"
onChange={(e) => { onChange={(e) => {
const selectedCity = cities[language].find( const selectedCity = cities["ru"].data.find(
(city) => city.id === e.target.value (city) => city.id === e.target.value
); );
setEditCommonData({ setEditCommonData({
@@ -150,7 +248,7 @@ export const StationEditPage = observer(() => {
}); });
}} }}
> >
{cities[language].map((city) => ( {cities["ru"].data.map((city) => (
<MenuItem key={city.id} value={city.id}> <MenuItem key={city.id} value={city.id}>
{city.name} {city.name}
</MenuItem> </MenuItem>
@@ -158,20 +256,38 @@ export const StationEditPage = observer(() => {
</Select> </Select>
</FormControl> </FormControl>
{id && (
<LinkedSights
parentId={Number(id)}
fields={[{ label: "Название", data: "name" }]}
type="edit"
/>
)}
<Button <Button
variant="contained" variant="contained"
className="w-min flex gap-2 items-center" className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />} startIcon={<Save size={20} />}
onClick={handleEdit} onClick={handleEdit}
disabled={isLoading || !editStationData[language]?.name} disabled={isLoading}
> >
{isLoading ? ( {isLoading ? (
<Loader2 size={20} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />
) : ( ) : (
"Обновить" "Сохранить"
)} )}
</Button> </Button>
</div> </div>
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
{isSaveWarningOpen && (
<SaveWithoutCityAgree
blocker={{
proceed: handleConfirmEdit,
reset: handleCancelEdit,
}}
/>
)}
</Paper> </Paper>
); );
}); });

View File

@@ -1,20 +1,45 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, stationsStore } from "@shared"; import { ruRU } from "@mui/x-data-grid/locales";
import {
languageStore,
stationsStore,
selectedCityStore,
cityStore,
} from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Eye, Pencil, Trash2 } from "lucide-react"; import { Eye, Pencil, Trash2, Minus, Route } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; import {
CreateButton,
DeleteModal,
LanguageSwitcher,
EditStationTransfersModal,
} from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const StationListPage = observer(() => { export const StationListPage = observer(() => {
const { stationLists, getStationList, deleteStation } = stationsStore; const { stationLists, getStationList, deleteStation } = stationsStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null); // Lifted state const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [isTransfersModalOpen, setIsTransfersModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const [selectedStationId, setSelectedStationId] = useState<number | null>(
null
);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
getStationList(); const fetchStations = async () => {
setIsLoading(true);
await cityStore.getCities(language);
await getStationList();
setIsLoading(false);
};
fetchStations();
}, [language]); }, [language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
@@ -22,11 +47,33 @@ export const StationListPage = observer(() => {
field: "name", field: "name",
headerName: "Название", headerName: "Название",
flex: 1, flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
}, },
{ {
field: "system_name", field: "description",
headerName: "Системное название", headerName: "Описание",
flex: 1, flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
}, },
{ {
field: "direction", field: "direction",
@@ -50,9 +97,10 @@ export const StationListPage = observer(() => {
{ {
field: "actions", field: "actions",
headerName: "Действия", headerName: "Действия",
width: 140, width: 200,
align: "center", align: "center",
headerAlign: "center", headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
return ( return (
@@ -63,6 +111,15 @@ export const StationListPage = observer(() => {
<button onClick={() => navigate(`/station/${params.row.id}`)}> <button onClick={() => navigate(`/station/${params.row.id}`)}>
<Eye size={20} className="text-green-500" /> <Eye size={20} className="text-green-500" />
</button> </button>
<button
onClick={() => {
setSelectedStationId(params.row.id);
setIsTransfersModalOpen(true);
}}
title="Редактировать пересадки"
>
<Route size={20} className="text-purple-500" />
</button>
<button <button
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
@@ -77,10 +134,21 @@ export const StationListPage = observer(() => {
}, },
]; ];
const rows = stationLists[language].data.map((station: any) => ({ // Фильтрация станций по выбранному городу
const filteredStations = () => {
const { selectedCityId } = selectedCityStore;
if (!selectedCityId) {
return stationLists[language].data;
}
return stationLists[language].data.filter(
(station: any) => station.city_id === selectedCityId
);
};
const rows = filteredStations().map((station: any) => ({
id: station.id, id: station.id,
name: station.name, name: station.name,
system_name: station.system_name, description: station.description,
direction: station.direction, direction: station.direction,
})); }));
@@ -88,16 +156,43 @@ export const StationListPage = observer(() => {
<> <>
<LanguageSwitcher /> <LanguageSwitcher />
<div style={{ width: "100%" }}> <div className="w-full">
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Станции</h1> <h1 className="text-2xl">Станции</h1>
<CreateButton label="Создать станцию" path="/station/create" /> <CreateButton label="Создать остановки" path="/station/create" />
</div> </div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooterPagination hideFooterPagination
checkboxSelection
loading={isLoading}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter hideFooter
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет станций"}
</Box>
),
}}
/> />
</div> </div>
@@ -115,6 +210,28 @@ export const StationListPage = observer(() => {
setRowId(null); setRowId(null);
}} }}
/> />
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteStation(id)));
getStationList();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
<EditStationTransfersModal
open={isTransfersModalOpen}
onClose={() => {
setIsTransfersModalOpen(false);
setSelectedStationId(null);
}}
stationId={selectedStationId}
/>
</> </>
); );
}); });

View File

@@ -1,27 +1,51 @@
import { Paper } from "@mui/material"; import { Paper, Box } from "@mui/material";
import { languageStore, stationsStore } from "@shared"; import { languageStore, stationsStore, LoadingSpinner } from "@shared";
import { LanguageSwitcher } from "@widgets"; import { LanguageSwitcher } from "@widgets";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { LinkedSights } from "../LinkedSights";
export const StationPreviewPage = observer(() => { export const StationPreviewPage = observer(() => {
const { id } = useParams(); const { id } = useParams();
const { stationPreview, getStationPreview } = stationsStore; const { stationPreview, getStationPreview } = stationsStore;
const navigate = useNavigate(); const navigate = useNavigate();
const { language } = languageStore; const { language } = languageStore;
const [isLoadingData, setIsLoadingData] = useState(true);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
setIsLoadingData(true);
try {
await getStationPreview(Number(id)); await getStationPreview(Number(id));
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
} }
})(); })();
}, [id, language]); }, [id, language]);
if (isLoadingData) {
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных станции..." />
</Box>
);
}
return (
<Paper className="w-full p-3 py-5 flex flex-col gap-10">
<LanguageSwitcher /> <LanguageSwitcher />
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<button <button
@@ -71,6 +95,17 @@ export const StationPreviewPage = observer(() => {
<p>{stationPreview[id!]?.[language]?.data.description}</p> <p>{stationPreview[id!]?.[language]?.data.description}</p>
</div> </div>
)} )}
{id && (
<LinkedSights
parentId={Number(id)}
fields={[
{ label: "Название", data: "name" },
{ label: "Описание", data: "description" },
]}
type="show"
/>
)}
</div> </div>
</Paper> </Paper>
); );

View File

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

View File

@@ -4,21 +4,29 @@ import {
Checkbox, Checkbox,
Paper, Paper,
TextField, TextField,
Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { userStore } from "@shared"; import { userStore, languageStore, LoadingSpinner } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export const UserEditPage = observer(() => { export const UserEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const { id } = useParams(); const { id } = useParams();
const { editUserData, editUser, getUser, setEditUserData } = userStore; const { editUserData, editUser, getUser, setEditUserData } = userStore;
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
const handleEdit = async () => { const handleEdit = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
@@ -35,6 +43,8 @@ export const UserEditPage = observer(() => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
setIsLoadingData(true);
try {
const data = await getUser(Number(id)); const data = await getUser(Number(id));
setEditUserData( setEditUserData(
@@ -43,10 +53,30 @@ export const UserEditPage = observer(() => {
data?.password || "", data?.password || "",
data?.is_admin || false data?.is_admin || false
); );
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
} }
})(); })();
}, [id]); }, [id]);
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных пользователя..." />
</Box>
);
}
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -130,7 +160,7 @@ export const UserEditPage = observer(() => {
{isLoading ? ( {isLoading ? (
<Loader2 size={20} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />
) : ( ) : (
"Обновить" "Сохранить"
)} )}
</Button> </Button>
</div> </div>

View File

@@ -1,20 +1,29 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { userStore } from "@shared"; import { userStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Pencil, Trash2 } from "lucide-react"; import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal } from "@widgets"; import { CreateButton, DeleteModal } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const UserListPage = observer(() => { export const UserListPage = observer(() => {
const { users, getUsers, deleteUser } = userStore; const { users, getUsers, deleteUser } = userStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null); // Lifted state const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => { useEffect(() => {
getUsers(); const fetchUsers = async () => {
setIsLoading(true);
await getUsers();
setIsLoading(false);
};
fetchUsers();
}, []); }, []);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
@@ -22,11 +31,33 @@ export const UserListPage = observer(() => {
field: "name", field: "name",
headerName: "Имя", headerName: "Имя",
width: 400, width: 400,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
}, },
{ {
field: "email", field: "email",
headerName: "Email", headerName: "Email",
width: 400, width: 400,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
}, },
{ {
field: "is_admin", field: "is_admin",
@@ -52,6 +83,7 @@ export const UserListPage = observer(() => {
flex: 1, flex: 1,
align: "center", align: "center",
headerAlign: "center", headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
return ( return (
@@ -93,11 +125,42 @@ export const UserListPage = observer(() => {
<h1 className="text-2xl">Пользователи</h1> <h1 className="text-2xl">Пользователи</h1>
<CreateButton label="Создать пользователя" path="/user/create" /> <CreateButton label="Создать пользователя" path="/user/create" />
</div> </div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooterPagination hideFooterPagination
checkboxSelection
loading={isLoading}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter hideFooter
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? (
<CircularProgress size={20} />
) : (
"Нет пользователей"
)}
</Box>
),
}}
/> />
</div> </div>
@@ -107,7 +170,6 @@ export const UserListPage = observer(() => {
if (rowId) { if (rowId) {
await deleteUser(rowId); await deleteUser(rowId);
} }
setIsDeleteModalOpen(false); setIsDeleteModalOpen(false);
setRowId(null); setRowId(null);
}} }}
@@ -116,6 +178,19 @@ export const UserListPage = observer(() => {
setRowId(null); setRowId(null);
}} }}
/> />
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteUser(id)));
getUsers();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</> </>
); );
}); });

View File

@@ -7,7 +7,12 @@ import {
FormControl, FormControl,
InputLabel, InputLabel,
} from "@mui/material"; } from "@mui/material";
import { vehicleStore, VEHICLE_TYPES, carrierStore } from "@shared"; import {
vehicleStore,
VEHICLE_TYPES,
carrierStore,
languageStore,
} from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
@@ -21,10 +26,11 @@ export const VehicleCreatePage = observer(() => {
const [type, setType] = useState(""); const [type, setType] = useState("");
const [carrierId, setCarrierId] = useState<number | null>(null); const [carrierId, setCarrierId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
useEffect(() => { useEffect(() => {
carrierStore.getCarriers(); carrierStore.getCarriers(language);
}, []); }, [language]);
const handleCreate = async () => { const handleCreate = async () => {
try { try {
@@ -32,7 +38,8 @@ export const VehicleCreatePage = observer(() => {
await vehicleStore.createVehicle( await vehicleStore.createVehicle(
Number(tailNumber), Number(tailNumber),
Number(type), Number(type),
carrierStore.carriers.data.find((c) => c.id === carrierId)?.full_name!, carrierStore.carriers[language].data?.find((c) => c.id === carrierId)
?.full_name as string,
carrierId! carrierId!
); );
toast.success("Транспорт успешно создан"); toast.success("Транспорт успешно создан");
@@ -88,7 +95,7 @@ export const VehicleCreatePage = observer(() => {
required required
onChange={(e) => setCarrierId(e.target.value as number)} onChange={(e) => setCarrierId(e.target.value as number)}
> >
{carrierStore.carriers.data.map((carrier) => ( {carrierStore.carriers[language].data?.map((carrier) => (
<MenuItem key={carrier.id} value={carrier.id}> <MenuItem key={carrier.id} value={carrier.id}>
{carrier.full_name} {carrier.full_name}
</MenuItem> </MenuItem>

View File

@@ -11,7 +11,12 @@ import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react"; import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { carrierStore, VEHICLE_TYPES, vehicleStore } from "@shared"; import {
carrierStore,
languageStore,
VEHICLE_TYPES,
vehicleStore,
} from "@shared";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
export const VehicleEditPage = observer(() => { export const VehicleEditPage = observer(() => {
@@ -25,11 +30,18 @@ export const VehicleEditPage = observer(() => {
editVehicle, editVehicle,
} = vehicleStore; } = vehicleStore;
const { getCarriers } = carrierStore; const { getCarriers } = carrierStore;
const { language } = languageStore;
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
await getVehicle(Number(id)); await getVehicle(Number(id));
await getCarriers(); await getCarriers(language);
setEditVehicleData({ setEditVehicleData({
tail_number: vehicle[Number(id)]?.vehicle.tail_number, tail_number: vehicle[Number(id)]?.vehicle.tail_number,
type: vehicle[Number(id)]?.vehicle.type, type: vehicle[Number(id)]?.vehicle.type,
@@ -37,7 +49,7 @@ export const VehicleEditPage = observer(() => {
carrier_id: vehicle[Number(id)]?.vehicle.carrier_id, carrier_id: vehicle[Number(id)]?.vehicle.carrier_id,
}); });
})(); })();
}, [id]); }, [id, language]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const handleEdit = async () => { const handleEdit = async () => {
try { try {
@@ -108,7 +120,7 @@ export const VehicleEditPage = observer(() => {
}) })
} }
> >
{carrierStore.carriers.data.map((carrier) => ( {carrierStore.carriers[language].data?.map((carrier) => (
<MenuItem key={carrier.id} value={carrier.id}> <MenuItem key={carrier.id} value={carrier.id}>
{carrier.full_name} {carrier.full_name}
</MenuItem> </MenuItem>

View File

@@ -1,23 +1,33 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { carrierStore, languageStore, vehicleStore } from "@shared"; import { carrierStore, languageStore, vehicleStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Eye, Pencil, Trash2 } from "lucide-react"; import { Eye, Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal } from "@widgets"; import { CreateButton, DeleteModal } from "@widgets";
import { VEHICLE_TYPES } from "@shared"; import { VEHICLE_TYPES } from "@shared";
import { Box, CircularProgress } from "@mui/material";
export const VehicleListPage = observer(() => { export const VehicleListPage = observer(() => {
const { vehicles, getVehicles, deleteVehicle } = vehicleStore; const { vehicles, getVehicles, deleteVehicle } = vehicleStore;
const { carriers, getCarriers } = carrierStore; const { carriers, getCarriers } = carrierStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null); const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
getVehicles(); const fetchData = async () => {
getCarriers(); setIsLoading(true);
await getVehicles();
await getCarriers(language);
setIsLoading(false);
};
fetchData();
}, [language]); }, [language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
@@ -25,17 +35,31 @@ export const VehicleListPage = observer(() => {
field: "tail_number", field: "tail_number",
headerName: "Бортовой номер", headerName: "Бортовой номер",
flex: 1, flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
}, },
{ {
field: "type", field: "type",
headerName: "Тип", headerName: "Тип",
flex: 1, flex: 1,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
return ( return (
<div className="flex h-full gap-7 items-center"> <div className="w-full h-full flex items-center">
{VEHICLE_TYPES.find((type) => type.value === params.row.type) {params.value ? (
?.label || params.row.type} VEHICLE_TYPES.find((type) => type.value === params.row.type)
?.label || params.row.type
) : (
<Minus size={20} className="text-red-500" />
)}
</div> </div>
); );
}, },
@@ -44,19 +68,41 @@ export const VehicleListPage = observer(() => {
field: "carrier", field: "carrier",
headerName: "Перевозчик", headerName: "Перевозчик",
flex: 1, flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
}, },
{ {
field: "city", field: "city",
headerName: "Город", headerName: "Город",
flex: 1, flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
}, },
{ {
field: "actions", field: "actions",
headerName: "Действия", headerName: "Действия",
width: 200, width: 200,
align: "center", align: "center",
headerAlign: "center", headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
return ( return (
@@ -86,7 +132,7 @@ export const VehicleListPage = observer(() => {
tail_number: vehicle.vehicle.tail_number, tail_number: vehicle.vehicle.tail_number,
type: vehicle.vehicle.type, type: vehicle.vehicle.type,
carrier: vehicle.vehicle.carrier, carrier: vehicle.vehicle.carrier,
city: carriers.data?.find( city: carriers[language].data?.find(
(carrier) => carrier.id === vehicle.vehicle.carrier_id (carrier) => carrier.id === vehicle.vehicle.carrier_id
)?.city, )?.city,
})); }));
@@ -101,11 +147,42 @@ export const VehicleListPage = observer(() => {
path="/vehicle/create" path="/vehicle/create"
/> />
</div> </div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooterPagination hideFooterPagination
checkboxSelection
loading={isLoading}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter hideFooter
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? (
<CircularProgress size={20} />
) : (
"Нет транспортных средств"
)}
</Box>
),
}}
/> />
</div> </div>
@@ -123,6 +200,19 @@ export const VehicleListPage = observer(() => {
setRowId(null); setRowId(null);
}} }}
/> />
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteVehicle(id)));
getVehicles();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</> </>
); );
}); });

View File

@@ -2,7 +2,7 @@ import { languageStore, Language } from "@shared";
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"; import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
const authInstance = axios.create({ const authInstance = axios.create({
baseURL: "https://wn.krbl.ru", baseURL: import.meta.env.VITE_API_URL,
}); });
authInstance.interceptors.request.use((config: InternalAxiosRequestConfig) => { authInstance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
@@ -24,7 +24,7 @@ authInstance.interceptors.response.use(
const languageInstance = (language: Language) => { const languageInstance = (language: Language) => {
const instance = axios.create({ const instance = axios.create({
baseURL: "https://wn.krbl.ru", baseURL: import.meta.env.VITE_API_URL,
}); });
instance.interceptors.request.use((config) => { instance.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${localStorage.getItem("token")}`; config.headers.Authorization = `Bearer ${localStorage.getItem("token")}`;

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

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

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 = { export const MEDIA_TYPE_LABELS = {
1: "Фото", 1: "Фото",
2: "Видео", 2: "Видео",
@@ -8,8 +26,10 @@ export const MEDIA_TYPE_LABELS = {
6: "3Д-модель", 6: "3Д-модель",
}; };
export * from "./mediaTypes";
export const MEDIA_TYPE_VALUES = { export const MEDIA_TYPE_VALUES = {
photo: 1, image: 1,
video: 2, video: 2,
icon: 3, icon: 3,
thumbnail: 3, thumbnail: 3,
@@ -17,4 +37,9 @@ export const MEDIA_TYPE_VALUES = {
watermark_rd: 4, watermark_rd: 4,
panorama: 5, panorama: 5,
model: 6, model: 6,
video_preview: 2,
}; };
export const RU_COUNTRIES = generateCountriesList("ru");
export const EN_COUNTRIES = generateCountriesList("en");
export const ZH_COUNTRIES = generateCountriesList("zh");

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 "./const";
export * from "./api"; export * from "./api";
export * from "./modals"; export * from "./modals";
export * from "./hooks";

View File

@@ -0,0 +1,57 @@
let useGLTF: any = null;
const initializeUseGLTF = async () => {
if (!useGLTF) {
try {
const drei = await import("@react-three/drei");
useGLTF = drei.useGLTF;
} catch (error) {
console.warn(
"⚠️ GLTFCacheManager: Не удалось импортировать useGLTF",
error
);
}
}
return useGLTF;
};
export const clearGLTFCacheForUrl = async (url: string) => {
try {
const gltf = await initializeUseGLTF();
if (gltf && gltf.clear) {
gltf.clear(url);
}
} catch (error) {}
};
export const clearAllGLTFCache = async () => {
try {
const gltf = await initializeUseGLTF();
if (gltf && gltf.clear) {
gltf.clear();
}
} catch (error) {}
};
export const revokeBlobURL = (url: string) => {
if (url && url.startsWith("blob:")) {
try {
URL.revokeObjectURL(url);
} catch (error) {}
}
};
export const clearBlobAndGLTFCache = async (url: string) => {
revokeBlobURL(url);
await clearGLTFCacheForUrl(url);
};
export const clearMediaTransitionCache = async (
previousMediaId: string | number | null,
newMediaType?: number
) => {
if (newMediaType === 6 || previousMediaId) {
await clearAllGLTFCache();
}
};

View File

@@ -1,2 +1,35 @@
export * from "./mui/theme"; export * from "./mui/theme";
export * from "./DecodeJWT"; export * from "./DecodeJWT";
export * from "./gltfCacheManager";
export const generateDefaultMediaName = (
objectName: string,
fileName: string,
mediaType: number | string,
isArticle: boolean = false
): string => {
const fileNameWithoutExtension = fileName.split(".").slice(0, -1).join(".");
if (isArticle && typeof mediaType === "string") {
return `${objectName}_${fileNameWithoutExtension}_${mediaType}`;
} else if (typeof mediaType === "number") {
const mediaTypeLabels: Record<number, string> = {
1: "Фото",
2: "Видео",
3: "Иконка",
4: "Водяной знак",
5: "Панорама",
6: "3Д-модель",
};
const mediaTypeLabel = mediaTypeLabels[mediaType] || "Медиа";
if (objectName && objectName.trim() !== "") {
return `${objectName}_${fileNameWithoutExtension}_${mediaTypeLabel}`;
} else {
return `Название_${fileNameWithoutExtension}_${mediaTypeLabel}`;
}
}
return `${objectName || "Название"}_${fileNameWithoutExtension}_Медиа`;
};

File diff suppressed because it is too large Load Diff

View File

@@ -75,6 +75,7 @@ export const PreviewMediaDialog = observer(
setError(err instanceof Error ? err.message : "Failed to save media"); setError(err instanceof Error ? err.message : "Failed to save media");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
onClose();
} }
}; };
@@ -96,7 +97,6 @@ export const PreviewMediaDialog = observer(
className="flex gap-4" className="flex gap-4"
dividers dividers
sx={{ sx={{
height: "600px",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: 2, gap: 2,
@@ -120,7 +120,6 @@ export const PreviewMediaDialog = observer(
disabled={isLoading} disabled={isLoading}
/> />
</Box> </Box>
<TextField <TextField
fullWidth fullWidth
label="Тип медиа" label="Тип медиа"
@@ -133,7 +132,7 @@ export const PreviewMediaDialog = observer(
sx={{ width: "50%" }} sx={{ width: "50%" }}
/> />
<Box className="flex gap-4 h-full"> <Box className="flex gap-4 h-[40vh]">
<Paper <Paper
elevation={2} elevation={2}
sx={{ sx={{
@@ -142,7 +141,6 @@ export const PreviewMediaDialog = observer(
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
minHeight: 400,
}} }}
> >
<MediaViewer <MediaViewer
@@ -151,6 +149,8 @@ export const PreviewMediaDialog = observer(
media_type: media.media_type, media_type: media.media_type,
filename: media.filename, filename: media.filename,
}} }}
className="h-full w-full object-contain"
fullHeight
/> />
</Paper> </Paper>

View File

@@ -38,7 +38,9 @@ export const SelectArticleModal = observer(
onSelectArticle, onSelectArticle,
linkedArticleIds = [], linkedArticleIds = [],
}: SelectArticleModalProps) => { }: SelectArticleModalProps) => {
const { articles, getArticle, getArticleMedia } = articlesStore; const { language } = languageStore;
const { articles, getArticle, getArticleMedia, getArticles } =
articlesStore;
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [selectedArticleId, setSelectedArticleId] = useState<number | null>( const [selectedArticleId, setSelectedArticleId] = useState<number | null>(
null null
@@ -54,6 +56,21 @@ export const SelectArticleModal = observer(
} }
}, [open]); }, [open]);
useEffect(() => {
const fetchData = async () => {
await getArticles("ru");
await getArticles("en");
await getArticles("zh");
};
fetchData();
}, []);
useEffect(() => {
if (selectedArticleId) {
handleArticleClick(selectedArticleId);
}
}, [language]);
useEffect(() => { useEffect(() => {
const handleKeyPress = async (event: KeyboardEvent) => { const handleKeyPress = async (event: KeyboardEvent) => {
if (event.key.toLowerCase() === "enter") { if (event.key.toLowerCase() === "enter") {
@@ -273,6 +290,25 @@ export const SelectArticleModal = observer(
fontSize: "24px", fontSize: "24px",
fontWeight: 700, fontWeight: 700,
lineHeight: "120%", lineHeight: "120%",
cursor: "pointer",
"&:hover": {
textDecoration: "underline",
},
}}
onDoubleClick={async () => {
if (selectedArticleId) {
const media = await authInstance.get(
`/article/${selectedArticleId}/media`
);
onSelectArticle(
selectedArticleId,
articlesStore.articleData?.heading || "",
articlesStore.articleData?.body || "",
media.data || []
);
onClose();
setSelectedArticleId(null);
}
}} }}
> >
{articlesStore.articleData?.heading || "Название cтатьи"} {articlesStore.articleData?.heading || "Название cтатьи"}

View File

@@ -102,7 +102,6 @@ export const SelectMediaDialog = observer(
filteredMedia = filteredMedia.filter( filteredMedia = filteredMedia.filter(
(mediaItem) => mediaItem.media_type === mediaType (mediaItem) => mediaItem.media_type === mediaType
); );
console.log(filteredMedia);
} }
return ( return (
@@ -163,7 +162,13 @@ export const SelectMediaDialog = observer(
}, },
}} }}
> >
<ListItemText primary={mediaItem.media_name} /> <ListItemText
primary={
mediaItem.media_name
? mediaItem.media_name
: mediaItem.filename
}
/>
</ListItemButton> </ListItemButton>
) )
) )

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