Compare commits

...

19 Commits

Author SHA1 Message Date
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
119 changed files with 10884 additions and 2962 deletions

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

95
package-lock.json generated
View File

@ -15,6 +15,7 @@
"@mui/material": "^7.1.0", "@mui/material": "^7.1.0",
"@mui/x-data-grid": "^8.5.1", "@mui/x-data-grid": "^8.5.1",
"@photo-sphere-viewer/core": "^5.13.2", "@photo-sphere-viewer/core": "^5.13.2",
"@pixi/react": "^8.0.2",
"@react-three/drei": "^10.1.2", "@react-three/drei": "^10.1.2",
"@react-three/fiber": "^9.1.2", "@react-three/fiber": "^9.1.2",
"@tailwindcss/vite": "^4.1.8", "@tailwindcss/vite": "^4.1.8",
@ -25,6 +26,7 @@
"mobx-react-lite": "^4.1.0", "mobx-react-lite": "^4.1.0",
"ol": "^10.5.0", "ol": "^10.5.0",
"path": "^0.12.7", "path": "^0.12.7",
"pixi.js": "^8.10.1",
"react": "^19.1.0", "react": "^19.1.0",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
@ -1201,6 +1203,29 @@
"integrity": "sha512-nNE3pnTHxXN/Phw768u0Grr7W4+rumGg/H6PgeseNJojkJtmeHJfZWi41Gp2mpXl1pg1pf1zjwR4McM1jTqkpg==", "integrity": "sha512-nNE3pnTHxXN/Phw768u0Grr7W4+rumGg/H6PgeseNJojkJtmeHJfZWi41Gp2mpXl1pg1pf1zjwR4McM1jTqkpg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@pixi/colord": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz",
"integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==",
"license": "MIT"
},
"node_modules/@pixi/react": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@pixi/react/-/react-8.0.2.tgz",
"integrity": "sha512-A42Bw/1YlxxCXhb+nDIgzPpACx5dOh7Yi+ZfBMlZ1sBB/qc7qyX9k7bdDtrnMqz8OofyF2FXg9gPw9MISFsQTA==",
"license": "MIT",
"workspaces": [
"docs"
],
"dependencies": {
"its-fine": "^2.0.0",
"react-reconciler": "0.31.0"
},
"peerDependencies": {
"pixi.js": "^8.2.6",
"react": ">=19.0.0"
}
},
"node_modules/@popperjs/core": { "node_modules/@popperjs/core": {
"version": "2.11.8", "version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@ -1476,6 +1501,12 @@
"@types/tern": "*" "@types/tern": "*"
} }
}, },
"node_modules/@types/css-font-loading-module": {
"version": "0.0.12",
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz",
"integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==",
"license": "MIT"
},
"node_modules/@types/debug": { "node_modules/@types/debug": {
"version": "4.1.12", "version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@ -1491,6 +1522,12 @@
"integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/earcut": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz",
"integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==",
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
@ -1980,6 +2017,15 @@
"integrity": "sha512-w2HbBvH+qO19SB5pJOJFKs533CdZqxl3fcGonqL321VHkW7W/iBo6H8bjDy6pr/+pbMwIu5dnuaAxH7NxBqUrQ==", "integrity": "sha512-w2HbBvH+qO19SB5pJOJFKs533CdZqxl3fcGonqL321VHkW7W/iBo6H8bjDy6pr/+pbMwIu5dnuaAxH7NxBqUrQ==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/@xmldom/xmldom": {
"version": "0.8.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
"integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.14.1", "version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
@ -3281,6 +3327,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/gifuct-js": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz",
"integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==",
"license": "MIT",
"dependencies": {
"js-binary-schema-parser": "^2.0.3"
}
},
"node_modules/glob-parent": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@ -3762,6 +3817,12 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ismobilejs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz",
"integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==",
"license": "MIT"
},
"node_modules/its-fine": { "node_modules/its-fine": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz",
@ -3783,6 +3844,12 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/js-binary-schema-parser": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz",
"integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==",
"license": "MIT"
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -5005,6 +5072,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/parse-svg-path": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
"license": "MIT"
},
"node_modules/parse5": { "node_modules/parse5": {
"version": "7.3.0", "version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@ -5091,6 +5164,28 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pixi.js": {
"version": "8.11.0",
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.11.0.tgz",
"integrity": "sha512-dyuThzncsgEgJZnvd/A/5x6IkUERbK+phXqUQrI+0C6WE+8xqGH5VChRTLecemhgZF0kQ+gZOM3tJTX9937xpg==",
"license": "MIT",
"dependencies": {
"@pixi/colord": "^2.9.6",
"@types/css-font-loading-module": "^0.0.12",
"@types/earcut": "^3.0.0",
"@webgpu/types": "^0.1.40",
"@xmldom/xmldom": "^0.8.10",
"earcut": "^3.0.1",
"eventemitter3": "^5.0.1",
"gifuct-js": "^2.1.2",
"ismobilejs": "^1.1.1",
"parse-svg-path": "^0.1.2"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/pixijs"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.3", "version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",

View File

@ -56,5 +56,6 @@
"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"
} },
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
} }

View File

@ -1 +0,0 @@

View File

@ -16,12 +16,12 @@ import {
SnapshotListPage, SnapshotListPage,
CarrierListPage, CarrierListPage,
StationListPage, StationListPage,
VehicleListPage, // VehicleListPage,
ArticleListPage, ArticleListPage,
CityPreviewPage,
CountryPreviewPage, // CountryPreviewPage,
VehiclePreviewPage, // VehiclePreviewPage,
CarrierPreviewPage, // CarrierPreviewPage,
SnapshotCreatePage, SnapshotCreatePage,
CountryCreatePage, CountryCreatePage,
CityCreatePage, CityCreatePage,
@ -31,7 +31,7 @@ import {
CityEditPage, CityEditPage,
UserCreatePage, UserCreatePage,
UserEditPage, UserEditPage,
VehicleEditPage, // VehicleEditPage,
CarrierEditPage, CarrierEditPage,
StationCreatePage, StationCreatePage,
StationPreviewPage, StationPreviewPage,
@ -39,6 +39,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";
@ -56,7 +58,7 @@ import {
const PublicRoute = ({ children }: { children: React.ReactNode }) => { const PublicRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated } = authStore; const { isAuthenticated } = authStore;
if (isAuthenticated) { if (isAuthenticated) {
return <Navigate to="/sight" replace />; return <Navigate to="/map" replace />;
} }
return <>{children}</>; return <>{children}</>;
}; };
@ -68,7 +70,7 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
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}</>;
}; };
@ -133,12 +135,13 @@ const router = createBrowserRouter([
// Country // 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", element: <CountryPreviewPage /> },
{ path: "country/:id/edit", element: <CountryEditPage /> }, { path: "country/:id/edit", element: <CountryEditPage /> },
// City // 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", element: <CityPreviewPage /> },
{ path: "city/:id/edit", element: <CityEditPage /> }, { path: "city/:id/edit", element: <CityEditPage /> },
// Route // Route
{ path: "route", element: <RouteListPage /> }, { path: "route", element: <RouteListPage /> },
@ -156,7 +159,7 @@ const router = createBrowserRouter([
// Carrier // 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", element: <CarrierPreviewPage /> },
{ path: "carrier/:id/edit", element: <CarrierEditPage /> }, { path: "carrier/:id/edit", element: <CarrierEditPage /> },
// Station // Station
{ path: "station", element: <StationListPage /> }, { path: "station", element: <StationListPage /> },
@ -164,13 +167,13 @@ const router = createBrowserRouter([
{ path: "station/:id", element: <StationPreviewPage /> }, { path: "station/:id", element: <StationPreviewPage /> },
{ path: "station/:id/edit", element: <StationEditPage /> }, { path: "station/:id/edit", element: <StationEditPage /> },
// Vehicle // Vehicle
{ path: "vehicle", element: <VehicleListPage /> }, // { path: "vehicle", element: <VehicleListPage /> },
{ path: "vehicle/create", element: <VehicleCreatePage /> }, { path: "vehicle/create", element: <VehicleCreatePage /> },
{ path: "vehicle/:id", element: <VehiclePreviewPage /> }, // { path: "vehicle/:id", element: <VehiclePreviewPage /> },
{ path: "vehicle/:id/edit", element: <VehicleEditPage /> }, // { path: "vehicle/:id/edit", element: <VehicleEditPage /> },
// Article // Article
{ path: "article", element: <ArticleListPage /> }, { path: "article", element: <ArticleListPage /> },
// { path: "article/:id", element: <ArticlePreviewPage /> }, { path: "article/:id", element: <ArticlePreviewPage /> },
// { path: "media/create", element: <CreateMediaPage /> }, // { path: "media/create", element: <CreateMediaPage /> },
], ],
}, },

View File

@ -9,12 +9,14 @@ 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";
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,6 +24,7 @@ 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();
@ -31,6 +34,9 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
const isActive = item.path ? location.pathname.startsWith(item.path) : false; const isActive = item.path ? location.pathname.startsWith(item.path) : false;
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 +64,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 +90,7 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
}, },
]} ]}
> >
<Icon /> {Icon ? <Icon /> : <Plus />}
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText
primary={item.label} primary={item.label}
@ -108,7 +114,7 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
</ListItemButton> </ListItemButton>
</ListItem> </ListItem>
{item.nestedItems && ( {item.nestedItems && (
<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) => ( {item.nestedItems.map((nestedItem) => (
<NavigationItemComponent <NavigationItemComponent

View File

@ -3,7 +3,12 @@ import Divider from "@mui/material/Divider";
import { NAVIGATION_ITEMS } from "@shared"; import { NAVIGATION_ITEMS } from "@shared";
import { NavigationItem, NavigationItemComponent } from "@entities"; import { NavigationItem, NavigationItemComponent } from "@entities";
export const NavigationList = ({ open }: { open: boolean }) => { interface NavigationListProps {
open: boolean;
onDrawerOpen?: () => void;
}
export const NavigationList = ({ open, onDrawerOpen }: NavigationListProps) => {
const primaryItems = NAVIGATION_ITEMS.primary; const primaryItems = NAVIGATION_ITEMS.primary;
const secondaryItems = NAVIGATION_ITEMS.secondary; const secondaryItems = NAVIGATION_ITEMS.secondary;
@ -15,6 +20,7 @@ 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>
@ -26,6 +32,7 @@ export const NavigationList = ({ open }: { open: boolean }) => {
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

@ -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,6 +32,15 @@ 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>
);
},
}, },
{ {
@ -59,18 +78,53 @@ export const ArticleListPage = observer(() => {
<LanguageSwitcher /> <LanguageSwitcher />
<div className="w-full"> <div className="w-full">
<DataGrid <div className="flex justify-between items-center mb-10">
rows={rows} <h1 className="text-2xl">Статьи</h1>
columns={columns} </div>
hideFooterPagination
hideFooter <div
/> className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<div className="w-full">
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
checkboxSelection
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter
slots={{
noRowsOverlay: () => (
<Box
sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}
>
{isLoading ? <CircularProgress size={20} /> : "Нет статей"}
</Box>
),
}}
/>
</div>
</div> </div>
<DeleteModal <DeleteModal
open={isDeleteModalOpen} open={isDeleteModalOpen}
onDelete={async () => { onDelete={async () => {
if (rowId) { if (rowId) {
await deleteArticles([parseInt(rowId)]);
getArticleList(); getArticleList();
} }
setIsDeleteModalOpen(false); setIsDeleteModalOpen(false);
@ -81,6 +135,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,57 @@
import { useNavigate, useParams } from "react-router-dom";
import { useEffect } from "react";
import { Box } from "@mui/material";
import { PreviewLeftWidget } from "./PreviewLeftWidget";
import { PreviewRightWidget } from "./PreviewRightWidget";
import { articlesStore, languageStore } from "@shared";
import { ArrowLeft } from "lucide-react";
export const ArticlePreviewPage = () => {
const navigate = useNavigate();
const { id } = useParams();
const { getArticle, getArticleMedia, getArticlePreview } = articlesStore;
const { language } = languageStore;
useEffect(() => {
const fetchData = async () => {
if (id) {
await getArticle(Number(id), language);
await getArticleMedia(Number(id));
await getArticlePreview(Number(id));
}
};
fetchData();
}, [id, language]);
return (
<>
<div className="flex items-center gap-4 mb-10">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<Box
sx={{
display: "flex",
gap: 2,
p: 2,
justifyContent: "center",
margin: "0 auto",
}}
>
<Box sx={{ width: "320px" }}>
<PreviewLeftWidget />
</Box>
<Box sx={{ width: "500px" }}>
<PreviewRightWidget />
</Box>
</Box>
</>
);
};

View File

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

View File

@ -12,41 +12,40 @@ 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 } 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 [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");
}, []); }, []);
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 +55,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 +90,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 +122,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 +207,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 +220,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

@ -12,35 +12,70 @@ 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 } 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 [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)); await cityStore.getCities("ru");
setEditCarrierData( await cityStore.getCities("en");
carrier?.[Number(id)]?.full_name as string, await cityStore.getCities("zh");
carrier?.[Number(id)]?.short_name as string, const carrierData = await getCarrier(Number(id));
carrier?.[Number(id)]?.city as string,
carrier?.[Number(id)]?.city_id as number, if (carrierData) {
carrier?.[Number(id)]?.main_color as string, setEditCarrierData(
carrier?.[Number(id)]?.left_color as string, carrierData.ru?.full_name || "",
carrier?.[Number(id)]?.right_color as string, carrierData.ru?.short_name || "",
carrier?.[Number(id)]?.slogan as string, carrierData.ru?.city_id || 0,
carrier?.[Number(id)]?.logo as string carrierData.ru?.slogan || "",
); carrierData.ru?.logo || "",
cityStore.getCities("ru"); "ru"
);
setEditCarrierData(
carrierData.en?.full_name || "",
carrierData.en?.short_name || "",
carrierData.en?.city_id || 0,
carrierData.en?.slogan || "",
carrierData.en?.logo || "",
"en"
);
setEditCarrierData(
carrierData.zh?.full_name || "",
carrierData.zh?.short_name || "",
carrierData.zh?.city_id || 0,
carrierData.zh?.slogan || "",
carrierData.zh?.logo || "",
"zh"
);
}
mediaStore.getMedia(); mediaStore.getMedia();
})(); })();
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, [id]); }, [id]);
const handleEdit = async () => { const handleEdit = async () => {
@ -56,8 +91,29 @@ export const CarrierEditPage = observer(() => {
} }
}; };
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
media.id,
language
);
};
const selectedMedia = editCarrierData.logo
? mediaStore.media.find((m) => m.id === editCarrierData.logo)
: null;
return ( 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 +124,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 +136,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 +156,16 @@ export const CarrierEditPage = observer(() => {
<TextField <TextField
fullWidth fullWidth
label="Полное название" label="Полное название"
value={editCarrierData.full_name} value={editCarrierData[language].full_name}
required required
onChange={(e) => onChange={(e) =>
setEditCarrierData( setEditCarrierData(
e.target.value, e.target.value,
editCarrierData.short_name, editCarrierData[language].short_name,
editCarrierData.city,
editCarrierData.city_id, editCarrierData.city_id,
editCarrierData.main_color, editCarrierData[language].slogan,
editCarrierData.left_color, editCarrierData.logo,
editCarrierData.right_color, language
editCarrierData.slogan,
editCarrierData.logo
) )
} }
/> />
@ -120,166 +173,57 @@ export const CarrierEditPage = observer(() => {
<TextField <TextField
fullWidth fullWidth
label="Короткое название" label="Короткое название"
value={editCarrierData.short_name} value={editCarrierData[language].short_name}
required required
onChange={(e) => onChange={(e) =>
setEditCarrierData( setEditCarrierData(
editCarrierData.full_name, editCarrierData[language].full_name,
e.target.value, e.target.value,
editCarrierData.city,
editCarrierData.city_id, editCarrierData.city_id,
editCarrierData.main_color, editCarrierData[language].slogan,
editCarrierData.left_color, editCarrierData.logo,
editCarrierData.right_color, language
editCarrierData.slogan,
editCarrierData.logo
) )
} }
/> />
<div className="flex gap-4 w-full">
<TextField
fullWidth
label="Основной цвет"
value={editCarrierData.main_color}
className="flex-1 w-full"
onChange={(e) =>
setEditCarrierData(
editCarrierData.full_name,
editCarrierData.short_name,
editCarrierData.city,
editCarrierData.city_id,
e.target.value,
editCarrierData.left_color,
editCarrierData.right_color,
editCarrierData.slogan,
editCarrierData.logo
)
}
type="color"
sx={{
"& input": {
height: "50px",
paddingBlock: "14px",
paddingInline: "14px",
cursor: "pointer",
},
}}
/>
<TextField
fullWidth
label="Цвет левого виджета"
value={editCarrierData.left_color}
className="flex-1 w-full"
onChange={(e) =>
setEditCarrierData(
editCarrierData.full_name,
editCarrierData.short_name,
editCarrierData.city,
editCarrierData.city_id,
editCarrierData.main_color,
e.target.value,
editCarrierData.right_color,
editCarrierData.slogan,
editCarrierData.logo
)
}
type="color"
sx={{
"& input": {
height: "50px",
paddingBlock: "14px",
paddingInline: "14px",
cursor: "pointer",
},
}}
/>
<TextField
fullWidth
label="Цвет правого виджета"
value={editCarrierData.right_color}
className="flex-1 w-full"
onChange={(e) =>
setEditCarrierData(
editCarrierData.full_name,
editCarrierData.short_name,
editCarrierData.city,
editCarrierData.city_id,
editCarrierData.main_color,
editCarrierData.left_color,
e.target.value,
editCarrierData.slogan,
editCarrierData.logo
)
}
type="color"
sx={{
"& input": {
height: "50px",
paddingBlock: "14px",
paddingInline: "14px",
cursor: "pointer",
},
}}
/>
</div>
<TextField <TextField
fullWidth fullWidth
label="Слоган" label="Слоган"
value={editCarrierData.slogan} value={editCarrierData[language].slogan}
onChange={(e) => onChange={(e) =>
setEditCarrierData( setEditCarrierData(
editCarrierData.full_name, editCarrierData[language].full_name,
editCarrierData.short_name, editCarrierData[language].short_name,
editCarrierData.city,
editCarrierData.city_id, editCarrierData.city_id,
editCarrierData.main_color,
editCarrierData.left_color,
editCarrierData.right_color,
e.target.value, e.target.value,
editCarrierData.logo editCarrierData.logo,
language
) )
} }
/> />
<div className="w-full flex flex-col gap-4"> <div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<FormControl fullWidth> <ImageUploadCard
<InputLabel>Логотип</InputLabel> title="Логотип перевозчика"
<Select imageKey="thumbnail"
value={editCarrierData.logo || ""} imageUrl={selectedMedia?.id}
label="Логотип" onImageClick={() => {
required setIsPreviewMediaOpen(true);
onChange={(e) => setMediaId(selectedMedia?.id ?? "");
setEditCarrierData( }}
editCarrierData.full_name, onDeleteImageClick={() => {
editCarrierData.short_name, setIsDeleteLogoModalOpen(true);
editCarrierData.city, }}
editCarrierData.city_id, onSelectFileClick={() => {
editCarrierData.main_color, setActiveMenuType("image");
editCarrierData.left_color, setIsSelectMediaOpen(true);
editCarrierData.right_color, }}
editCarrierData.slogan, setUploadMediaOpen={() => {
e.target.value as string setIsUploadMediaOpen(true);
) setActiveMenuType("image");
} }}
> />
{mediaStore.media
.filter((media) => media.media_type === 3)
.map((media) => (
<MenuItem key={media.id} value={media.id}>
{media.media_name || media.filename}
</MenuItem>
))}
</Select>
</FormControl>
{editCarrierData.logo && (
<div className="w-32 h-32">
<MediaViewer
media={{ id: editCarrierData.logo, media_type: 1 }}
/>
</div>
)}
</div> </div>
<Button <Button
@ -289,19 +233,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,37 +1,87 @@
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",
@ -45,9 +95,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 +112,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 +179,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> onDeleteImageClick={() => {
{selectedMedia && ( setCreateCityData(
<span className="text-sm text-gray-600"> createCityData[language].name,
{selectedMedia.media_name || selectedMedia.filename} createCityData.country_code,
</span> "",
)} language
</div> );
{selectedMedia && ( setActiveMenuType(null);
<Box }}
sx={{ onSelectFileClick={() => {
width: "200px", setActiveMenuType("image");
height: "200px", setIsSelectMediaOpen(true);
border: "1px solid #e0e0e0", }}
borderRadius: "8px", setUploadMediaOpen={() => {
overflow: "hidden", setIsUploadMediaOpen(true);
display: "flex", setActiveMenuType("image");
alignItems: "center", }}
justifyContent: "center", setHardcodeType={() => {
}} setActiveMenuType("image");
> }}
<MediaViewer />
media={{
id: selectedMedia.id,
media_type: selectedMedia.media_type,
filename: selectedMedia.filename,
}}
/>
</Box>
)}
</div> </div>
<Button <Button
@ -182,7 +182,24 @@ export const CityCreatePage = observer(() => {
open={isSelectMediaOpen} open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)} onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect} onSelectMedia={handleMediaSelect}
mediaType={3} // Тип медиа для иконок mediaType={1} // Тип медиа для иконок
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={createCityData[language]?.name}
contextType="city"
afterUpload={handleMediaSelect}
hardcodeType={
activeMenuType as "thumbnail" | "watermark_lu" | "watermark_rd" | null
}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/> />
</Paper> </Paper>
); );

View File

@ -6,10 +6,9 @@ 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, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@ -21,19 +20,34 @@ import {
CashedCities, CashedCities,
} 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 [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 +63,23 @@ export const CityEditPage = observer(() => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
const data = await getCity(id as string, language); await getCountries("ru");
setEditCityData( // Fetch data for all languages
data.name, const ruData = await getCity(id as string, "ru");
data.country, const enData = await getCity(id as string, "en");
data.country_code, const zhData = await getCity(id as string, "zh");
data.arms,
language // Set data for each language
); setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
await getOneMedia(data.arms as string); setEditCityData(enData.name, enData.country_code, enData.arms, "en");
await getCountries(language); setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
await getOneMedia(ruData.arms as string);
await getMedia(); await getMedia();
} }
})(); })();
}, [id, language]); }, [id]);
const handleMediaSelect = (media: { const handleMediaSelect = (media: {
id: string; id: string;
@ -72,7 +89,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
@ -97,6 +113,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 +124,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 +138,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 +154,36 @@ export const CityEditPage = observer(() => {
</Select> </Select>
</FormControl> </FormControl>
<div className="w-full flex flex-col gap-4"> <div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<label className="text-sm text-gray-600">Герб города</label> <ImageUploadCard
<div className="flex items-center gap-4"> title="Герб города"
<Button imageKey="image"
variant="outlined" imageUrl={selectedMedia?.id}
onClick={() => setIsSelectMediaOpen(true)} onImageClick={() => {
startIcon={<ImagePlus size={20} />} setIsPreviewMediaOpen(true);
> setMediaId(selectedMedia?.id ?? "");
Выбрать герб }}
</Button> onDeleteImageClick={() => {
{selectedMedia && ( setEditCityData(
<span className="text-sm text-gray-600"> editCityData[language].name,
{selectedMedia.media_name || selectedMedia.filename} editCityData.country_code,
</span> "",
)} language
</div> );
{selectedMedia && ( setActiveMenuType(null);
<Box }}
sx={{ onSelectFileClick={() => {
width: "200px", setActiveMenuType("image");
height: "200px", setIsSelectMediaOpen(true);
border: "1px solid #e0e0e0", }}
borderRadius: "8px", setUploadMediaOpen={() => {
overflow: "hidden", setIsUploadMediaOpen(true);
display: "flex", setActiveMenuType("image");
alignItems: "center", }}
justifyContent: "center", setHardcodeType={() => {
}} setActiveMenuType("image");
> }}
<MediaViewer />
media={{
id: selectedMedia.id,
media_type: selectedMedia.media_type,
filename: selectedMedia.filename,
}}
/>
</Box>
)}
</div> </div>
<Button <Button
@ -192,7 +198,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 +207,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,18 @@ export const CityListPage = observer(() => {
align: "center", align: "center",
headerAlign: "center", headerAlign: "center",
width: 200, width: 200,
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 +117,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 +126,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 +164,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 +175,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

@ -16,6 +16,11 @@ export const CountryEditPage = observer(() => {
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,18 @@ export const CountryEditPage = observer(() => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
const data = await getCountry(id as string, language); // Fetch data for all languages
setEditCountryData(data.name, language); const ruData = await getCountry(id as string, "ru");
const enData = await getCountry(id as string, "en");
const zhData = await getCountry(id as string, "zh");
// Set data for each language
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">
@ -51,6 +63,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 +93,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",
@ -32,16 +53,17 @@ export const CountryListPage = observer(() => {
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 +76,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 +89,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

@ -3,12 +3,7 @@ import { InformationTab, LeaveAgree, RightWidgetTab } from "@widgets";
import { LeftWidgetTab } from "@widgets"; import { LeftWidgetTab } from "@widgets";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { import { articlesStore, cityStore, editSightStore } from "@shared";
articlesStore,
cityStore,
editSightStore,
languageStore,
} from "@shared";
import { useBlocker, useParams } from "react-router-dom"; import { useBlocker, useParams } from "react-router-dom";
function a11yProps(index: number) { function a11yProps(index: number) {
@ -22,9 +17,9 @@ export const EditSightPage = observer(() => {
const [value, setValue] = useState(0); const [value, setValue] = useState(0);
const { sight, getSightInfo, needLeaveAgree } = editSightStore; const { sight, getSightInfo, needLeaveAgree } = 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 +33,17 @@ export const EditSightPage = observer(() => {
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
if (id) { if (id) {
await getSightInfo(+id, language); await getCities("ru");
await getArticles(language); await getSightInfo(+id, "ru");
await getRuCities(); await getSightInfo(+id, "en");
await getSightInfo(+id, "zh");
await getArticles("ru");
await getArticles("en");
await getArticles("zh");
} }
}; };
fetchData(); fetchData();
}, [id, language]); }, [id]);
return ( return (
<Box <Box

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,21 @@ 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(() => {
// Load saved credentials if they exist
const savedEmail = localStorage.getItem("rememberedEmail");
const savedPassword = localStorage.getItem("rememberedPassword");
if (savedEmail && savedPassword) {
setEmail(savedEmail);
setPassword(savedPassword);
setRememberMe(true);
}
}, []);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -26,7 +41,18 @@ export const LoginPage = () => {
try { try {
await login(email, password); await login(email, password);
navigate("/sight");
// Save or clear credentials based on remember me checkbox
if (rememberMe) {
localStorage.setItem("rememberedEmail", email);
localStorage.setItem("rememberedPassword", password);
} else {
localStorage.removeItem("rememberedEmail");
localStorage.removeItem("rememberedPassword");
}
navigate("/map");
await getUsers();
toast.success("Вход в систему выполнен успешно"); toast.success("Вход в систему выполнен успешно");
} catch (err) { } catch (err) {
setError( setError(
@ -47,73 +73,102 @@ export const LoginPage = () => {
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
minHeight: "100vh", width: "100vw",
height: "100vh",
gap: 3, gap: 3,
p: 3, p: 3,
backgroundImage: "url('/login-bg.png')",
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}} }}
> >
<Typography variant="h4" component="h1" gutterBottom> <Paper
Вход в систему elevation={3}
</Typography>
<Box
component="form"
onSubmit={handleSubmit}
sx={{ sx={{
display: "flex", p: 4,
flexDirection: "column", borderRadius: 2,
gap: 2, backgroundColor: "white",
width: "100%", width: "100%",
maxWidth: "400px", maxWidth: "400px",
}} }}
> >
{error && ( <Typography
<Alert severity="error" sx={{ mb: 2 }}> variant="h4"
{error} component="h1"
</Alert> className="text-center pb-[50px]"
)} >
<TextField Вход в систему
label="Email" </Typography>
type="email" <Box
variant="outlined" component="form"
fullWidth onSubmit={handleSubmit}
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
error={!!error}
/>
<TextField
label="Пароль"
type="password"
variant="outlined"
fullWidth
required
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
error={!!error}
/>
<Button
variant="contained"
color="primary"
size="large"
type="submit"
disabled={isLoading}
sx={{ sx={{
width: "100%",
height: "50px",
position: "relative",
display: "flex", display: "flex",
alignItems: "center", flexDirection: "column",
justifyContent: "center", gap: 2,
borderRadius: "10px", width: "100%",
}} }}
> >
{isLoading ? <CircularProgress size={24} sx={{}} /> : "Войти"} {error && (
</Button> <Alert severity="error" sx={{ mb: 2 }}>
</Box> {error}
</Alert>
)}
<TextField
label="Email"
type="email"
variant="outlined"
fullWidth
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
error={!!error}
/>
<TextField
label="Пароль"
type="password"
variant="outlined"
fullWidth
required
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
error={!!error}
/>
<FormControlLabel
control={
<Checkbox
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
disabled={isLoading}
/>
}
label="Запомнить пароль"
/>
<Button
variant="contained"
color="primary"
size="large"
type="submit"
disabled={isLoading}
sx={{
width: "100%",
height: "50px",
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "10px",
}}
>
{isLoading ? <CircularProgress size={24} sx={{}} /> : "Войти"}
</Button>
</Box>
</Paper>
</Box> </Box>
); );
}; };

File diff suppressed because it is too large Load Diff

View File

@ -54,20 +54,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,
id: route.id,
route_number: route.route_number,
path: route.path,
}); });
} }
const mappedRoutes: ApiRoute[] = response.data.map((route: any) => ({
id: route.id,
route_number: route.route_number,
path: route.path,
}));
this.routes = mappedRoutes.sort((a, b) => this.routes = mappedRoutes.sort((a, b) =>
a.route_number.localeCompare(b.route_number) a.route_number.localeCompare(b.route_number)
); );
@ -114,7 +116,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;
@ -209,13 +210,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 +231,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,12 @@ 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,
} from "@shared";
import { MediaViewer } from "@widgets"; import { MediaViewer } from "@widgets";
export const MediaEditPage = observer(() => { export const MediaEditPage = observer(() => {
@ -40,10 +45,10 @@ 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);
@ -64,6 +69,11 @@ export const MediaEditPage = observer(() => {
} }
}, [media]); }, [media]);
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
// const handleDrop = (e: DragEvent<HTMLDivElement>) => { // const handleDrop = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault(); // e.preventDefault();
// e.stopPropagation(); // e.stopPropagation();

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>
); );
}, },
}, },
@ -76,15 +99,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 +148,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,30 +15,32 @@ export const MediaPreviewPage = observer(() => {
}, []); }, []);
return ( return (
<div className="w-full h-[80vh] flex flex-col justify-center items-center gap-4"> <div className="w-full flex flex-col justify-center items-center gap-4">
<div className="w-full h-full flex justify-center items-center"> <div className="w-full flex flex-col justify-center items-center gap-4">
<MediaViewer className="w-full h-full" media={oneMedia!} /> <div className="flex justify-center items-center max-w-[60%]">
</div> <MediaViewer media={oneMedia!} />
{oneMedia && (
<div className="flex flex-col items-center gap-4 bg-[#998879] p-4 rounded-md">
<p className="text-white text-center">
Чтобы скачать файл, нажмите на кнопку ниже
</p>
<Button
variant="contained"
color="primary"
startIcon={<Download size={16} />}
component="a"
href={`${
import.meta.env.VITE_KRBL_MEDIA
}${id}/download?token=${localStorage.getItem("token")}`}
target="_blank"
>
Скачать
</Button>
</div> </div>
)}
{oneMedia && (
<div className="flex-1 flex flex-col items-center gap-4 bg-[#998879] p-4 rounded-md">
<p className="text-white text-center">
Чтобы скачать файл, нажмите на кнопку ниже
</p>
<Button
variant="contained"
color="primary"
startIcon={<Download size={16} />}
component="a"
href={`${
import.meta.env.VITE_KRBL_MEDIA
}${id}/download?token=${localStorage.getItem("token")}`}
target="_blank"
>
Скачать
</Button>
</div>
)}
</div>
</div> </div>
); );
}); });

View File

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

View File

@ -8,16 +8,21 @@ import {
InputLabel, InputLabel,
Typography, Typography,
Box, Box,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material"; } from "@mui/material";
import { MediaViewer } 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 } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } 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, SelectArticleModal, SelectMediaDialog } from "@shared";
export const RouteCreatePage = observer(() => { export const RouteCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -32,12 +37,74 @@ export const RouteCreatePage = observer(() => {
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 { language } = languageStore;
useEffect(() => { useEffect(() => {
carrierStore.getCarriers(); carrierStore.getCarriers(language);
articlesStore.getArticleList(); articlesStore.getArticleList();
}, []); }, [language]);
const validateCoordinates = (value: string) => {
try {
const lines = value.trim().split("\n");
const coordinates = lines.map((line) => {
const [lat, lon] = line
.trim()
.split(/[\s,]+/)
.map(Number);
return [lat, lon];
});
if (coordinates.length === 0) {
return "Введите хотя бы одну пару координат";
}
if (
!coordinates.every(
(point) => Array.isArray(point) && point.length === 2
)
) {
return "Каждая строка должна содержать две координаты";
}
if (
!coordinates.every((point) =>
point.every((coord) => !isNaN(coord) && typeof coord === "number")
)
) {
return "Координаты должны быть числами";
}
return true;
} catch {
return "Неверный формат координат";
}
};
const handleArticleSelect = (articleId: number) => {
setGovernorAppeal(articleId.toString());
setIsSelectArticleDialogOpen(false);
};
const handleVideoSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setVideoPreview(media.id);
setIsSelectVideoDialogOpen(false);
};
const handleVideoPreviewClick = () => {
setIsVideoPreviewOpen(true);
};
const handleCreateRoute = async () => { const handleCreateRoute = async () => {
try { try {
@ -51,22 +118,31 @@ export const RouteCreatePage = observer(() => {
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 validationResult = validateCoordinates(routeCoords);
if (validationResult !== true) {
toast.error(validationResult);
return;
}
// Координаты маршрута как массив массивов чисел // Координаты маршрута как массив массивов чисел
const path = routeCoords const path = routeCoords
.trim()
.split("\n") .split("\n")
.map((line) => .map((line) => {
line const [lat, lon] = line
.split(" ") .trim()
.map((coord) => Number(coord.trim())) .split(/[\s,]+/)
.filter((n) => !isNaN(n)) .map(Number);
) return [lat, lon];
.filter((arr) => arr.length === 2); });
// Собираем объект маршрута // Собираем объект маршрута
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,
@ -78,6 +154,8 @@ export const RouteCreatePage = observer(() => {
center_latitude, center_latitude,
center_longitude, center_longitude,
path, path,
video_preview:
videoPreview && videoPreview !== "" ? videoPreview : undefined,
}; };
await routeStore.createRoute(newRoute); await routeStore.createRoute(newRoute);
@ -91,137 +169,306 @@ 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"> <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={
disabled={carrierStore.carriers.data.length === 0} carrierStore.carriers[
> language as keyof typeof carrierStore.carriers
<MenuItem value="">Не выбрано</MenuItem> ].data?.length === 0
{carrierStore.carriers.data.map( }
(c: (typeof carrierStore.carriers.data)[number]) => ( >
<MenuItem key={c.id} value={c.id}> <MenuItem value="">Не выбрано</MenuItem>
{c.full_name} {carrierStore.carriers[
language as keyof typeof carrierStore.carriers
].data?.map((carrier) => (
<MenuItem key={carrier.id} value={carrier.id}>
{carrier.full_name}
</MenuItem> </MenuItem>
) ))}
)} </Select>
</Select> </FormControl>
</FormControl> <TextField
<TextField className="w-full"
className="w-full" label="Номер маршрута"
label="Номер маршрута" required
required value={routeNumber}
value={routeNumber} onChange={(e) => setRouteNumber(e.target.value)}
onChange={(e) => setRouteNumber(e.target.value)} />
/> <TextField
<TextField className="w-full"
className="w-full" label="Координаты маршрута"
label="Координаты маршрута" multiline
multiline minRows={2}
minRows={3} maxRows={10}
value={routeCoords} value={routeCoords}
onChange={(e) => setRouteCoords(e.target.value)} onChange={(e) => {
/> const newValue = e.target.value;
<TextField setRouteCoords(newValue);
className="w-full" }}
label="Номер маршрута в Говорящем Городе" onKeyDown={(e) => {
required if (e.key === "Enter") {
value={govRouteNumber} const lines = routeCoords.split("\n");
onChange={(e) => setGovRouteNumber(e.target.value)} const lastLine = lines[lines.length - 1];
/>
<FormControl fullWidth required> // Если мы на последней строке и она не пустая
<InputLabel>Обращение губернатора</InputLabel> if (lastLine && lastLine.trim()) {
<Select e.preventDefault();
value={governorAppeal} const newValue = routeCoords + "\n";
label="Обращение губернатора" setRouteCoords(newValue);
onChange={(e) => setGovernorAppeal(e.target.value as string)} }
disabled={articlesStore.articleList.ru.data.length === 0} }
}}
error={validateCoordinates(routeCoords) !== true}
helperText={
typeof validateCoordinates(routeCoords) === "string"
? validateCoordinates(routeCoords)
: "Формат: широта долгота"
}
placeholder="55.7558 37.6173&#10;55.7539 37.6208"
sx={{
"& .MuiInputBase-root": {
maxHeight: "500px",
overflow: "auto",
},
"& .MuiInputBase-input": {
fontFamily: "monospace",
fontSize: "0.8rem",
lineHeight: "1.2",
padding: "8px 12px",
},
"& .MuiFormHelperText-root": {
fontSize: "0.75rem",
marginTop: "2px",
},
}}
/>
<TextField
className="w-full"
label="Номер маршрута в Говорящем Городе"
required
value={govRouteNumber}
onChange={(e) => setGovRouteNumber(e.target.value)}
/>
{/* Заменяем Select на кнопку для выбора статьи */}
<Box className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">
Обращение к пассажирам
</label>
<Box className="flex gap-2">
<TextField
className="flex-1"
value={selectedArticle?.heading || "Статья не выбрана"}
placeholder="Выберите статью"
disabled
sx={{
"& .MuiInputBase-input": {
color: selectedArticle ? "inherit" : "#999",
},
}}
/>
<Button
variant="outlined"
onClick={() => setIsSelectArticleDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
</Box>
{/* Селектор видео превью */}
<Box className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">
Видео превью
</label>
<Box className="flex gap-2">
<Box
className="flex-1"
onClick={handleVideoPreviewClick}
sx={{
cursor:
videoPreview && videoPreview !== "" ? "pointer" : "default",
}}
>
<Box
className="w-full h-[50px] border border-gray-400 rounded-sm flex items-center justify-between px-4"
sx={{
"& .MuiInputBase-input": {
color:
videoPreview && videoPreview !== ""
? "inherit"
: "#999",
cursor:
videoPreview && videoPreview !== ""
? "pointer"
: "default",
},
}}
>
<Typography variant="body1" className="text-sm">
{videoPreview && videoPreview !== ""
? "Видео выбрано"
: "Видео не выбрано"}
</Typography>
{videoPreview && videoPreview !== "" && (
<Box
onClick={(e) => {
e.stopPropagation();
setVideoPreview("");
}}
sx={{
cursor: "pointer",
color: "#999",
"&:hover": {
color: "#666",
},
}}
>
<Typography variant="body1" className="text-lg font-bold">
×
</Typography>
</Box>
)}
</Box>
</Box>
<Button
variant="outlined"
onClick={() => setIsSelectVideoDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
</Box>
<FormControl fullWidth required>
<InputLabel>Прямой/обратный маршрут</InputLabel>
<Select
value={direction}
label="Прямой/обратный маршрут"
onChange={(e) => setDirection(e.target.value)}
>
<MenuItem value="forward">Прямой</MenuItem>
<MenuItem value="backward">Обратный</MenuItem>
</Select>
</FormControl>
<TextField
className="w-full"
label="Масштаб (мин)"
value={scaleMin}
onChange={(e) => setScaleMin(e.target.value)}
/>
<TextField
className="w-full"
label="Масштаб (макс)"
value={scaleMax}
onChange={(e) => setScaleMax(e.target.value)}
/>
<TextField
className="w-full"
label="Поворот"
value={turn}
onChange={(e) => setTurn(e.target.value)}
/>
<TextField
className="w-full"
label="Центр. широта"
value={centerLat}
onChange={(e) => setCenterLat(e.target.value)}
/>
<TextField
className="w-full"
label="Центр. долгота"
value={centerLng}
onChange={(e) => setCenterLng(e.target.value)}
/>
</Box>
<div className="flex w-full justify-end">
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreateRoute}
disabled={isLoading}
> >
<MenuItem value="">Не выбрано</MenuItem> {isLoading ? (
{articlesStore.articleList.ru.data.map( <Loader2 size={20} className="animate-spin" />
(a: (typeof articlesStore.articleList.ru.data)[number]) => ( ) : (
<MenuItem key={a.id} value={a.id}> "Сохранить"
{a.heading}
</MenuItem>
)
)} )}
</Select> </Button>
</FormControl> </div>
<FormControl fullWidth required>
<InputLabel>Прямой/обратный маршрут</InputLabel>
<Select
value={direction}
label="Прямой/обратный маршрут"
onChange={(e) => setDirection(e.target.value)}
>
<MenuItem value="forward">Прямой</MenuItem>
<MenuItem value="backward">Обратный</MenuItem>
</Select>
</FormControl>
<TextField
className="w-full"
label="Масштаб (мин)"
value={scaleMin}
onChange={(e) => setScaleMin(e.target.value)}
/>
<TextField
className="w-full"
label="Масштаб (макс)"
value={scaleMax}
onChange={(e) => setScaleMax(e.target.value)}
/>
<TextField
className="w-full"
label="Поворот"
value={turn}
onChange={(e) => setTurn(e.target.value)}
/>
<TextField
className="w-full"
label="Центр. широта"
value={centerLat}
onChange={(e) => setCenterLat(e.target.value)}
/>
<TextField
className="w-full"
label="Центр. долгота"
value={centerLng}
onChange={(e) => setCenterLng(e.target.value)}
/>
</Box>
<div className="flex w-full justify-end">
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreateRoute}
disabled={isLoading}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Сохранить"
)}
</Button>
</div> </div>
{/* Модальное окно выбора статьи */}
<SelectArticleModal
open={isSelectArticleDialogOpen}
onClose={() => setIsSelectArticleDialogOpen(false)}
onSelectArticle={handleArticleSelect}
/>
{/* Модальное окно выбора видео */}
<SelectMediaDialog
open={isSelectVideoDialogOpen}
onClose={() => setIsSelectVideoDialogOpen(false)}
onSelectMedia={handleVideoSelect}
mediaType={2}
/>
{/* Модальное окно предпросмотра видео */}
{videoPreview && videoPreview !== "" && (
<Dialog
open={isVideoPreviewOpen}
onClose={() => setIsVideoPreviewOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>Предпросмотр видео</DialogTitle>
<DialogContent>
<Box className="flex justify-center items-center p-4">
<MediaViewer
media={{
id: videoPreview,
media_type: 2,
filename: "video_preview",
}}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsVideoPreviewOpen(false)}>
Закрыть
</Button>
</DialogActions>
</Dialog>
)}
</Paper> </Paper>
); );
}); });

View File

@ -8,33 +8,68 @@ import {
InputLabel, InputLabel,
Typography, Typography,
Box, Box,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material"; } from "@mui/material";
import { MediaViewer } 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 } 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,
SelectArticleModal,
SelectMediaDialog,
} 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 [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
useState(false);
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const { language } = languageStore;
const [coordinates, setCoordinates] = useState<string>("");
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
// Устанавливаем русский язык при загрузке страницы
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");
};
fetchData();
}, []);
useEffect(() => {
const fetchData = async () => {
carrierStore.getCarriers(language);
stationsStore.getStations();
articlesStore.getArticleList(); articlesStore.getArticleList();
}; };
fetchData(); fetchData();
}, [id]); }, [id, language]);
useEffect(() => {
if (editRouteData.path && editRouteData.path.length > 0) {
const formattedPath = editRouteData.path
.map((coords) => coords.join(" "))
.join("\n");
setCoordinates(formattedPath);
}
}, [editRouteData.path]);
const handleSave = async () => { const handleSave = async () => {
setIsLoading(true); setIsLoading(true);
@ -43,183 +78,460 @@ export const RouteEditPage = observer(() => {
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);
};
const handleVideoSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
routeStore.setEditRouteData({
video_preview: media.id,
});
setIsSelectVideoDialogOpen(false);
};
const handleVideoPreviewClick = () => {
if (editRouteData.video_preview && editRouteData.video_preview !== "") {
setIsVideoPreviewOpen(true);
}
};
// Получаем название выбранной статьи для отображения
const selectedArticle = articlesStore.articleList.ru.data.find(
(article) => article.id === editRouteData.governor_appeal
);
return ( 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"> <FormControl fullWidth required>
<FormControl fullWidth required> <InputLabel>Выберите перевозчика</InputLabel>
<InputLabel>Выберите перевозчика</InputLabel> <Select
<Select value={editRouteData.carrier_id}
value={editRouteData.carrier_id} label="Выберите перевозчика"
label="Выберите перевозчика" onChange={(e) =>
onChange={(e) => routeStore.setEditRouteData({
routeStore.setEditRouteData({ carrier_id: Number(e.target.value),
carrier_id: Number(e.target.value), carrier:
carrier: carrierStore.carriers[
carrierStore.carriers.data.find( language as keyof typeof carrierStore.carriers
(c) => c.id === Number(e.target.value) ].data?.find((c) => c.id === Number(e.target.value))
)?.full_name || "", ?.full_name || "",
}) })
} }
disabled={carrierStore.carriers.data.length === 0} disabled={
> carrierStore.carriers[
<MenuItem value="">Не выбрано</MenuItem> language as keyof typeof carrierStore.carriers
{carrierStore.carriers.data.map( ].data?.length === 0
(c: (typeof carrierStore.carriers.data)[number]) => ( }
<MenuItem key={c.id} value={c.id}> >
{c.full_name} <MenuItem value="">Не выбрано</MenuItem>
{carrierStore.carriers[
language as keyof typeof carrierStore.carriers
].data?.map((carrier) => (
<MenuItem key={carrier.id} value={carrier.id}>
{carrier.full_name}
</MenuItem> </MenuItem>
) ))}
)} </Select>
</Select> </FormControl>
</FormControl> <TextField
<TextField className="w-full"
className="w-full" label="Номер маршрута"
label="Номер маршрута" required
required value={editRouteData.route_number || ""}
value={editRouteData.route_number || ""}
onChange={(e) =>
routeStore.setEditRouteData({
route_number: e.target.value,
})
}
/>
<TextField
className="w-full"
label="Координаты маршрута"
multiline
minRows={3}
value={editRouteData.path.map((p) => p.join(" ")).join("\n") || ""}
onChange={(e) =>
routeStore.setEditRouteData({
path: e.target.value
.split("\n")
.map((line) => line.split(" ").map(Number)),
})
}
/>
<TextField
className="w-full"
label="Номер маршрута в Говорящем Городе"
required
value={editRouteData.route_sys_number || ""}
onChange={(e) =>
routeStore.setEditRouteData({
route_sys_number: e.target.value,
})
}
/>
<FormControl fullWidth required>
<InputLabel>Обращение губернатора</InputLabel>
<Select
value={editRouteData.governor_appeal || ""}
label="Обращение губернатора"
onChange={(e) => onChange={(e) =>
routeStore.setEditRouteData({ routeStore.setEditRouteData({
governor_appeal: Number(e.target.value), route_number: e.target.value,
}) })
} }
disabled={articlesStore.articleList.ru.data.length === 0} />
> <TextField
<MenuItem value="">Не выбрано</MenuItem> className="w-full"
{articlesStore.articleList.ru.data.map( label="Координаты маршрута"
(a: (typeof articlesStore.articleList.ru.data)[number]) => ( multiline
<MenuItem key={a.id} value={a.id}> minRows={2}
{a.heading} maxRows={10}
</MenuItem> value={coordinates}
) onChange={(e) => {
)} const newValue = e.target.value;
</Select> setCoordinates(newValue);
</FormControl>
<FormControl fullWidth required> const validationResult = validateCoordinates(newValue);
<InputLabel>Прямой/обратный маршрут</InputLabel> if (validationResult === true) {
<Select const lines = newValue.trim().split("\n");
value={editRouteData.route_direction ? "forward" : "backward"} const path = lines.map((line) => {
label="Прямой/обратный маршрут" const [lat, lon] = line
.trim()
.split(/[\s,]+/)
.map(Number);
return [lat, lon];
});
routeStore.setEditRouteData({ path });
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
const lines = coordinates.split("\n");
const lastLine = lines[lines.length - 1];
// Если мы на последней строке и она не пустая
if (lastLine && lastLine.trim()) {
e.preventDefault();
const newValue = coordinates + "\n";
setCoordinates(newValue);
}
}
}}
error={validateCoordinates(coordinates) !== true}
helperText={
typeof validateCoordinates(coordinates) === "string"
? validateCoordinates(coordinates)
: "Формат: широта долгота"
}
placeholder="55.7558 37.6173&#10;55.7539 37.6208"
sx={{
"& .MuiInputBase-root": {
maxHeight: "500px",
overflow: "auto",
},
"& .MuiInputBase-input": {
fontFamily: "monospace",
fontSize: "0.8rem",
lineHeight: "1.2",
padding: "8px 12px",
},
"& .MuiFormHelperText-root": {
fontSize: "0.75rem",
marginTop: "2px",
},
}}
/>
<TextField
className="w-full"
label="Номер маршрута в Говорящем Городе"
required
value={editRouteData.route_sys_number || ""}
onChange={(e) => onChange={(e) =>
routeStore.setEditRouteData({ routeStore.setEditRouteData({
route_direction: e.target.value === "forward", route_sys_number: e.target.value,
}) })
} }
/>
{/* Заменяем Select на кнопку для выбора статьи */}
<Box className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">
Обращение к пассажирам
</label>
<Box className="flex gap-2">
<TextField
className="flex-1"
value={selectedArticle?.heading || "Статья не выбрана"}
placeholder="Выберите статью"
disabled
sx={{
"& .MuiInputBase-input": {
color: selectedArticle ? "inherit" : "#999",
},
}}
/>
<Button
variant="outlined"
onClick={() => setIsSelectArticleDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
</Box>
{/* Селектор видео превью */}
<Box className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">
Видео превью
</label>
<Box className="flex gap-2">
<Box
className="flex-1"
onClick={handleVideoPreviewClick}
sx={{
cursor:
editRouteData.video_preview &&
editRouteData.video_preview !== ""
? "pointer"
: "default",
}}
>
<Box
className="w-full h-[50px] border border-gray-400 rounded-sm flex items-center justify-between px-4"
sx={{
"& .MuiInputBase-input": {
color:
editRouteData.video_preview &&
editRouteData.video_preview !== ""
? "inherit"
: "#999",
cursor:
editRouteData.video_preview &&
editRouteData.video_preview !== ""
? "pointer"
: "default",
},
}}
>
<Typography variant="body1" className="text-sm">
{editRouteData.video_preview &&
editRouteData.video_preview !== ""
? "Видео выбрано"
: "Видео не выбрано"}
</Typography>
{editRouteData.video_preview &&
editRouteData.video_preview !== "" && (
<Box
onClick={(e) => {
e.stopPropagation();
routeStore.setEditRouteData({ video_preview: "" });
}}
sx={{
cursor: "pointer",
color: "#999",
"&:hover": {
color: "#666",
},
}}
>
<Typography
variant="body1"
className="text-lg font-bold"
>
×
</Typography>
</Box>
)}
</Box>
</Box>
<Button
variant="outlined"
onClick={() => setIsSelectVideoDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
</Box>
<FormControl fullWidth required>
<InputLabel>Прямой/обратный маршрут</InputLabel>
<Select
value={editRouteData.route_direction ? "forward" : "backward"}
label="Прямой/обратный маршрут"
onChange={(e) =>
routeStore.setEditRouteData({
route_direction: e.target.value === "forward",
})
}
>
<MenuItem value="forward">Прямой</MenuItem>
<MenuItem value="backward">Обратный</MenuItem>
</Select>
</FormControl>
<TextField
className="w-full"
label="Масштаб (мин)"
value={editRouteData.scale_min ?? ""}
onChange={(e) =>
routeStore.setEditRouteData({
scale_min:
e.target.value === "" ? null : parseFloat(e.target.value),
})
}
/>
<TextField
className="w-full"
label="Масштаб (макс)"
value={editRouteData.scale_max ?? ""}
onChange={(e) =>
routeStore.setEditRouteData({
scale_max:
e.target.value === "" ? null : parseFloat(e.target.value),
})
}
/>
<TextField
className="w-full"
label="Поворот"
value={editRouteData.rotate ?? ""}
onChange={(e) =>
routeStore.setEditRouteData({
rotate:
e.target.value === "" ? null : parseFloat(e.target.value),
})
}
/>
<TextField
className="w-full"
label="Центр. широта"
value={editRouteData.center_latitude ?? ""}
type="text"
onChange={(e) =>
routeStore.setEditRouteData({
center_latitude: e.target.value,
})
}
/>
<TextField
className="w-full"
label="Центр. долгота"
value={editRouteData.center_longitude ?? ""}
type="text"
onChange={(e) =>
routeStore.setEditRouteData({
center_longitude: e.target.value,
})
}
/>
</Box>
<LinkedItems
parentId={id || ""}
type="edit"
dragAllowed={true}
fields={[
{ label: "Название", data: "name" },
{ label: "Описание", data: "description" },
]}
onUpdate={() => {
routeStore.getRoute(Number(id));
}}
routeDirection={editRouteData.route_direction}
/>
<div className="flex w-full justify-between">
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Copy size={20} />}
onClick={handleCopy}
disabled={isLoading}
> >
<MenuItem value="forward">Прямой</MenuItem> Скопировать
<MenuItem value="backward">Обратный</MenuItem> </Button>
</Select>
</FormControl> <Button
<TextField variant="contained"
className="w-full" color="primary"
label="Масштаб (мин)" className="w-min flex gap-2 items-center"
value={editRouteData.scale_min || ""} startIcon={<Save size={20} />}
onChange={(e) => onClick={handleSave}
routeStore.setEditRouteData({ disabled={isLoading}
scale_min: Number(e.target.value), >
}) Сохранить
} </Button>
/> </div>
<TextField
className="w-full"
label="Масштаб (макс)"
value={editRouteData.scale_max || ""}
onChange={(e) =>
routeStore.setEditRouteData({
scale_max: Number(e.target.value),
})
}
/>
<TextField
className="w-full"
label="Поворот"
value={editRouteData.rotate || ""}
onChange={(e) =>
routeStore.setEditRouteData({
rotate: Number(e.target.value),
})
}
/>
<TextField
className="w-full"
label="Центр. широта"
value={editRouteData.center_latitude || ""}
onChange={(e) =>
routeStore.setEditRouteData({
center_latitude: Number(e.target.value),
})
}
/>
<TextField
className="w-full"
label="Центр. долгота"
value={editRouteData.center_longitude || ""}
onChange={(e) =>
routeStore.setEditRouteData({
center_longitude: Number(e.target.value),
})
}
/>
</Box>
<div className="flex w-full justify-end">
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleSave}
disabled={isLoading}
>
Сохранить
</Button>
</div> </div>
{/* Модальное окно выбора статьи */}
<SelectArticleModal
open={isSelectArticleDialogOpen}
onClose={() => setIsSelectArticleDialogOpen(false)}
onSelectArticle={handleArticleSelect}
/>
{/* Модальное окно выбора видео */}
<SelectMediaDialog
open={isSelectVideoDialogOpen}
onClose={() => setIsSelectVideoDialogOpen(false)}
onSelectMedia={handleVideoSelect}
mediaType={2}
/>
{/* Модальное окно предпросмотра видео */}
<Dialog
open={isVideoPreviewOpen}
onClose={() => setIsVideoPreviewOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>Предпросмотр видео</DialogTitle>
<DialogContent>
<Box className="flex justify-center items-center p-4">
<MediaViewer
media={{
id: editRouteData.video_preview,
media_type: 2,
filename: "video_preview",
}}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsVideoPreviewOpen(false)}>Закрыть</Button>
</DialogActions>
</Dialog>
</Paper> </Paper>
); );
}); });

View File

@ -1,32 +1,70 @@
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_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",
@ -80,23 +118,51 @@ 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 ? "Прямой" : "Обратный",
})); }));
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 +180,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,8 +1,8 @@
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 = 0x111111;

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,6 +45,7 @@ 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);
@ -53,7 +54,7 @@ export function InfiniteCanvas({
const lastOriginalRotation = useRef<number | undefined>(undefined); const lastOriginalRotation = useRef<number | undefined>(undefined);
useEffect(() => { useEffect(() => {
const canvas = applicationRef?.app.canvas; const canvas = applicationRef?.app?.canvas;
if (!canvas) return; if (!canvas) return;
const canvasRect = canvas.getBoundingClientRect(); const canvasRect = canvas.getBoundingClientRect();
@ -62,10 +63,11 @@ export function InfiniteCanvas({
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?.canvas, setScreenCenter]);
const handlePointerDown = (e: FederatedMouseEvent) => { const handlePointerDown = (e: FederatedMouseEvent) => {
setIsDragging(true); setIsPointerDown(true);
setIsDragging(false);
setIsUserInteracting(true); // Устанавливаем флаг взаимодействия пользователя setIsUserInteracting(true); // Устанавливаем флаг взаимодействия пользователя
setStartPosition({ setStartPosition({
x: position.x, x: position.x,
@ -93,7 +95,18 @@ 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 };
@ -136,6 +149,12 @@ export function InfiniteCanvas({
}; };
const handlePointerUp = (e: FederatedMouseEvent) => { const handlePointerUp = (e: FederatedMouseEvent) => {
// Если не было перетаскивания, то это простой клик - закрываем виджет
if (!isDragging) {
setSelectedSight(undefined);
}
setIsPointerDown(false);
setIsDragging(false); setIsDragging(false);
// Сбрасываем флаг взаимодействия через небольшую задержку // Сбрасываем флаг взаимодействия через небольшую задержку
// чтобы избежать немедленного срабатывания useEffect // чтобы избежать немедленного срабатывания useEffect
@ -185,7 +204,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,30 @@
import { Stack, Typography, Button } from "@mui/material"; import { 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";
export function LeftSidebar() { export const LeftSidebar = observer(() => {
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") {
@ -27,6 +47,7 @@ export function LeftSidebar() {
color: "#fff", color: "#fff",
backgroundColor: "#222", backgroundColor: "#222",
borderRadius: 10, borderRadius: 10,
height: 40,
width: "100%", width: "100%",
border: "none", border: "none",
cursor: "pointer", cursor: "pointer",
@ -41,10 +62,30 @@ export function LeftSidebar() {
justifyContent="center" justifyContent="center"
my={10} my={10}
> >
<img src={"/Emblem.svg"} alt="logo" width={100} height={100} /> <div
<Typography sx={{ mb: 2, color: "#fff" }} textAlign="center"> style={{
При поддержке Правительства Санкт-Петербурга maxWidth: 200,
</Typography> display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 10,
}}
>
{carrierThumbnail && (
<MediaViewer
media={{
id: carrierThumbnail,
media_type: 1, // Тип "Фото" для логотипа
filename: "route_thumbnail",
}}
fullWidth
fullHeight
/>
)}
<Typography sx={{ mb: 2, color: "#fff" }} textAlign="center">
При поддержке Правительства
</Typography>{" "}
</div>
</Stack> </Stack>
<Stack <Stack
@ -65,15 +106,20 @@ export function LeftSidebar() {
<Stack <Stack
direction="column" direction="column"
alignItems="center" alignItems="center"
maxHeight={150}
justifyContent="center" justifyContent="center"
my={10} my={10}
> >
<img {carrierLogo && (
src={"/GET.png"} <MediaViewer
alt="logo" media={{
width="80%" id: carrierLogo,
style={{ margin: "0 auto" }} media_type: 1, // Тип "Фото" для логотипа
/> filename: "route_thumbnail_logo",
}}
fullHeight
/>
)}
</Stack> </Stack>
<Typography <Typography
@ -86,4 +132,4 @@ export function LeftSidebar() {
</Typography> </Typography>
</Stack> </Stack>
); );
} });

View File

@ -29,10 +29,13 @@ 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,
@ -50,10 +53,13 @@ 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: () => {},
saveChanges: () => {}, saveChanges: () => {},
}); });
@ -87,6 +93,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 +113,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);
@ -176,43 +184,136 @@ export const MapDataProvider = observer(
} }
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);
} }
} }
function setStationOffset(stationId: number, x: number, y: number) { function setStationOffset(stationId: number, x: number, y: number) {
setStationChanges((prev) => { const currentStation = stationData.ru?.find(
let found = prev.find((station) => station.station_id === stationId); (station) => station.id === stationId
if (found) { );
found.offset_x = x; if (
found.offset_y = y; currentStation &&
Math.abs(currentStation.offset_x - x) < 0.01 &&
Math.abs(currentStation.offset_y - y) < 0.01
) {
return;
}
return prev.map((station) => { setStationChanges((prev) => {
if (station.station_id === stationId) { const existingIndex = prev.findIndex(
return found; (station) => station.station_id === stationId
);
if (existingIndex !== -1) {
const newChanges = [...prev];
newChanges[existingIndex] = {
...newChanges[existingIndex],
offset_x: x,
offset_y: y,
};
return newChanges;
} else {
const originalStation = originalStationData?.find(
(s) => s.id === stationId
);
return [
...prev,
{
station_id: stationId,
offset_x: x,
offset_y: y,
align: originalStation?.align ?? 1,
transfers: originalStation?.transfers ?? {
bus: "",
metro_blue: "",
metro_green: "",
metro_orange: "",
metro_purple: "",
metro_red: "",
train: "",
tram: "",
trolleybus: "",
},
},
];
}
});
setStationData((prev) => {
const updated = { ...prev };
Object.keys(updated).forEach((lang) => {
updated[lang] = updated[lang].map((station) => {
if (station.id === stationId) {
return { ...station, offset_x: x, offset_y: y };
} }
return station; return station;
}); });
});
return updated;
});
}
function setStationAlign(stationId: number, align: number) {
const currentStation = stationData.ru?.find(
(station) => station.id === stationId
);
if (currentStation && currentStation.align === align) {
return;
}
setStationChanges((prev) => {
const existingIndex = prev.findIndex(
(station) => station.station_id === stationId
);
if (existingIndex !== -1) {
const newChanges = [...prev];
newChanges[existingIndex] = {
...newChanges[existingIndex],
align: align,
};
return newChanges;
} else { } else {
const foundStation = stationData.ru?.find( const originalStation = originalStationData?.find(
(station) => station.id === stationId (s) => s.id === stationId
); );
if (foundStation) { return [
return [ ...prev,
...prev, {
{ station_id: stationId,
station_id: stationId, align: align,
offset_x: x, offset_x: originalStation?.offset_x ?? 0,
offset_y: y, offset_y: originalStation?.offset_y ?? 0,
transfers: foundStation.transfers, transfers: originalStation?.transfers ?? {
bus: "",
metro_blue: "",
metro_green: "",
metro_orange: "",
metro_purple: "",
metro_red: "",
train: "",
tram: "",
trolleybus: "",
}, },
]; },
} ];
return prev;
} }
}); });
setStationData((prev) => {
const updated = { ...prev };
Object.keys(updated).forEach((lang) => {
updated[lang] = updated[lang].map((station) => {
if (station.id === stationId) {
return { ...station, align: align };
}
return station;
});
});
return updated;
});
} }
function setSightCoordinates( function setSightCoordinates(
@ -221,14 +322,18 @@ export const MapDataProvider = observer(
longitude: number longitude: number
) { ) {
setSightChanges((prev) => { setSightChanges((prev) => {
let found = prev.find((sight) => sight.sight_id === sightId); const existingIndex = prev.findIndex(
if (found) { (sight) => sight.sight_id === sightId
found.latitude = latitude; );
found.longitude = longitude;
return prev.map((sight) => { if (existingIndex !== -1) {
if (sight.sight_id === sightId) { return prev.map((sight, index) => {
return found; if (index === existingIndex) {
return {
...sight,
latitude,
longitude,
};
} }
return sight; return sight;
}); });
@ -249,9 +354,7 @@ export const MapDataProvider = observer(
}); });
} }
useEffect(() => { useEffect(() => {}, [sightChanges]);
console.log("sightChanges", sightChanges);
}, [sightChanges]);
const value = useMemo( const value = useMemo(
() => ({ () => ({
@ -264,11 +367,14 @@ 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,
}), }),
[ [
@ -281,6 +387,7 @@ export const MapDataProvider = observer(
isRouteLoading, isRouteLoading,
isStationLoading, isStationLoading,
isSightLoading, isSightLoading,
selectedSight,
] ]
); );

View File

@ -1,8 +1,10 @@
import { Button, Stack, TextField, Typography } from "@mui/material"; import { Button, Stack, TextField, Typography, Slider } from "@mui/material";
import { useMapData } from "./MapDataContext"; import { 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 { coordinatesToLocal, localToCoordinates } from "./utils";
import { SCALE_FACTOR } from "./Constants";
import { toast } from "react-toastify";
export function RightSidebar() { export function RightSidebar() {
const { const {
@ -20,19 +22,31 @@ export function RightSidebar() {
screenCenter, screenCenter,
rotateToAngle, rotateToAngle,
setTransform, setTransform,
scale,
setScaleAtCenter,
} = useTransform(); } = 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);
useEffect(() => { useEffect(() => {
if (originalRouteData) { if (originalRouteData) {
setMinScale(originalRouteData.scale_min ?? 1); // Проверяем и сбрасываем минимальный масштаб если нужно
setMaxScale(originalRouteData.scale_max ?? 10); const originalMinScale = originalRouteData.scale_min ?? 1;
const resetMinScale = originalMinScale < 1 ? 1 : originalMinScale;
// Проверяем и сбрасываем максимальный масштаб если нужно
const originalMaxScale = originalRouteData.scale_max ?? 5;
const resetMaxScale = originalMaxScale < 3 ? 3 : originalMaxScale;
setMinScale(resetMinScale);
setMaxScale(resetMaxScale);
setRotationDegrees(originalRouteData.rotate ?? 0); setRotationDegrees(originalRouteData.rotate ?? 0);
setLocalCenter({ setLocalCenter({
x: originalRouteData.center_latitude ?? 0, x: originalRouteData.center_latitude ?? 0,
@ -52,16 +66,26 @@ 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); const center = screenCenter ?? { x: 0, y: 0 };
const coordinates = localToCoordinates(localCenter.x, localCenter.y); const localCenter = screenToLocal(center.x, center.y);
setLocalCenter({ x: coordinates.latitude, y: coordinates.longitude }); const coordinates = localToCoordinates(localCenter.x, localCenter.y);
}, [position]); setLocalCenter({ x: coordinates.latitude, y: coordinates.longitude });
}
}, [
position,
screenCenter,
screenToLocal,
localToCoordinates,
setLocalCenter,
isUserEditing,
]);
useEffect(() => { useEffect(() => {
setMapCenter(localCenter.x, localCenter.y); setMapCenter(localCenter.x, localCenter.y);
@ -104,7 +128,30 @@ 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);
// Сбрасываем к 1 если меньше
if (newMinScale < 1) {
newMinScale = 1;
}
setMinScale(newMinScale);
if (maxScale - newMinScale < 2) {
let newMaxScale = newMinScale + 2;
// Сбрасываем максимальный к 3 если меньше минимального
if (newMaxScale < 3) {
newMaxScale = 3;
setMinScale(1); // Сбрасываем минимальный к 1
}
setMaxScale(newMaxScale);
}
if (newMinScale > scale * SCALE_FACTOR) {
setScaleAtCenter(newMinScale / SCALE_FACTOR);
}
}}
style={{ backgroundColor: "#222", borderRadius: 4 }} style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{ sx={{
"& .MuiInputLabel-root": { "& .MuiInputLabel-root": {
@ -116,7 +163,8 @@ export function RightSidebar() {
}} }}
slotProps={{ slotProps={{
input: { input: {
min: 0.1, min: 1,
max: 10,
}, },
}} }}
/> />
@ -125,7 +173,30 @@ 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);
// Сбрасываем к 3 если меньше минимального
if (newMaxScale < 3) {
newMaxScale = 3;
}
setMaxScale(newMaxScale);
if (newMaxScale - minScale < 2) {
let newMinScale = newMaxScale - 2;
// Сбрасываем минимальный к 1 если меньше
if (newMinScale < 1) {
newMinScale = 1;
setMaxScale(3); // Сбрасываем максимальный к минимальному значению
}
setMinScale(newMinScale);
}
if (newMaxScale < scale * SCALE_FACTOR) {
setScaleAtCenter(newMaxScale / SCALE_FACTOR);
}
}}
style={{ backgroundColor: "#222", borderRadius: 4, color: "#fff" }} style={{ backgroundColor: "#222", borderRadius: 4, color: "#fff" }}
sx={{ sx={{
"& .MuiInputLabel-root": { "& .MuiInputLabel-root": {
@ -137,12 +208,71 @@ export function RightSidebar() {
}} }}
slotProps={{ slotProps={{
input: { input: {
min: 0.1, min: 3,
max: 10,
}, },
}} }}
/> />
</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,
}}
/>
<TextField <TextField
type="number" type="number"
label="Поворот (в градусах)" label="Поворот (в градусах)"
@ -181,11 +311,13 @@ 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) => {
setIsUserEditing(true);
setLocalCenter((prev) => ({ ...prev, x: Number(e.target.value) })); setLocalCenter((prev) => ({ ...prev, x: Number(e.target.value) }));
pan({ x: Number(e.target.value), y: localCenter.y }); pan({ x: Number(e.target.value), y: localCenter.y });
}} }}
onBlur={() => setIsUserEditing(false)}
style={{ backgroundColor: "#222", borderRadius: 4 }} style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{ sx={{
"& .MuiInputLabel-root": { "& .MuiInputLabel-root": {
@ -195,16 +327,21 @@ 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) => {
setIsUserEditing(true);
setLocalCenter((prev) => ({ ...prev, y: Number(e.target.value) })); setLocalCenter((prev) => ({ ...prev, y: Number(e.target.value) }));
pan({ x: localCenter.x, y: Number(e.target.value) }); pan({ x: localCenter.x, y: Number(e.target.value) });
}} }}
onBlur={() => setIsUserEditing(false)}
style={{ backgroundColor: "#222", borderRadius: 4 }} style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{ sx={{
"& .MuiInputLabel-root": { "& .MuiInputLabel-root": {
@ -214,6 +351,9 @@ export function RightSidebar() {
color: "#fff", color: "#fff",
}, },
}} }}
inputProps={{
step: 0.001,
}}
/> />
</Stack> </Stack>
@ -221,8 +361,14 @@ export function RightSidebar() {
variant="contained" variant="contained"
color="secondary" color="secondary"
sx={{ mt: 2 }} sx={{ mt: 2 }}
onClick={() => { onClick={async () => {
saveChanges(); try {
await saveChanges();
toast.success("Изменения сохранены");
} catch (error) {
console.error(error);
toast.error("Ошибка при сохранении изменений");
}
}} }}
> >
Сохранить изменения Сохранить изменения

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("/SightIcon.png").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,8 @@
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 +11,565 @@ 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";
// --- Конец заглушек ---
// --- Декларации для react-pixi ---
// (В реальном проекте типы должны быть предоставлены библиотекой @pixi/react-pixi)
declare const pixiContainer: any;
declare const pixiGraphics: any;
declare const pixiText: any;
// --- Типы ---
type HorizontalAlign = "left" | "center" | "right";
type VerticalAlign = "top" | "center" | "bottom";
type TextAlign = HorizontalAlign | `${HorizontalAlign} ${VerticalAlign}`;
type LabelAlign = "left" | "center" | "right";
// --- Утилиты ---
/**
* Преобразует текстовое позиционирование в anchor координаты.
*/
/**
* Получает координату anchor.x из типа выравнивания.
*/
// --- Интерфейсы пропсов ---
interface StationProps { interface StationProps {
station: StationData; station: StationData;
ruLabel: string | null; ruLabel: string | null;
anchorPoint?: { x: number; y: number };
/** Anchor для всего блока с текстом. По умолчанию: `"right center"` */
labelBlockAnchor?: TextAlign | { x: number; y: number };
/** Внутреннее выравнивание текста в блоке. По умолчанию: `"left"` */
labelAlign?: LabelAlign;
/** Callback для изменения внутреннего выравнивания */
onLabelAlignChange?: (align: LabelAlign) => void;
/** Callback для отслеживания наведения на текст */
onTextHover?: (isHovered: boolean) => void;
} }
export const Station = observer( interface LabelAlignmentControlProps {
({ station, ruLabel }: Readonly<StationProps>) => { scale: number;
const draw = useCallback((g: Graphics) => { currentAlign: LabelAlign;
onAlignChange: (align: LabelAlign) => void;
onPointerOver: () => void;
onPointerOut: () => void;
onControlPointerEnter: () => void;
onControlPointerLeave: () => void;
}
interface StationLabelProps
extends Omit<StationProps, "ruLabelAnchor" | "nameLabelAnchor"> {}
const getAnchorFromOffset = (
offsetX: number,
offsetY: number
): { x: number; y: number } => {
if (offsetX === 0 && offsetY === 0) {
return { x: 0.5, y: 0.5 };
}
const length = Math.hypot(offsetX, offsetY);
const nx = offsetX / length;
const ny = offsetY / length;
return { x: (1 - nx) / 2, y: (1 - ny) / 2 };
};
// =========================================================================
// Компонент: Панель управления выравниванием в стиле УрФУ
// =========================================================================
const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
scale,
currentAlign,
onAlignChange,
onControlPointerEnter,
onControlPointerLeave,
}) => {
const controlHeight = 50 / scale;
const controlWidth = 200 / scale;
const fontSize = 18 / scale;
const borderRadius = 8 / scale;
const compensatedRuFontSize = (26 * 0.75) / scale;
const buttonWidth = controlWidth / 3;
const strokeWidth = 2 / scale;
const drawBg = useCallback(
(g: Graphics) => {
g.clear();
// Основной фон с градиентом
g.roundRect(
-controlWidth / 2,
0,
controlWidth,
controlHeight,
borderRadius
);
g.fill({ color: "#1a1a1a" }); // Темный фон как у УрФУ
// Тонкая рамка
g.roundRect(
-controlWidth / 2,
0,
controlWidth,
controlHeight,
borderRadius
);
g.stroke({ color: "#333333", width: strokeWidth });
// Разделители между кнопками
for (let i = 1; i < 3; i++) {
const x = -controlWidth / 2 + buttonWidth * i;
g.moveTo(x, strokeWidth);
g.lineTo(x, controlHeight - strokeWidth);
g.stroke({ color: "#333333", width: strokeWidth });
}
},
[controlWidth, controlHeight, borderRadius, buttonWidth, strokeWidth]
);
const drawButtonHighlight = useCallback(
(g: Graphics, index: number, isActive: boolean) => {
g.clear();
if (isActive) {
const x = -controlWidth / 2 + buttonWidth * index;
g.roundRect(
x + strokeWidth,
strokeWidth,
buttonWidth - strokeWidth * 2,
controlHeight - strokeWidth * 2,
borderRadius / 2
);
g.fill({ color: "#0066cc", alpha: 0.8 }); // Синий акцент УрФУ
}
},
[controlWidth, controlHeight, buttonWidth, strokeWidth, borderRadius]
);
const getTextStyle = (isActive: boolean) => ({
fontSize,
fontWeight: isActive ? ("bold" as const) : ("normal" as const),
fill: isActive ? "#ffffff" : "#cccccc",
fontFamily: "Arial, sans-serif",
});
const alignOptions = [
{ key: "left" as const, label: "Left" },
{ key: "center" as const, label: "Center" },
{ key: "right" as const, label: "Right" },
];
return (
<pixiContainer
position={{ x: 0, y: compensatedRuFontSize * 1.1 + 15 / scale }}
zIndex={999999999999999999}
eventMode="static"
onPointerOver={(e: FederatedMouseEvent) => {
e.stopPropagation();
onControlPointerEnter();
}}
onPointerOut={(e: FederatedMouseEvent) => {
e.stopPropagation();
onControlPointerLeave();
}}
onPointerDown={(e: FederatedMouseEvent) => {
e.stopPropagation();
}}
>
{/* Основной фон */}
<pixiGraphics draw={drawBg} />
{/* Кнопки с подсветкой */}
{alignOptions.map((option, index) => (
<pixiContainer key={option.key}>
{/* Подсветка активной кнопки */}
<pixiGraphics
draw={(g: Graphics) =>
drawButtonHighlight(g, index, option.key === currentAlign)
}
/>
{/* Текст кнопки */}
<pixiText
text={option.label}
anchor={{ x: 0.5, y: 0.5 }}
position={{
x: -controlWidth / 2 + buttonWidth * (index + 0.5),
y: controlHeight / 2,
}}
style={getTextStyle(option.key === currentAlign)}
eventMode="static"
cursor="pointer"
onClick={(e: FederatedMouseEvent) => {
e.stopPropagation();
onAlignChange(option.key);
}}
onPointerDown={(e: FederatedMouseEvent) => {
e.stopPropagation();
onAlignChange(option.key);
}}
onPointerOver={(e: FederatedMouseEvent) => {
e.stopPropagation();
onControlPointerEnter();
}}
/>
</pixiContainer>
))}
</pixiContainer>
);
};
// =========================================================================
// Компонент: Метка Станции (с логикой)
// =========================================================================
const StationLabel = observer(
({
station,
ruLabel,
labelAlign: labelAlignProp = "center",
onLabelAlignChange,
onTextHover,
}: Readonly<StationLabelProps>) => {
const { language } = languageStore;
const { rotation, scale } = useTransform();
const { setStationOffset, setStationAlign } = useMapData();
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [isPointerDown, setIsPointerDown] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isControlHovered, setIsControlHovered] = useState(false);
const [currentLabelAlign, setCurrentLabelAlign] = useState(labelAlignProp);
const [ruLabelWidth, setRuLabelWidth] = useState(0);
const dragStartPos = useRef({ x: 0, y: 0 });
const mouseStartPos = useRef({ x: 0, y: 0 });
const hideTimer = useRef<NodeJS.Timeout | null>(null);
const ruLabelRef = useRef<any>(null);
useEffect(() => {
return () => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
}
};
}, []);
const handlePointerEnter = () => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
setIsHovered(true);
onTextHover?.(true); // Call the callback to indicate text is hovered
};
const handleControlPointerEnter = () => {
// Дополнительная обработка для панели управления
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
setIsControlHovered(true);
setIsHovered(true);
onTextHover?.(true); // Call the callback to indicate text/control is hovered
};
const handleControlPointerLeave = () => {
setIsControlHovered(false);
// Если курсор не над основным контейнером, скрываем панель через некоторое время
if (!isHovered) {
hideTimer.current = setTimeout(() => {
setIsHovered(false);
onTextHover?.(false); // Call the callback to indicate neither text nor control is hovered
}, 0);
}
};
const handlePointerLeave = () => {
// Увеличиваем время до скрытия панели и добавляем проверку
hideTimer.current = setTimeout(() => {
setIsHovered(false);
// Если курсор не над панелью управления, скрываем и её
if (!isControlHovered) {
setIsControlHovered(false);
}
onTextHover?.(false); // Call the callback to indicate text is no longer hovered
}, 100); // Увеличиваем время до скрытия панели
};
useEffect(() => {
setPosition({ x: station.offset_x ?? 0, y: station.offset_y ?? 0 });
}, [station.offset_x, station.offset_y, station.id]);
// Функция для конвертации числового align в строковый
const convertNumericAlign = (align: number): LabelAlign => {
switch (align) {
case 0:
return "left";
case 1:
return "center";
case 2:
return "right";
default:
return "center";
}
};
// Функция для конвертации строкового align в числовой
const convertStringAlign = (align: LabelAlign): number => {
switch (align) {
case "left":
return 0;
case "center":
return 1;
case "right":
return 2;
default:
return 1;
}
};
useEffect(() => {
setCurrentLabelAlign(convertNumericAlign(station.align ?? 1));
}, [station.align]);
if (!station) return null;
const coordinates = coordinatesToLocal(station.latitude, station.longitude);
const compensatedRuFontSize = (26 * 0.75) / scale;
const compensatedNameFontSize = (16 * 0.75) / scale;
// Измеряем ширину верхнего лейбла
useEffect(() => {
if (ruLabelRef.current && ruLabel) {
setRuLabelWidth(ruLabelRef.current.width);
}
}, [ruLabel, compensatedRuFontSize]);
const handlePointerDown = (e: FederatedMouseEvent) => {
setIsPointerDown(true);
setIsDragging(false);
dragStartPos.current = { ...position };
mouseStartPos.current = { x: e.global.x, y: e.global.y };
e.stopPropagation();
};
const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isPointerDown) return;
if (!isDragging) {
const dx = e.global.x - mouseStartPos.current.x;
const dy = e.global.y - mouseStartPos.current.y;
if (Math.hypot(dx, dy) > 3) setIsDragging(true);
else return;
}
const dx_screen = e.global.x - mouseStartPos.current.x;
const dy_screen = e.global.y - mouseStartPos.current.y;
const newPosition = {
x: dragStartPos.current.x + dx_screen,
y: dragStartPos.current.y + dy_screen,
};
// Проверяем, изменилась ли позиция
if (
Math.abs(newPosition.x - position.x) > 0.01 ||
Math.abs(newPosition.y - position.y) > 0.01
) {
setPosition(newPosition);
setStationOffset(station.id, newPosition.x, newPosition.y);
}
e.stopPropagation();
};
const handlePointerUp = (e: FederatedMouseEvent) => {
setIsPointerDown(false);
setTimeout(() => setIsDragging(false), 50);
e.stopPropagation();
};
const handleAlignChange = async (align: LabelAlign) => {
setCurrentLabelAlign(align);
onLabelAlignChange?.(align);
// Сохраняем в стор
const numericAlign = convertStringAlign(align);
setStationAlign(station.id, numericAlign);
};
const dynamicAnchor = useMemo(
() => getAnchorFromOffset(position.x, position.y),
[position.x, position.y]
);
// Функция для расчета позиции нижнего лейбла относительно ширины верхнего
const getSecondLabelPosition = (): number => {
if (!ruLabelWidth) return 0;
switch (currentLabelAlign) {
case "left":
// Позиционируем относительно левого края верхнего текста
return -ruLabelWidth / 2;
case "center":
// Центрируем относительно центра верхнего текста
return 0;
case "right":
// Позиционируем относительно правого края верхнего текста
return ruLabelWidth / 2;
default:
return 0;
}
};
// Функция для расчета anchor нижнего лейбла
const getSecondLabelAnchor = (): number => {
switch (currentLabelAlign) {
case "left":
return 0; // anchor.x = 0 (левый край)
case "center":
return 0.5; // anchor.x = 0.5 (центр)
case "right":
return 1; // anchor.x = 1 (правый край)
default:
return 0.5;
}
};
return (
<pixiContainer
x={coordinates.x * UP_SCALE}
y={coordinates.y * UP_SCALE}
rotation={-rotation}
zIndex={isHovered || isControlHovered ? 1000 : 0}
eventMode="static"
interactive
cursor={isDragging ? "grabbing" : "grab"}
onPointerOver={handlePointerEnter}
onPointerOut={handlePointerLeave}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerUpOutside={handlePointerUp}
onGlobalPointerMove={handlePointerMove}
>
<pixiContainer
position={{
x:
(position.x + Math.cos(Math.atan2(position.y, position.x))) /
scale,
y:
(position.y + Math.sin(Math.atan2(position.y, position.x))) /
scale,
}}
anchor={dynamicAnchor}
zIndex={isHovered || isControlHovered ? 1000 : 0}
>
{ruLabel && (
<pixiText
ref={ruLabelRef}
text={ruLabel}
position={{ x: 0, y: 0 }}
anchor={{ x: 0.5, y: 0.5 }}
style={{
fontSize: compensatedRuFontSize,
fontWeight: "bold",
fill: "#ffffff",
}}
/>
)}
{station.name && language !== "ru" && ruLabel && (
<pixiText
text={station.name}
position={{
x: getSecondLabelPosition(),
y: compensatedRuFontSize * 1.1,
}}
anchor={{ x: getSecondLabelAnchor(), y: 0.5 }}
style={{
fontSize: compensatedNameFontSize,
fontWeight: "bold",
fill: "#CCCCCC",
}}
/>
)}
{(isHovered || isControlHovered) && !isDragging && (
<LabelAlignmentControl
scale={scale}
currentAlign={currentLabelAlign}
onAlignChange={handleAlignChange}
onPointerOver={handlePointerEnter}
onPointerOut={handlePointerLeave}
onControlPointerEnter={handleControlPointerEnter}
onControlPointerLeave={handleControlPointerLeave}
/>
)}
</pixiContainer>
</pixiContainer>
);
}
);
// =========================================================================
// Главный экспортируемый компонент: Станция
// =========================================================================
export const Station = ({
station,
ruLabel,
labelAlign,
onLabelAlignChange,
}: Readonly<StationProps>) => {
const [isTextHovered, setIsTextHovered] = useState(false);
const draw = useCallback(
(g: Graphics) => {
g.clear(); g.clear();
const coordinates = coordinatesToLocal( const coordinates = coordinatesToLocal(
station.latitude, station.latitude,
station.longitude station.longitude
); );
g.circle(
coordinates.x * UP_SCALE,
coordinates.y * UP_SCALE,
STATION_RADIUS
);
g.fill({ color: PATH_COLOR });
g.stroke({ color: BACKGROUND_COLOR, width: STATION_OUTLINE_WIDTH });
}, []);
return ( const radius = STATION_RADIUS;
<pixiContainer> const strokeWidth = STATION_OUTLINE_WIDTH;
<pixiGraphics draw={draw} />
<StationLabel station={station} ruLabel={ruLabel} />
</pixiContainer>
);
}
);
export const StationLabel = observer( g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius);
({ station, ruLabel }: Readonly<StationProps>) => {
const { rotation, scale } = useTransform();
const { setStationOffset } = useMapData();
const [position, setPosition] = useState({ // Change fill color when text is hovered
x: station.offset_x, if (isTextHovered) {
y: station.offset_y, g.fill({ color: 0x00aaff }); // Highlight color when hovered
}); g.stroke({ color: 0xffffff, width: strokeWidth + 1 }); // Brighter outline when hovered
const [isDragging, setIsDragging] = useState(false); } else {
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 }); g.fill({ color: PATH_COLOR });
const [startMousePosition, setStartMousePosition] = useState({ g.stroke({ color: BACKGROUND_COLOR, width: strokeWidth });
x: 0, }
y: 0, },
}); [station.latitude, station.longitude, isTextHovered]
);
if (!station) { return (
console.error("station is null"); <pixiContainer zIndex={isTextHovered ? 1000 : 0}>
return null; <pixiGraphics draw={draw} />
} <StationLabel
station={station}
const handlePointerDown = (e: FederatedMouseEvent) => { ruLabel={ruLabel}
setIsDragging(true); labelAlign={labelAlign}
setStartPosition({ onLabelAlignChange={onLabelAlignChange}
x: position.x, onTextHover={setIsTextHovered}
y: position.y, />
}); </pixiContainer>
setStartMousePosition({ );
x: e.globalX, };
y: e.globalY,
});
e.stopPropagation();
};
const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isDragging) return;
const dx = e.globalX - startMousePosition.x;
const dy = e.globalY - startMousePosition.y;
const newPosition = {
x: startPosition.x + dx,
y: startPosition.y + dy,
};
setPosition(newPosition);
setStationOffset(station.id, newPosition.x, newPosition.y);
e.stopPropagation();
};
const handlePointerUp = (e: FederatedMouseEvent) => {
setIsDragging(false);
e.stopPropagation();
};
const coordinates = coordinatesToLocal(station.latitude, station.longitude);
return (
<pixiContainer
eventMode="static"
interactive
onPointerDown={handlePointerDown}
onGlobalPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerUpOutside={handlePointerUp}
width={48}
height={48}
x={coordinates.x * UP_SCALE}
y={coordinates.y * UP_SCALE}
rotation={-rotation}
>
<pixiText
anchor={{ x: 1, y: 0.5 }}
text={station.name}
position={{
x: position.x / scale + 24,
y: position.y / scale,
}}
style={{
fontSize: 26,
fontWeight: "bold",
fill: "#ffffff",
}}
/>
{ruLabel && (
<pixiText
anchor={{ x: 1, y: -1 }}
text={ruLabel}
position={{
x: position.x / scale + 24,
y: position.y / scale,
}}
style={{
fontSize: 16,
fontWeight: "bold",
fill: "#CCCCCC",
}}
/>
)}
</pixiContainer>
);
}
);

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,7 +44,10 @@ const TransformContext = createContext<{
localToScreen: () => ({ x: 0, y: 0 }), localToScreen: () => ({ x: 0, y: 0 }),
rotateToAngle: () => {}, rotateToAngle: () => {},
setTransform: () => {}, setTransform: () => {},
setScaleOnly: () => {},
setScaleWithoutMovingCenter: () => {},
setScreenCenter: () => {}, setScreenCenter: () => {},
setScaleAtCenter: () => {},
}); });
// Provider component // Provider component
@ -136,8 +142,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,
@ -160,6 +164,37 @@ 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 +208,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,
] ]
); );

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
</Typography> sx={{ height: "100%", display: "flex", flexDirection: "column" }}
>
{/* Заголовок с кнопкой закрытия */}
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
mb: 1,
}}
>
<Box sx={{ display: "flex", gap: 0.5 }}>
<Landmark size={16} className="shrink-0" />
<Typography
variant="subtitle2"
sx={{ color: "#fff", fontWeight: "bold" }}
>
{selectedSight.name}
</Typography>
</Box>
<IconButton
size="small"
onClick={() => setSelectedSight(undefined)}
sx={{
color: "#fff",
p: 0,
minWidth: 20,
width: 20,
height: 20,
"&:hover": { backgroundColor: "rgba(255, 255, 255, 0.1)" },
}}
>
<Close fontSize="small" />
</IconButton>
</Box>
{/* Описание достопримечательности */}
{selectedSight.address && (
<Typography
variant="caption"
sx={{
color: "#fff",
mb: 1,
opacity: 0.9,
lineHeight: 1.3,
overflow: "hidden",
textOverflow: "ellipsis",
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
}}
>
{selectedSight.address}
</Typography>
)}
{/* Город */}
{selectedSight.city && (
<Typography
variant="caption"
sx={{
color: "#fff",
opacity: 0.7,
mt: "auto",
}}
>
Город: {selectedSight.city}
</Typography>
)}
</Box>
) : (
<Box
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 5,
justifyContent: "center",
textAlign: "center",
}}
>
<Landmark size={32} />
<Typography variant="body2" sx={{ color: "#fff", opacity: 0.8 }}>
Выберите достопримечательность
</Typography>
</Box>
)}
</Stack> </Stack>
</Stack> </Stack>
); );

View File

@ -1,5 +1,5 @@
import { useRef, useEffect, useState } from "react"; import { useRef, useEffect, useState } from "react";
import { Widgets } from "./Widgets";
import { Application, extend } from "@pixi/react"; import { Application, extend } from "@pixi/react";
import { import {
Container, Container,
@ -14,16 +14,19 @@ import { MapDataProvider, useMapData } from "./MapDataContext";
import { TransformProvider, useTransform } from "./TransformContext"; import { TransformProvider, useTransform } from "./TransformContext";
import { InfiniteCanvas } from "./InfiniteCanvas"; import { InfiniteCanvas } from "./InfiniteCanvas";
import { UP_SCALE } from "./Constants";
import { Station } from "./Station";
import { TravelPath } from "./TravelPath"; 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 { LanguageSwitcher } from "@widgets";
import { languageStore } from "@shared"; import { languageStore } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Sight } from "./Sight";
import { SightData } from "./types";
import { Station } from "./Station";
import { UP_SCALE } from "./Constants";
import CircularProgress from "@mui/material/CircularProgress";
extend({ extend({
Container, Container,
@ -34,17 +37,31 @@ extend({
Text, Text,
}); });
const Loading = () => {
const { isRouteLoading, isStationLoading, isSightLoading } = useMapData();
if (isRouteLoading || isStationLoading || isSightLoading) {
return (
<div className="fixed flex z-1 items-center justify-center h-screen w-screen bg-[#111]">
<CircularProgress />
</div>
);
}
return null;
};
export const RoutePreview = () => { export const RoutePreview = () => {
const { routeData, stationData, sightData } = useMapData();
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 /> {routeData && stationData && sightData ? <LanguageSwitcher /> : null}
<Loading />
<LeftSidebar /> <LeftSidebar />
<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>
@ -55,15 +72,27 @@ export const RoutePreview = () => {
export const RouteMap = observer(() => { export const RouteMap = observer(() => {
const { language } = languageStore; const { language } = languageStore;
const { setPosition, screenToLocal, setTransform, screenCenter } = const { setPosition, setTransform, screenCenter } = useTransform();
useTransform(); const {
const { routeData, stationData, sightData, originalRouteData } = useMapData(); routeData,
console.log(stationData); stationData,
sightData,
originalRouteData,
originalSightData,
} = useMapData();
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,12 +160,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}>
<LanguageSwitcher />
<Application resizeTo={parentRef} background="#fff"> <Application resizeTo={parentRef} background="#fff">
<InfiniteCanvas> <InfiniteCanvas>
<TravelPath points={points} /> <TravelPath points={points} />
@ -146,20 +175,14 @@ 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>
</div> </div>

View File

@ -1,69 +1,72 @@
export interface RouteData { export interface RouteData {
carrier: string; carrier: string;
carrier_id: number; carrier_id: number;
center_latitude: number; center_latitude: number;
center_longitude: number; center_longitude: number;
governor_appeal: number; governor_appeal: number;
id: number; id: number;
path: [number, number][]; path: [number, number][];
rotate: number; rotate: number;
route_direction: boolean; route_direction: boolean;
route_number: string; route_number: string;
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 {
bus: string; bus: string;
metro_blue: string; metro_blue: string;
metro_green: string; metro_green: string;
metro_orange: string; metro_orange: string;
metro_purple: string; metro_purple: string;
metro_red: string; metro_red: string;
train: string; train: string;
tram: string; tram: string;
trolleybus: string; trolleybus: string;
} }
export interface StationData { export interface StationData {
address: string; address: string;
city_id?: number; city_id?: number;
description: string; description: string;
id: number; id: number;
latitude: number; latitude: number;
longitude: number; longitude: number;
name: string; name: string;
offset_x: number; offset_x: number;
offset_y: number; offset_y: number;
system_name: string; system_name: string;
transfers: StationTransferData; transfers: StationTransferData;
align: number;
} }
export interface StationPatchData { export interface StationPatchData {
station_id: number; station_id: number;
offset_x: number; offset_x: number;
offset_y: number; offset_y: number;
transfers: StationTransferData; align: number;
transfers: StationTransferData;
} }
export interface SightPatchData { export interface SightPatchData {
sight_id: number; sight_id: number;
latitude: number; latitude: number;
longitude: number; longitude: number;
} }
export interface SightData { export interface SightData {
address: string; address: string;
city: string; city: string;
city_id: number; city_id: number;
id: number; id: number;
latitude: number; latitude: number;
left_article: number; left_article: number;
longitude: number; longitude: number;
name: string; name: string;
preview_media: number; preview_media: number;
thumbnail: string; // uuid thumbnail: string; // uuid
watermark_lu: string; // uuid watermark_lu: string; // uuid
watermark_rd: string; // uuid watermark_rd: string; // uuid
} }

View File

@ -1,20 +1,33 @@
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 { cityStore, languageStore, sightsStore } 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, 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,13 +35,34 @@ 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: "Действия",
@ -61,7 +95,7 @@ export const SightListPage = observer(() => {
const rows = sights.map((sight) => ({ const rows = sights.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 +110,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 +162,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,4 +1,4 @@
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";
@ -20,50 +20,56 @@ export const SnapshotCreatePage = observer(() => {
}, [id]); }, [id]);
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <div className="w-full h-[400px] flex justify-center items-center">
<div className="flex justify-between items-center"> <div className="w-full h-full p-3 flex flex-col gap-10">
<button <div className="flex justify-between items-center">
className="flex items-center gap-2" <button
onClick={() => navigate(-1)} className="flex items-center gap-2"
> onClick={() => navigate(-1)}
<ArrowLeft size={20} /> >
Назад <ArrowLeft size={20} />
</button> Назад
</div> </button>
<h1 className="text-2xl font-bold">Создание снапшота</h1> </div>
<div className="flex flex-col gap-10 w-full items-end"> <h1 className="text-2xl font-bold">Создание снапшота</h1>
<TextField <div className="flex flex-col gap-10 w-full items-end">
className="w-full" <TextField
label="Название" className="w-full"
required label="Название"
value={name} required
onChange={(e) => setName(e.target.value)} value={name}
/> onChange={(e) => setName(e.target.value)}
/>
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"
className="w-min flex gap-2 items-center" className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />} startIcon={<Save size={20} />}
onClick={async () => { onClick={async () => {
try { try {
setIsLoading(true); setIsLoading(true);
await createSnapshot(name); await createSnapshot(name);
setIsLoading(false); setIsLoading(false);
toast.success("Снапшот успешно создан"); toast.success("Снапшот успешно создан");
} catch (error) { navigate(-1);
console.error(error); } catch (error) {
} console.error(error);
}} toast.error("Ошибка при создании снапшота");
disabled={isLoading} } finally {
> setIsLoading(false);
{isLoading ? ( }
<Loader2 size={20} className="animate-spin" /> }}
) : ( disabled={isLoading || !name.trim()}
"Сохранить" >
)} {isLoading ? (
</Button> <Loader2 size={20} className="animate-spin" />
) : (
"Сохранить"
)}
</Button>
</div>
</div> </div>
</Paper> </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[] = [
@ -81,6 +88,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 +117,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,315 @@
import { useState, useEffect } from "react";
import {
Stack,
Typography,
Button,
Accordion,
AccordionSummary,
AccordionDetails,
useTheme,
TextField,
Autocomplete,
TableCell,
TableContainer,
Table,
TableHead,
TableRow,
Paper,
TableBody,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { authInstance, languageStore } 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>
</>
);
};
export const LinkedSightsContents = <
T extends { id: number; name: string; [key: string]: any }
>({
parentId,
setItemsParent,
fields,
type,
onUpdate,
disableCreation = false,
updatedLinkedItems,
refresh,
}: LinkedSightsProps<T>) => {
const { language } = languageStore;
const [allItems, setAllItems] = useState<T[]>([]);
const [linkedItems, setLinkedItems] = useState<T[]>([]);
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {}, [error]);
const parentResource = "station";
const childResource = "sight";
const availableItems = allItems
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
.sort((a, b) => a.name.localeCompare(b.name));
useEffect(() => {
if (updatedLinkedItems) {
setLinkedItems(updatedLinkedItems);
}
}, [updatedLinkedItems]);
useEffect(() => {
setItemsParent?.(linkedItems);
}, [linkedItems, setItemsParent]);
const linkItem = () => {
if (selectedItemId !== null) {
setError(null);
const requestData = {
sight_id: selectedItemId,
};
authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
.then(() => {
const newItem = allItems.find((item) => item.id === selectedItemId);
if (newItem) {
setLinkedItems([...linkedItems, newItem]);
}
setSelectedItemId(null);
onUpdate?.();
})
.catch((error) => {
console.error("Error linking sight:", error);
setError("Failed to link sight");
});
}
};
const deleteItem = (itemId: number) => {
setError(null);
authInstance
.delete(`/${parentResource}/${parentId}/${childResource}`, {
data: { [`${childResource}_id`]: itemId },
})
.then(() => {
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
onUpdate?.();
})
.catch((error) => {
console.error("Error deleting sight:", error);
setError("Failed to delete sight");
});
};
useEffect(() => {
if (parentId) {
setIsLoading(true);
setError(null);
authInstance
.get(`/${parentResource}/${parentId}/${childResource}`)
.then((response) => {
setLinkedItems(response?.data || []);
})
.catch((error) => {
console.error("Error fetching linked sights:", error);
setError("Failed to load linked sights");
setLinkedItems([]);
})
.finally(() => {
setIsLoading(false);
});
}
}, [parentId, language, refresh]);
useEffect(() => {
if (type === "edit") {
setError(null);
authInstance
.get(`/${childResource}`)
.then((response) => {
setAllItems(response?.data || []);
})
.catch((error) => {
console.error("Error fetching all sights:", error);
setError("Failed to load available sights");
setAllItems([]);
});
}
}, [type]);
return (
<>
{linkedItems?.length > 0 && (
<TableContainer component={Paper} sx={{ width: "100%" }}>
<Table sx={{ width: "100%" }}>
<TableHead>
<TableRow>
<TableCell key="id" width="60px">
</TableCell>
{fields.map((field) => (
<TableCell key={String(field.data)}>{field.label}</TableCell>
))}
{type === "edit" && (
<TableCell width="120px">Действие</TableCell>
)}
</TableRow>
</TableHead>
<TableBody>
{linkedItems.map((item, index) => (
<TableRow key={item.id} hover>
<TableCell>{index + 1}</TableCell>
{fields.map((field, idx) => (
<TableCell key={String(field.data) + String(idx)}>
{field.render
? field.render(item[field.data])
: item[field.data]}
</TableCell>
))}
{type === "edit" && (
<TableCell>
<Button
variant="outlined"
color="error"
size="small"
onClick={(e) => {
e.stopPropagation();
deleteItem(item.id);
}}
>
Отвязать
</Button>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
{linkedItems.length === 0 && !isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
Достопримечательности не найдены
</Typography>
)}
{type === "edit" && !disableCreation && (
<Stack gap={2} mt={2}>
<Typography variant="subtitle1">
Добавить достопримечательность
</Typography>
<Autocomplete
fullWidth
value={
availableItems?.find((item) => item.id === selectedItemId) || null
}
onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)}
options={availableItems}
getOptionLabel={(item) => String(item.name)}
renderInput={(params) => (
<TextField
{...params}
label="Выберите достопримечательность"
fullWidth
/>
)}
isOptionEqualToValue={(option, value) => option.id === value?.id}
filterOptions={(options, { inputValue }) => {
const searchWords = inputValue
.toLowerCase()
.split(" ")
.filter(Boolean);
return options.filter((option) => {
const optionWords = String(option.name)
.toLowerCase()
.split(" ");
return searchWords.every((searchWord) =>
optionWords.some((word) => word.startsWith(searchWord))
);
});
}}
renderOption={(props, option) => (
<li {...props} key={option.id}>
{String(option.name)}
</li>
)}
/>
<Button
variant="contained"
onClick={linkItem}
disabled={!selectedItemId}
sx={{ alignSelf: "flex-start" }}
>
Добавить
</Button>
</Stack>
)}
{isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
Загрузка...
</Typography>
)}
{error && (
<Typography color="error" textAlign="center" py={2}>
{error}
</Typography>
)}
</>
);
};

View File

@ -8,35 +8,66 @@ 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 { stationsStore, languageStore, cityStore } from "@shared";
import { useState } from "react"; import { useEffect, useState } from "react";
import { LanguageSwitcher } 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 [coordinates, setCoordinates] = useState<string>("");
useEffect(() => {
if (
createStationData.common.latitude !== 0 ||
createStationData.common.longitude !== 0
) {
setCoordinates(
`${createStationData.common.latitude}, ${createStationData.common.longitude}`
);
}
}, [createStationData.common.latitude, createStationData.common.longitude]);
const handleCreate = async () => { const handleCreate = 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);
} }
}; };
useEffect(() => {
const fetchCities = async () => {
await getCities("ru");
await getCities("en");
await getCities("zh");
};
fetchCities();
}, []);
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 +76,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 || !createStationData[language]?.name}
> >
{isLoading ? ( {isLoading ? (
<Loader2 size={20} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />

View File

@ -15,6 +15,7 @@ import { toast } from "react-toastify";
import { stationsStore, languageStore, cityStore } from "@shared"; import { stationsStore, languageStore, cityStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets"; import { LanguageSwitcher } from "@widgets";
import { LinkedSights } from "../LinkedSights";
export const StationEditPage = observer(() => { export const StationEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -29,12 +30,29 @@ export const StationEditPage = observer(() => {
setLanguageEditStationData, setLanguageEditStationData,
} = stationsStore; } = stationsStore;
const { cities, getCities } = cityStore; const { cities, getCities } = cityStore;
const [coordinates, setCoordinates] = useState<string>("");
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 handleEdit = async () => { const handleEdit = 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("Ошибка при обновлении станции");
@ -49,11 +67,13 @@ export const StationEditPage = observer(() => {
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");
}; };
fetchAndSetStationData(); fetchAndSetStationData();
}, [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">
@ -69,6 +89,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 +124,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 +141,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 +181,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 +190,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,6 +198,14 @@ 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"
@ -168,7 +216,7 @@ export const StationEditPage = 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 { languageStore, stationsStore } from "@shared"; import { languageStore, stationsStore } 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, LanguageSwitcher } from "@widgets"; import { CreateButton, DeleteModal, LanguageSwitcher } 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 [rowId, setRowId] = 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 getStationList();
setIsLoading(false);
};
fetchStations();
}, [language]); }, [language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
@ -22,11 +32,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: "system_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: "direction", field: "direction",
@ -88,16 +120,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 +174,19 @@ 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);
}}
/>
</> </>
); );
}); });

View File

@ -5,6 +5,7 @@ import { observer } from "mobx-react-lite";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { useEffect } from "react"; import { useEffect } 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();
@ -21,7 +22,7 @@ export const StationPreviewPage = observer(() => {
}, [id, language]); }, [id, language]);
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full p-3 py-5 flex flex-col gap-10">
<LanguageSwitcher /> <LanguageSwitcher />
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<button <button
@ -71,6 +72,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

@ -10,15 +10,21 @@ 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 } 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 { 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);
@ -130,7 +136,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",
@ -93,11 +124,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 +169,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 +177,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,13 +68,34 @@ 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: "Действия",
@ -86,7 +131,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 +146,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 +199,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

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

View File

@ -7,22 +7,23 @@ import {
Users, Users,
Earth, Earth,
Landmark, Landmark,
BusFront,
GitBranch, GitBranch,
Car, // Car,
Table, Table,
Split, Split,
Newspaper, // Newspaper,
PersonStanding, PersonStanding,
Cpu, Cpu,
BookImage, // BookImage,
} from "lucide-react"; } from "lucide-react";
import { CarrierSvg } from "./CarrierSvg";
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;
onClick?: () => void; onClick?: () => void;
nestedItems?: NavigationItem[]; nestedItems?: NavigationItem[];
@ -34,25 +35,6 @@ 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: "Снапшоты",
@ -71,23 +53,35 @@ export const NAVIGATION_ITEMS: {
icon: Cpu, icon: Cpu,
path: "/devices", path: "/devices",
}, },
// {
// id: "vehicles",
// label: "Транспорт",
// icon: Car,
// path: "/vehicle",
// },
{
id: "users",
label: "Пользователи",
icon: Users,
path: "/user",
},
{ {
id: "all", id: "all",
label: "Все сущности", label: "Справочник",
icon: Table, icon: Table,
nestedItems: [ nestedItems: [
{ // {
id: "media", // id: "media",
label: "Медиа", // label: "Медиа",
icon: BookImage, // icon: BookImage,
path: "/media", // path: "/media",
}, // },
{ // {
id: "articles", // id: "articles",
label: "Статьи", // label: "Статьи",
icon: Newspaper, // icon: Newspaper,
path: "/article", // path: "/article",
}, // },
{ {
id: "attractions", id: "attractions",
label: "Достопримечательности", label: "Достопримечательности",
@ -106,20 +100,27 @@ 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",
}, },
{ {
id: "users", id: "cities",
label: "Пользователи", label: "Города",
icon: Users, icon: Building2,
path: "/user", path: "/city",
},
{
id: "carriers",
label: "Перевозчики",
// @ts-ignore
icon: CarrierSvg,
path: "/carrier",
},
],
}, },
], ],
secondary: [ secondary: [

View File

@ -9,7 +9,7 @@ export const MEDIA_TYPE_LABELS = {
}; };
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 +17,754 @@ 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 = [
{ code: "AF", name: "Афганистан" },
{ code: "AX", name: "Аландские острова" },
{ code: "AL", name: "Албания" },
{ code: "DZ", name: "Алжир" },
{ code: "AS", name: "Американское Самоа" },
{ code: "AD", name: "Андорра" },
{ code: "AO", name: "Ангола" },
{ code: "AI", name: "Ангилья" },
{ code: "AQ", name: "Антарктида" },
{ code: "AG", name: "Антигуа и Барбуда" },
{ code: "AR", name: "Аргентина" },
{ code: "AM", name: "Армения" },
{ code: "AW", name: "Аруба" },
{ code: "AU", name: "Австралия" },
{ code: "AT", name: "Австрия" },
{ code: "AZ", name: "Азербайджан" },
{ code: "BS", name: "Багамы" },
{ code: "BH", name: "Бахрейн" },
{ code: "BD", name: "Бангладеш" },
{ code: "BB", name: "Барбадос" },
{ code: "BY", name: "Беларусь" },
{ code: "BE", name: "Бельгия" },
{ code: "BZ", name: "Белиз" },
{ code: "BJ", name: "Бенин" },
{ code: "BM", name: "Бермуды" },
{ code: "BT", name: "Бутан" },
{ code: "BO", name: "Боливия" },
{ code: "BA", name: "Босния и Герцеговина" },
{ code: "BW", name: "Ботсвана" },
{ code: "BV", name: "Остров Буве" },
{ code: "BR", name: "Бразилия" },
{ code: "IO", name: "Британская территория в Индийском океане" },
{ code: "BN", name: "Бруней-Даруссалам" },
{ code: "BG", name: "Болгария" },
{ code: "BF", name: "Буркина-Фасо" },
{ code: "BI", name: "Бурунди" },
{ code: "KH", name: "Камбоджа" },
{ code: "CM", name: "Камерун" },
{ code: "CA", name: "Канада" },
{ code: "CV", name: "Кабо-Верде" },
{ code: "KY", name: "Каймановы острова" },
{ code: "CF", name: "Центральноафриканская Республика" },
{ code: "TD", name: "Чад" },
{ code: "CL", name: "Чили" },
{ code: "CN", name: "Китай" },
{ code: "CX", name: "Остров Рождества" },
{ code: "CC", name: "Кокосовые (Килинг) острова" },
{ code: "CO", name: "Колумбия" },
{ code: "KM", name: "Коморы" },
{ code: "CG", name: "Конго" },
{ code: "CD", name: "Демократическая Республика Конго" },
{ code: "CK", name: "Острова Кука" },
{ code: "CR", name: "Коста-Рика" },
{ code: "CI", name: "Кот-д'Ивуар" },
{ code: "HR", name: "Хорватия" },
{ code: "CU", name: "Куба" },
{ code: "CY", name: "Кипр" },
{ code: "CZ", name: "Чехия" },
{ code: "DK", name: "Дания" },
{ code: "DJ", name: "Джибути" },
{ code: "DM", name: "Доминика" },
{ code: "DO", name: "Доминиканская Республика" },
{ code: "EC", name: "Эквадор" },
{ code: "EG", name: "Египет" },
{ code: "SV", name: "Сальвадор" },
{ code: "GQ", name: "Экваториальная Гвинея" },
{ code: "ER", name: "Эритрея" },
{ code: "EE", name: "Эстония" },
{ code: "ET", name: "Эфиопия" },
{ code: "FK", name: "Фолклендские острова (Мальвинские)" },
{ code: "FO", name: "Фарерские острова" },
{ code: "FJ", name: "Фиджи" },
{ code: "FI", name: "Финляндия" },
{ code: "FR", name: "Франция" },
{ code: "GF", name: "Французская Гвиана" },
{ code: "PF", name: "Французская Полинезия" },
{ code: "TF", name: "Французские Южные территории" },
{ code: "GA", name: "Габон" },
{ code: "GM", name: "Гамбия" },
{ code: "GE", name: "Грузия" },
{ code: "DE", name: "Германия" },
{ code: "GH", name: "Гана" },
{ code: "GI", name: "Гибралтар" },
{ code: "GR", name: "Греция" },
{ code: "GL", name: "Гренландия" },
{ code: "GD", name: "Гренада" },
{ code: "GP", name: "Гваделупа" },
{ code: "GU", name: "Гуам" },
{ code: "GT", name: "Гватемала" },
{ code: "GG", name: "Гернси" },
{ code: "GN", name: "Гвинея" },
{ code: "GW", name: "Гвинея-Бисау" },
{ code: "GY", name: "Гайана" },
{ code: "HT", name: "Гаити" },
{ code: "HM", name: "Остров Херд и острова Макдональд" },
{ code: "VA", name: "Ватикан" },
{ code: "HN", name: "Гондурас" },
{ code: "HK", name: "Гонконг" },
{ code: "HU", name: "Венгрия" },
{ code: "IS", name: "Исландия" },
{ code: "IN", name: "Индия" },
{ code: "ID", name: "Индонезия" },
{ code: "IR", name: "Иран" },
{ code: "IQ", name: "Ирак" },
{ code: "IE", name: "Ирландия" },
{ code: "IM", name: "Остров Мэн" },
{ code: "IL", name: "Израиль" },
{ code: "IT", name: "Италия" },
{ code: "JM", name: "Ямайка" },
{ code: "JP", name: "Япония" },
{ code: "JE", name: "Джерси" },
{ code: "JO", name: "Иордания" },
{ code: "KZ", name: "Казахстан" },
{ code: "KE", name: "Кения" },
{ code: "KI", name: "Кирибати" },
{ code: "KR", name: "Корея" },
{ code: "KP", name: "Северная Корея" },
{ code: "KW", name: "Кувейт" },
{ code: "KG", name: "Киргизия" },
{ code: "LA", name: "Лаос" },
{ code: "LV", name: "Латвия" },
{ code: "LB", name: "Ливан" },
{ code: "LS", name: "Лесото" },
{ code: "LR", name: "Либерия" },
{ code: "LY", name: "Ливия" },
{ code: "LI", name: "Лихтенштейн" },
{ code: "LT", name: "Литва" },
{ code: "LU", name: "Люксембург" },
{ code: "MO", name: "Макао" },
{ code: "MK", name: "Северная Македония" },
{ code: "MG", name: "Мадагаскар" },
{ code: "MW", name: "Малави" },
{ code: "MY", name: "Малайзия" },
{ code: "MV", name: "Мальдивы" },
{ code: "ML", name: "Мали" },
{ code: "MT", name: "Мальта" },
{ code: "MH", name: "Маршалловы Острова" },
{ code: "MQ", name: "Мартиника" },
{ code: "MR", name: "Мавритания" },
{ code: "MU", name: "Маврикий" },
{ code: "YT", name: "Майотта" },
{ code: "MX", name: "Мексика" },
{ code: "FM", name: "Микронезия" },
{ code: "MD", name: "Молдова" },
{ code: "MC", name: "Монако" },
{ code: "MN", name: "Монголия" },
{ code: "ME", name: "Черногория" },
{ code: "MS", name: "Монтсеррат" },
{ code: "MA", name: "Марокко" },
{ code: "MZ", name: "Мозамбик" },
{ code: "MM", name: "Мьянма" },
{ code: "NA", name: "Намибия" },
{ code: "NR", name: "Науру" },
{ code: "NP", name: "Непал" },
{ code: "NL", name: "Нидерланды" },
{ code: "AN", name: "Нидерландские Антильские острова" },
{ code: "NC", name: "Новая Каледония" },
{ code: "NZ", name: "Новая Зеландия" },
{ code: "NI", name: "Никарагуа" },
{ code: "NE", name: "Нигер" },
{ code: "NG", name: "Нигерия" },
{ code: "NU", name: "Ниуэ" },
{ code: "NF", name: "Остров Норфолк" },
{ code: "MP", name: "Северные Марианские острова" },
{ code: "NO", name: "Норвегия" },
{ code: "OM", name: "Оман" },
{ code: "PK", name: "Пакистан" },
{ code: "PW", name: "Палау" },
{ code: "PS", name: "Палестинская территория" },
{ code: "PA", name: "Панама" },
{ code: "PG", name: "Папуа — Новая Гвинея" },
{ code: "PY", name: "Парагвай" },
{ code: "PE", name: "Перу" },
{ code: "PH", name: "Филиппины" },
{ code: "PN", name: "Питкэрн" },
{ code: "PL", name: "Польша" },
{ code: "PT", name: "Португалия" },
{ code: "PR", name: "Пуэрто-Рико" },
{ code: "QA", name: "Катар" },
{ code: "RE", name: "Реюньон" },
{ code: "RO", name: "Румыния" },
{ code: "RU", name: "Россия" },
{ code: "RW", name: "Руанда" },
{ code: "BL", name: "Сен-Бартелеми" },
{ code: "SH", name: "Остров Святой Елены" },
{ code: "KN", name: "Сент-Китс и Невис" },
{ code: "LC", name: "Сент-Люсия" },
{ code: "MF", name: "Сен-Мартен" },
{ code: "PM", name: "Сен-Пьер и Микелон" },
{ code: "VC", name: "Сент-Винсент и Гренадины" },
{ code: "WS", name: "Самоа" },
{ code: "SM", name: "Сан-Марино" },
{ code: "ST", name: "Сан-Томе и Принсипи" },
{ code: "SA", name: "Саудовская Аравия" },
{ code: "SN", name: "Сенегал" },
{ code: "RS", name: "Сербия" },
{ code: "SC", name: "Сейшельские Острова" },
{ code: "SL", name: "Сьерра-Леоне" },
{ code: "SG", name: "Сингапур" },
{ code: "SK", name: "Словакия" },
{ code: "SI", name: "Словения" },
{ code: "SB", name: "Соломоновы Острова" },
{ code: "SO", name: "Сомали" },
{ code: "ZA", name: "Южная Африка" },
{ code: "GS", name: "Южная Георгия и Южные Сандвичевы острова" },
{ code: "ES", name: "Испания" },
{ code: "LK", name: "Шри-Ланка" },
{ code: "SD", name: "Судан" },
{ code: "SR", name: "Суринам" },
{ code: "SJ", name: "Шпицберген и Ян-Майен" },
{ code: "SZ", name: "Свазиленд" },
{ code: "SE", name: "Швеция" },
{ code: "CH", name: "Швейцария" },
{ code: "SY", name: "Сирия" },
{ code: "TW", name: "Тайвань" },
{ code: "TJ", name: "Таджикистан" },
{ code: "TZ", name: "Танзания" },
{ code: "TH", name: "Таиланд" },
{ code: "TL", name: "Восточный Тимор" },
{ code: "TG", name: "Того" },
{ code: "TK", name: "Токелау" },
{ code: "TO", name: "Тонга" },
{ code: "TT", name: "Тринидад и Тобаго" },
{ code: "TN", name: "Тунис" },
{ code: "TR", name: "Турция" },
{ code: "TM", name: "Туркмения" },
{ code: "TC", name: "Теркс и Кайкос" },
{ code: "TV", name: "Тувалу" },
{ code: "UG", name: "Уганда" },
{ code: "UA", name: "Украина" },
{ code: "AE", name: "Объединённые Арабские Эмираты" },
{ code: "GB", name: "Великобритания" },
{ code: "US", name: "США" },
{ code: "UM", name: "Внешние малые острова США" },
{ code: "UY", name: "Уругвай" },
{ code: "UZ", name: "Узбекистан" },
{ code: "VU", name: "Вануату" },
{ code: "VE", name: "Венесуэла" },
{ code: "VN", name: "Вьетнам" },
{ code: "VG", name: "Британские Виргинские острова" },
{ code: "VI", name: "Виргинские острова (США)" },
{ code: "WF", name: "Уоллис и Футуна" },
{ code: "EH", name: "Западная Сахара" },
{ code: "YE", name: "Йемен" },
{ code: "ZM", name: "Замбия" },
{ code: "ZW", name: "Зимбабве" },
];
// countries-en.js
export const EN_COUNTRIES = [
{ code: "AF", name: "Afghanistan" },
{ code: "AX", name: "Aland Islands" },
{ code: "AL", name: "Albania" },
{ code: "DZ", name: "Algeria" },
{ code: "AS", name: "American Samoa" },
{ code: "AD", name: "Andorra" },
{ code: "AO", name: "Angola" },
{ code: "AI", name: "Anguilla" },
{ code: "AQ", name: "Antarctica" },
{ code: "AG", name: "Antigua And Barbuda" },
{ code: "AR", name: "Argentina" },
{ code: "AM", name: "Armenia" },
{ code: "AW", name: "Aruba" },
{ code: "AU", name: "Australia" },
{ code: "AT", name: "Austria" },
{ code: "AZ", name: "Azerbaijan" },
{ code: "BS", name: "Bahamas" },
{ code: "BH", name: "Bahrain" },
{ code: "BD", name: "Bangladesh" },
{ code: "BB", name: "Barbados" },
{ code: "BY", name: "Belarus" },
{ code: "BE", name: "Belgium" },
{ code: "BZ", name: "Belize" },
{ code: "BJ", name: "Benin" },
{ code: "BM", name: "Bermuda" },
{ code: "BT", name: "Bhutan" },
{ code: "BO", name: "Bolivia" },
{ code: "BA", name: "Bosnia And Herzegovina" },
{ code: "BW", name: "Botswana" },
{ code: "BV", name: "Bouvet Island" },
{ code: "BR", name: "Brazil" },
{ code: "IO", name: "British Indian Ocean Territory" },
{ code: "BN", name: "Brunei Darussalam" },
{ code: "BG", name: "Bulgaria" },
{ code: "BF", name: "Burkina Faso" },
{ code: "BI", name: "Burundi" },
{ code: "KH", name: "Cambodia" },
{ code: "CM", name: "Cameroon" },
{ code: "CA", name: "Canada" },
{ code: "CV", name: "Cape Verde" },
{ code: "KY", name: "Cayman Islands" },
{ code: "CF", name: "Central African Republic" },
{ code: "TD", name: "Chad" },
{ code: "CL", name: "Chile" },
{ code: "CN", name: "China" },
{ code: "CX", name: "Christmas Island" },
{ code: "CC", name: "Cocos (Keeling) Islands" },
{ code: "CO", name: "Colombia" },
{ code: "KM", name: "Comoros" },
{ code: "CG", name: "Congo" },
{ code: "CD", name: "Congo, Democratic Republic" },
{ code: "CK", name: "Cook Islands" },
{ code: "CR", name: "Costa Rica" },
{ code: "CI", name: "Cote D'Ivoire" },
{ code: "HR", name: "Croatia" },
{ code: "CU", name: "Cuba" },
{ code: "CY", name: "Cyprus" },
{ code: "CZ", name: "Czech Republic" },
{ code: "DK", name: "Denmark" },
{ code: "DJ", name: "Djibouti" },
{ code: "DM", name: "Dominica" },
{ code: "DO", name: "Dominican Republic" },
{ code: "EC", name: "Ecuador" },
{ code: "EG", name: "Egypt" },
{ code: "SV", name: "El Salvador" },
{ code: "GQ", name: "Equatorial Guinea" },
{ code: "ER", name: "Eritrea" },
{ code: "EE", name: "Estonia" },
{ code: "ET", name: "Ethiopia" },
{ code: "FK", name: "Falkland Islands (Malvinas)" },
{ code: "FO", name: "Faroe Islands" },
{ code: "FJ", name: "Fiji" },
{ code: "FI", name: "Finland" },
{ code: "FR", name: "France" },
{ code: "GF", name: "French Guiana" },
{ code: "PF", name: "French Polynesia" },
{ code: "TF", name: "French Southern Territories" },
{ code: "GA", name: "Gabon" },
{ code: "GM", name: "Gambia" },
{ code: "GE", name: "Georgia" },
{ code: "DE", name: "Germany" },
{ code: "GH", name: "Ghana" },
{ code: "GI", name: "Gibraltar" },
{ code: "GR", name: "Greece" },
{ code: "GL", name: "Greenland" },
{ code: "GD", name: "Grenada" },
{ code: "GP", name: "Guadeloupe" },
{ code: "GU", name: "Guam" },
{ code: "GT", name: "Guatemala" },
{ code: "GG", name: "Guernsey" },
{ code: "GN", name: "Guinea" },
{ code: "GW", name: "Guinea-Bissau" },
{ code: "GY", name: "Guyana" },
{ code: "HT", name: "Haiti" },
{ code: "HM", name: "Heard Island & Mcdonald Islands" },
{ code: "VA", name: "Holy See (Vatican City State)" },
{ code: "HN", name: "Honduras" },
{ code: "HK", name: "Hong Kong" },
{ code: "HU", name: "Hungary" },
{ code: "IS", name: "Iceland" },
{ code: "IN", name: "India" },
{ code: "ID", name: "Indonesia" },
{ code: "IR", name: "Iran, Islamic Republic Of" },
{ code: "IQ", name: "Iraq" },
{ code: "IE", name: "Ireland" },
{ code: "IM", name: "Isle Of Man" },
{ code: "IL", name: "Israel" },
{ code: "IT", name: "Italy" },
{ code: "JM", name: "Jamaica" },
{ code: "JP", name: "Japan" },
{ code: "JE", name: "Jersey" },
{ code: "JO", name: "Jordan" },
{ code: "KZ", name: "Kazakhstan" },
{ code: "KE", name: "Kenya" },
{ code: "KI", name: "Kiribati" },
{ code: "KR", name: "Korea" },
{ code: "KP", name: "North Korea" },
{ code: "KW", name: "Kuwait" },
{ code: "KG", name: "Kyrgyzstan" },
{ code: "LA", name: "Lao People's Democratic Republic" },
{ code: "LV", name: "Latvia" },
{ code: "LB", name: "Lebanon" },
{ code: "LS", name: "Lesotho" },
{ code: "LR", name: "Liberia" },
{ code: "LY", name: "Libyan Arab Jamahiriya" },
{ code: "LI", name: "Liechtenstein" },
{ code: "LT", name: "Lithuania" },
{ code: "LU", name: "Luxembourg" },
{ code: "MO", name: "Macao" },
{ code: "MK", name: "Macedonia" },
{ code: "MG", name: "Madagascar" },
{ code: "MW", name: "Malawi" },
{ code: "MY", name: "Malaysia" },
{ code: "MV", name: "Maldives" },
{ code: "ML", name: "Mali" },
{ code: "MT", name: "Malta" },
{ code: "MH", name: "Marshall Islands" },
{ code: "MQ", name: "Martinique" },
{ code: "MR", name: "Mauritania" },
{ code: "MU", name: "Mauritius" },
{ code: "YT", name: "Mayotte" },
{ code: "MX", name: "Mexico" },
{ code: "FM", name: "Micronesia, Federated States Of" },
{ code: "MD", name: "Moldova" },
{ code: "MC", name: "Monaco" },
{ code: "MN", name: "Mongolia" },
{ code: "ME", name: "Montenegro" },
{ code: "MS", name: "Montserrat" },
{ code: "MA", name: "Morocco" },
{ code: "MZ", name: "Mozambique" },
{ code: "MM", name: "Myanmar" },
{ code: "NA", name: "Namibia" },
{ code: "NR", name: "Nauru" },
{ code: "NP", name: "Nepal" },
{ code: "NL", name: "Netherlands" },
{ code: "AN", name: "Netherlands Antilles" },
{ code: "NC", name: "New Caledonia" },
{ code: "NZ", name: "New Zealand" },
{ code: "NI", name: "Nicaragua" },
{ code: "NE", name: "Niger" },
{ code: "NG", name: "Nigeria" },
{ code: "NU", name: "Niue" },
{ code: "NF", name: "Norfolk Island" },
{ code: "MP", name: "Northern Mariana Islands" },
{ code: "NO", name: "Norway" },
{ code: "OM", name: "Oman" },
{ code: "PK", name: "Pakistan" },
{ code: "PW", name: "Palau" },
{ code: "PS", name: "Palestinian Territory, Occupied" },
{ code: "PA", name: "Panama" },
{ code: "PG", name: "Papua New Guinea" },
{ code: "PY", name: "Paraguay" },
{ code: "PE", name: "Peru" },
{ code: "PH", name: "Philippines" },
{ code: "PN", name: "Pitcairn" },
{ code: "PL", name: "Poland" },
{ code: "PT", name: "Portugal" },
{ code: "PR", name: "Puerto Rico" },
{ code: "QA", name: "Qatar" },
{ code: "RE", name: "Reunion" },
{ code: "RO", name: "Romania" },
{ code: "RU", name: "Russian Federation" },
{ code: "RW", name: "Rwanda" },
{ code: "BL", name: "Saint Barthelemy" },
{ code: "SH", name: "Saint Helena" },
{ code: "KN", name: "Saint Kitts And Nevis" },
{ code: "LC", name: "Saint Lucia" },
{ code: "MF", name: "Saint Martin" },
{ code: "PM", name: "Saint Pierre And Miquelon" },
{ code: "VC", name: "Saint Vincent And Grenadines" },
{ code: "WS", name: "Samoa" },
{ code: "SM", name: "San Marino" },
{ code: "ST", name: "Sao Tome And Principe" },
{ code: "SA", name: "Saudi Arabia" },
{ code: "SN", name: "Senegal" },
{ code: "RS", name: "Serbia" },
{ code: "SC", name: "Seychelles" },
{ code: "SL", name: "Sierra Leone" },
{ code: "SG", name: "Singapore" },
{ code: "SK", name: "Slovakia" },
{ code: "SI", name: "Slovenia" },
{ code: "SB", name: "Solomon Islands" },
{ code: "SO", name: "Somalia" },
{ code: "ZA", name: "South Africa" },
{ code: "GS", name: "South Georgia And Sandwich Isl." },
{ code: "ES", name: "Spain" },
{ code: "LK", name: "Sri Lanka" },
{ code: "SD", name: "Sudan" },
{ code: "SR", name: "Suriname" },
{ code: "SJ", name: "Svalbard And Jan Mayen" },
{ code: "SZ", name: "Swaziland" },
{ code: "SE", name: "Sweden" },
{ code: "CH", name: "Switzerland" },
{ code: "SY", name: "Syrian Arab Republic" },
{ code: "TW", name: "Taiwan" },
{ code: "TJ", name: "Tajikistan" },
{ code: "TZ", name: "Tanzania" },
{ code: "TH", name: "Thailand" },
{ code: "TL", name: "Timor-Leste" },
{ code: "TG", name: "Togo" },
{ code: "TK", name: "Tokelau" },
{ code: "TO", name: "Tonga" },
{ code: "TT", name: "Trinidad And Tobago" },
{ code: "TN", name: "Tunisia" },
{ code: "TR", name: "Turkey" },
{ code: "TM", name: "Turkmenistan" },
{ code: "TC", name: "Turks And Caicos Islands" },
{ code: "TV", name: "Tuvalu" },
{ code: "UG", name: "Uganda" },
{ code: "UA", name: "Ukraine" },
{ code: "AE", name: "United Arab Emirates" },
{ code: "GB", name: "United Kingdom" },
{ code: "US", name: "United States" },
{ code: "UM", name: "United States Outlying Islands" },
{ code: "UY", name: "Uruguay" },
{ code: "UZ", name: "Uzbekistan" },
{ code: "VU", name: "Vanuatu" },
{ code: "VE", name: "Venezuela" },
{ code: "VN", name: "Vietnam" },
{ code: "VG", name: "Virgin Islands, British" },
{ code: "VI", name: "Virgin Islands, U.S." },
{ code: "WF", name: "Wallis And Futuna" },
{ code: "EH", name: "Western Sahara" },
{ code: "YE", name: "Yemen" },
{ code: "ZM", name: "Zambia" },
{ code: "ZW", name: "Zimbabwe" },
];
// countries-zh.js
export const ZH_COUNTRIES = [
{ code: "AF", name: "阿富汗" },
{ code: "AX", name: "奥兰群岛" },
{ code: "AL", name: "阿尔巴尼亚" },
{ code: "DZ", name: "阿尔及利亚" },
{ code: "AS", name: "美属萨摩亚" },
{ code: "AD", name: "安道尔" },
{ code: "AO", name: "安哥拉" },
{ code: "AI", name: "安圭拉" },
{ code: "AQ", name: "南极洲" },
{ code: "AG", name: "安提瓜和巴布达" },
{ code: "AR", name: "阿根廷" },
{ code: "AM", name: "亚美尼亚" },
{ code: "AW", name: "阿鲁巴" },
{ code: "AU", name: "澳大利亚" },
{ code: "AT", name: "奥地利" },
{ code: "AZ", name: "阿塞拜疆" },
{ code: "BS", name: "巴哈马" },
{ code: "BH", name: "巴林" },
{ code: "BD", name: "孟加拉国" },
{ code: "BB", name: "巴巴多斯" },
{ code: "BY", name: "白俄罗斯" },
{ code: "BE", name: "比利时" },
{ code: "BZ", name: "伯利兹" },
{ code: "BJ", name: "贝宁" },
{ code: "BM", name: "百慕大" },
{ code: "BT", name: "不丹" },
{ code: "BO", name: "玻利维亚" },
{ code: "BA", name: "波斯尼亚和黑塞哥维那" },
{ code: "BW", name: "博茨瓦纳" },
{ code: "BV", name: "布韦岛" },
{ code: "BR", name: "巴西" },
{ code: "IO", name: "英属印度洋领地" },
{ code: "BN", name: "文莱" },
{ code: "BG", name: "保加利亚" },
{ code: "BF", name: "布基纳法索" },
{ code: "BI", name: "布隆迪" },
{ code: "KH", name: "柬埔寨" },
{ code: "CM", name: "喀麦隆" },
{ code: "CA", name: "加拿大" },
{ code: "CV", name: "佛得角" },
{ code: "KY", name: "开曼群岛" },
{ code: "CF", name: "中非共和国" },
{ code: "TD", name: "乍得" },
{ code: "CL", name: "智利" },
{ code: "CN", name: "中国" },
{ code: "CX", name: "圣诞岛" },
{ code: "CC", name: "科科斯(基林)群岛" },
{ code: "CO", name: "哥伦比亚" },
{ code: "KM", name: "科摩罗" },
{ code: "CG", name: "刚果" },
{ code: "CD", name: "刚果(金)" },
{ code: "CK", name: "库克群岛" },
{ code: "CR", name: "哥斯达黎加" },
{ code: "CI", name: "科特迪瓦" },
{ code: "HR", name: "克罗地亚" },
{ code: "CU", name: "古巴" },
{ code: "CY", name: "塞浦路斯" },
{ code: "CZ", name: "捷克" },
{ code: "DK", name: "丹麦" },
{ code: "DJ", name: "吉布提" },
{ code: "DM", name: "多米尼克" },
{ code: "DO", name: "多米尼加共和国" },
{ code: "EC", name: "厄瓜多尔" },
{ code: "EG", name: "埃及" },
{ code: "SV", name: "萨尔瓦多" },
{ code: "GQ", name: "赤道几内亚" },
{ code: "ER", name: "厄立特里亚" },
{ code: "EE", name: "爱沙尼亚" },
{ code: "ET", name: "埃塞俄比亚" },
{ code: "FK", name: "福克兰群岛" },
{ code: "FO", name: "法罗群岛" },
{ code: "FJ", name: "斐济" },
{ code: "FI", name: "芬兰" },
{ code: "FR", name: "法国" },
{ code: "GF", name: "法属圭亚那" },
{ code: "PF", name: "法属波利尼西亚" },
{ code: "TF", name: "法属南部领地" },
{ code: "GA", name: "加蓬" },
{ code: "GM", name: "冈比亚" },
{ code: "GE", name: "格鲁吉亚" },
{ code: "DE", name: "德国" },
{ code: "GH", name: "加纳" },
{ code: "GI", name: "直布罗陀" },
{ code: "GR", name: "希腊" },
{ code: "GL", name: "格陵兰" },
{ code: "GD", name: "格林纳达" },
{ code: "GP", name: "瓜德罗普" },
{ code: "GU", name: "关岛" },
{ code: "GT", name: "危地马拉" },
{ code: "GG", name: "根西岛" },
{ code: "GN", name: "几内亚" },
{ code: "GW", name: "几内亚比绍" },
{ code: "GY", name: "圭亚那" },
{ code: "HT", name: "海地" },
{ code: "HM", name: "赫德岛和麦克唐纳群岛" },
{ code: "VA", name: "梵蒂冈" },
{ code: "HN", name: "洪都拉斯" },
{ code: "HK", name: "中国香港" },
{ code: "HU", name: "匈牙利" },
{ code: "IS", name: "冰岛" },
{ code: "IN", name: "印度" },
{ code: "ID", name: "印度尼西亚" },
{ code: "IR", name: "伊朗" },
{ code: "IQ", name: "伊拉克" },
{ code: "IE", name: "爱尔兰" },
{ code: "IM", name: "马恩岛" },
{ code: "IL", name: "以色列" },
{ code: "IT", name: "意大利" },
{ code: "JM", name: "牙买加" },
{ code: "JP", name: "日本" },
{ code: "JE", name: "泽西岛" },
{ code: "JO", name: "约旦" },
{ code: "KZ", name: "哈萨克斯坦" },
{ code: "KE", name: "肯尼亚" },
{ code: "KI", name: "基里巴斯" },
{ code: "KR", name: "韩国" },
{ code: "KP", name: "朝鲜" },
{ code: "KW", name: "科威特" },
{ code: "KG", name: "吉尔吉斯斯坦" },
{ code: "LA", name: "老挝" },
{ code: "LV", name: "拉脱维亚" },
{ code: "LB", name: "黎巴嫩" },
{ code: "LS", name: "莱索托" },
{ code: "LR", name: "利比里亚" },
{ code: "LY", name: "利比亚" },
{ code: "LI", name: "列支敦士登" },
{ code: "LT", name: "立陶宛" },
{ code: "LU", name: "卢森堡" },
{ code: "MO", name: "中国澳门" },
{ code: "MK", name: "北马其顿" },
{ code: "MG", name: "马达加斯加" },
{ code: "MW", name: "马拉维" },
{ code: "MY", name: "马来西亚" },
{ code: "MV", name: "马尔代夫" },
{ code: "ML", name: "马里" },
{ code: "MT", name: "马耳他" },
{ code: "MH", name: "马绍尔群岛" },
{ code: "MQ", name: "马提尼克" },
{ code: "MR", name: "毛里塔尼亚" },
{ code: "MU", name: "毛里求斯" },
{ code: "YT", name: "马约特" },
{ code: "MX", name: "墨西哥" },
{ code: "FM", name: "密克罗尼西亚" },
{ code: "MD", name: "摩尔多瓦" },
{ code: "MC", name: "摩纳哥" },
{ code: "MN", name: "蒙古" },
{ code: "ME", name: "黑山" },
{ code: "MS", name: "蒙特塞拉特" },
{ code: "MA", name: "摩洛哥" },
{ code: "MZ", name: "莫桑比克" },
{ code: "MM", name: "缅甸" },
{ code: "NA", name: "纳米比亚" },
{ code: "NR", name: "瑙鲁" },
{ code: "NP", name: "尼泊尔" },
{ code: "NL", name: "荷兰" },
{ code: "AN", name: "荷属安的列斯" },
{ code: "NC", name: "新喀里多尼亚" },
{ code: "NZ", name: "新西兰" },
{ code: "NI", name: "尼加拉瓜" },
{ code: "NE", name: "尼日尔" },
{ code: "NG", name: "尼日利亚" },
{ code: "NU", name: "纽埃" },
{ code: "NF", name: "诺福克岛" },
{ code: "MP", name: "北马里亚纳群岛" },
{ code: "NO", name: "挪威" },
{ code: "OM", name: "阿曼" },
{ code: "PK", name: "巴基斯坦" },
{ code: "PW", name: "帕劳" },
{ code: "PS", name: "巴勒斯坦" },
{ code: "PA", name: "巴拿马" },
{ code: "PG", name: "巴布亚新几内亚" },
{ code: "PY", name: "巴拉圭" },
{ code: "PE", name: "秘鲁" },
{ code: "PH", name: "菲律宾" },
{ code: "PN", name: "皮特凯恩群岛" },
{ code: "PL", name: "波兰" },
{ code: "PT", name: "葡萄牙" },
{ code: "PR", name: "波多黎各" },
{ code: "QA", name: "卡塔尔" },
{ code: "RE", name: "留尼汪" },
{ code: "RO", name: "罗马尼亚" },
{ code: "RU", name: "俄罗斯" },
{ code: "RW", name: "卢旺达" },
{ code: "BL", name: "圣巴泰勒米" },
{ code: "SH", name: "圣赫勒拿" },
{ code: "KN", name: "圣基茨和尼维斯" },
{ code: "LC", name: "圣卢西亚" },
{ code: "MF", name: "法属圣马丁" },
{ code: "PM", name: "圣皮埃尔和密克隆" },
{ code: "VC", name: "圣文森特和格林纳丁斯" },
{ code: "WS", name: "萨摩亚" },
{ code: "SM", name: "圣马力诺" },
{ code: "ST", name: "圣多美和普林西比" },
{ code: "SA", name: "沙特阿拉伯" },
{ code: "SN", name: "塞内加尔" },
{ code: "RS", name: "塞尔维亚" },
{ code: "SC", name: "塞舌尔" },
{ code: "SL", name: "塞拉利昂" },
{ code: "SG", name: "新加坡" },
{ code: "SK", name: "斯洛伐克" },
{ code: "SI", name: "斯洛文尼亚" },
{ code: "SB", name: "所罗门群岛" },
{ code: "SO", name: "索马里" },
{ code: "ZA", name: "南非" },
{ code: "GS", name: "南乔治亚和南桑威奇群岛" },
{ code: "ES", name: "西班牙" },
{ code: "LK", name: "斯里兰卡" },
{ code: "SD", name: "苏丹" },
{ code: "SR", name: "苏里南" },
{ code: "SJ", name: "斯瓦尔巴和扬马延" },
{ code: "SZ", name: "斯威士兰" },
{ code: "SE", name: "瑞典" },
{ code: "CH", name: "瑞士" },
{ code: "SY", name: "叙利亚" },
{ code: "TW", name: "中国台湾" },
{ code: "TJ", name: "塔吉克斯坦" },
{ code: "TZ", name: "坦桑尼亚" },
{ code: "TH", name: "泰国" },
{ code: "TL", name: "东帝汶" },
{ code: "TG", name: "多哥" },
{ code: "TK", name: "托克劳" },
{ code: "TO", name: "汤加" },
{ code: "TT", name: "特立尼达和多巴哥" },
{ code: "TN", name: "突尼斯" },
{ code: "TR", name: "土耳其" },
{ code: "TM", name: "土库曼斯坦" },
{ code: "TC", name: "特克斯和凯科斯群岛" },
{ code: "TV", name: "图瓦卢" },
{ code: "UG", name: "乌干达" },
{ code: "UA", name: "乌克兰" },
{ code: "AE", name: "阿联酋" },
{ code: "GB", name: "英国" },
{ code: "US", name: "美国" },
{ code: "UM", name: "美国本土外小岛屿" },
{ code: "UY", name: "乌拉圭" },
{ code: "UZ", name: "乌兹别克斯坦" },
{ code: "VU", name: "瓦努阿图" },
{ code: "VE", name: "委内瑞拉" },
{ code: "VN", name: "越南" },
{ code: "VG", name: "英属维尔京群岛" },
{ code: "VI", name: "美属维尔京群岛" },
{ code: "WF", name: "瓦利斯和富图纳" },
{ code: "EH", name: "西撒哈拉" },
{ code: "YE", name: "也门" },
{ code: "ZM", name: "赞比亚" },
{ code: "ZW", name: "津巴布韦" },
];

View File

@ -1,2 +1,54 @@
export * from "./mui/theme"; export * from "./mui/theme";
export * from "./DecodeJWT"; export * from "./DecodeJWT";
/**
* Генерирует название медиа по умолчанию в разных форматах
*
* Примеры использования:
* - Для достопримечательности с названием: "Михайловский замок_mikhail-zamok_Фото"
* - Для достопримечательности без названия: "Название_mikhail-zamok_Фото"
* - Для статьи: "Михайловский замок_mikhail-zamok_Факты" (где "Факты" - название статьи)
*
* @param objectName - Название объекта (достопримечательности, города и т.д.)
* @param fileName - Название файла
* @param mediaType - Тип медиа (число) или название статьи
* @param isArticle - Флаг, указывающий что медиа добавляется к статье
* @returns Строка в нужном формате
*/
export const generateDefaultMediaName = (
objectName: string,
fileName: string,
mediaType: number | string,
isArticle: boolean = false
): string => {
// Убираем расширение из названия файла
const fileNameWithoutExtension = fileName.split(".").slice(0, -1).join(".");
if (isArticle && typeof mediaType === "string") {
// Для статей: "Название достопримечательности_название файлаазвание статьи"
return `${objectName}_${fileNameWithoutExtension}_${mediaType}`;
} else if (typeof mediaType === "number") {
// Получаем название типа медиа
const mediaTypeLabels: Record<number, string> = {
1: "Фото",
2: "Видео",
3: "Иконка",
4: "Водяной знак",
5: "Панорама",
6: "3Д-модель",
};
const mediaTypeLabel = mediaTypeLabels[mediaType] || "Медиа";
if (objectName && objectName.trim() !== "") {
// Если есть название объекта: "Название объектаазвание файла_тип медиа"
return `${objectName}_${fileNameWithoutExtension}_${mediaTypeLabel}`;
} else {
// Если нет названия объекта: "Названиеазвание файла_тип медиа"
return `Название_${fileNameWithoutExtension}_${mediaTypeLabel}`;
}
}
// Fallback
return `${objectName || "Название"}_${fileNameWithoutExtension}_Медиа`;
};

View File

@ -75,6 +75,7 @@ export const PreviewMediaDialog = observer(
setError(err instanceof Error ? err.message : "Failed to save media"); 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>
) )
) )

View File

@ -1,4 +1,9 @@
import { MEDIA_TYPE_LABELS, MEDIA_TYPE_VALUES, editSightStore } from "@shared"; import {
MEDIA_TYPE_LABELS,
MEDIA_TYPE_VALUES,
editSightStore,
generateDefaultMediaName,
} from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
@ -31,7 +36,24 @@ interface UploadMediaDialogProps {
media_type: number; media_type: number;
}) => void; }) => void;
afterUploadSight?: (id: string) => void; afterUploadSight?: (id: string) => void;
hardcodeType?: "thumbnail" | "watermark_lu" | "watermark_rd" | null; hardcodeType?:
| "thumbnail"
| "watermark_lu"
| "watermark_rd"
| "image"
| "video_preview"
| null;
contextObjectName?: string;
contextType?:
| "sight"
| "city"
| "carrier"
| "country"
| "vehicle"
| "station";
isArticle?: boolean;
articleName?: string;
initialFile?: File; // <--- добавлено
} }
export const UploadMediaDialog = observer( export const UploadMediaDialog = observer(
@ -41,6 +63,11 @@ export const UploadMediaDialog = observer(
afterUpload, afterUpload,
afterUploadSight, afterUploadSight,
hardcodeType, hardcodeType,
contextObjectName,
isArticle,
articleName,
initialFile, // <--- добавлено
}: UploadMediaDialogProps) => { }: UploadMediaDialogProps) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -54,6 +81,18 @@ export const UploadMediaDialog = observer(
const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>( const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>(
[] []
); );
const [isPreviewLoaded, setIsPreviewLoaded] = useState(false);
useEffect(() => {
if (initialFile) {
setMediaFile(initialFile);
setMediaFilename(initialFile.name);
setAvailableMediaTypes([2]);
setMediaType(2);
setMediaUrl(URL.createObjectURL(initialFile));
setMediaName(initialFile.name.replace(/\.[^/.]+$/, ""));
}
}, [initialFile]);
useEffect(() => { useEffect(() => {
if (fileToUpload) { if (fileToUpload) {
@ -66,7 +105,7 @@ export const UploadMediaDialog = observer(
setAvailableMediaTypes([6]); setAvailableMediaTypes([6]);
setMediaType(6); setMediaType(6);
} }
if (["jpg", "jpeg", "png", "gif"].includes(extension)) { if (["jpg", "jpeg", "png", "gif", "svg"].includes(extension)) {
// Для изображений доступны все типы кроме видео // Для изображений доступны все типы кроме видео
setAvailableMediaTypes([1, 3, 4, 5]); // Фото, Иконка, Водяной знак, Панорама, 3Д-модель setAvailableMediaTypes([1, 3, 4, 5]); // Фото, Иконка, Водяной знак, Панорама, 3Д-модель
setMediaType(1); // По умолчанию Фото setMediaType(1); // По умолчанию Фото
@ -76,12 +115,100 @@ export const UploadMediaDialog = observer(
setMediaType(2); setMediaType(2);
} }
} }
// Генерируем название по умолчанию если есть контекст
if (fileToUpload.name) {
let defaultName = "";
if (isArticle && articleName && contextObjectName) {
// Для статей: "Название достопримечательности_название файлаазвание статьи"
defaultName = generateDefaultMediaName(
contextObjectName,
fileToUpload.name,
articleName,
true
);
} else if (contextObjectName && contextObjectName.trim() !== "") {
// Для обычных медиа с названием объекта
const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType]
: 1; // По умолчанию фото
defaultName = generateDefaultMediaName(
contextObjectName,
fileToUpload.name,
currentMediaType,
false
);
} else {
// Для медиа без названия объекта
const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType]
: 1; // По умолчанию фото
defaultName = generateDefaultMediaName(
"",
fileToUpload.name,
currentMediaType,
false
);
}
setMediaName(defaultName);
}
} }
}, [fileToUpload]); }, [fileToUpload, contextObjectName, hardcodeType, isArticle, articleName]);
// Обновляем название при изменении типа медиа
useEffect(() => {
if (mediaFilename && mediaType > 0) {
let defaultName = "";
if (isArticle && articleName && contextObjectName) {
// Для статей: "Название достопримечательности_название файлаазвание статьи"
defaultName = generateDefaultMediaName(
contextObjectName,
mediaFilename,
articleName,
true
);
} else if (contextObjectName && contextObjectName.trim() !== "") {
// Для обычных медиа с названием объекта
const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType]
: mediaType;
defaultName = generateDefaultMediaName(
contextObjectName,
mediaFilename,
currentMediaType,
false
);
} else {
// Для медиа без названия объекта
const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType]
: mediaType;
defaultName = generateDefaultMediaName(
"",
mediaFilename,
currentMediaType,
false
);
}
setMediaName(defaultName);
}
}, [
mediaType,
contextObjectName,
mediaFilename,
hardcodeType,
isArticle,
articleName,
]);
useEffect(() => { useEffect(() => {
if (mediaFile) { if (mediaFile) {
setMediaUrl(URL.createObjectURL(mediaFile as Blob)); setMediaUrl(URL.createObjectURL(mediaFile as Blob));
setIsPreviewLoaded(false); // Сбрасываем состояние загрузки при смене файла
} }
}, [mediaFile]); }, [mediaFile]);
@ -120,6 +247,10 @@ export const UploadMediaDialog = observer(
} }
} }
setSuccess(true); setSuccess(true);
// Закрываем модальное окно после успешного сохранения
setTimeout(() => {
handleClose();
}, 1000); // Небольшая задержка, чтобы пользователь увидел сообщение об успехе
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to save media"); setError(err instanceof Error ? err.message : "Failed to save media");
} finally { } finally {
@ -141,7 +272,6 @@ export const UploadMediaDialog = 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,
@ -188,7 +318,7 @@ export const UploadMediaDialog = observer(
</Select> </Select>
</FormControl> </FormControl>
<Box className="flex gap-4 h-full"> <Box className="flex gap-4 h-[40vh]">
<Paper <Paper
elevation={2} elevation={2}
sx={{ sx={{
@ -197,28 +327,52 @@ export const UploadMediaDialog = observer(
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
minHeight: 400, height: "100%",
position: "relative",
}} }}
> >
{/* <MediaViewer {!isPreviewLoaded && mediaUrl && (
media={{ <Box
id: "", sx={{
media_type: mediaType, position: "absolute",
filename: mediaFilename, top: "50%",
}} left: "50%",
/> */} transform: "translate(-50%, -50%)",
zIndex: 1,
}}
>
<CircularProgress />
</Box>
)}
{mediaType == 2 && mediaUrl && (
<video
src={mediaUrl}
autoPlay
muted
loop
controls
style={{ maxWidth: "100%", maxHeight: "100%" }}
onLoadedData={() => setIsPreviewLoaded(true)}
onError={() => setIsPreviewLoaded(true)}
/>
)}
{mediaType === 6 && mediaUrl && ( {mediaType === 6 && mediaUrl && (
<ModelViewer3D fileUrl={mediaUrl} height="100%" /> <ModelViewer3D
fileUrl={mediaUrl}
height="100%"
onLoad={() => setIsPreviewLoaded(true)}
/>
)} )}
{mediaType !== 6 && mediaType !== 2 && mediaUrl && ( {mediaType !== 6 && mediaType !== 2 && mediaUrl && (
<img <img
src={mediaUrl ?? ""} src={mediaUrl ?? ""}
alt="Uploaded media" alt="Uploaded media"
style={{ style={{
maxWidth: "100%", height: "100%",
maxHeight: "100%",
objectFit: "contain", objectFit: "contain",
}} }}
onLoad={() => setIsPreviewLoaded(true)}
onError={() => setIsPreviewLoaded(true)}
/> />
)} )}
</Paper> </Paper>
@ -226,18 +380,31 @@ export const UploadMediaDialog = observer(
<Box className="flex flex-col gap-2 self-end"> <Box className="flex flex-col gap-2 self-end">
<Button <Button
variant="contained" variant="contained"
color="success" sx={{
backgroundColor: isLoading ? "#9e9e9e" : "#4caf50",
"&:hover": {
backgroundColor: isLoading ? "#9e9e9e" : "#45a049",
},
}}
startIcon={ startIcon={
isLoading ? ( isLoading ? (
<CircularProgress size={16} /> <CircularProgress size={16} color="inherit" />
) : ( ) : (
<Save size={16} /> <Save size={16} />
) )
} }
onClick={handleSave} onClick={handleSave}
disabled={isLoading || (!mediaName && !mediaFilename)} disabled={
isLoading ||
(!mediaName && !mediaFilename) ||
!isPreviewLoaded
}
> >
Сохранить {isLoading
? "Сохранение..."
: !isPreviewLoaded
? "Загрузка превью..."
: "Сохранить"}
</Button> </Button>
</Box> </Box>
</Box> </Box>

View File

@ -1,4 +1,10 @@
import { authInstance, editSightStore, Language, languageStore } from "@shared"; import {
authInstance,
editSightStore,
Language,
languageStore,
languageInstance,
} from "@shared";
import { computed, makeAutoObservable, runInAction } from "mobx"; import { computed, makeAutoObservable, runInAction } from "mobx";
export type Article = { export type Article = {
@ -6,6 +12,18 @@ export type Article = {
heading: string; heading: string;
body: string; body: string;
service_name: string; service_name: string;
ru?: {
heading: string;
body: string;
};
en?: {
heading: string;
body: string;
};
zh?: {
heading: string;
body: string;
};
}; };
type Media = { type Media = {
@ -91,7 +109,8 @@ class ArticlesStore {
getArticles = async (language: Language) => { getArticles = async (language: Language) => {
this.articleLoading = true; this.articleLoading = true;
const response = await authInstance.get("/article");
const response = await languageInstance(language).get("/article");
runInAction(() => { runInAction(() => {
this.articles[language] = response.data; this.articles[language] = response.data;
@ -99,13 +118,27 @@ class ArticlesStore {
this.articleLoading = false; this.articleLoading = false;
}; };
getArticle = async (id: number) => { getArticle = async (id: number, language?: Language) => {
this.articleLoading = true; this.articleLoading = true;
const response = await authInstance.get(`/article/${id}`); let response: any;
if (language) {
runInAction(() => { response = await languageInstance(language).get(`/article/${id}`);
this.articleData = response.data; runInAction(() => {
}); if (!this.articleData) {
this.articleData = { id, heading: "", body: "", service_name: "" };
}
this.articleData[language] = {
heading: response.data.heading,
body: response.data.body,
};
});
} else {
response = await authInstance.get(`/article/${id}`);
runInAction(() => {
this.articleData = response.data;
});
}
return response;
this.articleLoading = false; this.articleLoading = false;
}; };
@ -137,6 +170,20 @@ class ArticlesStore {
} }
return null; return null;
}); });
deleteArticles = async (ids: number[]) => {
for (const id of ids) {
await authInstance.delete(`/article/${id}`);
}
for (const id of ["ru", "en", "zh"] as Language[]) {
runInAction(() => {
this.articleList[id].data = this.articleList[id].data.filter(
(article) => !ids.includes(article.id)
);
});
}
};
} }
export const articlesStore = new ArticlesStore(); export const articlesStore = new ArticlesStore();

View File

@ -55,7 +55,11 @@ class AuthStore {
runInAction(() => { runInAction(() => {
this.setAuthToken(data.token); this.setAuthToken(data.token);
this.payload = response.data; this.payload = {
...response.data.user,
// @ts-ignore
user_id: response.data.user.id,
};
this.error = null; this.error = null;
}); });
} catch (error) { } catch (error) {

View File

@ -1,4 +1,10 @@
import { authInstance } from "@shared"; import {
authInstance,
cityStore,
languageStore,
languageInstance,
Language,
} from "@shared";
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
export type Carrier = { export type Carrier = {
@ -9,22 +15,45 @@ export type Carrier = {
city: string; city: string;
city_id: number; city_id: number;
logo: string; logo: string;
main_color: string; // main_color: string;
left_color: string; // left_color: string;
right_color: string; // right_color: string;
}; };
type Carriers = { type CarrierData = {
data: Carrier[]; data: Carrier[];
loaded: boolean; loaded: boolean;
}; };
type CashedCarrier = Record<number, Carrier>; type Carriers = {
ru: CarrierData;
en: CarrierData;
zh: CarrierData;
};
type CashedCarrier = Record<
number,
{
ru: Carrier | null;
en: Carrier | null;
zh: Carrier | null;
}
>;
class CarrierStore { class CarrierStore {
carriers: Carriers = { carriers: Carriers = {
data: [], ru: {
loaded: false, data: [],
loaded: false,
},
en: {
data: [],
loaded: false,
},
zh: {
data: [],
loaded: false,
},
}; };
carrier: CashedCarrier = {}; carrier: CashedCarrier = {};
@ -32,14 +61,14 @@ class CarrierStore {
makeAutoObservable(this); makeAutoObservable(this);
} }
getCarriers = async () => { getCarriers = async (language: Language) => {
if (this.carriers.loaded) return; if (this.carriers[language as keyof Carriers].loaded) return;
const response = await authInstance.get("/carrier"); const response = await languageInstance(language).get("/carrier");
runInAction(() => { runInAction(() => {
this.carriers.data = response.data; this.carriers[language as keyof Carriers].data = response.data;
this.carriers.loaded = true; this.carriers[language as keyof Carriers].loaded = true;
}); });
}; };
@ -47,113 +76,231 @@ class CarrierStore {
await authInstance.delete(`/carrier/${id}`); await authInstance.delete(`/carrier/${id}`);
runInAction(() => { runInAction(() => {
this.carriers.data = this.carriers.data.filter( for (const language of ["ru", "en", "zh"] as const) {
(carrier) => carrier.id !== id this.carriers[language].data = this.carriers[language].data.filter(
); (carrier: Carrier) => carrier.id !== id
);
}
delete this.carrier[id]; delete this.carrier[id];
}); });
}; };
getCarrier = async (id: number) => { getCarrier = async (id: number) => {
if (this.carrier[id]) return; if (this.carrier[id]?.ru && this.carrier[id]?.en && this.carrier[id]?.zh)
const response = await authInstance.get(`/carrier/${id}`); return;
const ruResponse = await languageInstance("ru").get(`/carrier/${id}`);
const enResponse = await languageInstance("en").get(`/carrier/${id}`);
const zhResponse = await languageInstance("zh").get(`/carrier/${id}`);
runInAction(() => { runInAction(() => {
if (!this.carrier[id]) { if (!this.carrier[id]) {
this.carrier[id] = { this.carrier[id] = {
id: 0, ru: null,
short_name: "", en: null,
full_name: "", zh: null,
slogan: "",
city: "",
city_id: 0,
logo: "",
main_color: "",
left_color: "",
right_color: "",
}; };
} }
this.carrier[id] = response.data; this.carrier[id].ru = ruResponse.data;
this.carrier[id].en = enResponse.data;
this.carrier[id].zh = zhResponse.data;
}); });
return response.data; return this.carrier[id];
}; };
createCarrier = async ( createCarrierData = {
city_id: 0,
logo: "",
ru: {
full_name: "",
short_name: "",
slogan: "",
},
en: {
full_name: "",
short_name: "",
slogan: "",
},
zh: {
full_name: "",
short_name: "",
slogan: "",
},
};
setCreateCarrierData = (
fullName: string, fullName: string,
shortName: string, shortName: string,
city: string,
cityId: number, cityId: number,
main_color: string,
left_color: string,
right_color: string,
slogan: string, slogan: string,
logoId: string logoId: string,
language: Language
) => { ) => {
const response = await authInstance.post("/carrier", { this.createCarrierData.city_id = cityId;
this.createCarrierData.logo = logoId;
this.createCarrierData[language] = {
full_name: fullName, full_name: fullName,
short_name: shortName, short_name: shortName,
city, slogan: slogan,
city_id: cityId, };
main_color, };
left_color,
right_color, createCarrier = async () => {
slogan, const { language } = languageStore;
logo: logoId, const cityName =
}); cityStore.cities[language].data.find(
(city) => city.id === this.createCarrierData.city_id
)?.name || "";
const payload = {
full_name: this.createCarrierData[language].full_name,
short_name: this.createCarrierData[language].short_name,
city: cityName,
city_id: this.createCarrierData.city_id,
slogan: this.createCarrierData[language].slogan,
...(this.createCarrierData.logo
? { logo: this.createCarrierData.logo }
: {}),
};
const response = await languageInstance(language).post("/carrier", payload);
const carrierId = response.data.id;
runInAction(() => { runInAction(() => {
this.carriers.data.push(response.data); this.carriers[language].data.push(response.data);
}); });
// Create translations for other languages
for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) {
const patchPayload = {
// @ts-ignore
full_name: this.createCarrierData[lang as any].full_name as string,
// @ts-ignore
short_name: this.createCarrierData[lang as any].short_name as string,
city: cityName,
city_id: this.createCarrierData.city_id,
// @ts-ignore
slogan: this.createCarrierData[lang as any].slogan as string,
...(this.createCarrierData.logo
? { logo: this.createCarrierData.logo }
: {}),
};
const response = await languageInstance(lang as Language).patch(
`/carrier/${carrierId}`,
patchPayload
);
runInAction(() => {
this.carriers[lang as keyof Carriers].data.push(response.data);
});
}
this.createCarrierData = {
city_id: 0,
logo: "",
ru: {
full_name: "",
short_name: "",
slogan: "",
},
en: {
full_name: "",
short_name: "",
slogan: "",
},
zh: {
full_name: "",
short_name: "",
slogan: "",
},
};
}; };
editCarrierData = { editCarrierData = {
full_name: "", ru: {
short_name: "", full_name: "",
city: "", short_name: "",
// main_color: "",
// left_color: "",
// right_color: "",
slogan: "",
},
en: {
full_name: "",
short_name: "",
// main_color: "",
// left_color: "",
// right_color: "",
slogan: "",
},
city_id: 0, city_id: 0,
main_color: "",
left_color: "",
right_color: "",
slogan: "",
logo: "", logo: "",
zh: {
full_name: "",
short_name: "",
// main_color: "",
// left_color: "",
// right_color: "",
slogan: "",
},
}; };
setEditCarrierData = ( setEditCarrierData = (
fullName: string, fullName: string,
shortName: string, shortName: string,
city: string,
cityId: number, cityId: number,
main_color: string, // main_color: string,
left_color: string, // left_color: string,
right_color: string, // right_color: string,
slogan: string, slogan: string,
logoId: string logoId: string,
language: Language
) => { ) => {
this.editCarrierData = { this.editCarrierData.city_id = cityId;
this.editCarrierData.logo = logoId;
this.editCarrierData[language] = {
full_name: fullName, full_name: fullName,
short_name: shortName, short_name: shortName,
city, // main_color: main_color,
city_id: cityId, // left_color: left_color,
main_color: main_color, // right_color: right_color,
left_color: left_color,
right_color: right_color,
slogan: slogan, slogan: slogan,
logo: logoId,
}; };
}; };
editCarrier = async (id: number) => { editCarrier = async (id: number) => {
const response = await authInstance.patch( const { language } = languageStore;
`/carrier/${id}`, const cityName =
this.editCarrierData cityStore.cities[language].data.find(
); (city) => city.id === this.editCarrierData.city_id
)?.name || "";
runInAction(() => { for (const lang of ["ru", "en", "zh"] as const) {
this.carriers.data = this.carriers.data.map((carrier) => const response = await languageInstance(lang).patch(`/carrier/${id}`, {
carrier.id === id ? { ...carrier, ...response.data } : carrier ...this.editCarrierData[lang],
); city: cityName,
city_id: this.editCarrierData.city_id,
...(this.editCarrierData.logo
? { logo: this.editCarrierData.logo }
: {}),
});
this.carrier[id] = response.data; runInAction(() => {
}); if (this.carrier[id]) {
this.carrier[id][lang] = response.data;
}
this.carriers[lang].data = this.carriers[lang].data.map(
(carrier: Carrier) =>
carrier.id === id ? { ...carrier, ...response.data } : carrier
);
});
}
}; };
} }

View File

@ -4,6 +4,7 @@ import {
Language, Language,
languageStore, languageStore,
countryStore, countryStore,
CashedCountries,
} from "@shared"; } from "@shared";
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
@ -16,9 +17,18 @@ export type City = {
}; };
export type CashedCities = { export type CashedCities = {
ru: City[]; ru: {
en: City[]; data: City[];
zh: City[]; loaded: boolean;
};
en: {
data: City[];
loaded: boolean;
};
zh: {
data: City[];
loaded: boolean;
};
}; };
export type CashedCity = { export type CashedCity = {
@ -29,9 +39,18 @@ export type CashedCity = {
class CityStore { class CityStore {
cities: CashedCities = { cities: CashedCities = {
ru: [], ru: {
en: [], data: [],
zh: [], loaded: false,
},
en: {
data: [],
loaded: false,
},
zh: {
data: [],
loaded: false,
},
}; };
city: Record<string, CashedCity> = {}; city: Record<string, CashedCity> = {};
@ -40,25 +59,37 @@ class CityStore {
makeAutoObservable(this); makeAutoObservable(this);
} }
ruCities: City[] = []; ruCities: {
data: City[];
loaded: boolean;
} = {
data: [],
loaded: false,
};
getRuCities = async () => { getRuCities = async () => {
if (this.ruCities.loaded) {
return;
}
const response = await languageInstance("ru").get(`/city`); const response = await languageInstance("ru").get(`/city`);
runInAction(() => { runInAction(() => {
this.ruCities = response.data; this.ruCities.data = response.data;
this.ruCities.loaded = true;
}); });
}; };
getCities = async (language: keyof CashedCities) => { getCities = async (language: keyof CashedCities) => {
if (this.cities[language] && this.cities[language].length > 0) { if (this.cities[language].loaded) {
return; return;
} }
const response = await authInstance.get(`/city`); const response = await languageInstance(language).get(`/city`);
runInAction(() => { runInAction(() => {
this.cities[language] = response.data; this.cities[language].data = response.data;
this.cities[language].loaded = true;
}); });
}; };
@ -67,7 +98,7 @@ class CityStore {
return; return;
} }
const response = await authInstance.get(`/city/${code}`); const response = await languageInstance(language).get(`/city/${code}`);
runInAction(() => { runInAction(() => {
if (!this.city[code]) { if (!this.city[code]) {
@ -83,19 +114,22 @@ class CityStore {
return response.data; return response.data;
}; };
deleteCity = async (code: string, language: keyof CashedCities) => { deleteCity = async (code: string) => {
await authInstance.delete(`/city/${code}`); await authInstance.delete(`/city/${code}`);
runInAction(() => { runInAction(() => {
this.cities[language] = this.cities[language].filter( for (const secondaryLanguage of ["ru", "en", "zh"] as Language[]) {
(city) => city.country_code !== code this.cities[secondaryLanguage].data = this.cities[
); secondaryLanguage
this.city[code][language] = null; ].data.filter((city) => city.id !== Number(code));
if (this.city[code]) {
this.city[code][secondaryLanguage] = null;
}
}
}); });
}; };
createCityData = { createCityData = {
country: "",
country_code: "", country_code: "",
arms: "", arms: "",
ru: { ru: {
@ -111,14 +145,12 @@ class CityStore {
setCreateCityData = ( setCreateCityData = (
name: string, name: string,
country: string,
country_code: string, country_code: string,
arms: string, arms: string,
language: keyof CashedCities language: keyof CashedCities
) => { ) => {
this.createCityData = { this.createCityData = {
...this.createCityData, ...this.createCityData,
country: country,
country_code: country_code, country_code: country_code,
arms: arms, arms: arms,
[language]: { [language]: {
@ -127,73 +159,91 @@ class CityStore {
}; };
}; };
createCity = async () => { async createCity() {
const { language } = languageStore; const language = languageStore.language as Language;
const { country, country_code, arms } = this.createCityData; const { country_code, arms } = this.createCityData;
const { name } = this.createCityData[language as keyof CashedCities]; const { name } = this.createCityData[language];
if (name && country && country_code && arms) { if (!name || !country_code) {
const cityResponse = await languageInstance(language as Language).post( return;
}
try {
// Create city in primary language
const cityPayload = {
name,
country:
countryStore.countries[language as keyof CashedCountries]?.data.find(
(c) => c.code === country_code
)?.name || "",
country_code,
...(arms ? { arms } : {}),
};
const cityResponse = await languageInstance(language).post(
"/city", "/city",
{ cityPayload
name: name,
country: country,
country_code: country_code,
arms: arms,
}
); );
runInAction(() => { const cityId = cityResponse.data.id;
this.cities[language as keyof CashedCities] = [
...this.cities[language as keyof CashedCities],
cityResponse.data,
];
});
for (const secondaryLanguage of ["ru", "en", "zh"].filter( // Create/update other language versions
for (const secondaryLanguage of (["ru", "en", "zh"] as Language[]).filter(
(l) => l !== language (l) => l !== language
)) { )) {
const { name } = const { name: secondaryName } = this.createCityData[secondaryLanguage];
this.createCityData[secondaryLanguage as keyof CashedCities];
const patchResponse = await languageInstance( // Get country name in secondary language
secondaryLanguage as Language const countryName =
).patch(`/city/${cityResponse.data.id}`, { countryStore.countries[secondaryLanguage]?.data.find(
name: name, (c) => c.code === country_code
country: country, )?.name || "";
country_code: country_code,
arms: arms, const patchPayload = {
}); name: secondaryName || "",
country: countryName,
country_code: country_code || "",
...(arms ? { arms } : {}),
};
const patchResponse = await languageInstance(secondaryLanguage).patch(
`/city/${cityId}`,
patchPayload
);
runInAction(() => { runInAction(() => {
this.cities[secondaryLanguage as keyof CashedCities] = [ this.cities[secondaryLanguage].data = [
...this.cities[secondaryLanguage as keyof CashedCities], ...this.cities[secondaryLanguage].data,
patchResponse.data, patchResponse.data,
]; ];
}); });
} }
}
runInAction(() => { // Update primary language data
this.createCityData = { runInAction(() => {
country: "", this.cities[language].data = [
country_code: "", ...this.cities[language].data,
arms: "", cityResponse.data,
ru: { ];
name: "", });
},
en: { // Reset form data
name: "", runInAction(() => {
}, this.createCityData = {
zh: { country_code: "",
name: "", arms: "",
}, ru: { name: "" },
}; en: { name: "" },
}); zh: { name: "" },
}; };
});
} catch (error) {
console.error("Error creating city:", error);
throw error;
}
}
editCityData = { editCityData = {
country: "",
country_code: "", country_code: "",
arms: "", arms: "",
ru: { ru: {
@ -209,14 +259,12 @@ class CityStore {
setEditCityData = ( setEditCityData = (
name: string, name: string,
country: string,
country_code: string, country_code: string,
arms: string, arms: string,
language: keyof CashedCities language: keyof CashedCities
) => { ) => {
this.editCityData = { this.editCityData = {
...this.editCityData, ...this.editCityData,
country: country,
country_code: country_code, country_code: country_code,
arms: arms, arms: arms,
@ -232,45 +280,43 @@ class CityStore {
const { name } = this.editCityData[language as keyof CashedCities]; const { name } = this.editCityData[language as keyof CashedCities];
const { countries } = countryStore; const { countries } = countryStore;
const country = countries[language as keyof CashedCities].find( const country = countries[language as keyof CashedCities].data.find(
(country) => country.code === country_code (country) => country.code === country_code
); );
if (name) { await languageInstance(language as Language).patch(`/city/${code}`, {
await languageInstance(language as Language).patch(`/city/${code}`, { name,
name, country: country?.name || "",
country: country?.name || "", country_code: country_code,
country_code: country_code, arms,
arms, });
});
runInAction(() => { runInAction(() => {
if (this.city[code]) { if (this.city[code]) {
this.city[code][language as keyof CashedCities] = { this.city[code][language as keyof CashedCities] = {
name, name,
country: country?.name || "", country: country?.name || "",
country_code: country_code, country_code: country_code,
arms, arms,
}; };
} }
if (this.cities[language as keyof CashedCities]) { if (this.cities[language as keyof CashedCities]) {
this.cities[language as keyof CashedCities] = this.cities[ this.cities[language as keyof CashedCities].data = this.cities[
language as keyof CashedCities language as keyof CashedCities
].map((city) => ].data.map((city) =>
city.id === Number(code) city.id === Number(code)
? { ? {
id: city.id, id: city.id,
name, name,
country: country?.name || "", country: country?.name || "",
country_code: country_code, country_code: country_code,
arms, arms,
} }
: city : city
); );
} }
}); });
}
} }
}; };
} }

View File

@ -12,9 +12,18 @@ export type Country = {
}; };
export type CashedCountries = { export type CashedCountries = {
ru: Country[]; ru: {
en: Country[]; data: Country[];
zh: Country[]; loaded: boolean;
};
en: {
data: Country[];
loaded: boolean;
};
zh: {
data: Country[];
loaded: boolean;
};
}; };
export type CashedCountry = { export type CashedCountry = {
@ -25,9 +34,18 @@ export type CashedCountry = {
class CountryStore { class CountryStore {
countries: CashedCountries = { countries: CashedCountries = {
ru: [], ru: {
en: [], data: [],
zh: [], loaded: false,
},
en: {
data: [],
loaded: false,
},
zh: {
data: [],
loaded: false,
},
}; };
country: Record<string, CashedCountry> = {}; country: Record<string, CashedCountry> = {};
@ -37,26 +55,20 @@ class CountryStore {
} }
getCountries = async (language: keyof CashedCountries) => { getCountries = async (language: keyof CashedCountries) => {
if (this.countries[language] && this.countries[language].length > 0) { if (this.countries[language].loaded) {
return; return;
} }
const response = await authInstance.get(`/country`); const response = await languageInstance(language).get(`/country`);
runInAction(() => { runInAction(() => {
this.countries[language] = response.data; this.countries[language].data = response.data;
this.countries[language].loaded = true;
}); });
}; };
getCountry = async (code: string, language: keyof CashedCountries) => { getCountry = async (code: string, language: keyof CashedCountries) => {
if ( const response = await languageInstance(language).get(`/country/${code}`);
this.country[code]?.[language] &&
this.country[code][language] !== null
) {
return;
}
const response = await authInstance.get(`/country/${code}`);
runInAction(() => { runInAction(() => {
if (!this.country[code]) { if (!this.country[code]) {
@ -72,14 +84,21 @@ class CountryStore {
return response.data; return response.data;
}; };
deleteCountry = async (code: string, language: keyof CashedCountries) => { deleteCountry = async (code: string) => {
await authInstance.delete(`/country/${code}`); await authInstance.delete(`/country/${code}`);
runInAction(() => { runInAction(() => {
this.countries[language] = this.countries[language].filter( for (const lang of ["ru", "en", "zh"]) {
(country) => country.code !== code this.countries[lang as keyof CashedCountries].data = this.countries[
); lang as keyof CashedCountries
this.country[code][language] = null; ].data.filter((country) => country.code !== code);
}
this.country[code] = {
ru: null,
en: null,
zh: null,
};
}); });
}; };
@ -121,8 +140,8 @@ class CountryStore {
}); });
runInAction(() => { runInAction(() => {
this.countries[language as keyof CashedCountries] = [ this.countries[language as keyof CashedCountries].data = [
...this.countries[language as keyof CashedCountries], ...this.countries[language as keyof CashedCountries].data,
{ code: code, name: name }, { code: code, name: name },
]; ];
}); });
@ -142,8 +161,8 @@ class CountryStore {
); );
} }
runInAction(() => { runInAction(() => {
this.countries[secondaryLanguage as keyof CashedCountries] = [ this.countries[secondaryLanguage as keyof CashedCountries].data = [
...this.countries[secondaryLanguage as keyof CashedCountries], ...this.countries[secondaryLanguage as keyof CashedCountries].data,
{ code: code, name: name }, { code: code, name: name },
]; ];
}); });
@ -204,11 +223,10 @@ class CountryStore {
}; };
} }
if (this.countries[language as keyof CashedCountries]) { if (this.countries[language as keyof CashedCountries]) {
this.countries[language as keyof CashedCountries] = this.countries[ this.countries[language as keyof CashedCountries].data =
language as keyof CashedCountries this.countries[language as keyof CashedCountries].data.map(
].map((country) => (country) => (country.code === code ? { code, name } : country)
country.code === code ? { code, name } : country );
);
} }
}); });
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -97,15 +97,19 @@ class SightsStore {
city: number, city: number,
coordinates: { latitude: number; longitude: number } coordinates: { latitude: number; longitude: number }
) => { ) => {
const id = ( const response = await authInstance.post("/sight", {
await authInstance.post("/sight", { name: this.createSight[languageStore.language].name,
name: this.createSight[languageStore.language].name, address: this.createSight[languageStore.language].address,
address: this.createSight[languageStore.language].address, city_id: city,
city_id: city, latitude: coordinates.latitude,
latitude: coordinates.latitude, longitude: coordinates.longitude,
longitude: coordinates.longitude, });
})
).data.id; runInAction(() => {
this.sights.push(response.data);
});
const id = response.data.id;
const anotherLanguages = ["ru", "en", "zh"].filter( const anotherLanguages = ["ru", "en", "zh"].filter(
(language) => language !== languageStore.language (language) => language !== languageStore.language

View File

@ -1,6 +1,24 @@
import { authInstance } from "@shared"; import { authInstance } from "@shared";
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
// Импорт функции сброса кешей карты
// import { clearMapCaches } from "../../pages/MapPage";
import {
articlesStore,
cityStore,
countryStore,
carrierStore,
stationsStore,
sightsStore,
routeStore,
vehicleStore,
userStore,
mediaStore,
createSightStore,
editSightStore,
devicesStore,
authStore,
} from "@shared";
type Snapshot = { type Snapshot = {
ID: string; ID: string;
@ -17,6 +35,248 @@ class SnapshotStore {
makeAutoObservable(this); makeAutoObservable(this);
} }
// Функция для сброса всех кешей в приложении
private clearAllCaches = () => {
// Сброс кешей статей
articlesStore.articleList = {
ru: { data: [], loaded: false },
en: { data: [], loaded: false },
zh: { data: [], loaded: false },
};
articlesStore.articlePreview = {};
articlesStore.articleData = null;
articlesStore.articleMedia = null;
// Сброс кешей городов
cityStore.cities = {
ru: { data: [], loaded: false },
en: { data: [], loaded: false },
zh: { data: [], loaded: false },
};
cityStore.ruCities = { data: [], loaded: false };
cityStore.city = {};
// Сброс кешей стран
countryStore.countries = {
ru: { data: [], loaded: false },
en: { data: [], loaded: false },
zh: { data: [], loaded: false },
};
// Сброс кешей перевозчиков
carrierStore.carriers = {
ru: { data: [], loaded: false },
en: { data: [], loaded: false },
zh: { data: [], loaded: false },
};
// Сброс кешей станций
stationsStore.stationLists = {
ru: { data: [], loaded: false },
en: { data: [], loaded: false },
zh: { data: [], loaded: false },
};
stationsStore.stationPreview = {};
// Сброс кешей достопримечательностей
sightsStore.sights = [];
sightsStore.sight = null;
// Сброс кешей маршрутов
routeStore.routes = { data: [], loaded: false };
// Сброс кешей транспорта
vehicleStore.vehicles = { data: [], loaded: false };
// Сброс кешей пользователей
userStore.users = { data: [], loaded: false };
// Сброс кешей медиа
mediaStore.media = [];
mediaStore.oneMedia = null;
// Сброс кешей создания и редактирования достопримечательностей
createSightStore.sight = JSON.parse(
JSON.stringify({
city_id: 0,
city: "",
latitude: 0,
longitude: 0,
thumbnail: null,
watermark_lu: null,
watermark_rd: null,
left_article: 0,
preview_media: null,
video_preview: null,
ru: {
name: "",
address: "",
left: { heading: "", body: "", media: [] },
right: [],
},
en: {
name: "",
address: "",
left: { heading: "", body: "", media: [] },
right: [],
},
zh: {
name: "",
address: "",
left: { heading: "", body: "", media: [] },
right: [],
},
})
);
createSightStore.uploadMediaOpen = false;
createSightStore.fileToUpload = null;
createSightStore.needLeaveAgree = false;
editSightStore.sight = {
common: {
id: 0,
city_id: 0,
city: "",
latitude: 0,
longitude: 0,
thumbnail: null,
watermark_lu: null,
watermark_rd: null,
left_article: 0,
preview_media: null,
video_preview: null,
},
ru: {
id: 0,
name: "",
address: "",
left: { heading: "", body: "", media: [] },
right: [],
},
en: {
id: 0,
name: "",
address: "",
left: { heading: "", body: "", media: [] },
right: [],
},
zh: {
id: 0,
name: "",
address: "",
left: { heading: "", body: "", media: [] },
right: [],
},
};
editSightStore.hasLoadedCommon = false;
editSightStore.uploadMediaOpen = false;
editSightStore.fileToUpload = null;
editSightStore.needLeaveAgree = false;
// Сброс кешей устройств
devicesStore.devices = [];
devicesStore.uuid = null;
devicesStore.sendSnapshotModalOpen = false;
// Сброс кешей авторизации (кроме токена)
authStore.payload = null;
authStore.error = null;
authStore.isLoading = false;
// Сброс кешей карты (если они загружены)
try {
// Сбрасываем кеши mapStore если он доступен
if (typeof window !== "undefined" && (window as any).mapStore) {
(window as any).mapStore.routes = [];
(window as any).mapStore.stations = [];
(window as any).mapStore.sights = [];
}
// Сбрасываем кеши MapService если он доступен
if (typeof window !== "undefined" && (window as any).mapServiceInstance) {
(window as any).mapServiceInstance.clearCaches();
}
} catch (error) {
console.warn("Не удалось сбросить кеши карты:", error);
}
// Сброс localStorage кешей (кроме токена авторизации)
const token = localStorage.getItem("token");
const rememberedEmail = localStorage.getItem("rememberedEmail");
const rememberedPassword = localStorage.getItem("rememberedPassword");
localStorage.clear();
sessionStorage.clear();
// Восстанавливаем важные данные
if (token) localStorage.setItem("token", token);
if (rememberedEmail)
localStorage.setItem("rememberedEmail", rememberedEmail);
if (rememberedPassword)
localStorage.setItem("rememberedPassword", rememberedPassword);
// Сброс кешей карты (если они есть)
const mapPositionKey = "mapPosition";
const activeSectionKey = "mapActiveSection";
if (localStorage.getItem(mapPositionKey)) {
localStorage.removeItem(mapPositionKey);
}
if (localStorage.getItem(activeSectionKey)) {
localStorage.removeItem(activeSectionKey);
}
// Попытка очистить кеш браузера (если поддерживается)
if ("caches" in window) {
try {
caches
.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
return caches.delete(cacheName);
})
);
})
.then(() => {
console.log("Кеш браузера очищен");
})
.catch((error) => {
console.warn("Не удалось очистить кеш браузера:", error);
});
} catch (error) {
console.warn("Кеш браузера не поддерживается:", error);
}
}
// Попытка очистить IndexedDB (если поддерживается)
if ("indexedDB" in window) {
try {
indexedDB
.databases()
.then((databases) => {
return Promise.all(
databases.map((db) => {
if (db.name) {
return indexedDB.deleteDatabase(db.name);
}
return Promise.resolve();
})
);
})
.then(() => {
console.log("IndexedDB очищен");
})
.catch((error) => {
console.warn("Не удалось очистить IndexedDB:", error);
});
} catch (error) {
console.warn("IndexedDB не поддерживается:", error);
}
}
console.log("Все кеши приложения сброшены");
};
getSnapshots = async () => { getSnapshots = async () => {
const response = await authInstance.get(`/snapshots`); const response = await authInstance.get(`/snapshots`);
@ -42,6 +302,10 @@ class SnapshotStore {
}; };
restoreSnapshot = async (id: string) => { restoreSnapshot = async (id: string) => {
// Сначала сбрасываем все кеши
this.clearAllCaches();
// Затем восстанавливаем снапшот
await authInstance.post(`/snapshots/${id}/restore`); await authInstance.post(`/snapshots/${id}/restore`);
}; };

View File

@ -6,7 +6,6 @@ type Language = "ru" | "en" | "zh";
type StationLanguageData = { type StationLanguageData = {
name: string; name: string;
system_name: string; system_name: string;
description: string;
address: string; address: string;
loaded: boolean; // Indicates if this language's data has been loaded/modified loaded: boolean; // Indicates if this language's data has been loaded/modified
}; };
@ -14,6 +13,7 @@ type StationLanguageData = {
type StationCommonData = { type StationCommonData = {
city_id: number; city_id: number;
direction: boolean; direction: boolean;
description: string;
icon: string; icon: string;
latitude: number; latitude: number;
longitude: number; longitude: number;
@ -64,6 +64,10 @@ type Station = {
}; };
}; };
type CreateStationData = {
[key in Language]: StationLanguageData;
} & { common: StationCommonData };
class StationsStore { class StationsStore {
stations: Station[] = []; stations: Station[] = [];
station: Station | null = null; station: Station | null = null;
@ -98,21 +102,21 @@ class StationsStore {
ru: { ru: {
name: "", name: "",
system_name: "", system_name: "",
description: "",
address: "", address: "",
loaded: false, loaded: false,
}, },
en: { en: {
name: "", name: "",
system_name: "", system_name: "",
description: "",
address: "", address: "",
loaded: false, loaded: false,
}, },
zh: { zh: {
name: "", name: "",
system_name: "", system_name: "",
description: "",
address: "", address: "",
loaded: false, loaded: false,
}, },
@ -120,6 +124,53 @@ class StationsStore {
city: "", city: "",
city_id: 0, city_id: 0,
direction: false, direction: false,
description: "",
icon: "",
latitude: 0,
longitude: 0,
offset_x: 0,
offset_y: 0,
transfers: {
bus: "",
metro_blue: "",
metro_green: "",
metro_orange: "",
metro_purple: "",
metro_red: "",
train: "",
tram: "",
trolleybus: "",
},
},
};
createStationData: CreateStationData = {
ru: {
name: "",
system_name: "",
address: "",
loaded: false,
},
en: {
name: "",
system_name: "",
address: "",
loaded: false,
},
zh: {
name: "",
system_name: "",
address: "",
loaded: false,
},
common: {
city: "",
city_id: 0,
direction: false,
description: "",
icon: "", icon: "",
latitude: 0, latitude: 0,
longitude: 0, longitude: 0,
@ -172,9 +223,6 @@ class StationsStore {
}; };
getEditStation = async (id: number) => { getEditStation = async (id: number) => {
if (this.editStationData.ru.loaded) {
return;
}
const ruResponse = await languageInstance("ru").get(`/station/${id}`); const ruResponse = await languageInstance("ru").get(`/station/${id}`);
const enResponse = await languageInstance("en").get(`/station/${id}`); const enResponse = await languageInstance("en").get(`/station/${id}`);
const zhResponse = await languageInstance("zh").get(`/station/${id}`); const zhResponse = await languageInstance("zh").get(`/station/${id}`);
@ -183,21 +231,21 @@ class StationsStore {
ru: { ru: {
name: ruResponse.data.name, name: ruResponse.data.name,
system_name: ruResponse.data.system_name, system_name: ruResponse.data.system_name,
description: ruResponse.data.description,
address: ruResponse.data.address, address: ruResponse.data.address,
loaded: true, loaded: true,
}, },
en: { en: {
name: enResponse.data.name, name: enResponse.data.name,
system_name: enResponse.data.system_name, system_name: enResponse.data.system_name,
description: enResponse.data.description,
address: enResponse.data.address, address: enResponse.data.address,
loaded: true, loaded: true,
}, },
zh: { zh: {
name: zhResponse.data.name, name: zhResponse.data.name,
system_name: zhResponse.data.system_name, system_name: zhResponse.data.system_name,
description: zhResponse.data.description,
address: zhResponse.data.address, address: zhResponse.data.address,
loaded: true, loaded: true,
}, },
@ -205,6 +253,7 @@ class StationsStore {
city: ruResponse.data.city, city: ruResponse.data.city,
city_id: ruResponse.data.city_id, city_id: ruResponse.data.city_id,
direction: ruResponse.data.direction, direction: ruResponse.data.direction,
description: ruResponse.data.description,
icon: ruResponse.data.icon, icon: ruResponse.data.icon,
latitude: ruResponse.data.latitude, latitude: ruResponse.data.latitude,
longitude: ruResponse.data.longitude, longitude: ruResponse.data.longitude,
@ -240,7 +289,8 @@ class StationsStore {
}; };
for (const language of ["ru", "en", "zh"] as const) { for (const language of ["ru", "en", "zh"] as const) {
const { name, description, address } = this.editStationData[language]; const { name, address } = this.editStationData[language];
const description = this.editStationData.common.description;
const response = await languageInstance(language).patch( const response = await languageInstance(language).patch(
`/station/${id}`, `/station/${id}`,
{ {
@ -277,7 +327,7 @@ class StationsStore {
...station, ...station,
name: response.data.name, name: response.data.name,
system_name: response.data.system_name, system_name: response.data.system_name,
description: response.data.description, description: description || "",
address: response.data.address, address: response.data.address,
...commonDataPayload, ...commonDataPayload,
} as Station) } as Station)
@ -336,35 +386,125 @@ class StationsStore {
}); });
}; };
createStation = async ( setCreateCommonData = (data: Partial<StationCommonData>) => {
name: string, this.createStationData.common = {
systemName: string, ...this.createStationData.common,
direction: string ...data,
};
};
setLanguageCreateStationData = (
language: Language,
data: Partial<StationLanguageData>
) => { ) => {
const response = await authInstance.post("/station", { this.createStationData[language] = {
station_name: name, ...this.createStationData[language],
system_name: systemName, ...data,
direction, };
};
createStation = async () => {
const { language } = languageStore;
let commonDataPayload: Partial<StationCommonData> = {
city_id: this.createStationData.common.city_id,
direction: this.createStationData.common.direction,
icon: this.createStationData.common.icon,
latitude: this.createStationData.common.latitude,
longitude: this.createStationData.common.longitude,
offset_x: this.createStationData.common.offset_x,
offset_y: this.createStationData.common.offset_y,
transfers: this.createStationData.common.transfers,
city: this.createStationData.common.city,
};
if (this.createStationData.common.icon === "") {
delete commonDataPayload.icon;
}
// First create station in Russian
const { name, address } = this.createStationData[language];
const description = this.createStationData.common.description;
const response = await languageInstance(language).post("/station", {
name: name || "",
system_name: name || "", // system_name is often derived from name
description: description || "",
address: address || "",
...commonDataPayload,
}); });
runInAction(() => { runInAction(() => {
this.stations.push(response.data); this.stationLists[language].data.push(response.data);
const newStation = response.data as Station; });
if (!this.stationPreview[newStation.id]) {
this.stationPreview[newStation.id] = { const stationId = response.data.id;
ru: { loaded: false, data: newStation },
en: { loaded: false, data: newStation }, // Then update for other languages
zh: { loaded: false, data: newStation }, for (const lang of ["ru", "en", "zh"].filter(
}; (lang) => lang !== language
} ) as Language[]) {
this.stationPreview[newStation.id]["ru"] = { const { name, address } = this.createStationData[lang];
loaded: true, const description = this.createStationData.common.description;
data: newStation, const response = await languageInstance(lang).patch(
}; `/station/${stationId}`,
this.stationPreview[newStation.id]["en"] = { {
loaded: true, name: name || "",
data: newStation, system_name: name || "", // system_name is often derived from name
description: description || "",
address: address || "",
...commonDataPayload,
}
);
runInAction(() => {
this.stationLists[lang].data.push(response.data);
});
}
runInAction(() => {
this.createStationData = {
ru: {
name: "",
system_name: "",
address: "",
loaded: false,
},
en: {
name: "",
system_name: "",
address: "",
loaded: false,
},
zh: {
name: "",
system_name: "",
address: "",
loaded: false,
},
common: {
city: "",
city_id: 0,
direction: false,
icon: "",
latitude: 0,
description: "",
longitude: 0,
offset_x: 0,
offset_y: 0,
transfers: {
bus: "",
metro_blue: "",
metro_green: "",
metro_orange: "",
metro_purple: "",
metro_red: "",
train: "",
tram: "",
trolleybus: "",
},
},
}; };
}); });
return response.data;
}; };
// Reset editStationData when navigating away or after saving // Reset editStationData when navigating away or after saving
@ -373,21 +513,18 @@ class StationsStore {
ru: { ru: {
name: "", name: "",
system_name: "", system_name: "",
description: "",
address: "", address: "",
loaded: false, loaded: false,
}, },
en: { en: {
name: "", name: "",
system_name: "", system_name: "",
description: "",
address: "", address: "",
loaded: false, loaded: false,
}, },
zh: { zh: {
name: "", name: "",
system_name: "", system_name: "",
description: "",
address: "", address: "",
loaded: false, loaded: false,
}, },
@ -395,6 +532,7 @@ class StationsStore {
city: "", city: "",
city_id: 0, city_id: 0,
direction: false, direction: false,
description: "",
icon: "", icon: "",
latitude: 0, latitude: 0,
longitude: 0, longitude: 0,

View File

@ -24,8 +24,6 @@ class UserStore {
} }
getUsers = async () => { getUsers = async () => {
if (this.users.loaded) return;
const response = await authInstance.get("/user"); const response = await authInstance.get("/user");
runInAction(() => { runInAction(() => {
@ -35,7 +33,7 @@ class UserStore {
}; };
getUser = async (id: number) => { getUser = async (id: number) => {
if (this.user[id]) return; if (this.user[id]) return this.user[id];
const response = await authInstance.get(`/user/${id}`); const response = await authInstance.get(`/user/${id}`);
runInAction(() => { runInAction(() => {

View File

@ -35,8 +35,6 @@ class VehicleStore {
} }
getVehicles = async () => { getVehicles = async () => {
if (this.vehicles.loaded) return;
const response = await languageInstance("ru").get(`/vehicle`); const response = await languageInstance("ru").get(`/vehicle`);
runInAction(() => { runInAction(() => {

View File

@ -3,21 +3,25 @@ import { Button } from "@mui/material";
export const DeleteModal = ({ export const DeleteModal = ({
onDelete, onDelete,
onCancel, onCancel,
edit = false,
open, open,
}: { }: {
onDelete: () => void; onDelete: () => void;
onCancel: () => void; onCancel: () => void;
edit?: boolean;
open: boolean; open: boolean;
}) => { }) => {
return ( return (
<div <div
className={`fixed top-0 left-0 w-screen h-screen flex justify-center items-center z-10000 bg-black/30 transition-all duration-300 ${ className={`fixed top-0 left-0 w-screen h-screen flex justify-center items-center z-1000000000000 bg-black/30 transition-all duration-300 ${
open ? "block" : "hidden" open ? "block" : "hidden"
}`} }`}
> >
<div className="bg-white p-4 w-100 rounded-lg flex flex-col gap-4 items-center"> <div className="bg-white p-4 w-100 rounded-lg flex flex-col gap-4 items-center">
<p className="text-black w-100 text-center"> <p className="text-black w-100 text-center">
Вы уверены, что хотите удалить этот элемент? {`Вы уверены, что хотите ${
edit ? "убрать" : "удалить"
} этот элемент?`}
</p> </p>
<div className="flex gap-4 justify-center"> <div className="flex gap-4 justify-center">
<Button variant="contained" color="error" onClick={onDelete}> <Button variant="contained" color="error" onClick={onDelete}>

View File

@ -5,7 +5,7 @@ import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead"; import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow"; import TableRow from "@mui/material/TableRow";
import Paper from "@mui/material/Paper"; import Paper from "@mui/material/Paper";
import { Check, RotateCcw, X } from "lucide-react"; import { Check, Copy, RotateCcw, X } from "lucide-react";
import { import {
authInstance, authInstance,
devicesStore, devicesStore,
@ -18,6 +18,7 @@ import { observer } from "mobx-react-lite";
import { Button, Checkbox, Typography } from "@mui/material"; import { Button, Checkbox, Typography } from "@mui/material";
import { Vehicle } from "@shared"; import { Vehicle } from "@shared";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { useNavigate } from "react-router-dom";
export type ConnectedDevice = string; export type ConnectedDevice = string;
@ -116,6 +117,7 @@ export const DevicesTable = observer(() => {
const { snapshots, getSnapshots } = snapshotStore; const { snapshots, getSnapshots } = snapshotStore;
const { getVehicles, vehicles } = vehicleStore; // Removed as devicesStore.devices should be the source of truth const { getVehicles, vehicles } = vehicleStore; // Removed as devicesStore.devices should be the source of truth
const { devices } = devicesStore; const { devices } = devicesStore;
const navigate = useNavigate();
const [selectedDeviceUuids, setSelectedDeviceUuids] = useState<string[]>([]); const [selectedDeviceUuids, setSelectedDeviceUuids] = useState<string[]>([]);
// Transform the raw devices data into rows suitable for the table // Transform the raw devices data into rows suitable for the table
@ -172,6 +174,7 @@ export const DevicesTable = observer(() => {
setSelectedDevice(uuid); // Sets the device in the store, useful for context elsewhere setSelectedDevice(uuid); // Sets the device in the store, useful for context elsewhere
try { try {
await authInstance.post(`/devices/${uuid}/request-status`); await authInstance.post(`/devices/${uuid}/request-status`);
await getVehicles();
await getDevices(); // Refresh devices to show updated status await getDevices(); // Refresh devices to show updated status
} catch (error) { } catch (error) {
console.error(`Error requesting status for device ${uuid}:`, error); console.error(`Error requesting status for device ${uuid}:`, error);
@ -182,13 +185,24 @@ export const DevicesTable = observer(() => {
const handleSendSnapshotAction = async (snapshotId: string) => { const handleSendSnapshotAction = async (snapshotId: string) => {
if (selectedDeviceUuids.length === 0) return; if (selectedDeviceUuids.length === 0) return;
const send = async (deviceUuid: string) => {
try {
await authInstance.post(
`/devices/${deviceUuid}/force-snapshot-update`,
{
snapshot_id: snapshotId,
}
);
toast.success(`Снапшот отправлен на устройство `);
} catch (error) {
console.error(`Error sending snapshot to device ${deviceUuid}:`, error);
toast.error(`Не удалось отправить снапшот на устройство`);
}
};
try { try {
// Create an array of promises for all snapshot requests // Create an array of promises for all snapshot requests
const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => { const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => {
console.log(`Sending snapshot ${snapshotId} to device ${deviceUuid}`); return send(deviceUuid);
return authInstance.post(`/devices/${deviceUuid}/force-snapshot`, {
snapshot_id: snapshotId,
});
}); });
// Wait for all promises to settle (either resolve or reject) // Wait for all promises to settle (either resolve or reject)
@ -209,6 +223,16 @@ export const DevicesTable = observer(() => {
return ( return (
<> <>
<TableContainer component={Paper} sx={{ mt: 2 }}> <TableContainer component={Paper} sx={{ mt: 2 }}>
<div className="flex justify-end p-3 gap-2 ">
<Button
variant="contained"
color="primary"
size="small"
onClick={() => navigate("/vehicle/create")}
>
Добавить устройство
</Button>
</div>
<div className="flex justify-end p-3 gap-2"> <div className="flex justify-end p-3 gap-2">
<Button <Button
variant="outlined" // Changed to outlined for distinction variant="outlined" // Changed to outlined for distinction
@ -269,16 +293,34 @@ export const DevicesTable = observer(() => {
'input[type="checkbox"]' 'input[type="checkbox"]'
) === null ) === null
) { ) {
handleSelectDevice( if (event.shiftKey) {
{ if (row.device_uuid) {
target: { navigator.clipboard
checked: !selectedDeviceUuids.includes( .writeText(row.device_uuid)
row.device_uuid ?? "" .then(() => {
), toast.success(`UUID скопирован`);
}, })
} as React.ChangeEvent<HTMLInputElement>, // Simulate event .catch(() => {
row.device_uuid ?? "" toast.error("Не удалось скопировать UUID");
); });
} else {
toast.warning("Устройство не имеет UUID");
}
}
// Only toggle checkbox if Shift key is not pressed
if (!event.shiftKey) {
handleSelectDevice(
{
target: {
checked: !selectedDeviceUuids.includes(
row.device_uuid ?? ""
),
},
} as React.ChangeEvent<HTMLInputElement>, // Simulate event
row.device_uuid ?? ""
);
}
} }
}} }}
sx={{ sx={{
@ -356,7 +398,6 @@ export const DevicesTable = observer(() => {
devices.find((device) => device === row.device_uuid) devices.find((device) => device === row.device_uuid)
) { ) {
await handleReloadStatus(row.device_uuid); await handleReloadStatus(row.device_uuid);
await getDevices();
toast.success("Статус устройства обновлен"); toast.success("Статус устройства обновлен");
} else { } else {
toast.error("Нет связи с устройством"); toast.error("Нет связи с устройством");
@ -371,6 +412,14 @@ export const DevicesTable = observer(() => {
> >
<RotateCcw size={16} /> <RotateCcw size={16} />
</Button> </Button>
<Button
onClick={() => {
navigator.clipboard.writeText(row.device_uuid ?? "");
toast.success("UUID скопирован");
}}
>
<Copy size={16} />
</Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}

View File

@ -5,7 +5,7 @@ import { editSightStore } from "@shared";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
interface ImageUploadCardProps { interface ImageUploadCardProps {
title: string; title: string;
imageKey?: "thumbnail" | "watermark_lu" | "watermark_rd"; imageKey?: "thumbnail" | "watermark_lu" | "watermark_rd" | "image";
imageUrl: string | null | undefined; imageUrl: string | null | undefined;
onImageClick: () => void; onImageClick: () => void;
onDeleteImageClick: () => void; onDeleteImageClick: () => void;
@ -46,10 +46,16 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
) => { ) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (file) { if (file) {
setFileToUpload(file); if (file.type.startsWith("image/") && file.type !== "image/gif") {
setUploadMediaOpen(true); setFileToUpload(file);
if (imageKey && setHardcodeType) { setUploadMediaOpen(true);
setHardcodeType(imageKey); if (imageKey && setHardcodeType) {
setHardcodeType(imageKey);
}
} else if (file.type === "image/gif") {
toast.error("GIF файлы не поддерживаются");
} else {
toast.error("Пожалуйста, выберите изображение");
} }
} }
// Reset the input value so selecting the same file again triggers change // Reset the input value so selecting the same file again triggers change
@ -78,9 +84,11 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
const files = event.dataTransfer.files; const files = event.dataTransfer.files;
if (files && files.length > 0) { if (files && files.length > 0) {
const file = files[0]; const file = files[0];
if (file.type.startsWith("image/")) { if (file.type.startsWith("image/") && file.type !== "image/gif") {
setFileToUpload(file); setFileToUpload(file);
setUploadMediaOpen(true); setUploadMediaOpen(true);
} else if (file.type === "image/gif") {
toast.error("GIF файлы не поддерживаются");
} else { } else {
toast.error("Пожалуйста, выберите изображение"); toast.error("Пожалуйста, выберите изображение");
} }

View File

@ -44,14 +44,14 @@ export const LanguageSwitcher = observer(() => {
}; };
return ( return (
<div className="fixed bottom-0 left-1/2 -translate-x-1/2 flex gap-2 p-4 z-100000000"> <div className="fixed top-0 left-1/2 -translate-x-1/2 flex gap-2 p-4 z-100000">
{/* Added some styling for better visualization */} {/* Added some styling for better visualization */}
{LANGUAGES.map((lang) => ( {LANGUAGES.map((lang) => (
<Button <Button
key={lang} key={lang}
onClick={() => handleLanguageChange(lang)} onClick={() => handleLanguageChange(lang)}
variant={language === lang ? "contained" : "outlined"} // Highlight the active language variant={"contained"} // Highlight the active language
color="primary" color={language === lang ? "primary" : "inherit"}
sx={{ minWidth: "60px" }} // Give buttons a consistent width sx={{ minWidth: "60px" }} // Give buttons a consistent width
> >
{getLanguageLabel(lang)} {getLanguageLabel(lang)}

View File

@ -2,20 +2,32 @@ import * as React from "react";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Toolbar from "@mui/material/Toolbar"; import Toolbar from "@mui/material/Toolbar";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import { Menu, ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; import { Menu, ChevronLeftIcon, ChevronRightIcon, User } from "lucide-react";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { AppBar } from "./ui/AppBar"; import { AppBar } from "./ui/AppBar";
import { Drawer } from "./ui/Drawer"; import { Drawer } from "./ui/Drawer";
import { DrawerHeader } from "./ui/DrawerHeader"; import { DrawerHeader } from "./ui/DrawerHeader";
import { NavigationList } from "@features"; import { NavigationList } from "@features";
import { authStore, userStore } from "@shared";
import { observer } from "mobx-react-lite";
import { useEffect } from "react";
import { Typography } from "@mui/material";
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
} }
export const Layout: React.FC<LayoutProps> = ({ children }) => { export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
const theme = useTheme(); const theme = useTheme();
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(true);
const { getUsers, users } = userStore;
useEffect(() => {
const fetchUsers = async () => {
await getUsers();
};
fetchUsers();
}, []);
const handleDrawerOpen = () => { const handleDrawerOpen = () => {
setOpen(true); setOpen(true);
@ -28,7 +40,7 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
return ( return (
<Box sx={{ display: "flex" }}> <Box sx={{ display: "flex" }}>
<AppBar position="fixed" open={open}> <AppBar position="fixed" open={open}>
<Toolbar> <Toolbar className="flex justify-between">
<IconButton <IconButton
color="inherit" color="inherit"
aria-label="open drawer" aria-label="open drawer"
@ -43,24 +55,93 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
> >
<Menu /> <Menu />
</IconButton> </IconButton>
<div></div>
<div className="flex gap-2 items-center">
<div className="flex flex-col gap-1">
{(() => {
return (
<>
<p className=" text-white">
{
users?.data?.find(
// @ts-ignore
(user) => user.id === authStore.payload?.user_id
)?.name
}
</p>
<div
className="text-center text-xs"
style={{
backgroundColor: "#877361",
borderRadius: "4px",
color: "white",
padding: "2px 10px",
}}
>
{/* @ts-ignore */}
{authStore.payload?.is_admin
? "Администратор"
: "Режим пользователя"}
</div>
</>
);
})()}
</div>
<div className="w-10 h-10 bg-gray-600 rounded-full flex items-center justify-center">
<User />
</div>
</div>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
<Drawer variant="permanent" open={open}> <Drawer variant="permanent" open={open}>
<DrawerHeader> <DrawerHeader>
<IconButton onClick={handleDrawerClose}> <Box
{theme.direction === "rtl" ? ( sx={{
<ChevronRightIcon /> display: "flex",
) : ( alignItems: "center",
<ChevronLeftIcon /> gap: 2,
)} cursor: "pointer",
</IconButton> }}
onClick={() => {
setOpen(!open);
}}
>
<img
src="/favicon_ship.png"
alt="logo"
width={40}
height={40}
style={{ filter: "brightness(0)", marginLeft: "-5px" }}
/>
<Typography variant="h6" component="h1">
Белые ночи
</Typography>
</Box>
{open && (
<IconButton onClick={handleDrawerClose}>
{theme.direction === "rtl" ? (
<ChevronRightIcon />
) : (
<ChevronLeftIcon />
)}
</IconButton>
)}
</DrawerHeader> </DrawerHeader>
<NavigationList open={open} /> <NavigationList open={open} onDrawerOpen={handleDrawerOpen} />
</Drawer> </Drawer>
<Box component="main" sx={{ flexGrow: 1, p: 3 }}> <Box
<DrawerHeader /> component="main"
sx={{
width: "100%",
flexGrow: 1,
p: 3,
overflow: "auto",
maxWidth: "100vw",
}}
>
<div className="mt-16"></div>
{children} {children}
</Box> </Box>
</Box> </Box>
); );
}; });

View File

@ -24,4 +24,12 @@ export const AppBar = styled(MuiAppBar, {
duration: theme.transitions.duration.enteringScreen, duration: theme.transitions.duration.enteringScreen,
}), }),
}), }),
...(!open && {
marginLeft: theme.spacing(7),
width: `calc(100% - ${theme.spacing(7)})`,
[theme.breakpoints.up("sm")]: {
marginLeft: theme.spacing(8),
width: `calc(100% - ${theme.spacing(8)})`,
},
}),
})); }));

View File

@ -1,10 +1,12 @@
import { styled } from "@mui/material/styles"; import { styled } from "@mui/material/styles";
import type { Theme } from "@mui/material/styles"; import type { Theme } from "@mui/material/styles";
import Box from "@mui/material/Box";
export const DrawerHeader = styled("div")(({ theme }: { theme: Theme }) => ({ export const DrawerHeader = styled(Box)(({ theme }: { theme: Theme }) => ({
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "flex-end", justifyContent: "space-between",
padding: theme.spacing(0, 1), padding: theme.spacing(2),
...theme.mixins.toolbar, ...theme.mixins.toolbar,
borderBottom: "1px solid rgba(0, 0, 0, 0.12)",
})); }));

View File

@ -109,6 +109,7 @@ export const MediaArea = observer(
media_type: m.media_type, media_type: m.media_type,
filename: m.filename, filename: m.filename,
}} }}
height="40px"
/> />
<button <button
className="absolute top-2 right-2" className="absolute top-2 right-2"

View File

@ -8,9 +8,23 @@ export const MediaAreaForSight = observer(
({ ({
onFilesDrop, // 👈 Проп для обработки загруженных файлов onFilesDrop, // 👈 Проп для обработки загруженных файлов
onFinishUpload, onFinishUpload,
contextObjectName,
contextType,
isArticle,
articleName,
}: { }: {
onFilesDrop?: (files: File[]) => void; onFilesDrop?: (files: File[]) => void;
onFinishUpload?: (mediaId: string) => void; onFinishUpload?: (mediaId: string) => void;
contextObjectName?: string;
contextType?:
| "sight"
| "city"
| "carrier"
| "country"
| "vehicle"
| "station";
isArticle?: boolean;
articleName?: string;
}) => { }) => {
const [selectMediaDialogOpen, setSelectMediaDialogOpen] = useState(false); const [selectMediaDialogOpen, setSelectMediaDialogOpen] = useState(false);
const [uploadMediaDialogOpen, setUploadMediaDialogOpen] = useState(false); const [uploadMediaDialogOpen, setUploadMediaDialogOpen] = useState(false);
@ -94,6 +108,10 @@ export const MediaAreaForSight = observer(
<UploadMediaDialog <UploadMediaDialog
open={uploadMediaDialogOpen} open={uploadMediaDialogOpen}
onClose={() => setUploadMediaDialogOpen(false)} onClose={() => setUploadMediaDialogOpen(false)}
contextObjectName={contextObjectName}
contextType={contextType}
isArticle={isArticle}
articleName={articleName}
afterUploadSight={onFinishUpload} afterUploadSight={onFinishUpload}
/> />
<SelectMediaDialog <SelectMediaDialog

View File

@ -2,15 +2,20 @@ import { Canvas } from "@react-three/fiber";
import { OrbitControls, Stage, useGLTF } from "@react-three/drei"; import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
type ModelViewerProps = { type ModelViewerProps = {
width?: string;
fileUrl: string; fileUrl: string;
height?: string; height?: string;
}; };
export const ThreeView = ({ fileUrl, height = "100%" }: ModelViewerProps) => { export const ThreeView = ({
fileUrl,
height = "100%",
width = "100%",
}: ModelViewerProps) => {
const { scene } = useGLTF(fileUrl); const { scene } = useGLTF(fileUrl);
return ( return (
<Canvas style={{ width: "100%", height: height }}> <Canvas style={{ height: height, width: width }}>
<ambientLight /> <ambientLight />
<directionalLight /> <directionalLight />
<Stage environment="city" intensity={0.6}> <Stage environment="city" intensity={0.6}>

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