Compare commits
47 Commits
078f051e8a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 50ad374cf5 | |||
| 9e47ab667f | |||
| 1b8fc3d215 | |||
| f5142ec95d | |||
| cdb96dfb8b | |||
| c50ccb3a0c | |||
| 4bcc2e2cca | |||
| 26e4d70b95 | |||
| a357994025 | |||
| 7382a85082 | |||
| db64beb3ee | |||
|
|
1abd6b30a4 | ||
|
|
b25df42960 | ||
|
34ba3c1db0
|
|||
| 4f038551a2 | |||
| 470a58a3fa | |||
| 89d7fc2748 | |||
| 97f95fc394 | |||
| bf117ef048 | |||
| ced3067915 | |||
| a908c63771 | |||
| 06eafee3f4 | |||
| 717031cd7a | |||
|
2d4a1e169b
|
|||
| e2547cb571 | |||
| 78800ee2ae | |||
| d415441af8 | |||
| 32a7cb44d1 | |||
| 481385c2f4 | |||
| 2117a6836e | |||
| f49caf3ec8 | |||
| 300ff262ce | |||
| 27cb644242 | |||
| d8302e05b4 | |||
| e2e750877a | |||
| 2ca1f2cba4 | |||
| 64c15b2622 | |||
| f4544c1888 | |||
| 02a1d2ea74 | |||
| b09c1b3214 | |||
| e37f9e14bc | |||
| 0fe4683683 | |||
| 1104e94ba8 | |||
| d74789a0d8 | |||
| f2aab1ab33 | |||
| 1973ff4304 | |||
| 89488d9921 |
1
.env
1
.env
@@ -1,2 +1,3 @@
|
||||
VITE_API_URL='https://wn.krbl.ru'
|
||||
VITE_REACT_APP ='https://wn.krbl.ru/'
|
||||
VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
|
||||
32
Dockerfile
Normal file
32
Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
# Stage 1: Build the application
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and yarn.lock
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
# Install dependencies
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
# Copy the rest of the application code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN yarn build
|
||||
|
||||
# Stage 2: Serve the application with Nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy the built application from the build stage
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration (optional, can be added later if needed)
|
||||
# COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Start Nginx server
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
38
Makefile
Normal file
38
Makefile
Normal file
@@ -0,0 +1,38 @@
|
||||
# Variables
|
||||
IMAGE_NAME = white-nights-admin-panel
|
||||
IMAGE_TAG = latest
|
||||
FULL_IMAGE_NAME = $(IMAGE_NAME):$(IMAGE_TAG)
|
||||
ARCHIVE_NAME = white-nights-admin-panel-image.zip
|
||||
|
||||
# Default target
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "Available commands:"
|
||||
@echo " make build-image - Build Docker image"
|
||||
@echo " make export-image - Build Docker image and export it to a zip archive"
|
||||
@echo " make clean - Remove Docker image and zip archive"
|
||||
@echo " make help - Show this help message"
|
||||
|
||||
# Build Docker image
|
||||
.PHONY: build-image
|
||||
build-image:
|
||||
@echo "Building Docker image: $(FULL_IMAGE_NAME)"
|
||||
docker build -t $(FULL_IMAGE_NAME) .
|
||||
|
||||
# Export Docker image to zip archive
|
||||
.PHONY: export-image
|
||||
export-image: build-image
|
||||
@echo "Exporting Docker image to $(ARCHIVE_NAME)"
|
||||
docker save $(FULL_IMAGE_NAME) | gzip > $(ARCHIVE_NAME)
|
||||
@echo "Image exported successfully to $(ARCHIVE_NAME)"
|
||||
|
||||
# Clean up
|
||||
.PHONY: clean
|
||||
clean:
|
||||
@echo "Removing Docker image and zip archive"
|
||||
-docker rmi $(FULL_IMAGE_NAME) 2>/dev/null || true
|
||||
-rm -f $(ARCHIVE_NAME) 2>/dev/null || true
|
||||
@echo "Clean up completed"
|
||||
|
||||
# Default target when no arguments provided
|
||||
.DEFAULT_GOAL := help
|
||||
1525
package-lock.json
generated
1525
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,8 +12,12 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@mui/icons-material": "^7.1.1",
|
||||
"@mui/material": "^7.1.0",
|
||||
"@mui/x-data-grid": "^8.5.1",
|
||||
"@photo-sphere-viewer/core": "^5.13.2",
|
||||
"@pixi/react": "^8.0.2",
|
||||
"@react-three/drei": "^10.1.2",
|
||||
"@react-three/fiber": "^9.1.2",
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
@@ -24,7 +28,9 @@
|
||||
"mobx-react-lite": "^4.1.0",
|
||||
"ol": "^10.5.0",
|
||||
"path": "^0.12.7",
|
||||
"pixi.js": "^8.10.1",
|
||||
"react": "^19.1.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -50,5 +56,6 @@
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
373
public/Emblem.svg
Normal file
373
public/Emblem.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 176 KiB |
BIN
public/GET.png
Normal file
BIN
public/GET.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
BIN
public/SightIcon.png
Normal file
BIN
public/SightIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 750 B |
BIN
public/favicon_ship.png
Normal file
BIN
public/favicon_ship.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
158
src/app/GlobalErrorBoundary.tsx
Normal file
158
src/app/GlobalErrorBoundary.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React, { Component, ReactNode } from "react";
|
||||
import { Box, Button, Typography, Paper, Container } from "@mui/material";
|
||||
import { RefreshCw, Home, AlertTriangle } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class GlobalErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error("❌ GlobalErrorBoundary: Критическая ошибка приложения", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
componentStack: errorInfo.componentStack,
|
||||
});
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
});
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
handleGoHome = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
});
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "background.default",
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="sm">
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={64} color="#f44336" />
|
||||
</Box>
|
||||
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Упс! Что-то пошло не так
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Приложение столкнулось с неожиданной ошибкой. Попробуйте
|
||||
перезагрузить страницу или вернуться на главную.
|
||||
</Typography>
|
||||
|
||||
{this.state.error?.message && (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 3,
|
||||
backgroundColor: "error.light",
|
||||
color: "error.contrastText",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: "bold", display: "block", mb: 1 }}
|
||||
>
|
||||
Информация об ошибке:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.75rem",
|
||||
wordBreak: "break-word",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{this.state.error.message}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 2,
|
||||
justifyContent: "center",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Home size={16} />}
|
||||
onClick={this.handleGoHome}
|
||||
size="large"
|
||||
>
|
||||
На главную
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<RefreshCw size={16} />}
|
||||
onClick={this.handleReset}
|
||||
size="large"
|
||||
>
|
||||
Перезагрузить
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
import { Router } from "./router";
|
||||
import { CustomTheme } from "@shared";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { GlobalErrorBoundary } from "./GlobalErrorBoundary";
|
||||
|
||||
export const App: React.FC = () => (
|
||||
<ThemeProvider theme={CustomTheme.Light}>
|
||||
<ToastContainer />
|
||||
<BrowserRouter>
|
||||
<GlobalErrorBoundary>
|
||||
<ThemeProvider theme={CustomTheme.Light}>
|
||||
<ToastContainer />
|
||||
<Router />
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</ThemeProvider>
|
||||
</GlobalErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -4,42 +4,82 @@ import {
|
||||
EditSightPage,
|
||||
LoginPage,
|
||||
MainPage,
|
||||
SightPage,
|
||||
SightListPage,
|
||||
MapPage,
|
||||
MediaListPage,
|
||||
PreviewMediaPage,
|
||||
EditMediaPage,
|
||||
// CreateMediaPage,
|
||||
MediaPreviewPage,
|
||||
MediaEditPage,
|
||||
CountryListPage,
|
||||
CityListPage,
|
||||
RouteListPage,
|
||||
UserListPage,
|
||||
SnapshotListPage,
|
||||
CarrierListPage,
|
||||
StationListPage,
|
||||
// VehicleListPage,
|
||||
ArticleListPage,
|
||||
|
||||
// CountryPreviewPage,
|
||||
// VehiclePreviewPage,
|
||||
// CarrierPreviewPage,
|
||||
SnapshotCreatePage,
|
||||
CountryCreatePage,
|
||||
CityCreatePage,
|
||||
CarrierCreatePage,
|
||||
VehicleCreatePage,
|
||||
CountryEditPage,
|
||||
CityEditPage,
|
||||
UserCreatePage,
|
||||
UserEditPage,
|
||||
// VehicleEditPage,
|
||||
CarrierEditPage,
|
||||
StationCreatePage,
|
||||
StationPreviewPage,
|
||||
StationEditPage,
|
||||
RouteCreatePage,
|
||||
RoutePreview,
|
||||
RouteEditPage,
|
||||
ArticlePreviewPage,
|
||||
CountryAddPage,
|
||||
} from "@pages";
|
||||
import { authStore, createSightStore, editSightStore } from "@shared";
|
||||
import { Layout } from "@widgets";
|
||||
import { runInAction } from "mobx";
|
||||
import { useEffect } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import { Navigate, Outlet, Route, Routes, useLocation } from "react-router-dom";
|
||||
import {
|
||||
createBrowserRouter,
|
||||
RouterProvider,
|
||||
Navigate,
|
||||
Outlet,
|
||||
useLocation,
|
||||
} from "react-router-dom";
|
||||
|
||||
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { isAuthenticated } = authStore;
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/sight" />;
|
||||
return <Navigate to="/map" replace />;
|
||||
}
|
||||
return children;
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { isAuthenticated } = authStore;
|
||||
const pathname = useLocation();
|
||||
const location = useLocation();
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" />;
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
if (pathname.pathname === "/") {
|
||||
return <Navigate to="/sight" />;
|
||||
if (location.pathname === "/") {
|
||||
return <Navigate to="/map" replace />;
|
||||
}
|
||||
return children;
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export const Router = () => {
|
||||
const pathname = useLocation();
|
||||
// Чтобы очистка сторов происходила при смене локации
|
||||
const ClearStoresWrapper: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
editSightStore.clearSightInfo();
|
||||
@@ -47,41 +87,98 @@ export const Router = () => {
|
||||
runInAction(() => {
|
||||
editSightStore.hasLoadedCommon = false;
|
||||
});
|
||||
}, [pathname]);
|
||||
}, [location]);
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<LoginPage />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Protected routes with layout */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Outlet />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<MainPage />} />
|
||||
<Route path="sight" element={<SightPage />} />
|
||||
<Route path="sight/:id" element={<EditSightPage />} />
|
||||
<Route path="sight/create" element={<CreateSightPage />} />
|
||||
<Route path="devices" element={<DevicesPage />} />
|
||||
<Route path="map" element={<MapPage />} />
|
||||
<Route path="media" element={<MediaListPage />} />
|
||||
<Route path="media/:id" element={<PreviewMediaPage />} />
|
||||
<Route path="media/:id/edit" element={<EditMediaPage />} />
|
||||
{/* <Route path="media/create" element={<CreateMediaPage />} /> */}
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "/login",
|
||||
element: (
|
||||
<PublicRoute>
|
||||
<LoginPage />
|
||||
</PublicRoute>
|
||||
),
|
||||
},
|
||||
{ path: "route-preview/:id", element: <RoutePreview /> },
|
||||
{
|
||||
path: "/",
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<ClearStoresWrapper>
|
||||
<Outlet />
|
||||
</ClearStoresWrapper>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: <MainPage /> },
|
||||
|
||||
// Sight
|
||||
{ path: "sight", element: <SightListPage /> },
|
||||
{ path: "sight/create", element: <CreateSightPage /> },
|
||||
{ path: "sight/:id/edit", element: <EditSightPage /> },
|
||||
|
||||
// Device
|
||||
{ path: "devices", element: <DevicesPage /> },
|
||||
|
||||
// Map
|
||||
{ path: "map", element: <MapPage /> },
|
||||
|
||||
// Media
|
||||
{ path: "media", element: <MediaListPage /> },
|
||||
{ path: "media/:id", element: <MediaPreviewPage /> },
|
||||
{ path: "media/:id/edit", element: <MediaEditPage /> },
|
||||
|
||||
// Country
|
||||
{ path: "country", element: <CountryListPage /> },
|
||||
{ path: "country/create", element: <CountryCreatePage /> },
|
||||
{ path: "country/add", element: <CountryAddPage /> },
|
||||
// { path: "country/:id", element: <CountryPreviewPage /> },
|
||||
{ path: "country/:id/edit", element: <CountryEditPage /> },
|
||||
// City
|
||||
{ path: "city", element: <CityListPage /> },
|
||||
{ path: "city/create", element: <CityCreatePage /> },
|
||||
// { path: "city/:id", element: <CityPreviewPage /> },
|
||||
{ path: "city/:id/edit", element: <CityEditPage /> },
|
||||
// Route
|
||||
{ path: "route", element: <RouteListPage /> },
|
||||
{ path: "route/create", element: <RouteCreatePage /> },
|
||||
{ path: "route/:id/edit", element: <RouteEditPage /> },
|
||||
|
||||
// User
|
||||
{ path: "user", element: <UserListPage /> },
|
||||
{ path: "user/create", element: <UserCreatePage /> },
|
||||
{ path: "user/:id/edit", element: <UserEditPage /> },
|
||||
// Snapshot
|
||||
{ path: "snapshot", element: <SnapshotListPage /> },
|
||||
{ path: "snapshot/create", element: <SnapshotCreatePage /> },
|
||||
|
||||
// Carrier
|
||||
{ path: "carrier", element: <CarrierListPage /> },
|
||||
{ path: "carrier/create", element: <CarrierCreatePage /> },
|
||||
// { path: "carrier/:id", element: <CarrierPreviewPage /> },
|
||||
{ path: "carrier/:id/edit", element: <CarrierEditPage /> },
|
||||
// Station
|
||||
{ path: "station", element: <StationListPage /> },
|
||||
{ path: "station/create", element: <StationCreatePage /> },
|
||||
{ path: "station/:id", element: <StationPreviewPage /> },
|
||||
{ path: "station/:id/edit", element: <StationEditPage /> },
|
||||
// Vehicle
|
||||
// { path: "vehicle", element: <VehicleListPage /> },
|
||||
{ path: "vehicle/create", element: <VehicleCreatePage /> },
|
||||
// { path: "vehicle/:id", element: <VehiclePreviewPage /> },
|
||||
// { path: "vehicle/:id/edit", element: <VehicleEditPage /> },
|
||||
// Article
|
||||
{ path: "article", element: <ArticleListPage /> },
|
||||
{ path: "article/:id", element: <ArticlePreviewPage /> },
|
||||
// { path: "media/create", element: <CreateMediaPage /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
export const Router = () => {
|
||||
return <RouterProvider router={router} />;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,10 @@ export interface NavigationItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
path: string;
|
||||
path?: string;
|
||||
for_admin?: boolean;
|
||||
onClick?: () => void;
|
||||
nestedItems?: NavigationItem[];
|
||||
}
|
||||
|
||||
export type NavigationSection = "primary" | "secondary";
|
||||
|
||||
@@ -3,80 +3,145 @@ import ListItem from "@mui/material/ListItem";
|
||||
import ListItemButton from "@mui/material/ListItemButton";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import Collapse from "@mui/material/Collapse";
|
||||
import List from "@mui/material/List";
|
||||
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import type { NavigationItem } from "../model";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { Plus } from "lucide-react";
|
||||
import { authStore } from "@shared";
|
||||
|
||||
interface NavigationItemProps {
|
||||
item: NavigationItem;
|
||||
open: boolean;
|
||||
onClick?: () => void;
|
||||
isNested?: boolean;
|
||||
onDrawerOpen?: () => void;
|
||||
}
|
||||
|
||||
export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
|
||||
item,
|
||||
open,
|
||||
onClick,
|
||||
isNested = false,
|
||||
onDrawerOpen,
|
||||
}) => {
|
||||
const Icon = item.icon;
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const { payload } = authStore;
|
||||
|
||||
// @ts-ignore
|
||||
const isAdmin = payload?.is_admin || false;
|
||||
|
||||
const isActive = item.path ? location.pathname.startsWith(item.path) : false;
|
||||
|
||||
const filteredNestedItems = item.nestedItems?.filter((nestedItem) => {
|
||||
if (nestedItem.for_admin) {
|
||||
return isAdmin;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleClick = () => {
|
||||
if (item.id === "all" && !open) {
|
||||
onDrawerOpen?.();
|
||||
}
|
||||
if (item.nestedItems) {
|
||||
setIsExpanded(!isExpanded);
|
||||
} else if (onClick) {
|
||||
onClick();
|
||||
} else if (item.path) {
|
||||
navigate(item.path);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
onClick={() => {
|
||||
if (onClick) {
|
||||
onClick();
|
||||
} else {
|
||||
navigate(item.path);
|
||||
}
|
||||
}}
|
||||
disablePadding
|
||||
sx={{ display: "block" }}
|
||||
>
|
||||
<ListItemButton
|
||||
sx={[
|
||||
{
|
||||
minHeight: 48,
|
||||
px: 2.5,
|
||||
},
|
||||
open
|
||||
? {
|
||||
justifyContent: "initial",
|
||||
}
|
||||
: {
|
||||
justifyContent: "center",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ListItemIcon
|
||||
<>
|
||||
<ListItem disablePadding sx={{ display: "block" }}>
|
||||
<ListItemButton
|
||||
onClick={handleClick}
|
||||
sx={[
|
||||
{
|
||||
minWidth: 0,
|
||||
justifyContent: "center",
|
||||
minHeight: 48,
|
||||
px: 2.5,
|
||||
},
|
||||
open
|
||||
? {
|
||||
mr: 3,
|
||||
justifyContent: "initial",
|
||||
}
|
||||
: {
|
||||
mr: "auto",
|
||||
justifyContent: "center",
|
||||
},
|
||||
isNested && {
|
||||
pl: open ? 4 : 2.5,
|
||||
},
|
||||
isActive && {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.08)",
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.12)",
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Icon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
sx={[
|
||||
open
|
||||
? {
|
||||
opacity: 1,
|
||||
}
|
||||
: {
|
||||
opacity: 0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
<ListItemIcon
|
||||
sx={[
|
||||
{
|
||||
minWidth: 0,
|
||||
justifyContent: "center",
|
||||
color: isActive ? "primary.main" : "inherit",
|
||||
},
|
||||
open
|
||||
? {
|
||||
mr: 3,
|
||||
}
|
||||
: {
|
||||
mr: "auto",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{Icon ? <Icon /> : <Plus />}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
sx={[
|
||||
open
|
||||
? {
|
||||
opacity: 1,
|
||||
}
|
||||
: {
|
||||
opacity: 0,
|
||||
},
|
||||
isActive && {
|
||||
color: "primary.main",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{filteredNestedItems &&
|
||||
filteredNestedItems.length > 0 &&
|
||||
open &&
|
||||
(isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />)}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
{filteredNestedItems && filteredNestedItems.length > 0 && (
|
||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
{filteredNestedItems.map((nestedItem) => (
|
||||
<NavigationItemComponent
|
||||
key={nestedItem.id}
|
||||
item={nestedItem}
|
||||
open={open}
|
||||
onClick={nestedItem.onClick}
|
||||
isNested={true}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,34 +1,62 @@
|
||||
import List from "@mui/material/List";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import { NAVIGATION_ITEMS } from "@shared";
|
||||
import { authStore, NAVIGATION_ITEMS } from "@shared";
|
||||
import { NavigationItem, NavigationItemComponent } from "@entities";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
export const NavigationList = ({ open }: { open: boolean }) => {
|
||||
const primaryItems = NAVIGATION_ITEMS.primary;
|
||||
const secondaryItems = NAVIGATION_ITEMS.secondary;
|
||||
interface NavigationListProps {
|
||||
open: boolean;
|
||||
onDrawerOpen?: () => void;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
{primaryItems.map((item) => (
|
||||
<NavigationItemComponent
|
||||
key={item.id}
|
||||
item={item as NavigationItem}
|
||||
open={open}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
{secondaryItems.map((item) => (
|
||||
<NavigationItemComponent
|
||||
key={item.id}
|
||||
item={item as NavigationItem}
|
||||
open={open}
|
||||
onClick={item.onClick ? item.onClick : undefined}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export const NavigationList = observer(
|
||||
({ open, onDrawerOpen }: NavigationListProps) => {
|
||||
const { payload } = authStore;
|
||||
// @ts-ignore
|
||||
const isAdmin = Boolean(payload?.is_admin) || false;
|
||||
|
||||
const primaryItems = NAVIGATION_ITEMS.primary.filter((item) => {
|
||||
if (item.for_admin) {
|
||||
return isAdmin;
|
||||
}
|
||||
|
||||
if (item.nestedItems && item.nestedItems.length > 0) {
|
||||
return item.nestedItems.some((nestedItem) => {
|
||||
if (nestedItem.for_admin) {
|
||||
return isAdmin;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
{primaryItems.map((item) => (
|
||||
<NavigationItemComponent
|
||||
key={item.id}
|
||||
item={item as NavigationItem}
|
||||
open={open}
|
||||
onDrawerOpen={onDrawerOpen}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
{NAVIGATION_ITEMS.secondary.map((item) => (
|
||||
<NavigationItemComponent
|
||||
key={item.id}
|
||||
item={item as NavigationItem}
|
||||
open={open}
|
||||
onClick={item.onClick ? item.onClick : undefined}
|
||||
onDrawerOpen={onDrawerOpen}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -3,3 +3,7 @@
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mde-preview {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./app";
|
||||
|
||||
import "./index.css";
|
||||
import { App } from "./app/index";
|
||||
|
||||
const container = document.getElementById("root");
|
||||
const root = createRoot(container!);
|
||||
|
||||
33
src/pages/Article/ArticleCreatePage/index.tsx
Normal file
33
src/pages/Article/ArticleCreatePage/index.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { articlesStore } from "@shared";
|
||||
|
||||
const ArticleCreatePage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { articleData } = articlesStore;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
|
||||
<h1 className="text-3xl break-words">
|
||||
{articleData?.ru?.heading || "Создание статьи"}
|
||||
</h1>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArticleCreatePage;
|
||||
49
src/pages/Article/ArticleEditPage/index.tsx
Normal file
49
src/pages/Article/ArticleEditPage/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { articlesStore, languageStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
const ArticleEditPage: React.FC = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
|
||||
const { articleData, getArticle } = articlesStore;
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
// Fetch data for all languages
|
||||
getArticle(parseInt(id), "ru");
|
||||
getArticle(parseInt(id), "en");
|
||||
getArticle(parseInt(id), "zh");
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
|
||||
<h1 className="text-3xl break-words">
|
||||
{articleData?.ru?.heading || "Редактирование статьи"}
|
||||
</h1>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ArticleEditPage;
|
||||
154
src/pages/Article/ArticleListPage/index.tsx
Normal file
154
src/pages/Article/ArticleListPage/index.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { articlesStore, languageStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Trash2, Eye, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { DeleteModal, LanguageSwitcher } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const ArticleListPage = observer(() => {
|
||||
const { articleList, getArticleList, deleteArticles } = articlesStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<string | null>(null);
|
||||
const { language } = languageStore;
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchArticles = async () => {
|
||||
setIsLoading(true);
|
||||
await getArticleList();
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchArticles();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "heading",
|
||||
headerName: "Название",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<div className="flex h-full gap-7 items-center">
|
||||
<Minus size={20} className="text-red-500" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
<button onClick={() => navigate(`/article/${params.row.id}`)}>
|
||||
<Eye size={20} className="text-green-500" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const rows = articleList[language].data.map((article) => ({
|
||||
id: article.id,
|
||||
heading: article.heading,
|
||||
body: article.body,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<LanguageSwitcher />
|
||||
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl">Статьи</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as number[]);
|
||||
}}
|
||||
hideFooter
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box
|
||||
sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}
|
||||
>
|
||||
{isLoading ? <CircularProgress size={20} /> : "Нет статей"}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DeleteModal
|
||||
open={isDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
if (rowId) {
|
||||
await deleteArticles([parseInt(rowId)]);
|
||||
getArticleList();
|
||||
}
|
||||
setIsDeleteModalOpen(false);
|
||||
setRowId(null);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setRowId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isBulkDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
await deleteArticles(ids);
|
||||
getArticleList();
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
setIds([]);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
85
src/pages/Article/ArticlePreviewPage/PreviewLeftWidget.tsx
Normal file
85
src/pages/Article/ArticlePreviewPage/PreviewLeftWidget.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Paper, Box, Typography } from "@mui/material";
|
||||
import { MediaViewer, ReactMarkdownComponent } from "@widgets";
|
||||
import { articlesStore, languageStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
export const PreviewLeftWidget = observer(() => {
|
||||
const { articleMedia, articleData } = articlesStore;
|
||||
const { language } = languageStore;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
width: "100%",
|
||||
minWidth: 320,
|
||||
background:
|
||||
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
|
||||
overflowY: "auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderRadius: "10px",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
width: "100%",
|
||||
minHeight: 100,
|
||||
padding: "3px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
"& img": {
|
||||
borderTopLeftRadius: "10px",
|
||||
borderTopRightRadius: "10px",
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
objectFit: "contain",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{articleMedia && <MediaViewer media={articleMedia} fullWidth />}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
background:
|
||||
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
|
||||
color: "white",
|
||||
margin: "5px 0px 5px 0px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1,
|
||||
padding: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h5"
|
||||
component="h2"
|
||||
sx={{
|
||||
wordBreak: "break-word",
|
||||
fontSize: "24px",
|
||||
fontWeight: 700,
|
||||
lineHeight: "120%",
|
||||
}}
|
||||
>
|
||||
{articleData?.[language]?.heading || "Название информации"}
|
||||
</Typography>
|
||||
</Box>
|
||||
{articleData?.[language]?.body && (
|
||||
<Box
|
||||
sx={{
|
||||
padding: 1,
|
||||
maxHeight: "300px",
|
||||
overflowY: "scroll",
|
||||
background:
|
||||
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
<ReactMarkdownComponent value={articleData?.[language]?.body} />
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
139
src/pages/Article/ArticlePreviewPage/PreviewRightWidget.tsx
Normal file
139
src/pages/Article/ArticlePreviewPage/PreviewRightWidget.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Paper, Box, Typography } from "@mui/material";
|
||||
import { MediaViewer, ReactMarkdownComponent } from "@widgets";
|
||||
import { articlesStore, languageStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ImagePlus } from "lucide-react";
|
||||
|
||||
export const PreviewRightWidget = observer(() => {
|
||||
const { articleData, articleMedia } = articlesStore;
|
||||
const { language } = languageStore;
|
||||
const article = articleData?.[language];
|
||||
if (!article) return null;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
className="flex-1 flex flex-col max-w-[500px]"
|
||||
sx={{
|
||||
borderRadius: "10px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
elevation={2}
|
||||
>
|
||||
<Box
|
||||
className="overflow-hidden"
|
||||
sx={{
|
||||
width: "100%",
|
||||
background: "#877361",
|
||||
borderColor: "grey.300",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{articleMedia ? (
|
||||
<Box
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
width: "100%",
|
||||
padding: "2px 2px 0px 2px",
|
||||
"& img": {
|
||||
borderTopLeftRadius: "10px",
|
||||
borderTopRightRadius: "10px",
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
objectFit: "contain",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MediaViewer media={articleMedia} fullWidth fullHeight />
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: 200,
|
||||
flexShrink: 0,
|
||||
backgroundColor: "rgba(0,0,0,0.1)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<ImagePlus size={48} color="white" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 1,
|
||||
wordBreak: "break-word",
|
||||
fontSize: "24px",
|
||||
fontWeight: 700,
|
||||
lineHeight: "120%",
|
||||
backdropFilter: "blur(12px)",
|
||||
borderBottom: "1px solid #A89F90",
|
||||
boxShadow: "inset 4px 4px 12px 0 rgba(255,255,255,0.12)",
|
||||
background:
|
||||
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" color="white">
|
||||
{article.heading || "Выберите статью"}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
padding: 1,
|
||||
minHeight: "200px",
|
||||
maxHeight: "300px",
|
||||
overflowY: "scroll",
|
||||
background:
|
||||
"rgba(179, 165, 152, 0.4), linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 100%)",
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
{article.body ? (
|
||||
<ReactMarkdownComponent value={article.body} />
|
||||
) : (
|
||||
<Typography
|
||||
color="rgba(255,255,255,0.7)"
|
||||
sx={{ textAlign: "center", mt: 4 }}
|
||||
>
|
||||
Предпросмотр статьи появится здесь
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* @ts-ignore */}
|
||||
{articleData?.right && articleData?.right.length > 1 && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "24px",
|
||||
fontWeight: 700,
|
||||
lineHeight: "120%",
|
||||
flexWrap: "wrap",
|
||||
gap: 1,
|
||||
backdropFilter: "blur(12px)",
|
||||
boxShadow: "inset 4px 4px 12px 0 rgba(255,255,255,0.12)",
|
||||
background:
|
||||
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
|
||||
}}
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
{articleData.right.map((a, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className="inline-block text-left text-xs text-white"
|
||||
>
|
||||
{a.heading}
|
||||
</button>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
57
src/pages/Article/ArticlePreviewPage/index.tsx
Normal file
57
src/pages/Article/ArticlePreviewPage/index.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { Box } from "@mui/material";
|
||||
import { PreviewLeftWidget } from "./PreviewLeftWidget";
|
||||
import { PreviewRightWidget } from "./PreviewRightWidget";
|
||||
import { articlesStore, languageStore } from "@shared";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
export const ArticlePreviewPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const { getArticle, getArticleMedia, getArticlePreview } = articlesStore;
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (id) {
|
||||
await getArticle(Number(id), language);
|
||||
await getArticleMedia(Number(id));
|
||||
await getArticlePreview(Number(id));
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [id, language]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-4 mb-10">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 2,
|
||||
p: 2,
|
||||
justifyContent: "center",
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: "320px" }}>
|
||||
<PreviewLeftWidget />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ width: "500px" }}>
|
||||
<PreviewRightWidget />
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
2
src/pages/Article/index.ts
Normal file
2
src/pages/Article/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./ArticleListPage";
|
||||
export * from "./ArticlePreviewPage";
|
||||
268
src/pages/Carrier/CarrierCreatePage/index.tsx
Normal file
268
src/pages/Carrier/CarrierCreatePage/index.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
} 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 {
|
||||
carrierStore,
|
||||
cityStore,
|
||||
mediaStore,
|
||||
languageStore,
|
||||
useSelectedCity,
|
||||
} from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
|
||||
import {
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
} from "@shared";
|
||||
|
||||
export const CarrierCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const { createCarrierData, setCreateCarrierData } = carrierStore;
|
||||
const { language } = languageStore;
|
||||
const { selectedCityId } = useSelectedCity();
|
||||
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||
const [mediaId, setMediaId] = useState("");
|
||||
const [activeMenuType, setActiveMenuType] = useState<
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
cityStore.getCities("ru");
|
||||
mediaStore.getMedia();
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
// Автоматически устанавливаем выбранный город при загрузке страницы
|
||||
useEffect(() => {
|
||||
if (selectedCityId && !createCarrierData.city_id) {
|
||||
setCreateCarrierData(
|
||||
createCarrierData[language].full_name,
|
||||
createCarrierData[language].short_name,
|
||||
selectedCityId,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
);
|
||||
}
|
||||
}, [selectedCityId, createCarrierData.city_id]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await carrierStore.createCarrier();
|
||||
|
||||
toast.success("Перевозчик успешно создан");
|
||||
navigate("/carrier");
|
||||
} catch (error) {
|
||||
toast.error("Ошибка при создании перевозчика");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMediaSelect = (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
setSelectedMediaId(media.id);
|
||||
setCreateCarrierData(
|
||||
createCarrierData[language].full_name,
|
||||
createCarrierData[language].short_name,
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
media.id,
|
||||
language
|
||||
);
|
||||
};
|
||||
|
||||
const selectedMedia = selectedMediaId
|
||||
? mediaStore.media.find((m) => m.id === selectedMediaId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<LanguageSwitcher />
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
|
||||
<h1 className="text-3xl break-words">Создание перевозчика</h1>
|
||||
</div>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Город</InputLabel>
|
||||
<Select
|
||||
value={createCarrierData.city_id || ""}
|
||||
label="Город"
|
||||
required
|
||||
onChange={(e) =>
|
||||
setCreateCarrierData(
|
||||
createCarrierData[language].full_name,
|
||||
createCarrierData[language].short_name,
|
||||
e.target.value as number,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
)
|
||||
}
|
||||
>
|
||||
{cityStore.cities["ru"].data.map((city) => (
|
||||
<MenuItem key={city.id} value={city.id}>
|
||||
{city.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Полное название"
|
||||
value={createCarrierData[language].full_name}
|
||||
required
|
||||
onChange={(e) =>
|
||||
setCreateCarrierData(
|
||||
e.target.value,
|
||||
createCarrierData[language].short_name,
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Короткое название"
|
||||
value={createCarrierData[language].short_name}
|
||||
required
|
||||
onChange={(e) =>
|
||||
setCreateCarrierData(
|
||||
createCarrierData[language].full_name,
|
||||
e.target.value,
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Слоган"
|
||||
value={createCarrierData[language].slogan}
|
||||
onChange={(e) =>
|
||||
setCreateCarrierData(
|
||||
createCarrierData[language].full_name,
|
||||
createCarrierData[language].short_name,
|
||||
createCarrierData.city_id,
|
||||
e.target.value,
|
||||
selectedMediaId || "",
|
||||
language
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||
<ImageUploadCard
|
||||
title="Логотип перевозчика"
|
||||
imageKey="thumbnail"
|
||||
imageUrl={selectedMedia?.id}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(selectedMedia?.id ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setSelectedMediaId(null);
|
||||
setActiveMenuType(null);
|
||||
setCreateCarrierData(
|
||||
createCarrierData[language].full_name,
|
||||
createCarrierData[language].short_name,
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
"",
|
||||
language
|
||||
);
|
||||
}}
|
||||
onSelectFileClick={() => {
|
||||
setActiveMenuType("image");
|
||||
setIsSelectMediaOpen(true);
|
||||
}}
|
||||
setUploadMediaOpen={() => {
|
||||
setIsUploadMediaOpen(true);
|
||||
setActiveMenuType("image");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleCreate}
|
||||
disabled={
|
||||
isLoading ||
|
||||
!createCarrierData[language].full_name ||
|
||||
!createCarrierData[language].short_name ||
|
||||
!createCarrierData.city_id
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Создать"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SelectMediaDialog
|
||||
open={isSelectMediaOpen}
|
||||
onClose={() => setIsSelectMediaOpen(false)}
|
||||
onSelectMedia={handleMediaSelect}
|
||||
mediaType={1}
|
||||
/>
|
||||
|
||||
<UploadMediaDialog
|
||||
open={isUploadMediaOpen}
|
||||
onClose={() => setIsUploadMediaOpen(false)}
|
||||
contextObjectName={createCarrierData[language].full_name}
|
||||
contextType="carrier"
|
||||
afterUpload={handleMediaSelect}
|
||||
hardcodeType={activeMenuType}
|
||||
/>
|
||||
|
||||
<PreviewMediaDialog
|
||||
open={isPreviewMediaOpen}
|
||||
onClose={() => setIsPreviewMediaOpen(false)}
|
||||
mediaId={mediaId}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
288
src/pages/Carrier/CarrierEditPage/index.tsx
Normal file
288
src/pages/Carrier/CarrierEditPage/index.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { carrierStore, cityStore, mediaStore, languageStore } from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets";
|
||||
import {
|
||||
SelectMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
UploadMediaDialog,
|
||||
} from "@shared";
|
||||
|
||||
export const CarrierEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const { getCarrier, setEditCarrierData, editCarrierData } = carrierStore;
|
||||
const { language } = languageStore;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||
const [mediaId, setMediaId] = useState("");
|
||||
const [isDeleteLogoModalOpen, setIsDeleteLogoModalOpen] = useState(false);
|
||||
const [activeMenuType, setActiveMenuType] = useState<
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await cityStore.getCities("ru");
|
||||
await cityStore.getCities("en");
|
||||
await cityStore.getCities("zh");
|
||||
const carrierData = await getCarrier(Number(id));
|
||||
|
||||
if (carrierData) {
|
||||
setEditCarrierData(
|
||||
carrierData.ru?.full_name || "",
|
||||
carrierData.ru?.short_name || "",
|
||||
carrierData.ru?.city_id || 0,
|
||||
carrierData.ru?.slogan || "",
|
||||
carrierData.ru?.logo || "",
|
||||
"ru"
|
||||
);
|
||||
setEditCarrierData(
|
||||
carrierData.en?.full_name || "",
|
||||
carrierData.en?.short_name || "",
|
||||
carrierData.en?.city_id || 0,
|
||||
carrierData.en?.slogan || "",
|
||||
carrierData.en?.logo || "",
|
||||
"en"
|
||||
);
|
||||
setEditCarrierData(
|
||||
carrierData.zh?.full_name || "",
|
||||
carrierData.zh?.short_name || "",
|
||||
carrierData.zh?.city_id || 0,
|
||||
carrierData.zh?.slogan || "",
|
||||
carrierData.zh?.logo || "",
|
||||
"zh"
|
||||
);
|
||||
}
|
||||
|
||||
mediaStore.getMedia();
|
||||
})();
|
||||
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, [id]);
|
||||
|
||||
const handleEdit = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await carrierStore.editCarrier(Number(id));
|
||||
toast.success("Перевозчик успешно обновлен");
|
||||
navigate("/carrier");
|
||||
} catch (error) {
|
||||
toast.error("Ошибка при обновлении перевозчика");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMediaSelect = (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
setEditCarrierData(
|
||||
editCarrierData[language].full_name,
|
||||
editCarrierData[language].short_name,
|
||||
editCarrierData.city_id,
|
||||
editCarrierData[language].slogan,
|
||||
media.id,
|
||||
language
|
||||
);
|
||||
};
|
||||
|
||||
const selectedMedia = editCarrierData.logo
|
||||
? mediaStore.media.find((m) => m.id === editCarrierData.logo)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<LanguageSwitcher />
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
|
||||
<h1 className="text-3xl break-words">{editCarrierData.ru.full_name}</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Город</InputLabel>
|
||||
<Select
|
||||
value={editCarrierData.city_id || ""}
|
||||
label="Город"
|
||||
required
|
||||
onChange={(e) =>
|
||||
setEditCarrierData(
|
||||
editCarrierData[language].full_name,
|
||||
editCarrierData[language].short_name,
|
||||
Number(e.target.value),
|
||||
editCarrierData[language].slogan,
|
||||
editCarrierData.logo,
|
||||
language
|
||||
)
|
||||
}
|
||||
>
|
||||
{cityStore.cities["ru"].data?.map((city) => (
|
||||
<MenuItem key={city.id} value={city.id}>
|
||||
{city.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Полное название"
|
||||
value={editCarrierData[language].full_name}
|
||||
required
|
||||
onChange={(e) =>
|
||||
setEditCarrierData(
|
||||
e.target.value,
|
||||
editCarrierData[language].short_name,
|
||||
editCarrierData.city_id,
|
||||
editCarrierData[language].slogan,
|
||||
editCarrierData.logo,
|
||||
language
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Короткое название"
|
||||
value={editCarrierData[language].short_name}
|
||||
required
|
||||
onChange={(e) =>
|
||||
setEditCarrierData(
|
||||
editCarrierData[language].full_name,
|
||||
e.target.value,
|
||||
editCarrierData.city_id,
|
||||
editCarrierData[language].slogan,
|
||||
editCarrierData.logo,
|
||||
language
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Слоган"
|
||||
value={editCarrierData[language].slogan}
|
||||
onChange={(e) =>
|
||||
setEditCarrierData(
|
||||
editCarrierData[language].full_name,
|
||||
editCarrierData[language].short_name,
|
||||
editCarrierData.city_id,
|
||||
e.target.value,
|
||||
editCarrierData.logo,
|
||||
language
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||
<ImageUploadCard
|
||||
title="Логотип перевозчика"
|
||||
imageKey="thumbnail"
|
||||
imageUrl={selectedMedia?.id}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(selectedMedia?.id ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setIsDeleteLogoModalOpen(true);
|
||||
}}
|
||||
onSelectFileClick={() => {
|
||||
setActiveMenuType("image");
|
||||
setIsSelectMediaOpen(true);
|
||||
}}
|
||||
setUploadMediaOpen={() => {
|
||||
setIsUploadMediaOpen(true);
|
||||
setActiveMenuType("image");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleEdit}
|
||||
disabled={
|
||||
isLoading ||
|
||||
!editCarrierData[language].full_name ||
|
||||
!editCarrierData.city_id
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Сохранить"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SelectMediaDialog
|
||||
open={isSelectMediaOpen}
|
||||
onClose={() => setIsSelectMediaOpen(false)}
|
||||
onSelectMedia={handleMediaSelect}
|
||||
mediaType={1}
|
||||
/>
|
||||
|
||||
<UploadMediaDialog
|
||||
open={isUploadMediaOpen}
|
||||
onClose={() => setIsUploadMediaOpen(false)}
|
||||
contextObjectName={editCarrierData[language].full_name}
|
||||
contextType="carrier"
|
||||
afterUpload={handleMediaSelect}
|
||||
hardcodeType={activeMenuType}
|
||||
/>
|
||||
|
||||
<PreviewMediaDialog
|
||||
open={isPreviewMediaOpen}
|
||||
onClose={() => setIsPreviewMediaOpen(false)}
|
||||
mediaId={mediaId}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isDeleteLogoModalOpen}
|
||||
onDelete={() => {
|
||||
setEditCarrierData(
|
||||
editCarrierData[language].full_name,
|
||||
editCarrierData[language].short_name,
|
||||
editCarrierData.city_id,
|
||||
editCarrierData[language].slogan,
|
||||
"",
|
||||
language
|
||||
);
|
||||
setIsDeleteLogoModalOpen(false);
|
||||
}}
|
||||
onCancel={() => setIsDeleteLogoModalOpen(false)}
|
||||
edit
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
198
src/pages/Carrier/CarrierListPage/index.tsx
Normal file
198
src/pages/Carrier/CarrierListPage/index.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { carrierStore, cityStore, languageStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const CarrierListPage = observer(() => {
|
||||
const { carriers, getCarriers, deleteCarrier } = carrierStore;
|
||||
const { getCities, cities } = cityStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<number | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
await getCities("ru");
|
||||
await getCities("en");
|
||||
await getCities("zh");
|
||||
await getCarriers(language);
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchData();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "full_name",
|
||||
headerName: "Полное имя",
|
||||
width: 300,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "short_name",
|
||||
headerName: "Короткое имя",
|
||||
width: 200,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "city_id",
|
||||
headerName: "Город",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
const city = cities[language]?.data.find(
|
||||
(city) => city.id == params.value
|
||||
);
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{city && city.name ? (
|
||||
city.name
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
headerAlign: "center",
|
||||
width: 200,
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
<button onClick={() => navigate(`/carrier/${params.row.id}/edit`)}>
|
||||
<Pencil size={20} className="text-blue-500" />
|
||||
</button>
|
||||
{/* <button onClick={() => navigate(`/carrier/${params.row.id}`)}>
|
||||
<Eye size={20} className="text-green-500" />
|
||||
</button> */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const rows = carriers[language].data?.map((carrier) => ({
|
||||
id: carrier.id,
|
||||
full_name: carrier.full_name,
|
||||
short_name: carrier.short_name,
|
||||
city_id: carrier.city_id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<LanguageSwitcher />
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl">Перевозчики</h1>
|
||||
<CreateButton label="Создать перевозчика" path="/carrier/create" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooter
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||
}}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
"Нет перевозчиков"
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DeleteModal
|
||||
open={isDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
if (rowId) {
|
||||
await deleteCarrier(rowId);
|
||||
}
|
||||
setIsDeleteModalOpen(false);
|
||||
setRowId(null);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setRowId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isBulkDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
await Promise.all(ids.map((id) => deleteCarrier(id)));
|
||||
await getCarriers(language);
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
setIds([]);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
4
src/pages/Carrier/index.ts
Normal file
4
src/pages/Carrier/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./CarrierListPage";
|
||||
|
||||
export * from "./CarrierCreatePage";
|
||||
export * from "./CarrierEditPage";
|
||||
206
src/pages/City/CityCreatePage/index.tsx
Normal file
206
src/pages/City/CityCreatePage/index.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
} 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 { cityStore, countryStore, languageStore, mediaStore } from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
||||
import {
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
} from "@shared";
|
||||
|
||||
export const CityCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const { language } = languageStore;
|
||||
const { createCityData, setCreateCityData } = cityStore;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||
const [mediaId, setMediaId] = useState("");
|
||||
const [activeMenuType, setActiveMenuType] = useState<
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||
>(null);
|
||||
const { getCountries } = countryStore;
|
||||
const { getMedia } = mediaStore;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await getCountries("ru");
|
||||
await getCountries("en");
|
||||
await getCountries("zh");
|
||||
await getMedia();
|
||||
})();
|
||||
}, [language]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await cityStore.createCity();
|
||||
toast.success("Город успешно создан");
|
||||
navigate("/city");
|
||||
} catch (error) {
|
||||
toast.error("Ошибка при создании города");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMediaSelect = (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
setCreateCityData(
|
||||
createCityData[language].name,
|
||||
createCityData.country_code,
|
||||
media.id,
|
||||
language
|
||||
);
|
||||
};
|
||||
|
||||
const selectedMedia = createCityData.arms
|
||||
? mediaStore.media.find((m) => m.id === createCityData.arms)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<LanguageSwitcher />
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
|
||||
<h1 className="text-3xl break-words">{createCityData.ru.name}</h1>
|
||||
</div>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Название города"
|
||||
value={createCityData[language]?.name || ""}
|
||||
required
|
||||
onChange={(e) =>
|
||||
setCreateCityData(
|
||||
e.target.value,
|
||||
createCityData.country_code,
|
||||
createCityData.arms,
|
||||
language
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Страна</InputLabel>
|
||||
<Select
|
||||
value={createCityData.country_code || ""}
|
||||
label="Страна"
|
||||
required
|
||||
onChange={(e) => {
|
||||
setCreateCityData(
|
||||
createCityData[language].name,
|
||||
e.target.value,
|
||||
createCityData.arms,
|
||||
language
|
||||
);
|
||||
}}
|
||||
>
|
||||
{countryStore.countries["ru"]?.data?.map((country) => (
|
||||
<MenuItem key={country.code} value={country.code}>
|
||||
{country.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||
<ImageUploadCard
|
||||
title="Герб города"
|
||||
imageKey="image"
|
||||
imageUrl={selectedMedia?.id}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(selectedMedia?.id ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setCreateCityData(
|
||||
createCityData[language].name,
|
||||
createCityData.country_code,
|
||||
"",
|
||||
language
|
||||
);
|
||||
setActiveMenuType(null);
|
||||
}}
|
||||
onSelectFileClick={() => {
|
||||
setActiveMenuType("image");
|
||||
setIsSelectMediaOpen(true);
|
||||
}}
|
||||
setUploadMediaOpen={() => {
|
||||
setIsUploadMediaOpen(true);
|
||||
setActiveMenuType("image");
|
||||
}}
|
||||
setHardcodeType={() => {
|
||||
setActiveMenuType("image");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleCreate}
|
||||
disabled={isLoading || !createCityData[language]?.name}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Создать"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SelectMediaDialog
|
||||
open={isSelectMediaOpen}
|
||||
onClose={() => setIsSelectMediaOpen(false)}
|
||||
onSelectMedia={handleMediaSelect}
|
||||
mediaType={1} // Тип медиа для иконок
|
||||
/>
|
||||
|
||||
<UploadMediaDialog
|
||||
open={isUploadMediaOpen}
|
||||
onClose={() => setIsUploadMediaOpen(false)}
|
||||
contextObjectName={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>
|
||||
);
|
||||
});
|
||||
236
src/pages/City/CityEditPage/index.tsx
Normal file
236
src/pages/City/CityEditPage/index.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import {
|
||||
cityStore,
|
||||
countryStore,
|
||||
languageStore,
|
||||
mediaStore,
|
||||
CashedCities,
|
||||
} from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
||||
import {
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
} from "@shared";
|
||||
|
||||
export const CityEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||||
const [mediaId, setMediaId] = useState("");
|
||||
const [activeMenuType, setActiveMenuType] = useState<
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||
>(null);
|
||||
const { language } = languageStore;
|
||||
const { id } = useParams();
|
||||
const { editCityData, editCity, getCity, setEditCityData } = cityStore;
|
||||
const { getCountries } = countryStore;
|
||||
const { getMedia, getOneMedia } = mediaStore;
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
const handleEdit = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await editCity(id as string);
|
||||
toast.success("Город успешно обновлен");
|
||||
} catch (error) {
|
||||
toast.error("Ошибка при обновлении города");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (id) {
|
||||
await getCountries("ru");
|
||||
// Fetch data for all languages
|
||||
const ruData = await getCity(id as string, "ru");
|
||||
const enData = await getCity(id as string, "en");
|
||||
const zhData = await getCity(id as string, "zh");
|
||||
|
||||
// Set data for each language
|
||||
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
|
||||
setEditCityData(enData.name, enData.country_code, enData.arms, "en");
|
||||
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
|
||||
|
||||
await getOneMedia(ruData.arms as string);
|
||||
|
||||
await getMedia();
|
||||
}
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
const handleMediaSelect = (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
setEditCityData(
|
||||
editCityData[language].name,
|
||||
editCityData.country_code,
|
||||
media.id,
|
||||
language
|
||||
);
|
||||
};
|
||||
|
||||
const selectedMedia = editCityData.arms
|
||||
? mediaStore.media.find((m) => m.id === editCityData.arms)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<LanguageSwitcher />
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start ">
|
||||
<h1 className="text-3xl break-words">{editCityData.ru.name}</h1>
|
||||
</div>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Название"
|
||||
value={editCityData[language].name}
|
||||
required
|
||||
onChange={(e) =>
|
||||
setEditCityData(
|
||||
e.target.value,
|
||||
editCityData.country_code,
|
||||
editCityData.arms,
|
||||
language
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Страна</InputLabel>
|
||||
<Select
|
||||
value={editCityData.country_code || ""}
|
||||
label="Страна"
|
||||
required
|
||||
onChange={(e) => {
|
||||
setEditCityData(
|
||||
editCityData[language].name,
|
||||
e.target.value,
|
||||
editCityData.arms,
|
||||
language
|
||||
);
|
||||
}}
|
||||
>
|
||||
{countryStore.countries.ru.data.map((country) => (
|
||||
<MenuItem key={country.code} value={country.code}>
|
||||
{country.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||
<ImageUploadCard
|
||||
title="Герб города"
|
||||
imageKey="image"
|
||||
imageUrl={selectedMedia?.id}
|
||||
onImageClick={() => {
|
||||
setIsPreviewMediaOpen(true);
|
||||
setMediaId(selectedMedia?.id ?? "");
|
||||
}}
|
||||
onDeleteImageClick={() => {
|
||||
setEditCityData(
|
||||
editCityData[language].name,
|
||||
editCityData.country_code,
|
||||
"",
|
||||
language
|
||||
);
|
||||
setActiveMenuType(null);
|
||||
}}
|
||||
onSelectFileClick={() => {
|
||||
setActiveMenuType("image");
|
||||
setIsSelectMediaOpen(true);
|
||||
}}
|
||||
setUploadMediaOpen={() => {
|
||||
setIsUploadMediaOpen(true);
|
||||
setActiveMenuType("image");
|
||||
}}
|
||||
setHardcodeType={() => {
|
||||
setActiveMenuType("image");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleEdit}
|
||||
disabled={
|
||||
isLoading || !editCityData[language as keyof CashedCities]?.name
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Сохранить"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SelectMediaDialog
|
||||
open={isSelectMediaOpen}
|
||||
onClose={() => setIsSelectMediaOpen(false)}
|
||||
onSelectMedia={handleMediaSelect}
|
||||
mediaType={1} // Тип медиа для иконок
|
||||
/>
|
||||
|
||||
<UploadMediaDialog
|
||||
open={isUploadMediaOpen}
|
||||
onClose={() => setIsUploadMediaOpen(false)}
|
||||
contextObjectName={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>
|
||||
);
|
||||
});
|
||||
195
src/pages/City/CityListPage/index.tsx
Normal file
195
src/pages/City/CityListPage/index.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { languageStore, cityStore, countryStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||
import { toast } from "react-toastify";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const CityListPage = observer(() => {
|
||||
const { cities, getCities, deleteCity } = cityStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<number | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
await countryStore.getCountries("ru");
|
||||
await countryStore.getCountries("en");
|
||||
await countryStore.getCountries("zh");
|
||||
await getCities(language);
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchData();
|
||||
}, [language]);
|
||||
|
||||
useEffect(() => {
|
||||
let newRows = cities[language]?.data?.map((city) => ({
|
||||
id: city.id,
|
||||
name: city.name,
|
||||
country: city.country_code,
|
||||
}));
|
||||
|
||||
let newRows2: any[] = [];
|
||||
for (const city of newRows) {
|
||||
const name = countryStore.countries[language]?.data?.find(
|
||||
(country) => country.code === city.country
|
||||
)?.name;
|
||||
if (name) {
|
||||
newRows2.push(city);
|
||||
}
|
||||
}
|
||||
|
||||
setRows(newRows2 || []);
|
||||
}, [cities, countryStore.countries, language, isLoading]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "country",
|
||||
headerName: "Страна",
|
||||
width: 150,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
countryStore.countries[language]?.data?.find(
|
||||
(country) => country.code === params.value
|
||||
)?.name
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "name",
|
||||
headerName: "Название",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
width: 200,
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
<button onClick={() => navigate(`/city/${params.row.id}/edit`)}>
|
||||
<Pencil size={20} className="text-blue-500" />
|
||||
</button>
|
||||
{/* <button onClick={() => navigate(`/city/${params.row.id}`)}>
|
||||
<Eye size={20} className="text-green-500" />
|
||||
</button> */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<LanguageSwitcher />
|
||||
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl">Города</h1>
|
||||
<CreateButton label="Создать город" path="/city/create" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooter
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||
}}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? <CircularProgress size={20} /> : "Нет городов"}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DeleteModal
|
||||
open={isDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
if (rowId) {
|
||||
await deleteCity(rowId.toString());
|
||||
toast.success("Город успешно удален");
|
||||
}
|
||||
setIsDeleteModalOpen(false);
|
||||
setRowId(null);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
69
src/pages/City/CityPreviewPage/index.tsx
Normal file
69
src/pages/City/CityPreviewPage/index.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Paper } from "@mui/material";
|
||||
import { cityStore, languageStore, mediaStore } from "@shared";
|
||||
import { LanguageSwitcher, 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 CityPreviewPage = observer(() => {
|
||||
const { id } = useParams();
|
||||
const { getCity, city, setEditCityData } = cityStore;
|
||||
const { oneMedia, getOneMedia } = mediaStore;
|
||||
const navigate = useNavigate();
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (id) {
|
||||
const ruData = await getCity(id as string, "ru");
|
||||
const enData = await getCity(id as string, "en");
|
||||
const zhData = await getCity(id as string, "zh");
|
||||
|
||||
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
|
||||
setEditCityData(enData.name, enData.country_code, enData.arms, "en");
|
||||
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
|
||||
|
||||
await getOneMedia(ruData.arms as string);
|
||||
}
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<LanguageSwitcher />
|
||||
<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>{city[id!]?.[language]?.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-lg font-bold">Страна</h1>
|
||||
<p>{city[id!]?.[language]?.country}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 pb-10">
|
||||
<h1 className="text-lg font-bold">Герб</h1>
|
||||
<div className="w-[300px] h-[200px]">
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: oneMedia?.id as string,
|
||||
media_type: oneMedia?.media_type as number,
|
||||
filename: oneMedia?.filename,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
4
src/pages/City/index.ts
Normal file
4
src/pages/City/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./CityListPage";
|
||||
export * from "./CityPreviewPage";
|
||||
export * from "./CityCreatePage";
|
||||
export * from "./CityEditPage";
|
||||
115
src/pages/Country/CountryAddPage/index.tsx
Normal file
115
src/pages/Country/CountryAddPage/index.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Autocomplete,
|
||||
FormControl,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import {
|
||||
countryStore,
|
||||
RU_COUNTRIES,
|
||||
EN_COUNTRIES,
|
||||
ZH_COUNTRIES,
|
||||
} from "@shared";
|
||||
import { useState } from "react";
|
||||
|
||||
export const CountryAddPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { createCountryData, setCountryData, createCountry } = countryStore;
|
||||
|
||||
const handleCountryCodeChange = (code: string) => {
|
||||
const ruCountry = RU_COUNTRIES.find((c) => c.code === code);
|
||||
const enCountry = EN_COUNTRIES.find((c) => c.code === code);
|
||||
const zhCountry = ZH_COUNTRIES.find((c) => c.code === code);
|
||||
|
||||
if (ruCountry && enCountry && zhCountry) {
|
||||
setCountryData(code, ruCountry.name, "ru");
|
||||
setCountryData(code, enCountry.name, "en");
|
||||
setCountryData(code, zhCountry.name, "zh");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await createCountry();
|
||||
toast.success("Страна успешно создана");
|
||||
navigate("/country");
|
||||
} catch (error) {
|
||||
toast.error("Ошибка при создании страны");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<FormControl fullWidth>
|
||||
<Autocomplete
|
||||
value={
|
||||
RU_COUNTRIES.find((c) => c.code === createCountryData.code) ||
|
||||
null
|
||||
}
|
||||
onChange={(_, newValue) => {
|
||||
if (newValue) {
|
||||
handleCountryCodeChange(newValue.code);
|
||||
}
|
||||
}}
|
||||
options={RU_COUNTRIES}
|
||||
getOptionLabel={(option) => `${option.code} - ${option.name}`}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Страна"
|
||||
required
|
||||
inputProps={{
|
||||
...params.inputProps,
|
||||
maxLength: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
const searchValue = inputValue.toUpperCase();
|
||||
return options.filter(
|
||||
(option) =>
|
||||
option.code.includes(searchValue) ||
|
||||
option.name.toLowerCase().includes(inputValue.toLowerCase())
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleCreate}
|
||||
disabled={isLoading || !createCountryData.code}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Создать"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
86
src/pages/Country/CountryCreatePage/index.tsx
Normal file
86
src/pages/Country/CountryCreatePage/index.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Button, Paper, TextField } 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, languageStore } from "@shared";
|
||||
import { useState } from "react";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
|
||||
export const CountryCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
const { createCountryData, setCountryData, createCountry } = countryStore;
|
||||
|
||||
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">
|
||||
<LanguageSwitcher />
|
||||
<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">
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
|
||||
<h1 className="text-3xl break-words">{createCountryData.ru.name}</h1>
|
||||
</div>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Код страны"
|
||||
value={createCountryData.code}
|
||||
required
|
||||
onChange={(e) =>
|
||||
setCountryData(
|
||||
e.target.value,
|
||||
createCountryData[language].name,
|
||||
language
|
||||
)
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Название"
|
||||
value={createCountryData[language].name}
|
||||
required
|
||||
onChange={(e) =>
|
||||
setCountryData(createCountryData.code, e.target.value, language)
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleCreate}
|
||||
disabled={isLoading || !createCountryData[language].name}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Создать"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
102
src/pages/Country/CountryEditPage/index.tsx
Normal file
102
src/pages/Country/CountryEditPage/index.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Button, Paper, TextField } from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { countryStore, languageStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
|
||||
export const CountryEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
const { id } = useParams();
|
||||
const { editCountryData, editCountry, getCountry, setEditCountryData } =
|
||||
countryStore;
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
const handleEdit = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await editCountry(id as string);
|
||||
toast.success("Страна успешно обновлена");
|
||||
} catch (error) {
|
||||
toast.error("Ошибка при обновлении страны");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (id) {
|
||||
// Fetch data for all languages
|
||||
const ruData = await getCountry(id as string, "ru");
|
||||
const enData = await getCountry(id as string, "en");
|
||||
const zhData = await getCountry(id as string, "zh");
|
||||
|
||||
// Set data for each language
|
||||
setEditCountryData(ruData.name, "ru");
|
||||
setEditCountryData(enData.name, "en");
|
||||
setEditCountryData(zhData.name, "zh");
|
||||
}
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<LanguageSwitcher />
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
|
||||
<h1 className="text-3xl break-words t">{editCountryData.ru.name}</h1>
|
||||
</div>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Код страны"
|
||||
value={id as string}
|
||||
required
|
||||
disabled
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Название"
|
||||
value={editCountryData[language].name}
|
||||
required
|
||||
onChange={(e) =>
|
||||
countryStore.setEditCountryData(e.target.value, language)
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleEdit}
|
||||
disabled={isLoading || !editCountryData[language].name}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Сохранить"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
157
src/pages/Country/CountryListPage/index.tsx
Normal file
157
src/pages/Country/CountryListPage/index.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { countryStore, languageStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Trash2, Minus } from "lucide-react";
|
||||
|
||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const CountryListPage = observer(() => {
|
||||
const { countries, getCountries, deleteCountry } = countryStore;
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<string | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCountries = async () => {
|
||||
setIsLoading(true);
|
||||
await getCountries(language);
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchCountries();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "name",
|
||||
headerName: "Название",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center ">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
width: 200,
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
{/* <button
|
||||
onClick={() => navigate(`/country/${params.row.code}/edit`)}
|
||||
>
|
||||
<Pencil size={20} className="text-blue-500" />
|
||||
</button> */}
|
||||
{/* <button onClick={() => navigate(`/country/${params.row.code}`)}>
|
||||
<Eye size={20} className="text-green-500" />
|
||||
</button> */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.code);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const rows = countries[language]?.data.map((country) => ({
|
||||
id: country.code,
|
||||
code: country.code,
|
||||
name: country.name,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<LanguageSwitcher />
|
||||
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl">Страны</h1>
|
||||
<CreateButton label="Добавить страну" path="/country/add" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
rows={rows || []}
|
||||
columns={columns}
|
||||
hideFooter
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||
}}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? <CircularProgress size={20} /> : "Нет стран"}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DeleteModal
|
||||
open={isDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
if (!rowId) return;
|
||||
await deleteCountry(rowId);
|
||||
setRowId(null);
|
||||
setIsDeleteModalOpen(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
69
src/pages/Country/CountryPreviewPage/index.tsx
Normal file
69
src/pages/Country/CountryPreviewPage/index.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Paper } from "@mui/material";
|
||||
import { countryStore, languageStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
|
||||
export const CountryPreviewPage = observer(() => {
|
||||
const { id } = useParams();
|
||||
const { getCountry, country, setEditCountryData } = countryStore;
|
||||
const navigate = useNavigate();
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (id) {
|
||||
const ruData = await getCountry(id as string, "ru");
|
||||
const enData = await getCountry(id as string, "en");
|
||||
const zhData = await getCountry(id as string, "zh");
|
||||
|
||||
setEditCountryData(ruData.name, "ru");
|
||||
setEditCountryData(enData.name, "en");
|
||||
setEditCountryData(zhData.name, "zh");
|
||||
}
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<LanguageSwitcher />
|
||||
<div className="flex justify-between items-center">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
{/* <div className="flex gap-2">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => navigate(`/user/${id}/edit`)}
|
||||
startIcon={<Pencil size={20} />}
|
||||
>
|
||||
Редактировать
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={() => navigate(`/user/${id}/delete`)}
|
||||
startIcon={<Trash2 size={20} />}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div> */}
|
||||
</div>
|
||||
{country[id!]?.[language] && (
|
||||
<div className="flex flex-col gap-10 w-full">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-lg font-bold">Название</h1>
|
||||
<p>{country[id!]?.ru?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
5
src/pages/Country/index.ts
Normal file
5
src/pages/Country/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./CountryListPage";
|
||||
export * from "./CountryPreviewPage";
|
||||
export * from "./CountryCreatePage";
|
||||
export * from "./CountryEditPage";
|
||||
export * from "./CountryAddPage";
|
||||
@@ -1,126 +0,0 @@
|
||||
// import { Button, Paper, Typography, Box, Alert, Snackbar } from "@mui/material";
|
||||
// import { useNavigate } from "react-router-dom";
|
||||
// import { ArrowLeft, Upload } from "lucide-react";
|
||||
// import { observer } from "mobx-react-lite";
|
||||
// import { useState, DragEvent, useRef, useEffect } from "react";
|
||||
// import { editSightStore, UploadMediaDialog } from "@shared";
|
||||
|
||||
// export const CreateMediaPage = observer(() => {
|
||||
// const navigate = useNavigate();
|
||||
// const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// const [error, setError] = useState<string | null>(null);
|
||||
// const [success, setSuccess] = useState(false);
|
||||
// const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
// const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
|
||||
|
||||
// const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
// e.preventDefault();
|
||||
// e.stopPropagation();
|
||||
// setIsDragging(false);
|
||||
|
||||
// const files = Array.from(e.dataTransfer.files);
|
||||
// if (files.length > 0) {
|
||||
// editSightStore.fileToUpload = files[0];
|
||||
// setUploadDialogOpen(true);
|
||||
// }
|
||||
// };
|
||||
|
||||
// const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
// e.preventDefault();
|
||||
// setIsDragging(true);
|
||||
// };
|
||||
|
||||
// const handleDragLeave = () => {
|
||||
// setIsDragging(false);
|
||||
// };
|
||||
|
||||
// const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// const files = e.target.files;
|
||||
// if (files && files.length > 0) {
|
||||
// editSightStore.fileToUpload = files[0];
|
||||
// setUploadDialogOpen(true);
|
||||
// }
|
||||
// };
|
||||
|
||||
// const handleUploadSuccess = () => {
|
||||
// setSuccess(true);
|
||||
// setUploadDialogOpen(false);
|
||||
// };
|
||||
|
||||
// return (
|
||||
// <div className="w-full h-full p-8">
|
||||
// <div className="flex items-center gap-4 mb-8">
|
||||
// <Button
|
||||
// variant="outlined"
|
||||
// startIcon={<ArrowLeft size={20} />}
|
||||
// onClick={() => navigate("/media")}
|
||||
// >
|
||||
// Назад
|
||||
// </Button>
|
||||
// <Typography variant="h5">Загрузка медиафайла</Typography>
|
||||
// </div>
|
||||
|
||||
// <Paper
|
||||
// elevation={3}
|
||||
// className={`w-full h-[60vh] flex flex-col items-center justify-center p-8 transition-colors ${
|
||||
// isDragging ? "bg-blue-50 border-2 border-blue-500" : "bg-gray-50"
|
||||
// }`}
|
||||
// onDrop={handleDrop}
|
||||
// onDragOver={handleDragOver}
|
||||
// onDragLeave={handleDragLeave}
|
||||
// >
|
||||
// <input
|
||||
// type="file"
|
||||
// ref={fileInputRef}
|
||||
// className="hidden"
|
||||
// onChange={handleFileSelect}
|
||||
// accept="image/*,video/*,.glb,.gltf"
|
||||
// />
|
||||
|
||||
// <Box className="flex flex-col items-center gap-4 text-center">
|
||||
// <Upload size={48} className="text-gray-400" />
|
||||
// <Typography variant="h6" className="text-gray-600">
|
||||
// Перетащите файл сюда или
|
||||
// </Typography>
|
||||
// <Button
|
||||
// variant="contained"
|
||||
// onClick={() => fileInputRef.current?.click()}
|
||||
// startIcon={<Upload size={20} />}
|
||||
// >
|
||||
// Выберите файл
|
||||
// </Button>
|
||||
// <Typography variant="body2" className="text-gray-500 mt-4">
|
||||
// Поддерживаемые форматы: JPG, PNG, GIF, MP4, WebM, GLB, GLTF
|
||||
// </Typography>
|
||||
// </Box>
|
||||
// </Paper>
|
||||
|
||||
// <UploadMediaDialog
|
||||
// open={uploadDialogOpen}
|
||||
// onClose={() => setUploadDialogOpen(false)}
|
||||
// afterUpload={handleUploadSuccess}
|
||||
// />
|
||||
|
||||
// <Snackbar
|
||||
// open={success}
|
||||
// autoHideDuration={2000}
|
||||
// onClose={() => setSuccess(false)}
|
||||
// >
|
||||
// <Alert severity="success" onClose={() => setSuccess(false)}>
|
||||
// Медиафайл успешно загружен
|
||||
// </Alert>
|
||||
// </Snackbar>
|
||||
|
||||
// <Snackbar
|
||||
// open={!!error}
|
||||
// autoHideDuration={6000}
|
||||
// onClose={() => setError(null)}
|
||||
// >
|
||||
// <Alert severity="error" onClose={() => setError(null)}>
|
||||
// {error}
|
||||
// </Alert>
|
||||
// </Snackbar>
|
||||
// </div>
|
||||
// );
|
||||
// });
|
||||
@@ -1,6 +1,16 @@
|
||||
import { Box, Tab, Tabs } from "@mui/material";
|
||||
import { articlesStore, cityStore, languageStore } from "@shared";
|
||||
import { CreateInformationTab, CreateLeftTab, CreateRightTab } from "@widgets";
|
||||
import {
|
||||
articlesStore,
|
||||
cityStore,
|
||||
createSightStore,
|
||||
languageStore,
|
||||
} from "@shared";
|
||||
import {
|
||||
CreateInformationTab,
|
||||
CreateLeftTab,
|
||||
CreateRightTab,
|
||||
LeaveAgree,
|
||||
} from "@widgets";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
@@ -11,17 +21,26 @@ function a11yProps(index: number) {
|
||||
};
|
||||
}
|
||||
|
||||
import { useBlocker } from "react-router";
|
||||
|
||||
export const CreateSightPage = observer(() => {
|
||||
const [value, setValue] = useState(0);
|
||||
const { getCities } = cityStore;
|
||||
const { getArticles } = articlesStore;
|
||||
const { needLeaveAgree } = createSightStore;
|
||||
|
||||
const handleChange = (_: React.SyntheticEvent, newValue: number) => {
|
||||
setValue(newValue);
|
||||
};
|
||||
|
||||
let blocker = useBlocker(
|
||||
({ currentLocation, nextLocation }) =>
|
||||
needLeaveAgree && currentLocation.pathname !== nextLocation.pathname
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
await getCities();
|
||||
await getCities("ru");
|
||||
await getArticles(languageStore.language);
|
||||
};
|
||||
fetchData();
|
||||
@@ -34,6 +53,7 @@ export const CreateSightPage = observer(() => {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: "100vh",
|
||||
z: 10,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
@@ -66,6 +86,8 @@ export const CreateSightPage = observer(() => {
|
||||
<CreateLeftTab value={value} index={1} />
|
||||
<CreateRightTab value={value} index={2} />
|
||||
</div>
|
||||
|
||||
{blocker.state === "blocked" ? <LeaveAgree blocker={blocker} /> : null}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { Box, Tab, Tabs } from "@mui/material";
|
||||
import { InformationTab, RightWidgetTab } from "@widgets";
|
||||
import { InformationTab, LeaveAgree, RightWidgetTab } from "@widgets";
|
||||
import { LeftWidgetTab } from "@widgets";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import {
|
||||
articlesStore,
|
||||
cityStore,
|
||||
editSightStore,
|
||||
languageStore,
|
||||
} from "@shared";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { articlesStore, cityStore, editSightStore } from "@shared";
|
||||
import { useBlocker, useParams } from "react-router-dom";
|
||||
|
||||
function a11yProps(index: number) {
|
||||
return {
|
||||
@@ -20,12 +15,17 @@ function a11yProps(index: number) {
|
||||
|
||||
export const EditSightPage = observer(() => {
|
||||
const [value, setValue] = useState(0);
|
||||
const { sight, getSightInfo } = editSightStore;
|
||||
const { sight, getSightInfo, needLeaveAgree } = editSightStore;
|
||||
const { getArticles } = articlesStore;
|
||||
const { language } = languageStore;
|
||||
|
||||
const { id } = useParams();
|
||||
const { getCities } = cityStore;
|
||||
|
||||
let blocker = useBlocker(
|
||||
({ currentLocation, nextLocation }) =>
|
||||
needLeaveAgree && currentLocation.pathname !== nextLocation.pathname
|
||||
);
|
||||
|
||||
const handleChange = (_: React.SyntheticEvent, newValue: number) => {
|
||||
setValue(newValue);
|
||||
};
|
||||
@@ -33,13 +33,17 @@ export const EditSightPage = observer(() => {
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (id) {
|
||||
await getSightInfo(+id, language);
|
||||
await getArticles(language);
|
||||
await getCities();
|
||||
await getCities("ru");
|
||||
await getSightInfo(+id, "ru");
|
||||
await getSightInfo(+id, "en");
|
||||
await getSightInfo(+id, "zh");
|
||||
await getArticles("ru");
|
||||
await getArticles("en");
|
||||
await getArticles("zh");
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [id, language]);
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -82,6 +86,8 @@ export const EditSightPage = observer(() => {
|
||||
<RightWidgetTab value={value} index={2} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{blocker.state === "blocked" ? <LeaveAgree blocker={blocker} /> : null}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,9 +5,12 @@ import {
|
||||
Typography,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Paper,
|
||||
} from "@mui/material";
|
||||
import { authStore } from "@shared";
|
||||
import { useState } from "react";
|
||||
import { authStore, userStore } from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
@@ -15,9 +18,21 @@ export const LoginPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { login } = authStore;
|
||||
const { getUsers } = userStore;
|
||||
useEffect(() => {
|
||||
// Load saved credentials if they exist
|
||||
const savedEmail = localStorage.getItem("rememberedEmail");
|
||||
const savedPassword = localStorage.getItem("rememberedPassword");
|
||||
if (savedEmail && savedPassword) {
|
||||
setEmail(savedEmail);
|
||||
setPassword(savedPassword);
|
||||
setRememberMe(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -26,7 +41,23 @@ export const LoginPage = () => {
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate("/sight");
|
||||
|
||||
// Save or clear credentials based on remember me checkbox
|
||||
if (rememberMe) {
|
||||
localStorage.setItem("rememberedEmail", email);
|
||||
localStorage.setItem("rememberedPassword", password);
|
||||
} else {
|
||||
localStorage.removeItem("rememberedEmail");
|
||||
localStorage.removeItem("rememberedPassword");
|
||||
}
|
||||
|
||||
navigate("/map");
|
||||
try {
|
||||
await getUsers();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
toast.success("Вход в систему выполнен успешно");
|
||||
} catch (err) {
|
||||
setError(
|
||||
@@ -47,73 +78,102 @@ export const LoginPage = () => {
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
gap: 3,
|
||||
p: 3,
|
||||
backgroundImage: "url('/login-bg.png')",
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Вход в систему
|
||||
</Typography>
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={handleSubmit}
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
p: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: "white",
|
||||
width: "100%",
|
||||
maxWidth: "400px",
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<TextField
|
||||
label="Email"
|
||||
type="email"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
required
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
error={!!error}
|
||||
/>
|
||||
<TextField
|
||||
label="Пароль"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
required
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
error={!!error}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="h1"
|
||||
className="text-center pb-[50px]"
|
||||
>
|
||||
Вход в систему
|
||||
</Typography>
|
||||
<Box
|
||||
component="form"
|
||||
onSubmit={handleSubmit}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "50px",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "10px",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{isLoading ? <CircularProgress size={24} sx={{}} /> : "Войти"}
|
||||
</Button>
|
||||
</Box>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<TextField
|
||||
label="Email"
|
||||
type="email"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
required
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
error={!!error}
|
||||
/>
|
||||
<TextField
|
||||
label="Пароль"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
required
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
error={!!error}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
}
|
||||
label="Запомнить пароль"
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "50px",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "10px",
|
||||
}}
|
||||
>
|
||||
{isLoading ? <CircularProgress size={24} sx={{}} /> : "Войти"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
252
src/pages/MapPage/mapStore.ts
Normal file
252
src/pages/MapPage/mapStore.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { languageInstance } from "@shared";
|
||||
import { makeAutoObservable } from "mobx";
|
||||
|
||||
interface ApiRoute {
|
||||
id: number;
|
||||
route_number: string;
|
||||
path: [number, number][];
|
||||
}
|
||||
|
||||
interface ApiStation {
|
||||
id: number;
|
||||
name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
interface ApiSight {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
// Допуск для сравнения координат, чтобы избежать ошибок с точностью чисел.
|
||||
const COORDINATE_PRECISION_TOLERANCE = 1e-9;
|
||||
|
||||
// Вспомогательная функция, обновленная для сравнения с допуском.
|
||||
const arePathsEqual = (
|
||||
path1: [number, number][],
|
||||
path2: [number, number][]
|
||||
): boolean => {
|
||||
if (path1.length !== path2.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < path1.length; i++) {
|
||||
if (
|
||||
Math.abs(path1[i][0] - path2[i][0]) > COORDINATE_PRECISION_TOLERANCE ||
|
||||
Math.abs(path1[i][1] - path2[i][1]) > COORDINATE_PRECISION_TOLERANCE
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
class MapStore {
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
routes: ApiRoute[] = [];
|
||||
stations: ApiStation[] = [];
|
||||
sights: ApiSight[] = [];
|
||||
|
||||
getRoutes = async () => {
|
||||
const response = await languageInstance("ru").get("/route");
|
||||
|
||||
const routesIds = response.data.map((route: any) => route.id);
|
||||
for (const id of routesIds) {
|
||||
const route = await languageInstance("ru").get(`/route/${id}`);
|
||||
this.routes.push({
|
||||
id: route.data.id,
|
||||
route_number: route.data.route_number,
|
||||
path: route.data.path,
|
||||
});
|
||||
}
|
||||
const mappedRoutes: ApiRoute[] = response.data.map((route: any) => ({
|
||||
id: route.id,
|
||||
route_number: route.route_number,
|
||||
path: route.path,
|
||||
}));
|
||||
this.routes = mappedRoutes.sort((a, b) =>
|
||||
a.route_number.localeCompare(b.route_number)
|
||||
);
|
||||
};
|
||||
|
||||
getStations = async () => {
|
||||
const stations = await languageInstance("ru").get("/station");
|
||||
this.stations = stations.data.map((station: any) => ({
|
||||
id: station.id,
|
||||
name: station.name,
|
||||
latitude: station.latitude,
|
||||
longitude: station.longitude,
|
||||
}));
|
||||
};
|
||||
|
||||
getSights = async () => {
|
||||
const sights = await languageInstance("ru").get("/sight");
|
||||
this.sights = sights.data.map((sight: any) => ({
|
||||
id: sight.id,
|
||||
name: sight.name,
|
||||
description: sight.description,
|
||||
latitude: sight.latitude,
|
||||
longitude: sight.longitude,
|
||||
}));
|
||||
};
|
||||
|
||||
deleteRecourse = async (recourse: string, id: number) => {
|
||||
await languageInstance("ru").delete(`/${recourse}/${id}`);
|
||||
if (recourse === "route") {
|
||||
this.routes = this.routes.filter((route) => route.id !== id);
|
||||
} else if (recourse === "station") {
|
||||
this.stations = this.stations.filter((station) => station.id !== id);
|
||||
} else if (recourse === "sight") {
|
||||
this.sights = this.sights.filter((sight) => sight.id !== id);
|
||||
}
|
||||
};
|
||||
|
||||
handleSave = async (json: string) => {
|
||||
const newSights: any[] = [];
|
||||
const newRoutes: any[] = [];
|
||||
const newStations: any[] = [];
|
||||
const updatedSights: any[] = [];
|
||||
const updatedRoutes: any[] = [];
|
||||
const updatedStations: any[] = [];
|
||||
|
||||
const parsedJSON = JSON.parse(json);
|
||||
|
||||
for (const feature of parsedJSON.features) {
|
||||
const { geometry, properties, id } = feature;
|
||||
const idParts = String(id).split("-");
|
||||
|
||||
if (idParts.length > 1) {
|
||||
const featureType = idParts[0];
|
||||
const numericId = parseInt(idParts[1], 10);
|
||||
if (isNaN(numericId)) continue;
|
||||
|
||||
if (featureType === "station") {
|
||||
const originalStation = this.stations.find((s) => s.id === numericId);
|
||||
if (!originalStation) continue;
|
||||
|
||||
const currentStation = {
|
||||
name: properties.name || "",
|
||||
latitude: geometry.coordinates[1],
|
||||
longitude: geometry.coordinates[0],
|
||||
};
|
||||
|
||||
// ИЗМЕНЕНИЕ: Сравнение координат с допуском
|
||||
if (
|
||||
originalStation.name !== currentStation.name ||
|
||||
Math.abs(originalStation.latitude - currentStation.latitude) >
|
||||
COORDINATE_PRECISION_TOLERANCE ||
|
||||
Math.abs(originalStation.longitude - currentStation.longitude) >
|
||||
COORDINATE_PRECISION_TOLERANCE
|
||||
) {
|
||||
updatedStations.push({ id: numericId, ...currentStation });
|
||||
}
|
||||
} else if (featureType === "route") {
|
||||
const originalRoute = this.routes.find((r) => r.id === numericId);
|
||||
if (!originalRoute) continue;
|
||||
|
||||
const currentRoute = {
|
||||
route_number: properties.name || "",
|
||||
path: geometry.coordinates,
|
||||
};
|
||||
|
||||
// ИЗМЕНЕНИЕ: Используется новая функция arePathsEqual с допуском
|
||||
if (
|
||||
originalRoute.route_number !== currentRoute.route_number ||
|
||||
!arePathsEqual(originalRoute.path, currentRoute.path)
|
||||
) {
|
||||
updatedRoutes.push({ id: numericId, ...currentRoute });
|
||||
}
|
||||
} else if (featureType === "sight") {
|
||||
const originalSight = this.sights.find((s) => s.id === numericId);
|
||||
if (!originalSight) continue;
|
||||
|
||||
const currentSight = {
|
||||
name: properties.name || "",
|
||||
description: properties.description || "",
|
||||
latitude: geometry.coordinates[1],
|
||||
longitude: geometry.coordinates[0],
|
||||
};
|
||||
|
||||
// ИЗМЕНЕНИЕ: Сравнение координат с допуском
|
||||
if (
|
||||
originalSight.name !== currentSight.name ||
|
||||
originalSight.description !== currentSight.description ||
|
||||
Math.abs(originalSight.latitude - currentSight.latitude) >
|
||||
COORDINATE_PRECISION_TOLERANCE ||
|
||||
Math.abs(originalSight.longitude - currentSight.longitude) >
|
||||
COORDINATE_PRECISION_TOLERANCE
|
||||
) {
|
||||
updatedSights.push({ id: numericId, ...currentSight });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (properties.featureType === "station") {
|
||||
newStations.push({
|
||||
name: properties.name || "",
|
||||
latitude: geometry.coordinates[1],
|
||||
longitude: geometry.coordinates[0],
|
||||
});
|
||||
} else if (properties.featureType === "route") {
|
||||
newRoutes.push({
|
||||
route_number: properties.name || "",
|
||||
path: geometry.coordinates,
|
||||
});
|
||||
} else if (properties.featureType === "sight") {
|
||||
newSights.push({
|
||||
name: properties.name || "",
|
||||
description: properties.description || "",
|
||||
latitude: geometry.coordinates[1],
|
||||
longitude: geometry.coordinates[0],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const requests: Promise<any>[] = [];
|
||||
|
||||
newStations.forEach((data) =>
|
||||
requests.push(languageInstance("ru").post("/station", data))
|
||||
);
|
||||
newRoutes.forEach((data) =>
|
||||
requests.push(languageInstance("ru").post("/route", data))
|
||||
);
|
||||
newSights.forEach((data) =>
|
||||
requests.push(languageInstance("ru").post("/sight", data))
|
||||
);
|
||||
|
||||
updatedStations.forEach(({ id, ...data }) =>
|
||||
requests.push(languageInstance("ru").patch(`/station/${id}`, data))
|
||||
);
|
||||
updatedRoutes.forEach(({ id, ...data }) =>
|
||||
requests.push(languageInstance("ru").patch(`/route/${id}`, data))
|
||||
);
|
||||
updatedSights.forEach(({ id, ...data }) =>
|
||||
requests.push(languageInstance("ru").patch(`/sight/${id}`, data))
|
||||
);
|
||||
|
||||
if (requests.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(requests);
|
||||
|
||||
await Promise.all([
|
||||
this.getRoutes(),
|
||||
this.getStations(),
|
||||
this.getSights(),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Ошибка при сохранении данных:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default new MapStore();
|
||||
89
src/pages/Media/MediaCreatePage/index.tsx
Normal file
89
src/pages/Media/MediaCreatePage/index.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
} from "@mui/material";
|
||||
import { mediaStore, MEDIA_TYPE_LABELS } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const MediaCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await mediaStore.createMedia(name, type);
|
||||
toast.success("Медиа успешно создано");
|
||||
navigate("/media");
|
||||
} catch (error) {
|
||||
toast.error("Ошибка при создании медиа");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<div className="flex justify-between items-center">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">Создание медиа</h1>
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Название"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Тип</InputLabel>
|
||||
<Select
|
||||
value={type}
|
||||
label="Тип"
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
required
|
||||
>
|
||||
{Object.entries(MEDIA_TYPE_LABELS).map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleCreate}
|
||||
disabled={isLoading || !name || !type}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Создать"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
@@ -16,10 +16,15 @@ import {
|
||||
Snackbar,
|
||||
} from "@mui/material";
|
||||
import { Save, ArrowLeft } from "lucide-react";
|
||||
import { authInstance, mediaStore, MEDIA_TYPE_LABELS } from "@shared";
|
||||
import {
|
||||
authInstance,
|
||||
mediaStore,
|
||||
MEDIA_TYPE_LABELS,
|
||||
languageStore,
|
||||
} from "@shared";
|
||||
import { MediaViewer } from "@widgets";
|
||||
|
||||
export const EditMediaPage = observer(() => {
|
||||
export const MediaEditPage = observer(() => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -34,23 +39,45 @@ export const EditMediaPage = observer(() => {
|
||||
const [mediaName, setMediaName] = useState(media?.media_name ?? "");
|
||||
const [mediaFilename, setMediaFilename] = useState(media?.filename ?? "");
|
||||
const [mediaType, setMediaType] = useState(media?.media_type ?? 1);
|
||||
const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
mediaStore.getOneMedia(id);
|
||||
}
|
||||
console.log(newFile);
|
||||
console.log(uploadDialogOpen);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {}, [newFile, uploadDialogOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (media) {
|
||||
setMediaName(media.media_name);
|
||||
setMediaFilename(media.filename);
|
||||
setMediaType(media.media_type);
|
||||
|
||||
// Set available media types based on current file extension
|
||||
const extension = media.filename.split(".").pop()?.toLowerCase();
|
||||
if (extension) {
|
||||
if (["glb", "gltf"].includes(extension)) {
|
||||
setAvailableMediaTypes([6]); // 3D model
|
||||
} else if (
|
||||
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
|
||||
extension
|
||||
)
|
||||
) {
|
||||
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
|
||||
} else if (["mp4", "webm", "mov"].includes(extension)) {
|
||||
setAvailableMediaTypes([2]); // Video
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [media]);
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
// const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
// e.preventDefault();
|
||||
// e.stopPropagation();
|
||||
@@ -76,8 +103,29 @@ export const EditMediaPage = observer(() => {
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
setNewFile(files[0]);
|
||||
setMediaFilename(files[0].name);
|
||||
const file = files[0];
|
||||
setNewFile(file);
|
||||
setMediaFilename(file.name);
|
||||
|
||||
// Determine media type based on file extension
|
||||
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||
if (extension) {
|
||||
if (["glb", "gltf"].includes(extension)) {
|
||||
setAvailableMediaTypes([6]); // 3D model
|
||||
setMediaType(6);
|
||||
} else if (
|
||||
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
|
||||
extension
|
||||
)
|
||||
) {
|
||||
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
|
||||
setMediaType(1); // Default to Photo
|
||||
} else if (["mp4", "webm", "mov"].includes(extension)) {
|
||||
setAvailableMediaTypes([2]); // Video
|
||||
setMediaType(2);
|
||||
}
|
||||
}
|
||||
|
||||
setUploadDialogOpen(true); // Open dialog on file selection
|
||||
}
|
||||
};
|
||||
@@ -134,7 +182,7 @@ export const EditMediaPage = observer(() => {
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowLeft size={20} />}
|
||||
onClick={() => navigate("/media")}
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
Назад
|
||||
</Button>
|
||||
@@ -175,11 +223,21 @@ export const EditMediaPage = observer(() => {
|
||||
onChange={(e) => setMediaType(Number(e.target.value))}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{Object.entries(MEDIA_TYPE_LABELS).map(([type, label]) => (
|
||||
<MenuItem key={type} value={Number(type)}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
{availableMediaTypes.length > 0
|
||||
? availableMediaTypes.map((type) => (
|
||||
<MenuItem key={type} value={type}>
|
||||
{
|
||||
MEDIA_TYPE_LABELS[
|
||||
type as keyof typeof MEDIA_TYPE_LABELS
|
||||
]
|
||||
}
|
||||
</MenuItem>
|
||||
))
|
||||
: Object.entries(MEDIA_TYPE_LABELS).map(([type, label]) => (
|
||||
<MenuItem key={type} value={Number(type)}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
167
src/pages/Media/MediaListPage/index.tsx
Normal file
167
src/pages/Media/MediaListPage/index.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Eye, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { DeleteModal } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const MediaListPage = observer(() => {
|
||||
const { media, getMedia, deleteMedia } = mediaStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<string | null>(null);
|
||||
const [ids, setIds] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMedia = async () => {
|
||||
setIsLoading(true);
|
||||
await getMedia();
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchMedia();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "media_name",
|
||||
headerName: "Название",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "media_type",
|
||||
headerName: "Тип",
|
||||
width: 200,
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
MEDIA_TYPE_LABELS[
|
||||
params.row.media_type as keyof typeof MEDIA_TYPE_LABELS
|
||||
]
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
width: 200,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
<button onClick={() => navigate(`/media/${params.row.id}`)}>
|
||||
<Eye size={20} className="text-green-500" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const rows = media.map((media) => ({
|
||||
id: media.id,
|
||||
media_name: media.media_name,
|
||||
media_type: media.media_type,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full">
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as string[]);
|
||||
}}
|
||||
hideFooter
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? <CircularProgress size={20} /> : "Нет медиафайлов"}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DeleteModal
|
||||
open={isDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
if (rowId) {
|
||||
deleteMedia(rowId.toString());
|
||||
getMedia();
|
||||
}
|
||||
setIsDeleteModalOpen(false);
|
||||
setRowId(null);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setRowId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isBulkDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
await Promise.all(ids.map((id) => deleteMedia(id)));
|
||||
getMedia();
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
setIds([]);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
46
src/pages/Media/MediaPreviewPage/index.tsx
Normal file
46
src/pages/Media/MediaPreviewPage/index.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { mediaStore } from "@shared";
|
||||
import { useEffect } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { MediaViewer } from "@widgets";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Download } from "lucide-react";
|
||||
import { Button } from "@mui/material";
|
||||
|
||||
export const MediaPreviewPage = observer(() => {
|
||||
const { id } = useParams();
|
||||
const { oneMedia, getOneMedia } = mediaStore;
|
||||
|
||||
useEffect(() => {
|
||||
getOneMedia(id!);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col justify-center items-center gap-4">
|
||||
<div className="w-full flex flex-col justify-center items-center gap-4">
|
||||
<div className="flex justify-center items-center max-w-[60%]">
|
||||
<MediaViewer media={oneMedia!} />
|
||||
</div>
|
||||
|
||||
{oneMedia && (
|
||||
<div className="flex-1 flex flex-col items-center gap-4 bg-[#998879] p-4 rounded-md">
|
||||
<p className="text-white text-center">
|
||||
Чтобы скачать файл, нажмите на кнопку ниже
|
||||
</p>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<Download size={16} />}
|
||||
component="a"
|
||||
href={`${
|
||||
import.meta.env.VITE_KRBL_MEDIA
|
||||
}${id}/download?token=${localStorage.getItem("token")}`}
|
||||
target="_blank"
|
||||
>
|
||||
Скачать
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
3
src/pages/Media/index.ts
Normal file
3
src/pages/Media/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./MediaEditPage";
|
||||
export * from "./MediaListPage";
|
||||
export * from "./MediaPreviewPage";
|
||||
@@ -1,67 +0,0 @@
|
||||
import { TableBody } from "@mui/material";
|
||||
import { TableRow, TableCell } from "@mui/material";
|
||||
import { Table, TableHead } from "@mui/material";
|
||||
import { mediaStore, MEDIA_TYPE_LABELS } from "@shared";
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Eye, Pencil, Trash2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const rows = (media: any[]) => {
|
||||
return media.map((row) => ({
|
||||
id: row.id,
|
||||
media_name: row.media_name,
|
||||
media_type:
|
||||
MEDIA_TYPE_LABELS[row.media_type as keyof typeof MEDIA_TYPE_LABELS],
|
||||
}));
|
||||
};
|
||||
|
||||
export const MediaListPage = observer(() => {
|
||||
const { media, getMedia, deleteMedia } = mediaStore;
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
getMedia();
|
||||
}, []);
|
||||
|
||||
const currentRows = rows(media);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table sx={{ minWidth: 650 }} aria-label="simple table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell align="center">Название</TableCell>
|
||||
<TableCell align="center">Тип</TableCell>
|
||||
<TableCell align="center">Действия</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{currentRows.map((row) => (
|
||||
<TableRow
|
||||
key={row.media_name}
|
||||
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
|
||||
>
|
||||
<TableCell align="center">{row.media_name}</TableCell>
|
||||
<TableCell align="center">{row.media_type}</TableCell>
|
||||
<TableCell align="center">
|
||||
<div className="flex gap-7 justify-center">
|
||||
<button onClick={() => navigate(`/media/${row.id}`)}>
|
||||
<Eye size={20} className="text-green-500" />
|
||||
</button>
|
||||
|
||||
<button onClick={() => navigate(`/media/${row.id}/edit`)}>
|
||||
<Pencil size={20} className="text-blue-500" />
|
||||
</button>
|
||||
|
||||
<button onClick={() => deleteMedia(row.id)}>
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { mediaStore } from "@shared";
|
||||
import { useEffect } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { MediaViewer } from "../../widgets/MediaViewer/index";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Download } from "lucide-react";
|
||||
import { Button } from "@mui/material";
|
||||
|
||||
export const PreviewMediaPage = observer(() => {
|
||||
const { id } = useParams();
|
||||
const { oneMedia, getOneMedia } = mediaStore;
|
||||
|
||||
useEffect(() => {
|
||||
getOneMedia(id!);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full h-[80vh] flex flex-col justify-center items-center gap-4">
|
||||
<div className="w-full h-full flex justify-center items-center">
|
||||
<MediaViewer className="w-full h-full" media={oneMedia!} />
|
||||
</div>
|
||||
|
||||
{oneMedia && (
|
||||
<div className="flex flex-col items-center gap-4 bg-[#998879] p-4 rounded-md">
|
||||
<p className="text-white text-center">
|
||||
Чтобы скачать файл, нажмите на кнопку ниже
|
||||
</p>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<Download size={16} />}
|
||||
component="a"
|
||||
href={`${
|
||||
import.meta.env.VITE_KRBL_MEDIA
|
||||
}${id}/download?token=${localStorage.getItem("token")}`}
|
||||
target="_blank"
|
||||
>
|
||||
Скачать
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
608
src/pages/Route/LinekedStations.tsx
Normal file
608
src/pages/Route/LinekedStations.tsx
Normal file
@@ -0,0 +1,608 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Typography,
|
||||
Button,
|
||||
FormControl,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
useTheme,
|
||||
TextField,
|
||||
Autocomplete,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
Table,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
TableBody,
|
||||
IconButton,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
Tabs,
|
||||
Tab,
|
||||
Box,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
Draggable,
|
||||
DropResult,
|
||||
} from "@hello-pangea/dnd";
|
||||
|
||||
import {
|
||||
authInstance,
|
||||
languageStore,
|
||||
routeStore,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { EditStationModal } from "../../widgets/modals/EditStationModal";
|
||||
|
||||
// Helper function to insert an item at a specific position (1-based index)
|
||||
function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] {
|
||||
const index = pos - 1;
|
||||
const result = [...arr];
|
||||
if (index >= result.length) {
|
||||
result.push(value);
|
||||
} else {
|
||||
result.splice(index, 0, value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Helper function to reorder items after drag and drop
|
||||
const reorder = <T,>(list: T[], startIndex: number, endIndex: number): T[] => {
|
||||
const result = Array.from(list);
|
||||
const [removed] = result.splice(startIndex, 1);
|
||||
result.splice(endIndex, 0, removed);
|
||||
return result;
|
||||
};
|
||||
|
||||
type Field<T> = {
|
||||
label: string;
|
||||
data: keyof T;
|
||||
render?: (value: any) => React.ReactNode;
|
||||
};
|
||||
|
||||
type LinkedItemsProps<T> = {
|
||||
parentId: string | number;
|
||||
fields: Field<T>[];
|
||||
setItemsParent?: (items: T[]) => void;
|
||||
type: "show" | "edit";
|
||||
dragAllowed?: boolean;
|
||||
onUpdate?: () => void;
|
||||
dontRecurse?: boolean;
|
||||
disableCreation?: boolean;
|
||||
updatedLinkedItems?: T[];
|
||||
refresh?: number;
|
||||
routeDirection?: boolean;
|
||||
};
|
||||
|
||||
export const LinkedItems = <
|
||||
T extends { id: number; name: string; [key: string]: any }
|
||||
>(
|
||||
props: LinkedItemsProps<T>
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Accordion sx={{ width: "100%" }}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
sx={{
|
||||
background: theme.palette.background.paper,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight="bold">
|
||||
Привязанные станции
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails
|
||||
sx={{ background: theme.palette.background.paper, width: "100%" }}
|
||||
>
|
||||
<Stack gap={2} width="100%">
|
||||
<LinkedItemsContents {...props} />
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const LinkedItemsContentsInner = <
|
||||
T extends { id: number; name: string; [key: string]: any }
|
||||
>({
|
||||
parentId,
|
||||
setItemsParent,
|
||||
fields,
|
||||
dragAllowed = false,
|
||||
type,
|
||||
onUpdate,
|
||||
disableCreation = false,
|
||||
updatedLinkedItems,
|
||||
refresh,
|
||||
routeDirection,
|
||||
}: LinkedItemsProps<T>) => {
|
||||
const { language } = languageStore;
|
||||
|
||||
const [position, setPosition] = useState<number>(1);
|
||||
const [allItems, setAllItems] = useState<T[]>([]);
|
||||
const [linkedItems, setLinkedItems] = useState<T[]>([]);
|
||||
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
useEffect(() => {}, [error]);
|
||||
|
||||
const parentResource = "route";
|
||||
const childResource = "station";
|
||||
|
||||
const availableItems = allItems
|
||||
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
|
||||
.filter((item) => {
|
||||
// Если направление маршрута не указано, показываем все станции
|
||||
if (routeDirection === undefined) return true;
|
||||
// Фильтруем станции по направлению маршрута
|
||||
return item.direction === routeDirection;
|
||||
})
|
||||
.filter((item) => {
|
||||
// Фильтруем по городу из навбара
|
||||
const selectedCityId = selectedCityStore.selectedCityId;
|
||||
if (selectedCityId && "city_id" in item) {
|
||||
return item.city_id === selectedCityId;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Фильтрация по поиску для массового режима
|
||||
const filteredAvailableItems = availableItems.filter((item) => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (updatedLinkedItems) {
|
||||
setLinkedItems(updatedLinkedItems);
|
||||
}
|
||||
}, [updatedLinkedItems]);
|
||||
|
||||
useEffect(() => {
|
||||
setItemsParent?.(linkedItems);
|
||||
}, [linkedItems, setItemsParent]);
|
||||
|
||||
useEffect(() => {
|
||||
setPosition(linkedItems.length + 1);
|
||||
}, [linkedItems.length]);
|
||||
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
|
||||
const reorderedItems = reorder(
|
||||
linkedItems,
|
||||
result.source.index,
|
||||
result.destination.index
|
||||
);
|
||||
|
||||
setLinkedItems(reorderedItems);
|
||||
|
||||
authInstance
|
||||
.post(`/${parentResource}/${parentId}/${childResource}`, {
|
||||
stations: reorderedItems.map((item) => ({ id: item.id })),
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error updating station order:", error);
|
||||
setError("Failed to update station order");
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (parentId) {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
authInstance
|
||||
.get(`/${parentResource}/${parentId}/${childResource}`)
|
||||
.then((response) => {
|
||||
setLinkedItems(response?.data || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching linked items:", error);
|
||||
setError("Failed to load linked stations");
|
||||
setLinkedItems([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [parentId, language, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (type === "edit") {
|
||||
setError(null);
|
||||
authInstance
|
||||
.get(`/${childResource}`)
|
||||
.then((response) => {
|
||||
setAllItems(response?.data || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching all items:", error);
|
||||
setError("Failed to load available stations");
|
||||
setAllItems([]);
|
||||
});
|
||||
}
|
||||
}, [type]);
|
||||
|
||||
const linkItem = () => {
|
||||
if (selectedItemId !== null) {
|
||||
setError(null);
|
||||
const requestData = {
|
||||
stations: insertAtPosition(
|
||||
linkedItems.map((item) => ({ id: item.id })),
|
||||
position,
|
||||
{ id: selectedItemId }
|
||||
),
|
||||
};
|
||||
|
||||
authInstance
|
||||
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
|
||||
.then(() => {
|
||||
const newItem = allItems.find((item) => item.id === selectedItemId);
|
||||
if (newItem) {
|
||||
const updatedList = insertAtPosition(
|
||||
[...linkedItems],
|
||||
position,
|
||||
newItem
|
||||
);
|
||||
setLinkedItems(updatedList);
|
||||
}
|
||||
setSelectedItemId(null);
|
||||
onUpdate?.();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error linking item:", error);
|
||||
setError("Failed to link station");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItem = (itemId: number) => {
|
||||
setError(null);
|
||||
authInstance
|
||||
.delete(`/${parentResource}/${parentId}/${childResource}`, {
|
||||
data: { [`${childResource}_id`]: itemId },
|
||||
})
|
||||
.then(() => {
|
||||
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
|
||||
onUpdate?.();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error deleting item:", error);
|
||||
setError("Failed to delete station");
|
||||
});
|
||||
};
|
||||
|
||||
const handleStationClick = (item: T) => {
|
||||
routeStore.setSelectedStationId(item.id);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (itemId: number) => {
|
||||
const newSelected = new Set(selectedItems);
|
||||
if (newSelected.has(itemId)) {
|
||||
newSelected.delete(itemId);
|
||||
} else {
|
||||
newSelected.add(itemId);
|
||||
}
|
||||
setSelectedItems(newSelected);
|
||||
};
|
||||
|
||||
const handleBulkLink = () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
|
||||
setError(null);
|
||||
const selectedStations = Array.from(selectedItems).map((id) => ({ id }));
|
||||
const requestData = {
|
||||
stations: [
|
||||
...linkedItems.map((item) => ({ id: item.id })),
|
||||
...selectedStations,
|
||||
],
|
||||
};
|
||||
|
||||
authInstance
|
||||
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
|
||||
.then(() => {
|
||||
const newItems = allItems.filter((item) => selectedItems.has(item.id));
|
||||
setLinkedItems([...linkedItems, ...newItems]);
|
||||
setSelectedItems(new Set());
|
||||
onUpdate?.();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error linking items:", error);
|
||||
setError("Failed to link stations");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{linkedItems?.length > 0 && (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<TableContainer component={Paper} sx={{ width: "100%" }}>
|
||||
<Table sx={{ width: "100%" }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{type === "edit" && dragAllowed && (
|
||||
<TableCell width="40px"></TableCell>
|
||||
)}
|
||||
<TableCell key="id" width="60px">
|
||||
№
|
||||
</TableCell>
|
||||
{fields.map((field) => (
|
||||
<TableCell key={String(field.data)}>
|
||||
{field.label}
|
||||
</TableCell>
|
||||
))}
|
||||
{type === "edit" && (
|
||||
<TableCell width="120px">Действие</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<Droppable
|
||||
droppableId="droppable-stations"
|
||||
isDropDisabled={type !== "edit" || !dragAllowed}
|
||||
>
|
||||
{(provided) => (
|
||||
<TableBody
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{linkedItems.map((item, index) => (
|
||||
<Draggable
|
||||
key={item.id}
|
||||
draggableId={"station-" + String(item.id)}
|
||||
index={index}
|
||||
isDragDisabled={type !== "edit" || !dragAllowed}
|
||||
>
|
||||
{(provided) => (
|
||||
<TableRow
|
||||
sx={{ cursor: "pointer" }}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
hover
|
||||
onClick={() => handleStationClick(item)}
|
||||
>
|
||||
{type === "edit" && dragAllowed && (
|
||||
<TableCell {...provided.dragHandleProps}>
|
||||
<IconButton size="small">
|
||||
<DragIndicatorIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
{fields.map((field, idx) => (
|
||||
<TableCell key={String(field.data) + String(idx)}>
|
||||
{field.render
|
||||
? field.render(item[field.data])
|
||||
: item[field.data]}
|
||||
</TableCell>
|
||||
))}
|
||||
{type === "edit" && (
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteItem(item.id);
|
||||
}}
|
||||
>
|
||||
Отвязать
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</TableBody>
|
||||
)}
|
||||
</Droppable>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</DragDropContext>
|
||||
)}
|
||||
|
||||
{linkedItems.length === 0 && !isLoading && (
|
||||
<Typography color="textSecondary" textAlign="center" py={2}>
|
||||
Станции не найдены
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{type === "edit" && !disableCreation && (
|
||||
<Stack gap={2} mt={2}>
|
||||
<Typography variant="subtitle1">Добавить остановки</Typography>
|
||||
{routeDirection !== undefined && (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Показываются только остановки для{" "}
|
||||
{routeDirection ? "прямого" : "обратного"} направления
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_, newValue) => setActiveTab(newValue)}
|
||||
>
|
||||
<Tab label="По одной" />
|
||||
<Tab label="Массово" />
|
||||
</Tabs>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{activeTab === 0 && (
|
||||
<Stack gap={2}>
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
value={
|
||||
availableItems?.find(
|
||||
(item) => item.id === selectedItemId
|
||||
) || null
|
||||
}
|
||||
onChange={(_, newValue) =>
|
||||
setSelectedItemId(newValue?.id || null)
|
||||
}
|
||||
options={availableItems}
|
||||
getOptionLabel={(item) => String(item.name)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Выберите остановку"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
option.id === value?.id
|
||||
}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
const searchWords = inputValue
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.filter(Boolean);
|
||||
return options.filter((option) => {
|
||||
const optionWords = String(option.name)
|
||||
.toLowerCase()
|
||||
.split(" ");
|
||||
return searchWords.every((searchWord) =>
|
||||
optionWords.some((word) => word.startsWith(searchWord))
|
||||
);
|
||||
});
|
||||
}}
|
||||
renderOption={(props, option) => (
|
||||
<li {...props} key={option.id}>
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<p>{String(option.name)}</p>
|
||||
<p className="text-xs text-gray-500 max-w-[300px] mr-4 truncate">
|
||||
{String(option.description)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<TextField
|
||||
type="number"
|
||||
label="Позиция добавляемой остановки"
|
||||
value={position}
|
||||
onChange={(e) => {
|
||||
const newValue = Math.max(1, Number(e.target.value));
|
||||
setPosition(
|
||||
newValue > linkedItems.length + 1
|
||||
? linkedItems.length + 1
|
||||
: newValue
|
||||
);
|
||||
}}
|
||||
InputProps={{
|
||||
inputProps: { min: 1, max: linkedItems.length + 1 },
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={linkItem}
|
||||
disabled={!selectedItemId}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{activeTab === 1 && (
|
||||
<Stack gap={2}>
|
||||
{/* Поле поиска */}
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Поиск остановок"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Введите название остановки..."
|
||||
size="small"
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
|
||||
{/* Список доступных остановок с чекбоксами */}
|
||||
<Paper sx={{ maxHeight: 300, overflow: "auto", p: 2 }}>
|
||||
<Stack gap={1}>
|
||||
{filteredAvailableItems.map((item) => (
|
||||
<FormControlLabel
|
||||
key={item.id}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selectedItems.has(item.id)}
|
||||
onChange={() => handleCheckboxChange(item.id)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={String(item.name)}
|
||||
sx={{
|
||||
margin: 0,
|
||||
"& .MuiFormControlLabel-label": {
|
||||
fontSize: "0.875rem",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{filteredAvailableItems.length === 0 && (
|
||||
<Typography
|
||||
color="textSecondary"
|
||||
textAlign="center"
|
||||
py={1}
|
||||
>
|
||||
{searchQuery.trim()
|
||||
? "Остановки не найдены"
|
||||
: "Нет доступных остановок"}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleBulkLink}
|
||||
disabled={selectedItems.size === 0}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Добавить выбранные ({selectedItems.size})
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
<EditStationModal open={isModalOpen} onClose={handleCloseModal} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const LinkedItemsContents = observer(
|
||||
LinkedItemsContentsInner
|
||||
) as typeof LinkedItemsContentsInner;
|
||||
488
src/pages/Route/RouteCreatePage/index.tsx
Normal file
488
src/pages/Route/RouteCreatePage/index.tsx
Normal file
@@ -0,0 +1,488 @@
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Typography,
|
||||
Box,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from "@mui/material";
|
||||
import { MediaViewer } from "@widgets";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save, Plus } from "lucide-react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { carrierStore } from "../../../shared/store/CarrierStore";
|
||||
import { articlesStore } from "../../../shared/store/ArticlesStore";
|
||||
import { Route, routeStore } from "../../../shared/store/RouteStore";
|
||||
import {
|
||||
languageStore,
|
||||
SelectArticleModal,
|
||||
SelectMediaDialog,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
|
||||
export const RouteCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const [carrier, setCarrier] = useState<string>("");
|
||||
const [routeNumber, setRouteNumber] = useState("");
|
||||
const [routeCoords, setRouteCoords] = useState("");
|
||||
const [govRouteNumber, setGovRouteNumber] = useState("");
|
||||
const [governorAppeal, setGovernorAppeal] = useState<string>("");
|
||||
const [direction, setDirection] = useState("backward");
|
||||
const [scaleMin, setScaleMin] = useState("");
|
||||
const [scaleMax, setScaleMax] = useState("");
|
||||
const [turn, setTurn] = useState("");
|
||||
const [centerLat, setCenterLat] = useState("");
|
||||
const [centerLng, setCenterLng] = useState("");
|
||||
const [videoPreview, setVideoPreview] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
|
||||
useState(false);
|
||||
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
|
||||
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
carrierStore.getCarriers(language);
|
||||
articlesStore.getArticleList();
|
||||
}, [language]);
|
||||
|
||||
// Фильтруем перевозчиков только из выбранного города
|
||||
const filteredCarriers = useMemo(() => {
|
||||
const carriers =
|
||||
carrierStore.carriers[language as keyof typeof carrierStore.carriers]
|
||||
.data || [];
|
||||
|
||||
if (!selectedCityStore.selectedCityId) {
|
||||
return carriers;
|
||||
}
|
||||
|
||||
return carriers.filter(
|
||||
(carrier: any) => carrier.city_id === selectedCityStore.selectedCityId
|
||||
);
|
||||
}, [carrierStore.carriers, language, selectedCityStore.selectedCityId]);
|
||||
|
||||
const validateCoordinates = (value: string) => {
|
||||
try {
|
||||
const lines = value.trim().split("\n");
|
||||
const coordinates = lines.map((line) => {
|
||||
const [lat, lon] = line
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map(Number);
|
||||
return [lat, lon];
|
||||
});
|
||||
|
||||
if (coordinates.length === 0) {
|
||||
return "Введите хотя бы одну пару координат";
|
||||
}
|
||||
|
||||
if (
|
||||
!coordinates.every(
|
||||
(point) => Array.isArray(point) && point.length === 2
|
||||
)
|
||||
) {
|
||||
return "Каждая строка должна содержать две координаты";
|
||||
}
|
||||
|
||||
if (
|
||||
!coordinates.every((point) =>
|
||||
point.every((coord) => !isNaN(coord) && typeof coord === "number")
|
||||
)
|
||||
) {
|
||||
return "Координаты должны быть числами";
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return "Неверный формат координат";
|
||||
}
|
||||
};
|
||||
|
||||
const handleArticleSelect = (articleId: number) => {
|
||||
setGovernorAppeal(articleId.toString());
|
||||
setIsSelectArticleDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleVideoSelect = (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
setVideoPreview(media.id);
|
||||
setIsSelectVideoDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleVideoPreviewClick = () => {
|
||||
setIsVideoPreviewOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateRoute = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
// Преобразуем значения в нужные типы
|
||||
const carrier_id = Number(carrier);
|
||||
const governor_appeal = Number(governorAppeal);
|
||||
const scale_min = scaleMin ? Number(scaleMin) : undefined;
|
||||
const scale_max = scaleMax ? Number(scaleMax) : undefined;
|
||||
const rotate = turn ? Number(turn) : undefined;
|
||||
const center_latitude = centerLat ? Number(centerLat) : undefined;
|
||||
const center_longitude = centerLng ? Number(centerLng) : undefined;
|
||||
const route_direction = direction === "forward";
|
||||
|
||||
const validationResult = validateCoordinates(routeCoords);
|
||||
if (validationResult !== true) {
|
||||
toast.error(validationResult);
|
||||
return;
|
||||
}
|
||||
|
||||
// Координаты маршрута как массив массивов чисел
|
||||
const path = routeCoords
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const [lat, lon] = line
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map(Number);
|
||||
return [lat, lon];
|
||||
});
|
||||
|
||||
// Собираем объект маршрута
|
||||
const newRoute: Partial<Route> = {
|
||||
carrier:
|
||||
carrierStore.carriers[
|
||||
language as keyof typeof carrierStore.carriers
|
||||
].data?.find((c: any) => c.id === carrier_id)?.full_name || "",
|
||||
carrier_id,
|
||||
route_number: routeNumber,
|
||||
route_sys_number: govRouteNumber,
|
||||
governor_appeal,
|
||||
route_direction,
|
||||
scale_min,
|
||||
scale_max,
|
||||
rotate,
|
||||
center_latitude,
|
||||
center_longitude,
|
||||
path,
|
||||
video_preview:
|
||||
videoPreview && videoPreview !== "" ? videoPreview : undefined,
|
||||
};
|
||||
|
||||
await routeStore.createRoute(newRoute);
|
||||
toast.success("Маршрут успешно создан");
|
||||
navigate(-1);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Произошла ошибка при создании маршрута");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Получаем название выбранной статьи для отображения
|
||||
const selectedArticle = articlesStore.articleList.ru.data.find(
|
||||
(article) => article.id === Number(governorAppeal)
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<div className="flex 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">
|
||||
<Box className="flex flex-col gap-6 w-full">
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Выберите перевозчика</InputLabel>
|
||||
<Select
|
||||
value={carrier}
|
||||
label="Выберите перевозчика"
|
||||
onChange={(e) => setCarrier(e.target.value as string)}
|
||||
disabled={filteredCarriers.length === 0}
|
||||
>
|
||||
<MenuItem value="">Не выбрано</MenuItem>
|
||||
{filteredCarriers.map((carrier: any) => (
|
||||
<MenuItem key={carrier.id} value={carrier.id}>
|
||||
{carrier.full_name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Номер маршрута"
|
||||
required
|
||||
value={routeNumber}
|
||||
onChange={(e) => setRouteNumber(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Координаты маршрута"
|
||||
multiline
|
||||
minRows={2}
|
||||
maxRows={10}
|
||||
value={routeCoords}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setRouteCoords(newValue);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const lines = routeCoords.split("\n");
|
||||
const lastLine = lines[lines.length - 1];
|
||||
|
||||
// Если мы на последней строке и она не пустая
|
||||
if (lastLine && lastLine.trim()) {
|
||||
e.preventDefault();
|
||||
const newValue = routeCoords + "\n";
|
||||
setRouteCoords(newValue);
|
||||
}
|
||||
}
|
||||
}}
|
||||
error={validateCoordinates(routeCoords) !== true}
|
||||
helperText={
|
||||
typeof validateCoordinates(routeCoords) === "string"
|
||||
? validateCoordinates(routeCoords)
|
||||
: "Формат: широта долгота"
|
||||
}
|
||||
placeholder="55.7558 37.6173 55.7539 37.6208"
|
||||
sx={{
|
||||
"& .MuiInputBase-root": {
|
||||
maxHeight: "500px",
|
||||
overflow: "auto",
|
||||
},
|
||||
"& .MuiInputBase-input": {
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.8rem",
|
||||
lineHeight: "1.2",
|
||||
padding: "8px 12px",
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
fontSize: "0.75rem",
|
||||
marginTop: "2px",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Номер маршрута в Говорящем Городе"
|
||||
required
|
||||
value={govRouteNumber}
|
||||
onChange={(e) => setGovRouteNumber(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Заменяем Select на кнопку для выбора статьи */}
|
||||
<Box className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Обращение к пассажирам
|
||||
</label>
|
||||
<Box className="flex gap-2">
|
||||
<TextField
|
||||
className="flex-1"
|
||||
value={selectedArticle?.heading || "Статья не выбрана"}
|
||||
placeholder="Выберите статью"
|
||||
disabled
|
||||
sx={{
|
||||
"& .MuiInputBase-input": {
|
||||
color: selectedArticle ? "inherit" : "#999",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setIsSelectArticleDialogOpen(true)}
|
||||
startIcon={<Plus size={16} />}
|
||||
sx={{ minWidth: "auto", px: 2 }}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Селектор видеозаставки */}
|
||||
<Box className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Видеозаставка
|
||||
</label>
|
||||
<Box className="flex gap-2">
|
||||
<Box
|
||||
className="flex-1"
|
||||
onClick={handleVideoPreviewClick}
|
||||
sx={{
|
||||
cursor:
|
||||
videoPreview && videoPreview !== "" ? "pointer" : "default",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className="w-full h-[50px] border border-gray-400 rounded-sm flex items-center justify-between px-4"
|
||||
sx={{
|
||||
"& .MuiInputBase-input": {
|
||||
color:
|
||||
videoPreview && videoPreview !== ""
|
||||
? "inherit"
|
||||
: "#999",
|
||||
cursor:
|
||||
videoPreview && videoPreview !== ""
|
||||
? "pointer"
|
||||
: "default",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" className="text-sm">
|
||||
{videoPreview && videoPreview !== ""
|
||||
? "Видео выбрано"
|
||||
: "Видео не выбрано"}
|
||||
</Typography>
|
||||
{videoPreview && videoPreview !== "" && (
|
||||
<Box
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setVideoPreview("");
|
||||
}}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
color: "#999",
|
||||
"&:hover": {
|
||||
color: "#666",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" className="text-lg font-bold">
|
||||
×
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setIsSelectVideoDialogOpen(true)}
|
||||
startIcon={<Plus size={16} />}
|
||||
sx={{ minWidth: "auto", px: 2 }}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Прямой/обратный маршрут</InputLabel>
|
||||
<Select
|
||||
value={direction}
|
||||
label="Прямой/обратный маршрут"
|
||||
onChange={(e) => setDirection(e.target.value)}
|
||||
>
|
||||
<MenuItem value="forward">Прямой</MenuItem>
|
||||
<MenuItem value="backward">Обратный</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Масштаб (мин)"
|
||||
value={scaleMin}
|
||||
onChange={(e) => setScaleMin(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Масштаб (макс)"
|
||||
value={scaleMax}
|
||||
onChange={(e) => setScaleMax(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Поворот"
|
||||
value={turn}
|
||||
onChange={(e) => setTurn(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Центр. широта"
|
||||
value={centerLat}
|
||||
onChange={(e) => setCenterLat(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Центр. долгота"
|
||||
value={centerLng}
|
||||
onChange={(e) => setCenterLng(e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleCreateRoute}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
});
|
||||
537
src/pages/Route/RouteEditPage/index.tsx
Normal file
537
src/pages/Route/RouteEditPage/index.tsx
Normal file
@@ -0,0 +1,537 @@
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Typography,
|
||||
Box,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from "@mui/material";
|
||||
import { MediaViewer } from "@widgets";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Copy, Save, Plus } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { carrierStore } from "../../../shared/store/CarrierStore";
|
||||
import { articlesStore } from "../../../shared/store/ArticlesStore";
|
||||
import {
|
||||
routeStore,
|
||||
languageStore,
|
||||
SelectArticleModal,
|
||||
SelectMediaDialog,
|
||||
} from "@shared";
|
||||
import { toast } from "react-toastify";
|
||||
import { stationsStore } from "@shared";
|
||||
import { LinkedItems } from "../LinekedStations";
|
||||
|
||||
export const RouteEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const { editRouteData, copyRouteAction } = routeStore;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
|
||||
useState(false);
|
||||
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
|
||||
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
|
||||
const { language } = languageStore;
|
||||
const [coordinates, setCoordinates] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
const response = await routeStore.getRoute(Number(id));
|
||||
routeStore.setEditRouteData(response);
|
||||
languageStore.setLanguage("ru");
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
carrierStore.getCarriers(language);
|
||||
stationsStore.getStations();
|
||||
articlesStore.getArticleList();
|
||||
};
|
||||
fetchData();
|
||||
}, [id, language]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editRouteData.path && editRouteData.path.length > 0) {
|
||||
const formattedPath = editRouteData.path
|
||||
.map((coords) => coords.join(" "))
|
||||
.join("\n");
|
||||
setCoordinates(formattedPath);
|
||||
}
|
||||
}, [editRouteData.path]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsLoading(true);
|
||||
await routeStore.editRoute(Number(id));
|
||||
toast.success("Маршрут успешно сохранен");
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const validateCoordinates = (value: string) => {
|
||||
try {
|
||||
const lines = value.trim().split("\n");
|
||||
const coordinates = lines.map((line) => {
|
||||
const [lat, lon] = line
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map(Number);
|
||||
return [lat, lon];
|
||||
});
|
||||
|
||||
if (coordinates.length === 0) {
|
||||
return "Введите хотя бы одну пару координат";
|
||||
}
|
||||
|
||||
if (
|
||||
!coordinates.every(
|
||||
(point) => Array.isArray(point) && point.length === 2
|
||||
)
|
||||
) {
|
||||
return "Каждая строка должна содержать две координаты";
|
||||
}
|
||||
|
||||
if (
|
||||
!coordinates.every((point) =>
|
||||
point.every((coord) => !isNaN(coord) && typeof coord === "number")
|
||||
)
|
||||
) {
|
||||
return "Координаты должны быть числами";
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return "Неверный формат координат";
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
await copyRouteAction(Number(id));
|
||||
toast.success("Маршрут успешно скопирован");
|
||||
};
|
||||
|
||||
const handleArticleSelect = (articleId: number) => {
|
||||
routeStore.setEditRouteData({
|
||||
governor_appeal: articleId,
|
||||
});
|
||||
setIsSelectArticleDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleVideoSelect = (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
routeStore.setEditRouteData({
|
||||
video_preview: media.id,
|
||||
});
|
||||
setIsSelectVideoDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleVideoPreviewClick = () => {
|
||||
if (editRouteData.video_preview && editRouteData.video_preview !== "") {
|
||||
setIsVideoPreviewOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Получаем название выбранной статьи для отображения
|
||||
const selectedArticle = articlesStore.articleList.ru.data.find(
|
||||
(article) => article.id === editRouteData.governor_appeal
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<div className="flex 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">
|
||||
<Box className="flex flex-col gap-6 w-full">
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Выберите перевозчика</InputLabel>
|
||||
<Select
|
||||
value={editRouteData.carrier_id}
|
||||
label="Выберите перевозчика"
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
carrier_id: Number(e.target.value),
|
||||
carrier:
|
||||
carrierStore.carriers[
|
||||
language as keyof typeof carrierStore.carriers
|
||||
].data?.find((c) => c.id === Number(e.target.value))
|
||||
?.full_name || "",
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
carrierStore.carriers[
|
||||
language as keyof typeof carrierStore.carriers
|
||||
].data?.length === 0
|
||||
}
|
||||
>
|
||||
<MenuItem value="">Не выбрано</MenuItem>
|
||||
{carrierStore.carriers[
|
||||
language as keyof typeof carrierStore.carriers
|
||||
].data?.map((carrier) => (
|
||||
<MenuItem key={carrier.id} value={carrier.id}>
|
||||
{carrier.full_name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Номер маршрута"
|
||||
required
|
||||
value={editRouteData.route_number || ""}
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
route_number: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Координаты маршрута"
|
||||
multiline
|
||||
minRows={2}
|
||||
maxRows={10}
|
||||
value={coordinates}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setCoordinates(newValue);
|
||||
|
||||
const validationResult = validateCoordinates(newValue);
|
||||
if (validationResult === true) {
|
||||
const lines = newValue.trim().split("\n");
|
||||
const path = lines.map((line) => {
|
||||
const [lat, lon] = line
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map(Number);
|
||||
return [lat, lon];
|
||||
});
|
||||
routeStore.setEditRouteData({ path });
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const lines = coordinates.split("\n");
|
||||
const lastLine = lines[lines.length - 1];
|
||||
|
||||
// Если мы на последней строке и она не пустая
|
||||
if (lastLine && lastLine.trim()) {
|
||||
e.preventDefault();
|
||||
const newValue = coordinates + "\n";
|
||||
setCoordinates(newValue);
|
||||
}
|
||||
}
|
||||
}}
|
||||
error={validateCoordinates(coordinates) !== true}
|
||||
helperText={
|
||||
typeof validateCoordinates(coordinates) === "string"
|
||||
? validateCoordinates(coordinates)
|
||||
: "Формат: широта долгота"
|
||||
}
|
||||
placeholder="55.7558 37.6173 55.7539 37.6208"
|
||||
sx={{
|
||||
"& .MuiInputBase-root": {
|
||||
maxHeight: "500px",
|
||||
overflow: "auto",
|
||||
},
|
||||
"& .MuiInputBase-input": {
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.8rem",
|
||||
lineHeight: "1.2",
|
||||
padding: "8px 12px",
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
fontSize: "0.75rem",
|
||||
marginTop: "2px",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Номер маршрута в Говорящем Городе"
|
||||
required
|
||||
value={editRouteData.route_sys_number || ""}
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
route_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}
|
||||
>
|
||||
Скопировать
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleSave}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модальное окно выбора статьи */}
|
||||
<SelectArticleModal
|
||||
open={isSelectArticleDialogOpen}
|
||||
onClose={() => setIsSelectArticleDialogOpen(false)}
|
||||
onSelectArticle={handleArticleSelect}
|
||||
/>
|
||||
|
||||
{/* Модальное окно выбора видео */}
|
||||
<SelectMediaDialog
|
||||
open={isSelectVideoDialogOpen}
|
||||
onClose={() => setIsSelectVideoDialogOpen(false)}
|
||||
onSelectMedia={handleVideoSelect}
|
||||
mediaType={2}
|
||||
/>
|
||||
|
||||
{/* Модальное окно предпросмотра видео */}
|
||||
<Dialog
|
||||
open={isVideoPreviewOpen}
|
||||
onClose={() => setIsVideoPreviewOpen(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Предпросмотр видео</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box className="flex justify-center items-center p-4">
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: editRouteData.video_preview,
|
||||
media_type: 2,
|
||||
filename: "video_preview",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setIsVideoPreviewOpen(false)}>Закрыть</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
199
src/pages/Route/RouteListPage/index.tsx
Normal file
199
src/pages/Route/RouteListPage/index.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { carrierStore, languageStore, routeStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Map, Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const RouteListPage = observer(() => {
|
||||
const { routes, getRoutes, deleteRoute } = routeStore;
|
||||
const { carriers, getCarriers } = carrierStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<number | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
await getCarriers("ru");
|
||||
await getCarriers("en");
|
||||
await getCarriers("zh");
|
||||
await getRoutes();
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchData();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "carrier_id",
|
||||
headerName: "Перевозчик",
|
||||
width: 250,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
carriers[language].data.find(
|
||||
(carrier) => carrier.id == params.value
|
||||
)?.short_name
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "route_number",
|
||||
headerName: "Номер маршрута",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "route_direction",
|
||||
headerName: "Направление",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<p
|
||||
className={
|
||||
params.row.route_direction === "Прямой"
|
||||
? "text-green-500"
|
||||
: "text-red-500"
|
||||
}
|
||||
>
|
||||
{params.row.route_direction}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
width: 250,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
<button onClick={() => navigate(`/route/${params.row.id}/edit`)}>
|
||||
<Pencil size={20} className="text-blue-500" />
|
||||
</button>
|
||||
<button onClick={() => navigate(`/route-preview/${params.row.id}`)}>
|
||||
<Map size={20} className="text-purple-500" />
|
||||
</button>
|
||||
{/* <button onClick={() => navigate(`/route/${params.row.id}`)}>
|
||||
<Eye size={20} className="text-green-500" />
|
||||
</button> */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const rows = routes.data.map((route) => ({
|
||||
id: route.id,
|
||||
carrier_id: route.carrier_id,
|
||||
route_number: route.route_number,
|
||||
route_direction: route.route_direction ? "Прямой" : "Обратный",
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<LanguageSwitcher />
|
||||
|
||||
<div style={{ width: "100%" }}>
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl">Маршруты</h1>
|
||||
<CreateButton label="Создать маршрут" path="/route/create" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooter
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||
}}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? <CircularProgress size={20} /> : "Нет маршрутов"}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DeleteModal
|
||||
open={isDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
if (rowId) {
|
||||
deleteRoute(rowId);
|
||||
}
|
||||
setIsDeleteModalOpen(false);
|
||||
setRowId(null);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setRowId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isBulkDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
await Promise.all(ids.map((id) => deleteRoute(id)));
|
||||
getRoutes();
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
setIds([]);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
4
src/pages/Route/index.ts
Normal file
4
src/pages/Route/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./RouteListPage";
|
||||
export * from "./RouteCreatePage";
|
||||
export { RoutePreview } from "./route-preview";
|
||||
export * from "./RouteEditPage";
|
||||
9
src/pages/Route/route-preview/Constants.ts
Normal file
9
src/pages/Route/route-preview/Constants.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const UP_SCALE = 10000;
|
||||
export const PATH_WIDTH = 5;
|
||||
export const STATION_RADIUS = 8;
|
||||
export const STATION_OUTLINE_WIDTH = 4;
|
||||
export const SIGHT_SIZE = 40;
|
||||
export const SCALE_FACTOR = 50;
|
||||
|
||||
export const BACKGROUND_COLOR = 0x111111;
|
||||
export const PATH_COLOR = 0xff4d4d;
|
||||
248
src/pages/Route/route-preview/InfiniteCanvas.tsx
Normal file
248
src/pages/Route/route-preview/InfiniteCanvas.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { FederatedMouseEvent, FederatedWheelEvent } from "pixi.js";
|
||||
import { Component, ReactNode, useEffect, useState, useRef } from "react";
|
||||
import { useTransform } from "./TransformContext";
|
||||
import { useMapData } from "./MapDataContext";
|
||||
import { SCALE_FACTOR } from "./Constants";
|
||||
import { useApplication } from "@pixi/react";
|
||||
|
||||
class ErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
state = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
console.error("Error caught:", error, info);
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.state.hasError ? <p>Whoopsie Daisy!</p> : this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export function InfiniteCanvas({
|
||||
children,
|
||||
}: Readonly<{ children?: ReactNode }>) {
|
||||
const {
|
||||
position,
|
||||
setPosition,
|
||||
scale,
|
||||
setScale,
|
||||
rotation,
|
||||
setRotation,
|
||||
setScreenCenter,
|
||||
screenCenter,
|
||||
} = useTransform();
|
||||
const { routeData, originalRouteData, setSelectedSight } = useMapData();
|
||||
|
||||
const applicationRef = useApplication();
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
|
||||
const [startRotation, setStartRotation] = useState(0);
|
||||
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
|
||||
const [isPointerDown, setIsPointerDown] = useState(false);
|
||||
|
||||
// Флаг для предотвращения конфликта между пользовательским вводом и данными маршрута
|
||||
const [isUserInteracting, setIsUserInteracting] = useState(false);
|
||||
|
||||
// Реф для отслеживания последнего значения originalRouteData?.rotate
|
||||
const lastOriginalRotation = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!applicationRef?.app?.canvas) return;
|
||||
|
||||
const canvas = applicationRef.app.canvas;
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
const canvasLeft = canvasRect.left;
|
||||
const canvasTop = canvasRect.top;
|
||||
const centerX = window.innerWidth / 2 - canvasLeft;
|
||||
const centerY = window.innerHeight / 2 - canvasTop;
|
||||
setScreenCenter({ x: centerX, y: centerY });
|
||||
}, [applicationRef?.app, setScreenCenter]);
|
||||
|
||||
const handlePointerDown = (e: FederatedMouseEvent) => {
|
||||
setIsPointerDown(true);
|
||||
setIsDragging(false);
|
||||
setIsUserInteracting(true); // Устанавливаем флаг взаимодействия пользователя
|
||||
setStartPosition({
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
});
|
||||
setStartMousePosition({
|
||||
x: e.globalX,
|
||||
y: e.globalY,
|
||||
});
|
||||
setStartRotation(rotation);
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
// Устанавливаем rotation только при изменении originalRouteData и отсутствии взаимодействия пользователя
|
||||
useEffect(() => {
|
||||
const newRotation = originalRouteData?.rotate ?? 0;
|
||||
|
||||
// Обновляем rotation только если:
|
||||
// 1. Пользователь не взаимодействует с канвасом
|
||||
// 2. Значение действительно изменилось
|
||||
if (!isUserInteracting && lastOriginalRotation.current !== newRotation) {
|
||||
setRotation((newRotation * Math.PI) / 180);
|
||||
lastOriginalRotation.current = newRotation;
|
||||
}
|
||||
}, [originalRouteData?.rotate, isUserInteracting, setRotation]);
|
||||
|
||||
const handlePointerMove = (e: FederatedMouseEvent) => {
|
||||
if (!isPointerDown) return;
|
||||
|
||||
// Проверяем, началось ли перетаскивание
|
||||
if (!isDragging) {
|
||||
const dx = e.globalX - startMousePosition.x;
|
||||
const dy = e.globalY - startMousePosition.y;
|
||||
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
|
||||
setIsDragging(true);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.shiftKey) {
|
||||
const center = screenCenter ?? { x: 0, y: 0 };
|
||||
const startAngle = Math.atan2(
|
||||
startMousePosition.y - center.y,
|
||||
startMousePosition.x - center.x
|
||||
);
|
||||
const currentAngle = Math.atan2(
|
||||
e.globalY - center.y,
|
||||
e.globalX - center.x
|
||||
);
|
||||
|
||||
// Calculate rotation difference in radians
|
||||
const rotationDiff = currentAngle - startAngle;
|
||||
|
||||
// Update rotation
|
||||
setRotation(startRotation + rotationDiff);
|
||||
|
||||
const cosDelta = Math.cos(rotationDiff);
|
||||
const sinDelta = Math.sin(rotationDiff);
|
||||
|
||||
setPosition({
|
||||
x:
|
||||
center.x * (1 - cosDelta) +
|
||||
startPosition.x * cosDelta +
|
||||
(center.y - startPosition.y) * sinDelta,
|
||||
y:
|
||||
center.y * (1 - cosDelta) +
|
||||
startPosition.y * cosDelta +
|
||||
(startPosition.x - center.x) * sinDelta,
|
||||
});
|
||||
} else {
|
||||
setRotation(startRotation);
|
||||
setPosition({
|
||||
x: startPosition.x - startMousePosition.x + e.globalX,
|
||||
y: startPosition.y - startMousePosition.y + e.globalY,
|
||||
});
|
||||
}
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: FederatedMouseEvent) => {
|
||||
// Если не было перетаскивания, то это простой клик - закрываем виджет
|
||||
if (!isDragging) {
|
||||
setSelectedSight(undefined);
|
||||
}
|
||||
|
||||
setIsPointerDown(false);
|
||||
setIsDragging(false);
|
||||
// Сбрасываем флаг взаимодействия через небольшую задержку
|
||||
// чтобы избежать немедленного срабатывания useEffect
|
||||
setTimeout(() => {
|
||||
setIsUserInteracting(false);
|
||||
}, 100);
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleWheel = (e: FederatedWheelEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsUserInteracting(true); // Устанавливаем флаг при зуме
|
||||
|
||||
// Get mouse position relative to canvas
|
||||
const mouseX = e.globalX - position.x;
|
||||
const mouseY = e.globalY - position.y;
|
||||
|
||||
// Calculate new scale
|
||||
const scaleMin = (routeData?.scale_min ?? 10) / SCALE_FACTOR;
|
||||
const scaleMax = (routeData?.scale_max ?? 20) / SCALE_FACTOR;
|
||||
|
||||
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; // Zoom out/in
|
||||
const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor));
|
||||
const actualZoomFactor = newScale / scale;
|
||||
|
||||
if (scale === newScale) {
|
||||
// Сбрасываем флаг, если зум не изменился
|
||||
setTimeout(() => {
|
||||
setIsUserInteracting(false);
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update position to zoom towards mouse cursor
|
||||
setPosition({
|
||||
x: position.x + mouseX * (1 - actualZoomFactor),
|
||||
y: position.y + mouseY * (1 - actualZoomFactor),
|
||||
});
|
||||
|
||||
setScale(newScale);
|
||||
|
||||
// Сбрасываем флаг взаимодействия через задержку
|
||||
setTimeout(() => {
|
||||
setIsUserInteracting(false);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
applicationRef?.app.render();
|
||||
}, [position, scale, rotation]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
{applicationRef?.app && (
|
||||
<pixiGraphics
|
||||
draw={(g) => {
|
||||
const canvas = applicationRef.app.canvas;
|
||||
g.clear();
|
||||
g.rect(0, 0, canvas?.width ?? 0, canvas?.height ?? 0);
|
||||
g.fill("#111");
|
||||
}}
|
||||
eventMode={"static"}
|
||||
interactive
|
||||
onPointerDown={handlePointerDown}
|
||||
onGlobalPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerUpOutside={handlePointerUp}
|
||||
onWheel={handleWheel}
|
||||
/>
|
||||
)}
|
||||
<pixiContainer
|
||||
x={position.x}
|
||||
y={position.y}
|
||||
scale={scale}
|
||||
rotation={rotation}
|
||||
>
|
||||
{children}
|
||||
</pixiContainer>
|
||||
{/* Show center of the screen.
|
||||
<pixiGraphics
|
||||
eventMode="none"
|
||||
draw={(g) => {
|
||||
g.clear();
|
||||
const center = screenCenter ?? {x: 0, y: 0};
|
||||
g.circle(center.x, center.y, 1);
|
||||
g.fill("#fff");
|
||||
}}
|
||||
/> */}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
135
src/pages/Route/route-preview/LeftSidebar.tsx
Normal file
135
src/pages/Route/route-preview/LeftSidebar.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Stack, Typography, Button } from "@mui/material";
|
||||
import { useNavigate, useNavigationType } from "react-router";
|
||||
import { MediaViewer } from "@widgets";
|
||||
import { useMapData } from "./MapDataContext";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import { authInstance } from "@shared";
|
||||
|
||||
export const LeftSidebar = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const navigationType = useNavigationType(); // PUSH, POP, REPLACE
|
||||
const { routeData } = useMapData();
|
||||
const [carrierThumbnail, setCarrierThumbnail] = useState<string | null>(null);
|
||||
const [carrierLogo, setCarrierLogo] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
async function fetchCarrierThumbnail() {
|
||||
if (routeData?.carrier_id) {
|
||||
const { city_id, logo } = (
|
||||
await authInstance.get(`/carrier/${routeData.carrier_id}`)
|
||||
).data;
|
||||
const { arms } = (await authInstance.get(`/city/${city_id}`)).data;
|
||||
setCarrierThumbnail(arms);
|
||||
setCarrierLogo(logo);
|
||||
}
|
||||
}
|
||||
fetchCarrierThumbnail();
|
||||
}, [routeData?.carrier_id]);
|
||||
|
||||
const handleBack = () => {
|
||||
if (navigationType === "PUSH") {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate("/route");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack direction="column" width="300px" p={2} bgcolor="primary.main">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
type="button"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
color: "#fff",
|
||||
backgroundColor: "#222",
|
||||
borderRadius: 10,
|
||||
height: 40,
|
||||
width: "100%",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<p>Назад</p>
|
||||
</button>
|
||||
|
||||
<Stack
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
my={10}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 200,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{carrierThumbnail && (
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: carrierThumbnail,
|
||||
media_type: 1, // Тип "Фото" для логотипа
|
||||
filename: "route_thumbnail",
|
||||
}}
|
||||
fullWidth
|
||||
fullHeight
|
||||
/>
|
||||
)}
|
||||
<Typography sx={{ mb: 2, color: "#fff" }} textAlign="center">
|
||||
При поддержке Правительства
|
||||
</Typography>{" "}
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
my={10}
|
||||
spacing={2}
|
||||
>
|
||||
<Button variant="outlined" color="warning" fullWidth>
|
||||
Достопримечательности
|
||||
</Button>
|
||||
<Button variant="outlined" color="warning" fullWidth>
|
||||
Остановки
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
maxHeight={150}
|
||||
justifyContent="center"
|
||||
my={10}
|
||||
>
|
||||
{carrierLogo && (
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: carrierLogo,
|
||||
media_type: 1, // Тип "Фото" для логотипа
|
||||
filename: "route_thumbnail_logo",
|
||||
}}
|
||||
fullHeight
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Typography
|
||||
variant="h6"
|
||||
textAlign="center"
|
||||
mt="auto"
|
||||
sx={{ color: "#fff" }}
|
||||
>
|
||||
#ВсемПоПути
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
408
src/pages/Route/route-preview/MapDataContext.tsx
Normal file
408
src/pages/Route/route-preview/MapDataContext.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
import { useParams } from "react-router";
|
||||
import { authInstance, languageInstance } from "@shared";
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
RouteData,
|
||||
SightData,
|
||||
SightPatchData,
|
||||
StationData,
|
||||
StationPatchData,
|
||||
} from "./types";
|
||||
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
const MapDataContext = createContext<{
|
||||
originalRouteData?: RouteData;
|
||||
originalStationData?: StationData[];
|
||||
originalSightData?: SightData[];
|
||||
routeData?: RouteData;
|
||||
stationData?: StationDataWithLanguage;
|
||||
sightData?: SightData[];
|
||||
|
||||
isRouteLoading: boolean;
|
||||
isStationLoading: boolean;
|
||||
isSightLoading: boolean;
|
||||
selectedSight?: SightData;
|
||||
setSelectedSight: (sight?: SightData) => void;
|
||||
setScaleRange: (min: number, max: number) => void;
|
||||
setMapRotation: (rotation: number) => void;
|
||||
setMapCenter: (x: number, y: number) => void;
|
||||
setStationOffset: (stationId: number, x: number, y: number) => void;
|
||||
setStationAlign: (stationId: number, align: number) => void;
|
||||
setSightCoordinates: (
|
||||
sightId: number,
|
||||
latitude: number,
|
||||
longitude: number
|
||||
) => void;
|
||||
saveChanges: () => void;
|
||||
}>({
|
||||
originalRouteData: undefined,
|
||||
originalStationData: undefined,
|
||||
originalSightData: undefined,
|
||||
routeData: undefined,
|
||||
stationData: undefined,
|
||||
sightData: undefined,
|
||||
|
||||
isRouteLoading: true,
|
||||
isStationLoading: true,
|
||||
isSightLoading: true,
|
||||
selectedSight: undefined,
|
||||
setSelectedSight: () => {},
|
||||
setScaleRange: () => {},
|
||||
setMapRotation: () => {},
|
||||
setMapCenter: () => {},
|
||||
setStationOffset: () => {},
|
||||
setStationAlign: () => {},
|
||||
setSightCoordinates: () => {},
|
||||
saveChanges: () => {},
|
||||
});
|
||||
|
||||
type StationDataWithLanguage = {
|
||||
[key: string]: StationData[];
|
||||
};
|
||||
export const MapDataProvider = observer(
|
||||
({ children }: Readonly<{ children: ReactNode }>) => {
|
||||
const { id: routeId } = useParams<{ id: string }>();
|
||||
|
||||
const [originalRouteData, setOriginalRouteData] = useState<RouteData>();
|
||||
const [originalStationData, setOriginalStationData] =
|
||||
useState<StationData[]>();
|
||||
const [originalSightData, setOriginalSightData] = useState<SightData[]>();
|
||||
|
||||
const [routeData, setRouteData] = useState<RouteData>();
|
||||
const [stationData, setStationData] = useState<StationDataWithLanguage>({
|
||||
RU: [],
|
||||
EN: [],
|
||||
ZH: [],
|
||||
});
|
||||
const [sightData, setSightData] = useState<SightData[]>();
|
||||
|
||||
const [routeChanges, setRouteChanges] = useState<Partial<RouteData>>({});
|
||||
const [stationChanges, setStationChanges] = useState<StationPatchData[]>(
|
||||
[]
|
||||
);
|
||||
const [sightChanges, setSightChanges] = useState<SightPatchData[]>([]);
|
||||
|
||||
const [isRouteLoading, setIsRouteLoading] = useState(true);
|
||||
const [isStationLoading, setIsStationLoading] = useState(true);
|
||||
const [isSightLoading, setIsSightLoading] = useState(true);
|
||||
const [selectedSight, setSelectedSight] = useState<SightData>();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setIsRouteLoading(true);
|
||||
setIsStationLoading(true);
|
||||
setIsSightLoading(true);
|
||||
|
||||
const [
|
||||
routeResponse,
|
||||
ruStationResponse,
|
||||
enStationResponse,
|
||||
zhStationResponse,
|
||||
sightResponse,
|
||||
] = await Promise.all([
|
||||
authInstance.get(`/route/${routeId}`),
|
||||
languageInstance("ru").get(`/route/${routeId}/station`),
|
||||
languageInstance("en").get(`/route/${routeId}/station`),
|
||||
languageInstance("zh").get(`/route/${routeId}/station`),
|
||||
languageInstance("ru").get(`/route/${routeId}/sight`),
|
||||
]);
|
||||
|
||||
const routeData = routeResponse.data as RouteData;
|
||||
setOriginalRouteData(routeData);
|
||||
setOriginalStationData(ruStationResponse.data as StationData[]);
|
||||
setStationData({
|
||||
ru: ruStationResponse.data as StationData[],
|
||||
en: enStationResponse.data as StationData[],
|
||||
zh: zhStationResponse.data as StationData[],
|
||||
});
|
||||
setOriginalSightData(sightResponse.data as SightData[]);
|
||||
|
||||
setIsRouteLoading(false);
|
||||
setIsStationLoading(false);
|
||||
setIsSightLoading(false);
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
setIsRouteLoading(false);
|
||||
setIsStationLoading(false);
|
||||
setIsSightLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [routeId]);
|
||||
|
||||
useEffect(() => {
|
||||
// combine changes with original data
|
||||
if (originalRouteData)
|
||||
setRouteData({ ...originalRouteData, ...routeChanges });
|
||||
if (originalSightData) setSightData(originalSightData);
|
||||
}, [
|
||||
originalRouteData,
|
||||
originalSightData,
|
||||
routeChanges,
|
||||
stationChanges,
|
||||
sightChanges,
|
||||
]);
|
||||
|
||||
function setScaleRange(min: number, max: number) {
|
||||
setRouteChanges((prev) => {
|
||||
return { ...prev, scale_min: min, scale_max: max };
|
||||
});
|
||||
}
|
||||
|
||||
function setMapRotation(rotation: number) {
|
||||
setRouteChanges((prev) => {
|
||||
return { ...prev, rotate: rotation };
|
||||
});
|
||||
}
|
||||
|
||||
function setMapCenter(x: number, y: number) {
|
||||
setRouteChanges((prev) => {
|
||||
return { ...prev, center_latitude: x, center_longitude: y };
|
||||
});
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
await authInstance.patch(`/route/${routeId}`, routeData);
|
||||
await saveStationChanges();
|
||||
await saveSightChanges();
|
||||
}
|
||||
|
||||
async function saveStationChanges() {
|
||||
for (const station of stationChanges) {
|
||||
await authInstance.patch(`/route/${routeId}/station`, station);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSightChanges() {
|
||||
for (const sight of sightChanges) {
|
||||
await authInstance.patch(`/route/${routeId}/sight`, sight);
|
||||
}
|
||||
}
|
||||
|
||||
function setStationOffset(stationId: number, x: number, y: number) {
|
||||
const currentStation = stationData.ru?.find(
|
||||
(station) => station.id === stationId
|
||||
);
|
||||
if (
|
||||
currentStation &&
|
||||
Math.abs(currentStation.offset_x - x) < 0.01 &&
|
||||
Math.abs(currentStation.offset_y - y) < 0.01
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStationChanges((prev) => {
|
||||
const existingIndex = prev.findIndex(
|
||||
(station) => station.station_id === stationId
|
||||
);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
const newChanges = [...prev];
|
||||
newChanges[existingIndex] = {
|
||||
...newChanges[existingIndex],
|
||||
offset_x: x,
|
||||
offset_y: y,
|
||||
};
|
||||
return newChanges;
|
||||
} else {
|
||||
const originalStation = originalStationData?.find(
|
||||
(s) => s.id === stationId
|
||||
);
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
station_id: stationId,
|
||||
offset_x: x,
|
||||
offset_y: y,
|
||||
align: originalStation?.align ?? 1,
|
||||
transfers: originalStation?.transfers ?? {
|
||||
bus: "",
|
||||
metro_blue: "",
|
||||
metro_green: "",
|
||||
metro_orange: "",
|
||||
metro_purple: "",
|
||||
metro_red: "",
|
||||
train: "",
|
||||
tram: "",
|
||||
trolleybus: "",
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
setStationData((prev) => {
|
||||
const updated = { ...prev };
|
||||
Object.keys(updated).forEach((lang) => {
|
||||
updated[lang] = updated[lang].map((station) => {
|
||||
if (station.id === stationId) {
|
||||
return { ...station, offset_x: x, offset_y: y };
|
||||
}
|
||||
return station;
|
||||
});
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
function setStationAlign(stationId: number, align: number) {
|
||||
const currentStation = stationData.ru?.find(
|
||||
(station) => station.id === stationId
|
||||
);
|
||||
if (currentStation && currentStation.align === align) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStationChanges((prev) => {
|
||||
const existingIndex = prev.findIndex(
|
||||
(station) => station.station_id === stationId
|
||||
);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
const newChanges = [...prev];
|
||||
newChanges[existingIndex] = {
|
||||
...newChanges[existingIndex],
|
||||
align: align,
|
||||
};
|
||||
return newChanges;
|
||||
} else {
|
||||
const originalStation = originalStationData?.find(
|
||||
(s) => s.id === stationId
|
||||
);
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
station_id: stationId,
|
||||
align: align,
|
||||
offset_x: originalStation?.offset_x ?? 0,
|
||||
offset_y: originalStation?.offset_y ?? 0,
|
||||
transfers: originalStation?.transfers ?? {
|
||||
bus: "",
|
||||
metro_blue: "",
|
||||
metro_green: "",
|
||||
metro_orange: "",
|
||||
metro_purple: "",
|
||||
metro_red: "",
|
||||
train: "",
|
||||
tram: "",
|
||||
trolleybus: "",
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
setStationData((prev) => {
|
||||
const updated = { ...prev };
|
||||
Object.keys(updated).forEach((lang) => {
|
||||
updated[lang] = updated[lang].map((station) => {
|
||||
if (station.id === stationId) {
|
||||
return { ...station, align: align };
|
||||
}
|
||||
return station;
|
||||
});
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
function setSightCoordinates(
|
||||
sightId: number,
|
||||
latitude: number,
|
||||
longitude: number
|
||||
) {
|
||||
setSightChanges((prev) => {
|
||||
const existingIndex = prev.findIndex(
|
||||
(sight) => sight.sight_id === sightId
|
||||
);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
return prev.map((sight, index) => {
|
||||
if (index === existingIndex) {
|
||||
return {
|
||||
...sight,
|
||||
latitude,
|
||||
longitude,
|
||||
};
|
||||
}
|
||||
return sight;
|
||||
});
|
||||
} else {
|
||||
const foundSight = sightData?.find((sight) => sight.id === sightId);
|
||||
if (foundSight) {
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
sight_id: sightId,
|
||||
latitude,
|
||||
longitude,
|
||||
},
|
||||
];
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {}, [sightChanges]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
originalRouteData,
|
||||
originalStationData,
|
||||
originalSightData,
|
||||
routeData,
|
||||
stationData,
|
||||
sightData,
|
||||
isRouteLoading,
|
||||
isStationLoading,
|
||||
isSightLoading,
|
||||
selectedSight,
|
||||
setSelectedSight,
|
||||
setScaleRange,
|
||||
setMapRotation,
|
||||
setMapCenter,
|
||||
saveChanges,
|
||||
setStationOffset,
|
||||
setStationAlign,
|
||||
setSightCoordinates,
|
||||
}),
|
||||
[
|
||||
originalRouteData,
|
||||
originalStationData,
|
||||
originalSightData,
|
||||
routeData,
|
||||
stationData,
|
||||
sightData,
|
||||
isRouteLoading,
|
||||
isStationLoading,
|
||||
isSightLoading,
|
||||
selectedSight,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<MapDataContext.Provider value={value}>
|
||||
{children}
|
||||
</MapDataContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const useMapData = () => {
|
||||
const context = useContext(MapDataContext);
|
||||
if (!context) {
|
||||
throw new Error("useMapData must be used within a MapDataProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
377
src/pages/Route/route-preview/RightSidebar.tsx
Normal file
377
src/pages/Route/route-preview/RightSidebar.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import { Button, Stack, TextField, Typography, Slider } from "@mui/material";
|
||||
import { useMapData } from "./MapDataContext";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTransform } from "./TransformContext";
|
||||
import { coordinatesToLocal, localToCoordinates } from "./utils";
|
||||
import { SCALE_FACTOR } from "./Constants";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export function RightSidebar() {
|
||||
const {
|
||||
routeData,
|
||||
setScaleRange,
|
||||
saveChanges,
|
||||
originalRouteData,
|
||||
setMapRotation,
|
||||
setMapCenter,
|
||||
} = useMapData();
|
||||
const {
|
||||
rotation,
|
||||
position,
|
||||
screenToLocal,
|
||||
screenCenter,
|
||||
rotateToAngle,
|
||||
setTransform,
|
||||
scale,
|
||||
setScaleAtCenter,
|
||||
} = useTransform();
|
||||
|
||||
const [minScale, setMinScale] = useState<number>(1);
|
||||
const [maxScale, setMaxScale] = useState<number>(5);
|
||||
const [localCenter, setLocalCenter] = useState<{ x: number; y: number }>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const [rotationDegrees, setRotationDegrees] = useState<number>(0);
|
||||
const [isUserEditing, setIsUserEditing] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (originalRouteData) {
|
||||
// Проверяем и сбрасываем минимальный масштаб если нужно
|
||||
const originalMinScale = originalRouteData.scale_min ?? 1;
|
||||
const resetMinScale = originalMinScale < 1 ? 1 : originalMinScale;
|
||||
|
||||
// Проверяем и сбрасываем максимальный масштаб если нужно
|
||||
const originalMaxScale = originalRouteData.scale_max ?? 5;
|
||||
const resetMaxScale = originalMaxScale < 3 ? 3 : originalMaxScale;
|
||||
|
||||
setMinScale(resetMinScale);
|
||||
setMaxScale(resetMaxScale);
|
||||
setRotationDegrees(originalRouteData.rotate ?? 0);
|
||||
setLocalCenter({
|
||||
x: originalRouteData.center_latitude ?? 0,
|
||||
y: originalRouteData.center_longitude ?? 0,
|
||||
});
|
||||
}
|
||||
}, [originalRouteData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (minScale && maxScale) {
|
||||
setScaleRange(minScale, maxScale);
|
||||
}
|
||||
}, [minScale, maxScale]);
|
||||
|
||||
useEffect(() => {
|
||||
setRotationDegrees(
|
||||
((Math.round((rotation * 180) / Math.PI) % 360) + 360) % 360
|
||||
);
|
||||
}, [rotation]);
|
||||
|
||||
useEffect(() => {
|
||||
setMapRotation(rotationDegrees);
|
||||
}, [rotationDegrees]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUserEditing) {
|
||||
const center = screenCenter ?? { x: 0, y: 0 };
|
||||
const localCenter = screenToLocal(center.x, center.y);
|
||||
const coordinates = localToCoordinates(localCenter.x, localCenter.y);
|
||||
setLocalCenter({ x: coordinates.latitude, y: coordinates.longitude });
|
||||
}
|
||||
}, [
|
||||
position,
|
||||
screenCenter,
|
||||
screenToLocal,
|
||||
localToCoordinates,
|
||||
setLocalCenter,
|
||||
isUserEditing,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setMapCenter(localCenter.x, localCenter.y);
|
||||
}, [localCenter]);
|
||||
|
||||
function setRotationFromDegrees(degrees: number) {
|
||||
rotateToAngle((degrees * Math.PI) / 180);
|
||||
}
|
||||
|
||||
function pan({ x, y }: { x: number; y: number }) {
|
||||
const coordinates = coordinatesToLocal(x, y);
|
||||
setTransform(coordinates.x, coordinates.y);
|
||||
}
|
||||
|
||||
if (!routeData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
position="absolute"
|
||||
right={8}
|
||||
top={8}
|
||||
bottom={8}
|
||||
p={2}
|
||||
gap={1}
|
||||
minWidth="400px"
|
||||
bgcolor="primary.main"
|
||||
border="1px solid #e0e0e0"
|
||||
borderRadius={2}
|
||||
>
|
||||
<Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center">
|
||||
Детали о достопримечательностях
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={2} direction="row" alignItems="center">
|
||||
<TextField
|
||||
type="number"
|
||||
label="Минимальный масштаб"
|
||||
variant="filled"
|
||||
value={minScale}
|
||||
onChange={(e) => {
|
||||
let newMinScale = Number(e.target.value);
|
||||
|
||||
// Сбрасываем к 1 если меньше
|
||||
if (newMinScale < 1) {
|
||||
newMinScale = 1;
|
||||
}
|
||||
|
||||
setMinScale(newMinScale);
|
||||
|
||||
if (maxScale - newMinScale < 2) {
|
||||
let newMaxScale = newMinScale + 2;
|
||||
// Сбрасываем максимальный к 3 если меньше минимального
|
||||
if (newMaxScale < 3) {
|
||||
newMaxScale = 3;
|
||||
setMinScale(1); // Сбрасываем минимальный к 1
|
||||
}
|
||||
setMaxScale(newMaxScale);
|
||||
}
|
||||
|
||||
if (newMinScale > scale * SCALE_FACTOR) {
|
||||
setScaleAtCenter(newMinScale / SCALE_FACTOR);
|
||||
}
|
||||
}}
|
||||
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
||||
sx={{
|
||||
"& .MuiInputLabel-root": {
|
||||
color: "#fff",
|
||||
},
|
||||
"& .MuiInputBase-input": {
|
||||
color: "#fff",
|
||||
},
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
min: 1,
|
||||
max: 10,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
type="number"
|
||||
label="Максимальный масштаб"
|
||||
variant="filled"
|
||||
value={maxScale}
|
||||
onChange={(e) => {
|
||||
let newMaxScale = Number(e.target.value);
|
||||
|
||||
// Сбрасываем к 3 если меньше минимального
|
||||
if (newMaxScale < 3) {
|
||||
newMaxScale = 3;
|
||||
}
|
||||
|
||||
setMaxScale(newMaxScale);
|
||||
|
||||
if (newMaxScale - minScale < 2) {
|
||||
let newMinScale = newMaxScale - 2;
|
||||
// Сбрасываем минимальный к 1 если меньше
|
||||
if (newMinScale < 1) {
|
||||
newMinScale = 1;
|
||||
setMaxScale(3); // Сбрасываем максимальный к минимальному значению
|
||||
}
|
||||
setMinScale(newMinScale);
|
||||
}
|
||||
|
||||
if (newMaxScale < scale * SCALE_FACTOR) {
|
||||
setScaleAtCenter(newMaxScale / SCALE_FACTOR);
|
||||
}
|
||||
}}
|
||||
style={{ backgroundColor: "#222", borderRadius: 4, color: "#fff" }}
|
||||
sx={{
|
||||
"& .MuiInputLabel-root": {
|
||||
color: "#fff",
|
||||
},
|
||||
"& .MuiInputBase-input": {
|
||||
color: "#fff",
|
||||
},
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
min: 3,
|
||||
max: 10,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Typography variant="body2" sx={{ color: "#fff", textAlign: "center" }}>
|
||||
Текущий масштаб: {Math.round(scale * SCALE_FACTOR * 100) / 100}
|
||||
</Typography>
|
||||
|
||||
<Slider
|
||||
value={scale * SCALE_FACTOR}
|
||||
onChange={(_, newValue) => {
|
||||
if (typeof newValue === "number") {
|
||||
setScaleAtCenter(newValue / SCALE_FACTOR);
|
||||
}
|
||||
}}
|
||||
min={minScale}
|
||||
max={maxScale}
|
||||
step={0.1}
|
||||
sx={{
|
||||
color: "#fff",
|
||||
"& .MuiSlider-thumb": {
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
"& .MuiSlider-track": {
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
"& .MuiSlider-rail": {
|
||||
backgroundColor: "#666",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
type="number"
|
||||
label="Текущий масштаб"
|
||||
variant="filled"
|
||||
value={Math.round(scale * SCALE_FACTOR * 100) / 100}
|
||||
onChange={(e) => {
|
||||
const newScale = Number(e.target.value);
|
||||
if (
|
||||
!isNaN(newScale) &&
|
||||
newScale >= minScale &&
|
||||
newScale <= maxScale
|
||||
) {
|
||||
setScaleAtCenter(newScale / SCALE_FACTOR);
|
||||
}
|
||||
}}
|
||||
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
||||
sx={{
|
||||
"& .MuiInputLabel-root": {
|
||||
color: "#fff",
|
||||
},
|
||||
"& .MuiInputBase-input": {
|
||||
color: "#fff",
|
||||
},
|
||||
}}
|
||||
inputProps={{
|
||||
min: minScale,
|
||||
max: maxScale,
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
type="number"
|
||||
label="Поворот (в градусах)"
|
||||
variant="filled"
|
||||
value={rotationDegrees}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
if (!isNaN(value)) {
|
||||
setRotationFromDegrees(value);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
||||
sx={{
|
||||
"& .MuiInputLabel-root": {
|
||||
color: "#fff",
|
||||
},
|
||||
"& .MuiInputBase-input": {
|
||||
color: "#fff",
|
||||
},
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
min: 0,
|
||||
max: 360,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextField
|
||||
type="number"
|
||||
label="Центр карты, широта"
|
||||
variant="filled"
|
||||
value={Math.round(localCenter.x * 1000) / 1000}
|
||||
onChange={(e) => {
|
||||
setIsUserEditing(true);
|
||||
setLocalCenter((prev) => ({ ...prev, x: Number(e.target.value) }));
|
||||
pan({ x: Number(e.target.value), y: localCenter.y });
|
||||
}}
|
||||
onBlur={() => setIsUserEditing(false)}
|
||||
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
||||
sx={{
|
||||
"& .MuiInputLabel-root": {
|
||||
color: "#fff",
|
||||
},
|
||||
"& .MuiInputBase-input": {
|
||||
color: "#fff",
|
||||
},
|
||||
}}
|
||||
inputProps={{
|
||||
step: 0.001,
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
type="number"
|
||||
label="Центр карты, высота"
|
||||
variant="filled"
|
||||
value={Math.round(localCenter.y * 1000) / 1000}
|
||||
onChange={(e) => {
|
||||
setIsUserEditing(true);
|
||||
setLocalCenter((prev) => ({ ...prev, y: Number(e.target.value) }));
|
||||
pan({ x: localCenter.x, y: Number(e.target.value) });
|
||||
}}
|
||||
onBlur={() => setIsUserEditing(false)}
|
||||
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
||||
sx={{
|
||||
"& .MuiInputLabel-root": {
|
||||
color: "#fff",
|
||||
},
|
||||
"& .MuiInputBase-input": {
|
||||
color: "#fff",
|
||||
},
|
||||
}}
|
||||
inputProps={{
|
||||
step: 0.001,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
sx={{ mt: 2 }}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await saveChanges();
|
||||
toast.success("Изменения сохранены");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Ошибка при сохранении изменений");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Сохранить изменения
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
135
src/pages/Route/route-preview/Sight.tsx
Normal file
135
src/pages/Route/route-preview/Sight.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTransform } from "./TransformContext";
|
||||
import { SightData } from "./types";
|
||||
import { Assets, FederatedMouseEvent, Texture } from "pixi.js";
|
||||
|
||||
import { SIGHT_SIZE, UP_SCALE } from "./Constants";
|
||||
import { coordinatesToLocal, localToCoordinates } from "./utils";
|
||||
import { useMapData } from "./MapDataContext";
|
||||
|
||||
interface SightProps {
|
||||
sight: SightData;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export const Sight = ({ sight, id }: Readonly<SightProps>) => {
|
||||
const { rotation, scale } = useTransform();
|
||||
const { setSightCoordinates, setSelectedSight } = useMapData();
|
||||
|
||||
const [position, setPosition] = useState(
|
||||
coordinatesToLocal(sight.latitude, sight.longitude)
|
||||
);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isPointerDown, setIsPointerDown] = useState(false);
|
||||
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
|
||||
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
const handlePointerDown = (e: FederatedMouseEvent) => {
|
||||
setIsPointerDown(true);
|
||||
setIsDragging(false);
|
||||
setStartPosition({
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
});
|
||||
setStartMousePosition({
|
||||
x: e.globalX,
|
||||
y: e.globalY,
|
||||
});
|
||||
|
||||
e.stopPropagation();
|
||||
};
|
||||
const handlePointerMove = (e: FederatedMouseEvent) => {
|
||||
if (!isPointerDown) return;
|
||||
|
||||
if (!isDragging) {
|
||||
const dx = e.globalX - startMousePosition.x;
|
||||
const dy = e.globalY - startMousePosition.y;
|
||||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
|
||||
setIsDragging(true);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const dx = (e.globalX - startMousePosition.x) / scale / UP_SCALE;
|
||||
const dy = (e.globalY - startMousePosition.y) / scale / UP_SCALE;
|
||||
const cos = Math.cos(rotation);
|
||||
const sin = Math.sin(rotation);
|
||||
const newPosition = {
|
||||
x: startPosition.x + dx * cos + dy * sin,
|
||||
y: startPosition.y - dx * sin + dy * cos,
|
||||
};
|
||||
setPosition(newPosition);
|
||||
const coordinates = localToCoordinates(newPosition.x, newPosition.y);
|
||||
setSightCoordinates(sight.id, coordinates.latitude, coordinates.longitude);
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: FederatedMouseEvent) => {
|
||||
setIsPointerDown(false);
|
||||
|
||||
// Если не было перетаскивания, то это клик
|
||||
if (!isDragging) {
|
||||
setSelectedSight(sight);
|
||||
}
|
||||
|
||||
setIsDragging(false);
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const [texture, setTexture] = useState(Texture.EMPTY);
|
||||
useEffect(() => {
|
||||
Assets.load("/SightIcon.png").then(setTexture);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {}, [id, sight.latitude, sight.longitude]);
|
||||
|
||||
if (!sight) {
|
||||
console.error("sight is null");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Компенсируем масштаб для сохранения постоянного размера
|
||||
const compensatedSize = SIGHT_SIZE / scale;
|
||||
const compensatedFontSize = 24 / scale;
|
||||
|
||||
return (
|
||||
<pixiContainer
|
||||
rotation={-rotation}
|
||||
eventMode="static"
|
||||
interactive
|
||||
onPointerDown={handlePointerDown}
|
||||
onGlobalPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerUpOutside={handlePointerUp}
|
||||
x={position.x * UP_SCALE - SIGHT_SIZE / 2}
|
||||
y={position.y * UP_SCALE - SIGHT_SIZE / 2}
|
||||
>
|
||||
<pixiSprite
|
||||
texture={texture}
|
||||
width={compensatedSize}
|
||||
height={compensatedSize}
|
||||
/>
|
||||
<pixiGraphics
|
||||
draw={(g) => {
|
||||
g.clear();
|
||||
g.circle(0, 0, 20 / scale);
|
||||
g.fill({ color: "#000" });
|
||||
}}
|
||||
x={compensatedSize}
|
||||
y={0}
|
||||
/>
|
||||
<pixiText
|
||||
text={`${id + 1}`}
|
||||
x={compensatedSize + 1 / scale}
|
||||
y={0}
|
||||
anchor={0.5}
|
||||
style={{
|
||||
fontSize: compensatedFontSize,
|
||||
fontWeight: "bold",
|
||||
fill: "#ffffff",
|
||||
}}
|
||||
/>
|
||||
</pixiContainer>
|
||||
);
|
||||
};
|
||||
60
src/pages/Route/route-preview/SightInfoWidget.tsx
Normal file
60
src/pages/Route/route-preview/SightInfoWidget.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Box, Typography, IconButton } from "@mui/material";
|
||||
import { Close } from "@mui/icons-material";
|
||||
import { useMapData } from "./MapDataContext";
|
||||
|
||||
export function SightInfoWidget() {
|
||||
const { selectedSight, setSelectedSight } = useMapData();
|
||||
|
||||
if (!selectedSight) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 16,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.9)",
|
||||
color: "white",
|
||||
padding: "12px 16px",
|
||||
borderRadius: "4px",
|
||||
minWidth: 250,
|
||||
maxWidth: 400,
|
||||
backdropFilter: "blur(10px)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.2)",
|
||||
zIndex: 1000,
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ fontWeight: "bold", color: "#fff" }}>
|
||||
{selectedSight.name}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setSelectedSight(undefined)}
|
||||
sx={{ color: "#fff", p: 0, minWidth: 24, width: 24, height: 24 }}
|
||||
>
|
||||
<Close fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" sx={{ color: "#ccc", mb: 1 }}>
|
||||
{selectedSight.address}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="caption" sx={{ color: "#999" }}>
|
||||
Город: {selectedSight.city}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
575
src/pages/Route/route-preview/Station.tsx
Normal file
575
src/pages/Route/route-preview/Station.tsx
Normal file
@@ -0,0 +1,575 @@
|
||||
import { FederatedMouseEvent, Graphics } from "pixi.js";
|
||||
import { useCallback, useState, useEffect, useRef, FC, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
// --- Заглушки для зависимостей (замените на ваши реальные импорты) ---
|
||||
import {
|
||||
BACKGROUND_COLOR,
|
||||
PATH_COLOR,
|
||||
STATION_RADIUS,
|
||||
STATION_OUTLINE_WIDTH,
|
||||
UP_SCALE,
|
||||
} from "./Constants";
|
||||
import { useTransform } from "./TransformContext";
|
||||
import { StationData } from "./types";
|
||||
import { useMapData } from "./MapDataContext";
|
||||
import { coordinatesToLocal } from "./utils";
|
||||
import { languageStore } from "@shared";
|
||||
// --- Конец заглушек ---
|
||||
|
||||
// --- Декларации для react-pixi ---
|
||||
// (В реальном проекте типы должны быть предоставлены библиотекой @pixi/react-pixi)
|
||||
declare const pixiContainer: any;
|
||||
declare const pixiGraphics: any;
|
||||
declare const pixiText: any;
|
||||
|
||||
// --- Типы ---
|
||||
type HorizontalAlign = "left" | "center" | "right";
|
||||
type VerticalAlign = "top" | "center" | "bottom";
|
||||
type TextAlign = HorizontalAlign | `${HorizontalAlign} ${VerticalAlign}`;
|
||||
type LabelAlign = "left" | "center" | "right";
|
||||
|
||||
// --- Утилиты ---
|
||||
|
||||
/**
|
||||
* Преобразует текстовое позиционирование в anchor координаты.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Получает координату anchor.x из типа выравнивания.
|
||||
*/
|
||||
|
||||
// --- Интерфейсы пропсов ---
|
||||
|
||||
interface StationProps {
|
||||
station: StationData;
|
||||
ruLabel: string | null;
|
||||
anchorPoint?: { x: number; y: number };
|
||||
/** Anchor для всего блока с текстом. По умолчанию: `"right center"` */
|
||||
labelBlockAnchor?: TextAlign | { x: number; y: number };
|
||||
/** Внутреннее выравнивание текста в блоке. По умолчанию: `"left"` */
|
||||
labelAlign?: LabelAlign;
|
||||
/** Callback для изменения внутреннего выравнивания */
|
||||
onLabelAlignChange?: (align: LabelAlign) => void;
|
||||
/** Callback для отслеживания наведения на текст */
|
||||
onTextHover?: (isHovered: boolean) => void;
|
||||
}
|
||||
|
||||
interface LabelAlignmentControlProps {
|
||||
scale: number;
|
||||
currentAlign: LabelAlign;
|
||||
onAlignChange: (align: LabelAlign) => void;
|
||||
onPointerOver: () => void;
|
||||
onPointerOut: () => void;
|
||||
onControlPointerEnter: () => void;
|
||||
onControlPointerLeave: () => void;
|
||||
}
|
||||
|
||||
interface StationLabelProps
|
||||
extends Omit<StationProps, "ruLabelAnchor" | "nameLabelAnchor"> {}
|
||||
|
||||
const getAnchorFromOffset = (
|
||||
offsetX: number,
|
||||
offsetY: number
|
||||
): { x: number; y: number } => {
|
||||
if (offsetX === 0 && offsetY === 0) {
|
||||
return { x: 0.5, y: 0.5 };
|
||||
}
|
||||
|
||||
const length = Math.hypot(offsetX, offsetY);
|
||||
const nx = offsetX / length;
|
||||
const ny = offsetY / length;
|
||||
|
||||
return { x: (1 - nx) / 2, y: (1 - ny) / 2 };
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// Компонент: Панель управления выравниванием в стиле УрФУ
|
||||
// =========================================================================
|
||||
|
||||
const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
|
||||
scale,
|
||||
currentAlign,
|
||||
onAlignChange,
|
||||
|
||||
onControlPointerEnter,
|
||||
onControlPointerLeave,
|
||||
}) => {
|
||||
const controlHeight = 50 / scale;
|
||||
const controlWidth = 200 / scale;
|
||||
const fontSize = 18 / scale;
|
||||
const borderRadius = 8 / scale;
|
||||
const compensatedRuFontSize = (26 * 0.75) / scale;
|
||||
const buttonWidth = controlWidth / 3;
|
||||
const strokeWidth = 2 / scale;
|
||||
|
||||
const drawBg = useCallback(
|
||||
(g: Graphics) => {
|
||||
g.clear();
|
||||
|
||||
// Основной фон с градиентом
|
||||
g.roundRect(
|
||||
-controlWidth / 2,
|
||||
0,
|
||||
controlWidth,
|
||||
controlHeight,
|
||||
borderRadius
|
||||
);
|
||||
g.fill({ color: "#1a1a1a" }); // Темный фон как у УрФУ
|
||||
|
||||
// Тонкая рамка
|
||||
g.roundRect(
|
||||
-controlWidth / 2,
|
||||
0,
|
||||
controlWidth,
|
||||
controlHeight,
|
||||
borderRadius
|
||||
);
|
||||
g.stroke({ color: "#333333", width: strokeWidth });
|
||||
|
||||
// Разделители между кнопками
|
||||
for (let i = 1; i < 3; i++) {
|
||||
const x = -controlWidth / 2 + buttonWidth * i;
|
||||
g.moveTo(x, strokeWidth);
|
||||
g.lineTo(x, controlHeight - strokeWidth);
|
||||
g.stroke({ color: "#333333", width: strokeWidth });
|
||||
}
|
||||
},
|
||||
[controlWidth, controlHeight, borderRadius, buttonWidth, strokeWidth]
|
||||
);
|
||||
|
||||
const drawButtonHighlight = useCallback(
|
||||
(g: Graphics, index: number, isActive: boolean) => {
|
||||
g.clear();
|
||||
|
||||
if (isActive) {
|
||||
const x = -controlWidth / 2 + buttonWidth * index;
|
||||
g.roundRect(
|
||||
x + strokeWidth,
|
||||
strokeWidth,
|
||||
buttonWidth - strokeWidth * 2,
|
||||
controlHeight - strokeWidth * 2,
|
||||
borderRadius / 2
|
||||
);
|
||||
g.fill({ color: "#0066cc", alpha: 0.8 }); // Синий акцент УрФУ
|
||||
}
|
||||
},
|
||||
[controlWidth, controlHeight, buttonWidth, strokeWidth, borderRadius]
|
||||
);
|
||||
|
||||
const getTextStyle = (isActive: boolean) => ({
|
||||
fontSize,
|
||||
fontWeight: isActive ? ("bold" as const) : ("normal" as const),
|
||||
fill: isActive ? "#ffffff" : "#cccccc",
|
||||
fontFamily: "Arial, sans-serif",
|
||||
});
|
||||
|
||||
const alignOptions = [
|
||||
{ key: "left" as const, label: "Left" },
|
||||
{ key: "center" as const, label: "Center" },
|
||||
{ key: "right" as const, label: "Right" },
|
||||
];
|
||||
|
||||
return (
|
||||
<pixiContainer
|
||||
position={{ x: 0, y: compensatedRuFontSize * 1.1 + 15 / scale }}
|
||||
zIndex={999999999999999999}
|
||||
eventMode="static"
|
||||
onPointerOver={(e: FederatedMouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onControlPointerEnter();
|
||||
}}
|
||||
onPointerOut={(e: FederatedMouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onControlPointerLeave();
|
||||
}}
|
||||
onPointerDown={(e: FederatedMouseEvent) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{/* Основной фон */}
|
||||
<pixiGraphics draw={drawBg} />
|
||||
|
||||
{/* Кнопки с подсветкой */}
|
||||
{alignOptions.map((option, index) => (
|
||||
<pixiContainer key={option.key}>
|
||||
{/* Подсветка активной кнопки */}
|
||||
<pixiGraphics
|
||||
draw={(g: Graphics) =>
|
||||
drawButtonHighlight(g, index, option.key === currentAlign)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Текст кнопки */}
|
||||
<pixiText
|
||||
text={option.label}
|
||||
anchor={{ x: 0.5, y: 0.5 }}
|
||||
position={{
|
||||
x: -controlWidth / 2 + buttonWidth * (index + 0.5),
|
||||
y: controlHeight / 2,
|
||||
}}
|
||||
style={getTextStyle(option.key === currentAlign)}
|
||||
eventMode="static"
|
||||
cursor="pointer"
|
||||
onClick={(e: FederatedMouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onAlignChange(option.key);
|
||||
}}
|
||||
onPointerDown={(e: FederatedMouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onAlignChange(option.key);
|
||||
}}
|
||||
onPointerOver={(e: FederatedMouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onControlPointerEnter();
|
||||
}}
|
||||
/>
|
||||
</pixiContainer>
|
||||
))}
|
||||
</pixiContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// Компонент: Метка Станции (с логикой)
|
||||
// =========================================================================
|
||||
|
||||
const StationLabel = observer(
|
||||
({
|
||||
station,
|
||||
ruLabel,
|
||||
|
||||
labelAlign: labelAlignProp = "center",
|
||||
onLabelAlignChange,
|
||||
onTextHover,
|
||||
}: Readonly<StationLabelProps>) => {
|
||||
const { language } = languageStore;
|
||||
const { rotation, scale } = useTransform();
|
||||
const { setStationOffset, setStationAlign } = useMapData();
|
||||
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isPointerDown, setIsPointerDown] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isControlHovered, setIsControlHovered] = useState(false);
|
||||
const [currentLabelAlign, setCurrentLabelAlign] = useState(labelAlignProp);
|
||||
const [ruLabelWidth, setRuLabelWidth] = useState(0);
|
||||
|
||||
const dragStartPos = useRef({ x: 0, y: 0 });
|
||||
const mouseStartPos = useRef({ x: 0, y: 0 });
|
||||
const hideTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
const ruLabelRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handlePointerEnter = () => {
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
hideTimer.current = null;
|
||||
}
|
||||
setIsHovered(true);
|
||||
onTextHover?.(true); // Call the callback to indicate text is hovered
|
||||
};
|
||||
|
||||
const handleControlPointerEnter = () => {
|
||||
// Дополнительная обработка для панели управления
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
hideTimer.current = null;
|
||||
}
|
||||
setIsControlHovered(true);
|
||||
setIsHovered(true);
|
||||
onTextHover?.(true); // Call the callback to indicate text/control is hovered
|
||||
};
|
||||
|
||||
const handleControlPointerLeave = () => {
|
||||
setIsControlHovered(false);
|
||||
// Если курсор не над основным контейнером, скрываем панель через некоторое время
|
||||
if (!isHovered) {
|
||||
hideTimer.current = setTimeout(() => {
|
||||
setIsHovered(false);
|
||||
onTextHover?.(false); // Call the callback to indicate neither text nor control is hovered
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
// Увеличиваем время до скрытия панели и добавляем проверку
|
||||
hideTimer.current = setTimeout(() => {
|
||||
setIsHovered(false);
|
||||
// Если курсор не над панелью управления, скрываем и её
|
||||
if (!isControlHovered) {
|
||||
setIsControlHovered(false);
|
||||
}
|
||||
onTextHover?.(false); // Call the callback to indicate text is no longer hovered
|
||||
}, 100); // Увеличиваем время до скрытия панели
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setPosition({ x: station.offset_x ?? 0, y: station.offset_y ?? 0 });
|
||||
}, [station.offset_x, station.offset_y, station.id]);
|
||||
|
||||
// Функция для конвертации числового align в строковый
|
||||
const convertNumericAlign = (align: number): LabelAlign => {
|
||||
switch (align) {
|
||||
case 0:
|
||||
return "left";
|
||||
case 1:
|
||||
return "center";
|
||||
case 2:
|
||||
return "right";
|
||||
default:
|
||||
return "center";
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для конвертации строкового align в числовой
|
||||
const convertStringAlign = (align: LabelAlign): number => {
|
||||
switch (align) {
|
||||
case "left":
|
||||
return 0;
|
||||
case "center":
|
||||
return 1;
|
||||
case "right":
|
||||
return 2;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentLabelAlign(convertNumericAlign(station.align ?? 1));
|
||||
}, [station.align]);
|
||||
|
||||
if (!station) return null;
|
||||
|
||||
const coordinates = coordinatesToLocal(station.latitude, station.longitude);
|
||||
const compensatedRuFontSize = (26 * 0.75) / scale;
|
||||
const compensatedNameFontSize = (16 * 0.75) / scale;
|
||||
|
||||
// Измеряем ширину верхнего лейбла
|
||||
useEffect(() => {
|
||||
if (ruLabelRef.current && ruLabel) {
|
||||
setRuLabelWidth(ruLabelRef.current.width);
|
||||
}
|
||||
}, [ruLabel, compensatedRuFontSize]);
|
||||
|
||||
const handlePointerDown = (e: FederatedMouseEvent) => {
|
||||
setIsPointerDown(true);
|
||||
setIsDragging(false);
|
||||
dragStartPos.current = { ...position };
|
||||
mouseStartPos.current = { x: e.global.x, y: e.global.y };
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: FederatedMouseEvent) => {
|
||||
if (!isPointerDown) return;
|
||||
|
||||
if (!isDragging) {
|
||||
const dx = e.global.x - mouseStartPos.current.x;
|
||||
const dy = e.global.y - mouseStartPos.current.y;
|
||||
if (Math.hypot(dx, dy) > 3) setIsDragging(true);
|
||||
else return;
|
||||
}
|
||||
|
||||
const dx_screen = e.global.x - mouseStartPos.current.x;
|
||||
const dy_screen = e.global.y - mouseStartPos.current.y;
|
||||
|
||||
const newPosition = {
|
||||
x: dragStartPos.current.x + dx_screen,
|
||||
y: dragStartPos.current.y + dy_screen,
|
||||
};
|
||||
|
||||
// Проверяем, изменилась ли позиция
|
||||
if (
|
||||
Math.abs(newPosition.x - position.x) > 0.01 ||
|
||||
Math.abs(newPosition.y - position.y) > 0.01
|
||||
) {
|
||||
setPosition(newPosition);
|
||||
setStationOffset(station.id, newPosition.x, newPosition.y);
|
||||
}
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: FederatedMouseEvent) => {
|
||||
setIsPointerDown(false);
|
||||
setTimeout(() => setIsDragging(false), 50);
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleAlignChange = async (align: LabelAlign) => {
|
||||
setCurrentLabelAlign(align);
|
||||
onLabelAlignChange?.(align);
|
||||
// Сохраняем в стор
|
||||
const numericAlign = convertStringAlign(align);
|
||||
setStationAlign(station.id, numericAlign);
|
||||
};
|
||||
|
||||
const dynamicAnchor = useMemo(
|
||||
() => getAnchorFromOffset(position.x, position.y),
|
||||
[position.x, position.y]
|
||||
);
|
||||
|
||||
// Функция для расчета позиции нижнего лейбла относительно ширины верхнего
|
||||
const getSecondLabelPosition = (): number => {
|
||||
if (!ruLabelWidth) return 0;
|
||||
|
||||
switch (currentLabelAlign) {
|
||||
case "left":
|
||||
// Позиционируем относительно левого края верхнего текста
|
||||
return -ruLabelWidth / 2;
|
||||
case "center":
|
||||
// Центрируем относительно центра верхнего текста
|
||||
return 0;
|
||||
case "right":
|
||||
// Позиционируем относительно правого края верхнего текста
|
||||
return ruLabelWidth / 2;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для расчета anchor нижнего лейбла
|
||||
const getSecondLabelAnchor = (): number => {
|
||||
switch (currentLabelAlign) {
|
||||
case "left":
|
||||
return 0; // anchor.x = 0 (левый край)
|
||||
case "center":
|
||||
return 0.5; // anchor.x = 0.5 (центр)
|
||||
case "right":
|
||||
return 1; // anchor.x = 1 (правый край)
|
||||
default:
|
||||
return 0.5;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<pixiContainer
|
||||
x={coordinates.x * UP_SCALE}
|
||||
y={coordinates.y * UP_SCALE}
|
||||
rotation={-rotation}
|
||||
zIndex={isHovered || isControlHovered ? 1000 : 0}
|
||||
eventMode="static"
|
||||
interactive
|
||||
cursor={isDragging ? "grabbing" : "grab"}
|
||||
onPointerOver={handlePointerEnter}
|
||||
onPointerOut={handlePointerLeave}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerUpOutside={handlePointerUp}
|
||||
onGlobalPointerMove={handlePointerMove}
|
||||
>
|
||||
<pixiContainer
|
||||
position={{
|
||||
x:
|
||||
(position.x + Math.cos(Math.atan2(position.y, position.x))) /
|
||||
scale,
|
||||
y:
|
||||
(position.y + Math.sin(Math.atan2(position.y, position.x))) /
|
||||
scale,
|
||||
}}
|
||||
anchor={dynamicAnchor}
|
||||
zIndex={isHovered || isControlHovered ? 1000 : 0}
|
||||
>
|
||||
{ruLabel && (
|
||||
<pixiText
|
||||
ref={ruLabelRef}
|
||||
text={ruLabel}
|
||||
position={{ x: 0, y: 0 }}
|
||||
anchor={{ x: 0.5, y: 0.5 }}
|
||||
style={{
|
||||
fontSize: compensatedRuFontSize,
|
||||
fontWeight: "bold",
|
||||
fill: "#ffffff",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{station.name && language !== "ru" && ruLabel && (
|
||||
<pixiText
|
||||
text={station.name}
|
||||
position={{
|
||||
x: getSecondLabelPosition(),
|
||||
y: compensatedRuFontSize * 1.1,
|
||||
}}
|
||||
anchor={{ x: getSecondLabelAnchor(), y: 0.5 }}
|
||||
style={{
|
||||
fontSize: compensatedNameFontSize,
|
||||
fontWeight: "bold",
|
||||
fill: "#CCCCCC",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(isHovered || isControlHovered) && !isDragging && (
|
||||
<LabelAlignmentControl
|
||||
scale={scale}
|
||||
currentAlign={currentLabelAlign}
|
||||
onAlignChange={handleAlignChange}
|
||||
onPointerOver={handlePointerEnter}
|
||||
onPointerOut={handlePointerLeave}
|
||||
onControlPointerEnter={handleControlPointerEnter}
|
||||
onControlPointerLeave={handleControlPointerLeave}
|
||||
/>
|
||||
)}
|
||||
</pixiContainer>
|
||||
</pixiContainer>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// =========================================================================
|
||||
// Главный экспортируемый компонент: Станция
|
||||
// =========================================================================
|
||||
|
||||
export const Station = ({
|
||||
station,
|
||||
ruLabel,
|
||||
|
||||
labelAlign,
|
||||
onLabelAlignChange,
|
||||
}: Readonly<StationProps>) => {
|
||||
const [isTextHovered, setIsTextHovered] = useState(false);
|
||||
|
||||
const draw = useCallback(
|
||||
(g: Graphics) => {
|
||||
g.clear();
|
||||
const coordinates = coordinatesToLocal(
|
||||
station.latitude,
|
||||
station.longitude
|
||||
);
|
||||
|
||||
const radius = STATION_RADIUS;
|
||||
const strokeWidth = STATION_OUTLINE_WIDTH;
|
||||
|
||||
g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius);
|
||||
|
||||
// Change fill color when text is hovered
|
||||
if (isTextHovered) {
|
||||
g.fill({ color: 0x00aaff }); // Highlight color when hovered
|
||||
g.stroke({ color: 0xffffff, width: strokeWidth + 1 }); // Brighter outline when hovered
|
||||
} else {
|
||||
g.fill({ color: PATH_COLOR });
|
||||
g.stroke({ color: BACKGROUND_COLOR, width: strokeWidth });
|
||||
}
|
||||
},
|
||||
[station.latitude, station.longitude, isTextHovered]
|
||||
);
|
||||
|
||||
return (
|
||||
<pixiContainer zIndex={isTextHovered ? 1000 : 0}>
|
||||
<pixiGraphics draw={draw} />
|
||||
<StationLabel
|
||||
station={station}
|
||||
ruLabel={ruLabel}
|
||||
labelAlign={labelAlign}
|
||||
onLabelAlignChange={onLabelAlignChange}
|
||||
onTextHover={setIsTextHovered}
|
||||
/>
|
||||
</pixiContainer>
|
||||
);
|
||||
};
|
||||
247
src/pages/Route/route-preview/TransformContext.tsx
Normal file
247
src/pages/Route/route-preview/TransformContext.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { SCALE_FACTOR, UP_SCALE } from "./Constants";
|
||||
|
||||
const TransformContext = createContext<{
|
||||
position: { x: number; y: number };
|
||||
scale: number;
|
||||
rotation: number;
|
||||
screenCenter?: { x: number; y: number };
|
||||
|
||||
setPosition: React.Dispatch<React.SetStateAction<{ x: number; y: number }>>;
|
||||
setScale: React.Dispatch<React.SetStateAction<number>>;
|
||||
setRotation: React.Dispatch<React.SetStateAction<number>>;
|
||||
screenToLocal: (x: number, y: number) => { x: number; y: number };
|
||||
localToScreen: (x: number, y: number) => { x: number; y: number };
|
||||
rotateToAngle: (to: number, fromPosition?: { x: number; y: number }) => void;
|
||||
setTransform: (
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
rotationDegrees?: number,
|
||||
scale?: number
|
||||
) => void;
|
||||
setScaleOnly: (newScale: number) => void;
|
||||
setScaleWithoutMovingCenter: (newScale: number) => void;
|
||||
setScreenCenter: React.Dispatch<
|
||||
React.SetStateAction<{ x: number; y: number } | undefined>
|
||||
>;
|
||||
setScaleAtCenter: (newScale: number) => void;
|
||||
}>({
|
||||
position: { x: 0, y: 0 },
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
screenCenter: undefined,
|
||||
setPosition: () => {},
|
||||
setScale: () => {},
|
||||
setRotation: () => {},
|
||||
screenToLocal: () => ({ x: 0, y: 0 }),
|
||||
localToScreen: () => ({ x: 0, y: 0 }),
|
||||
rotateToAngle: () => {},
|
||||
setTransform: () => {},
|
||||
setScaleOnly: () => {},
|
||||
setScaleWithoutMovingCenter: () => {},
|
||||
setScreenCenter: () => {},
|
||||
setScaleAtCenter: () => {},
|
||||
});
|
||||
|
||||
// Provider component
|
||||
export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [scale, setScale] = useState(1);
|
||||
const [rotation, setRotation] = useState(0);
|
||||
const [screenCenter, setScreenCenter] = useState<{ x: number; y: number }>();
|
||||
|
||||
const screenToLocal = useCallback(
|
||||
(screenX: number, screenY: number) => {
|
||||
// Translate point relative to current pan position
|
||||
const translatedX = (screenX - position.x) / scale;
|
||||
const translatedY = (screenY - position.y) / scale;
|
||||
|
||||
// Rotate point around center
|
||||
const cosRotation = Math.cos(-rotation); // Negative rotation to reverse transform
|
||||
const sinRotation = Math.sin(-rotation);
|
||||
const rotatedX = translatedX * cosRotation - translatedY * sinRotation;
|
||||
const rotatedY = translatedX * sinRotation + translatedY * cosRotation;
|
||||
|
||||
return {
|
||||
x: rotatedX / UP_SCALE,
|
||||
y: rotatedY / UP_SCALE,
|
||||
};
|
||||
},
|
||||
[position.x, position.y, scale, rotation]
|
||||
);
|
||||
|
||||
// Inverse of screenToLocal
|
||||
const localToScreen = useCallback(
|
||||
(localX: number, localY: number) => {
|
||||
const upscaledX = localX * UP_SCALE;
|
||||
const upscaledY = localY * UP_SCALE;
|
||||
|
||||
const cosRotation = Math.cos(rotation);
|
||||
const sinRotation = Math.sin(rotation);
|
||||
const rotatedX = upscaledX * cosRotation - upscaledY * sinRotation;
|
||||
const rotatedY = upscaledX * sinRotation + upscaledY * cosRotation;
|
||||
|
||||
const translatedX = rotatedX * scale + position.x;
|
||||
const translatedY = rotatedY * scale + position.y;
|
||||
|
||||
return {
|
||||
x: translatedX,
|
||||
y: translatedY,
|
||||
};
|
||||
},
|
||||
[position.x, position.y, scale, rotation]
|
||||
);
|
||||
|
||||
const rotateToAngle = useCallback(
|
||||
(to: number, fromPosition?: { x: number; y: number }) => {
|
||||
const rotationDiff = to - rotation;
|
||||
|
||||
const center = screenCenter ?? { x: 0, y: 0 };
|
||||
const cosDelta = Math.cos(rotationDiff);
|
||||
const sinDelta = Math.sin(rotationDiff);
|
||||
|
||||
const currentFromPosition = fromPosition ?? position;
|
||||
|
||||
const newPosition = {
|
||||
x:
|
||||
center.x * (1 - cosDelta) +
|
||||
currentFromPosition.x * cosDelta +
|
||||
(center.y - currentFromPosition.y) * sinDelta,
|
||||
y:
|
||||
center.y * (1 - cosDelta) +
|
||||
currentFromPosition.y * cosDelta +
|
||||
(currentFromPosition.x - center.x) * sinDelta,
|
||||
};
|
||||
|
||||
// Update both rotation and position in a single batch to avoid stale closure
|
||||
setRotation(to);
|
||||
setPosition(newPosition);
|
||||
},
|
||||
[rotation, position, screenCenter]
|
||||
);
|
||||
|
||||
const setTransform = useCallback(
|
||||
(
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
rotationDegrees?: number,
|
||||
useScale?: number
|
||||
) => {
|
||||
const selectedRotation =
|
||||
rotationDegrees !== undefined
|
||||
? (rotationDegrees * Math.PI) / 180
|
||||
: rotation;
|
||||
const selectedScale =
|
||||
useScale !== undefined ? useScale / SCALE_FACTOR : scale;
|
||||
const center = screenCenter ?? { x: 0, y: 0 };
|
||||
|
||||
const newPosition = {
|
||||
x: -latitude * UP_SCALE * selectedScale,
|
||||
y: -longitude * UP_SCALE * selectedScale,
|
||||
};
|
||||
|
||||
const cosRot = Math.cos(selectedRotation);
|
||||
const sinRot = Math.sin(selectedRotation);
|
||||
|
||||
// Translate point relative to center, rotate, then translate back
|
||||
const dx = newPosition.x;
|
||||
const dy = newPosition.y;
|
||||
newPosition.x = dx * cosRot - dy * sinRot + center.x;
|
||||
newPosition.y = dx * sinRot + dy * cosRot + center.y;
|
||||
|
||||
// Batch state updates to avoid intermediate renders
|
||||
setPosition(newPosition);
|
||||
setRotation(selectedRotation);
|
||||
setScale(selectedScale);
|
||||
},
|
||||
[rotation, scale, screenCenter]
|
||||
);
|
||||
|
||||
const setScaleAtCenter = useCallback(
|
||||
(newScale: number) => {
|
||||
if (scale === newScale) return;
|
||||
|
||||
const center = screenCenter ?? { x: 0, y: 0 };
|
||||
|
||||
const actualZoomFactor = newScale / scale;
|
||||
|
||||
const newPosition = {
|
||||
x: position.x + (center.x - position.x) * (1 - actualZoomFactor),
|
||||
y: position.y + (center.y - position.y) * (1 - actualZoomFactor),
|
||||
};
|
||||
|
||||
setPosition(newPosition);
|
||||
setScale(newScale);
|
||||
},
|
||||
[position, scale, screenCenter]
|
||||
);
|
||||
|
||||
const setScaleOnly = useCallback((newScale: number) => {
|
||||
// Изменяем только масштаб, не трогая позицию и поворот
|
||||
setScale(newScale);
|
||||
}, []);
|
||||
|
||||
const setScaleWithoutMovingCenter = useCallback(
|
||||
(newScale: number) => {
|
||||
setScale(newScale);
|
||||
},
|
||||
[setScale]
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
position,
|
||||
scale,
|
||||
rotation,
|
||||
screenCenter,
|
||||
setPosition,
|
||||
setScale,
|
||||
setRotation,
|
||||
rotateToAngle,
|
||||
screenToLocal,
|
||||
localToScreen,
|
||||
setTransform,
|
||||
setScaleOnly,
|
||||
setScaleWithoutMovingCenter,
|
||||
setScreenCenter,
|
||||
setScaleAtCenter,
|
||||
}),
|
||||
[
|
||||
position,
|
||||
scale,
|
||||
rotation,
|
||||
screenCenter,
|
||||
setScale,
|
||||
rotateToAngle,
|
||||
screenToLocal,
|
||||
localToScreen,
|
||||
setTransform,
|
||||
setScaleOnly,
|
||||
setScaleWithoutMovingCenter,
|
||||
setScreenCenter,
|
||||
setScaleAtCenter,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<TransformContext.Provider value={value}>
|
||||
{children}
|
||||
</TransformContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom hook for easy access to transform values
|
||||
export const useTransform = () => {
|
||||
const context = useContext(TransformContext);
|
||||
if (!context) {
|
||||
throw new Error("useTransform must be used within a TransformProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
34
src/pages/Route/route-preview/TravelPath.tsx
Normal file
34
src/pages/Route/route-preview/TravelPath.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Graphics } from "pixi.js";
|
||||
import { useCallback } from "react";
|
||||
import { PATH_COLOR, PATH_WIDTH } from "./Constants";
|
||||
import { coordinatesToLocal } from "./utils";
|
||||
|
||||
interface TravelPathProps {
|
||||
points: { x: number; y: number }[];
|
||||
}
|
||||
|
||||
export function TravelPath({ points }: Readonly<TravelPathProps>) {
|
||||
const draw = useCallback(
|
||||
(g: Graphics) => {
|
||||
g.clear();
|
||||
const coordStart = coordinatesToLocal(points[0].x, points[0].y);
|
||||
g.moveTo(coordStart.x, coordStart.y);
|
||||
for (let i = 1; i <= points.length - 1; i++) {
|
||||
const coordinates = coordinatesToLocal(points[i].x, points[i].y);
|
||||
g.lineTo(coordinates.x, coordinates.y);
|
||||
}
|
||||
g.stroke({
|
||||
color: PATH_COLOR,
|
||||
width: PATH_WIDTH,
|
||||
});
|
||||
},
|
||||
[points]
|
||||
);
|
||||
|
||||
if (points.length === 0) {
|
||||
console.error("points is empty");
|
||||
return null;
|
||||
}
|
||||
|
||||
return <pixiGraphics draw={draw} />;
|
||||
}
|
||||
140
src/pages/Route/route-preview/Widgets.tsx
Normal file
140
src/pages/Route/route-preview/Widgets.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { Stack, Typography, Box, IconButton } from "@mui/material";
|
||||
import { Close } from "@mui/icons-material";
|
||||
import { Landmark } from "lucide-react";
|
||||
import { useMapData } from "./MapDataContext";
|
||||
|
||||
export function Widgets() {
|
||||
const { selectedSight, setSelectedSight } = useMapData();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="column"
|
||||
spacing={2}
|
||||
position="absolute"
|
||||
top={32}
|
||||
left={32}
|
||||
sx={{ pointerEvents: "none" }}
|
||||
>
|
||||
<Stack
|
||||
bgcolor="primary.main"
|
||||
width={361}
|
||||
height={96}
|
||||
p={2}
|
||||
m={2}
|
||||
borderRadius={2}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Typography variant="h6" sx={{ color: "#fff" }}>
|
||||
Станция
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{/* Виджет выбранной достопримечательности (заменяет виджет погоды) */}
|
||||
<Stack
|
||||
bgcolor="primary.main"
|
||||
width={223}
|
||||
height={262}
|
||||
p={2}
|
||||
m={2}
|
||||
borderRadius={2}
|
||||
sx={{
|
||||
pointerEvents: "auto",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{selectedSight ? (
|
||||
<Box
|
||||
sx={{ height: "100%", display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
{/* Заголовок с кнопкой закрытия */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", gap: 0.5 }}>
|
||||
<Landmark size={16} className="shrink-0" />
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ color: "#fff", fontWeight: "bold" }}
|
||||
>
|
||||
{selectedSight.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setSelectedSight(undefined)}
|
||||
sx={{
|
||||
color: "#fff",
|
||||
p: 0,
|
||||
minWidth: 20,
|
||||
width: 20,
|
||||
height: 20,
|
||||
"&:hover": { backgroundColor: "rgba(255, 255, 255, 0.1)" },
|
||||
}}
|
||||
>
|
||||
<Close fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Описание достопримечательности */}
|
||||
{selectedSight.address && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "#fff",
|
||||
mb: 1,
|
||||
opacity: 0.9,
|
||||
lineHeight: 1.3,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
}}
|
||||
>
|
||||
{selectedSight.address}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Город */}
|
||||
{selectedSight.city && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "#fff",
|
||||
opacity: 0.7,
|
||||
mt: "auto",
|
||||
}}
|
||||
>
|
||||
Город: {selectedSight.city}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Landmark size={32} />
|
||||
<Typography variant="body2" sx={{ color: "#fff", opacity: 0.8 }}>
|
||||
Выберите достопримечательность
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
190
src/pages/Route/route-preview/index.tsx
Normal file
190
src/pages/Route/route-preview/index.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { Widgets } from "./Widgets";
|
||||
import { Application, extend } from "@pixi/react";
|
||||
import {
|
||||
Container,
|
||||
Graphics,
|
||||
Sprite,
|
||||
Texture,
|
||||
TilingSprite,
|
||||
Text,
|
||||
} from "pixi.js";
|
||||
import { Stack } from "@mui/material";
|
||||
import { MapDataProvider, useMapData } from "./MapDataContext";
|
||||
import { TransformProvider, useTransform } from "./TransformContext";
|
||||
import { InfiniteCanvas } from "./InfiniteCanvas";
|
||||
|
||||
import { TravelPath } from "./TravelPath";
|
||||
import { LeftSidebar } from "./LeftSidebar";
|
||||
import { RightSidebar } from "./RightSidebar";
|
||||
|
||||
import { coordinatesToLocal } from "./utils";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { languageStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Sight } from "./Sight";
|
||||
import { SightData } from "./types";
|
||||
import { Station } from "./Station";
|
||||
import { UP_SCALE } from "./Constants";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
|
||||
extend({
|
||||
Container,
|
||||
Graphics,
|
||||
Sprite,
|
||||
Texture,
|
||||
TilingSprite,
|
||||
Text,
|
||||
});
|
||||
|
||||
const Loading = () => {
|
||||
const { isRouteLoading, isStationLoading, isSightLoading } = useMapData();
|
||||
|
||||
if (isRouteLoading || isStationLoading || isSightLoading) {
|
||||
return (
|
||||
<div className="fixed flex z-1 items-center justify-center h-screen w-screen bg-[#111]">
|
||||
<CircularProgress />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
export const RoutePreview = () => {
|
||||
const { routeData, stationData, sightData } = useMapData();
|
||||
return (
|
||||
<MapDataProvider>
|
||||
<TransformProvider>
|
||||
<Stack direction="row" height="100vh" width="100vw" overflow="hidden">
|
||||
{routeData && stationData && sightData ? <LanguageSwitcher /> : null}
|
||||
<Loading />
|
||||
<LeftSidebar />
|
||||
<Stack direction="row" flex={1} position="relative" height="100%">
|
||||
<RouteMap />
|
||||
<Widgets />
|
||||
<RightSidebar />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</TransformProvider>
|
||||
</MapDataProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const RouteMap = observer(() => {
|
||||
const { language } = languageStore;
|
||||
const { setPosition, setTransform, screenCenter } = useTransform();
|
||||
const {
|
||||
routeData,
|
||||
stationData,
|
||||
sightData,
|
||||
originalRouteData,
|
||||
originalSightData,
|
||||
} = useMapData();
|
||||
|
||||
const [points, setPoints] = useState<{ x: number; y: number }[]>([]);
|
||||
const [isSetup, setIsSetup] = useState(false);
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = "auto";
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (originalRouteData) {
|
||||
const path = originalRouteData?.path;
|
||||
const points =
|
||||
path?.map(([x, y]: [number, number]) => ({
|
||||
x: x * UP_SCALE,
|
||||
y: y * UP_SCALE,
|
||||
})) ?? [];
|
||||
setPoints(points);
|
||||
}
|
||||
}, [originalRouteData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSetup || !screenCenter) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
originalRouteData?.center_latitude ===
|
||||
originalRouteData?.center_longitude &&
|
||||
originalRouteData?.center_latitude === 0
|
||||
) {
|
||||
if (points.length > 0) {
|
||||
let boundingBox = {
|
||||
from: { x: Infinity, y: Infinity },
|
||||
to: { x: -Infinity, y: -Infinity },
|
||||
};
|
||||
for (const point of points) {
|
||||
boundingBox.from.x = Math.min(boundingBox.from.x, point.x);
|
||||
boundingBox.from.y = Math.min(boundingBox.from.y, point.y);
|
||||
boundingBox.to.x = Math.max(boundingBox.to.x, point.x);
|
||||
boundingBox.to.y = Math.max(boundingBox.to.y, point.y);
|
||||
}
|
||||
const newCenter = {
|
||||
x: -(boundingBox.from.x + boundingBox.to.x) / 2,
|
||||
y: -(boundingBox.from.y + boundingBox.to.y) / 2,
|
||||
};
|
||||
setPosition(newCenter);
|
||||
setIsSetup(true);
|
||||
}
|
||||
} else if (
|
||||
originalRouteData?.center_latitude &&
|
||||
originalRouteData?.center_longitude
|
||||
) {
|
||||
const coordinates = coordinatesToLocal(
|
||||
originalRouteData?.center_latitude,
|
||||
originalRouteData?.center_longitude
|
||||
);
|
||||
|
||||
setTransform(
|
||||
coordinates.x,
|
||||
coordinates.y,
|
||||
originalRouteData?.rotate,
|
||||
originalRouteData?.scale_min
|
||||
);
|
||||
setIsSetup(true);
|
||||
}
|
||||
}, [
|
||||
points,
|
||||
originalRouteData?.center_latitude,
|
||||
originalRouteData?.center_longitude,
|
||||
originalRouteData?.rotate,
|
||||
isSetup,
|
||||
screenCenter,
|
||||
]);
|
||||
|
||||
if (!routeData || !stationData || !sightData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", height: "100%" }} ref={parentRef}>
|
||||
<LanguageSwitcher />
|
||||
<Application resizeTo={parentRef} background="#fff" preference="webgl">
|
||||
<InfiniteCanvas>
|
||||
<TravelPath points={points} />
|
||||
{stationData[language].map((obj, index) => (
|
||||
<Station
|
||||
station={obj}
|
||||
key={obj.id}
|
||||
ruLabel={
|
||||
language === "ru"
|
||||
? stationData.ru[index].name
|
||||
: stationData.ru[index].name
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{originalSightData?.map((sight: SightData, index: number) => {
|
||||
return <Sight sight={sight} id={index} key={sight.id} />;
|
||||
})}
|
||||
</InfiniteCanvas>
|
||||
</Application>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
72
src/pages/Route/route-preview/types.ts
Normal file
72
src/pages/Route/route-preview/types.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export interface RouteData {
|
||||
carrier: string;
|
||||
carrier_id: number;
|
||||
center_latitude: number;
|
||||
center_longitude: number;
|
||||
governor_appeal: number;
|
||||
id: number;
|
||||
path: [number, number][];
|
||||
rotate: number;
|
||||
route_direction: boolean;
|
||||
route_number: string;
|
||||
route_sys_number: string;
|
||||
scale_max: number;
|
||||
scale_min: number;
|
||||
thumbnail?: string; // uuid логотипа маршрута
|
||||
}
|
||||
|
||||
export interface StationTransferData {
|
||||
bus: string;
|
||||
metro_blue: string;
|
||||
metro_green: string;
|
||||
metro_orange: string;
|
||||
metro_purple: string;
|
||||
metro_red: string;
|
||||
train: string;
|
||||
tram: string;
|
||||
trolleybus: string;
|
||||
}
|
||||
|
||||
export interface StationData {
|
||||
address: string;
|
||||
city_id?: number;
|
||||
description: string;
|
||||
id: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
name: string;
|
||||
offset_x: number;
|
||||
offset_y: number;
|
||||
system_name: string;
|
||||
transfers: StationTransferData;
|
||||
align: number;
|
||||
}
|
||||
|
||||
export interface StationPatchData {
|
||||
station_id: number;
|
||||
offset_x: number;
|
||||
offset_y: number;
|
||||
align: number;
|
||||
transfers: StationTransferData;
|
||||
}
|
||||
|
||||
export interface SightPatchData {
|
||||
sight_id: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface SightData {
|
||||
address: string;
|
||||
city: string;
|
||||
city_id: number;
|
||||
id: number;
|
||||
latitude: number;
|
||||
left_article: number;
|
||||
longitude: number;
|
||||
name: string;
|
||||
preview_media: number;
|
||||
thumbnail: string; // uuid
|
||||
watermark_lu: string; // uuid
|
||||
watermark_rd: string; // uuid
|
||||
}
|
||||
14
src/pages/Route/route-preview/utils.ts
Normal file
14
src/pages/Route/route-preview/utils.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// approximation
|
||||
export function coordinatesToLocal(latitude: number, longitude: number) {
|
||||
return {
|
||||
x: longitude,
|
||||
y: -latitude*2,
|
||||
}
|
||||
}
|
||||
|
||||
export function localToCoordinates(x: number, y: number) {
|
||||
return {
|
||||
longitude: x,
|
||||
latitude: -y/2,
|
||||
}
|
||||
}
|
||||
195
src/pages/Sight/SightListPage/index.tsx
Normal file
195
src/pages/Sight/SightListPage/index.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import {
|
||||
cityStore,
|
||||
languageStore,
|
||||
sightsStore,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const SightListPage = observer(() => {
|
||||
const { sights, getSights, deleteListSight } = sightsStore;
|
||||
const { cities, getCities } = cityStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<string | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSights = async () => {
|
||||
setIsLoading(true);
|
||||
await getCities(language);
|
||||
await getSights();
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchSights();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "name",
|
||||
headerName: "Имя",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "city_id",
|
||||
headerName: "Город",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
cities[language].data.find((el) => el.id == params.value)?.name
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
<button onClick={() => navigate(`/sight/${params.row.id}/edit`)}>
|
||||
<Pencil size={20} className="text-blue-500" />
|
||||
</button>
|
||||
{/* <button onClick={() => navigate(`/sight/${params.row.id}`)}>
|
||||
<Eye size={20} className="text-green-500" />
|
||||
</button> */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Фильтрация достопримечательностей по выбранному городу
|
||||
const filteredSights = useMemo(() => {
|
||||
const { selectedCityId } = selectedCityStore;
|
||||
if (!selectedCityId) {
|
||||
return sights;
|
||||
}
|
||||
return sights.filter((sight: any) => sight.city_id === selectedCityId);
|
||||
}, [sights, selectedCityStore.selectedCityId]);
|
||||
|
||||
const rows = filteredSights.map((sight) => ({
|
||||
id: sight.id,
|
||||
name: sight.name,
|
||||
city_id: sight.city_id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<LanguageSwitcher />
|
||||
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl">Достопримечательности</h1>
|
||||
<CreateButton
|
||||
label="Создать достопримечательность"
|
||||
path="/sight/create"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooter
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids as unknown as number[]));
|
||||
}}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
"Нет достопримечательностей"
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DeleteModal
|
||||
open={isDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
if (rowId) {
|
||||
await deleteListSight(Number(rowId));
|
||||
}
|
||||
setIsDeleteModalOpen(false);
|
||||
setRowId(null);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setRowId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isBulkDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
await Promise.all(ids.map((id) => deleteListSight(id)));
|
||||
getSights();
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
setIds([]);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
1
src/pages/Sight/index.ts
Normal file
1
src/pages/Sight/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./SightListPage";
|
||||
68
src/pages/Snapshot/SnapshotCreatePage/index.tsx
Normal file
68
src/pages/Snapshot/SnapshotCreatePage/index.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Button, TextField } from "@mui/material";
|
||||
import { snapshotStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const SnapshotCreatePage = observer(() => {
|
||||
const { createSnapshot } = snapshotStore;
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="w-full h-[400px] flex justify-center items-center">
|
||||
<div className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<div className="flex justify-between items-center">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">Создание снапшота</h1>
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Название"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await createSnapshot(name);
|
||||
setIsLoading(false);
|
||||
toast.success("Снапшот успешно создан");
|
||||
navigate(-1);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Ошибка при создании снапшота");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading || !name.trim()}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Сохранить"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
138
src/pages/Snapshot/SnapshotListPage/index.tsx
Normal file
138
src/pages/Snapshot/SnapshotListPage/index.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { languageStore, snapshotStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { DatabaseBackup, Trash2 } from "lucide-react";
|
||||
import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const SnapshotListPage = observer(() => {
|
||||
const { snapshots, getSnapshots, deleteSnapshot, restoreSnapshot } =
|
||||
snapshotStore;
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
|
||||
const { language } = languageStore;
|
||||
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSnapshots = async () => {
|
||||
setIsLoading(true);
|
||||
await getSnapshots();
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchSnapshots();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "name",
|
||||
headerName: "Название",
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
field: "parent",
|
||||
headerName: "Родитель",
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
width: 300,
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsRestoreModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
}}
|
||||
>
|
||||
<DatabaseBackup size={20} className="text-blue-500" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const rows = snapshots.map((snapshot) => ({
|
||||
id: snapshot.ID,
|
||||
name: snapshot.Name,
|
||||
parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ width: "100%" }}>
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl ">Снапшоты</h1>
|
||||
<CreateButton label="Создать снапшот" path="/snapshot/create" />
|
||||
</div>
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
hideFooter
|
||||
loading={isLoading}
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? <CircularProgress size={20} /> : "Нет снапшотов"}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DeleteModal
|
||||
open={isDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
if (rowId) {
|
||||
await deleteSnapshot(rowId);
|
||||
}
|
||||
setIsDeleteModalOpen(false);
|
||||
setRowId(null);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setRowId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<SnapshotRestore
|
||||
open={isRestoreModalOpen}
|
||||
loading={isLoading}
|
||||
onDelete={async () => {
|
||||
setIsLoading(true);
|
||||
if (rowId) {
|
||||
await restoreSnapshot(rowId);
|
||||
}
|
||||
setIsRestoreModalOpen(false);
|
||||
setRowId(null);
|
||||
setIsLoading(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsRestoreModalOpen(false);
|
||||
setRowId(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
2
src/pages/Snapshot/index.ts
Normal file
2
src/pages/Snapshot/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./SnapshotListPage";
|
||||
export * from "./SnapshotCreatePage";
|
||||
328
src/pages/Station/LinkedSights.tsx
Normal file
328
src/pages/Station/LinkedSights.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Typography,
|
||||
Button,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
useTheme,
|
||||
TextField,
|
||||
Autocomplete,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
Table,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
TableBody,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
|
||||
import { authInstance, languageStore, selectedCityStore } from "@shared";
|
||||
|
||||
type Field<T> = {
|
||||
label: string;
|
||||
data: keyof T;
|
||||
render?: (value: any) => React.ReactNode;
|
||||
};
|
||||
|
||||
type LinkedSightsProps<T> = {
|
||||
parentId: string | number;
|
||||
fields: Field<T>[];
|
||||
setItemsParent?: (items: T[]) => void;
|
||||
type: "show" | "edit";
|
||||
onUpdate?: () => void;
|
||||
disableCreation?: boolean;
|
||||
updatedLinkedItems?: T[];
|
||||
refresh?: number;
|
||||
};
|
||||
|
||||
export const LinkedSights = <
|
||||
T extends { id: number; name: string; [key: string]: any }
|
||||
>(
|
||||
props: LinkedSightsProps<T>
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Accordion sx={{ width: "100%" }}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
sx={{
|
||||
background: theme.palette.background.paper,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight="bold">
|
||||
Привязанные достопримечательности
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails
|
||||
sx={{ background: theme.palette.background.paper, width: "100%" }}
|
||||
>
|
||||
<Stack gap={2} width="100%">
|
||||
<LinkedSightsContents {...props} />
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const LinkedSightsContentsInner = <
|
||||
T extends { id: number; name: string; [key: string]: any }
|
||||
>({
|
||||
parentId,
|
||||
setItemsParent,
|
||||
fields,
|
||||
type,
|
||||
onUpdate,
|
||||
disableCreation = false,
|
||||
updatedLinkedItems,
|
||||
refresh,
|
||||
}: LinkedSightsProps<T>) => {
|
||||
const { language } = languageStore;
|
||||
|
||||
const [allItems, setAllItems] = useState<T[]>([]);
|
||||
const [linkedItems, setLinkedItems] = useState<T[]>([]);
|
||||
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {}, [error]);
|
||||
|
||||
const parentResource = "station";
|
||||
const childResource = "sight";
|
||||
|
||||
const availableItems = allItems
|
||||
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
|
||||
.filter((item) => {
|
||||
// Фильтруем по городу из навбара
|
||||
const selectedCityId = selectedCityStore.selectedCityId;
|
||||
if (selectedCityId && "city_id" in item) {
|
||||
return item.city_id === selectedCityId;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
useEffect(() => {
|
||||
if (updatedLinkedItems) {
|
||||
setLinkedItems(updatedLinkedItems);
|
||||
}
|
||||
}, [updatedLinkedItems]);
|
||||
|
||||
useEffect(() => {
|
||||
setItemsParent?.(linkedItems);
|
||||
}, [linkedItems, setItemsParent]);
|
||||
|
||||
const linkItem = () => {
|
||||
if (selectedItemId !== null) {
|
||||
setError(null);
|
||||
const requestData = {
|
||||
sight_id: selectedItemId,
|
||||
};
|
||||
|
||||
authInstance
|
||||
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
|
||||
.then(() => {
|
||||
const newItem = allItems.find((item) => item.id === selectedItemId);
|
||||
if (newItem) {
|
||||
setLinkedItems([...linkedItems, newItem]);
|
||||
}
|
||||
setSelectedItemId(null);
|
||||
onUpdate?.();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error linking sight:", error);
|
||||
setError("Failed to link sight");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItem = (itemId: number) => {
|
||||
setError(null);
|
||||
authInstance
|
||||
.delete(`/${parentResource}/${parentId}/${childResource}`, {
|
||||
data: { [`${childResource}_id`]: itemId },
|
||||
})
|
||||
.then(() => {
|
||||
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
|
||||
onUpdate?.();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error deleting sight:", error);
|
||||
setError("Failed to delete sight");
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (parentId) {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
authInstance
|
||||
.get(`/${parentResource}/${parentId}/${childResource}`)
|
||||
.then((response) => {
|
||||
setLinkedItems(response?.data || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching linked sights:", error);
|
||||
setError("Failed to load linked sights");
|
||||
setLinkedItems([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [parentId, language, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (type === "edit") {
|
||||
setError(null);
|
||||
authInstance
|
||||
.get(`/${childResource}`)
|
||||
.then((response) => {
|
||||
setAllItems(response?.data || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching all sights:", error);
|
||||
setError("Failed to load available sights");
|
||||
setAllItems([]);
|
||||
});
|
||||
}
|
||||
}, [type]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{linkedItems?.length > 0 && (
|
||||
<TableContainer component={Paper} sx={{ width: "100%" }}>
|
||||
<Table sx={{ width: "100%" }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell key="id" width="60px">
|
||||
№
|
||||
</TableCell>
|
||||
{fields.map((field) => (
|
||||
<TableCell key={String(field.data)}>{field.label}</TableCell>
|
||||
))}
|
||||
{type === "edit" && (
|
||||
<TableCell width="120px">Действие</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{linkedItems.map((item, index) => (
|
||||
<TableRow key={item.id} hover>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
{fields.map((field, idx) => (
|
||||
<TableCell key={String(field.data) + String(idx)}>
|
||||
{field.render
|
||||
? field.render(item[field.data])
|
||||
: item[field.data]}
|
||||
</TableCell>
|
||||
))}
|
||||
{type === "edit" && (
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteItem(item.id);
|
||||
}}
|
||||
>
|
||||
Отвязать
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{linkedItems.length === 0 && !isLoading && (
|
||||
<Typography color="textSecondary" textAlign="center" py={2}>
|
||||
Достопримечательности не найдены
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{type === "edit" && !disableCreation && (
|
||||
<Stack gap={2} mt={2}>
|
||||
<Typography variant="subtitle1">
|
||||
Добавить достопримечательность
|
||||
</Typography>
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
value={
|
||||
availableItems?.find((item) => item.id === selectedItemId) || null
|
||||
}
|
||||
onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)}
|
||||
options={availableItems}
|
||||
getOptionLabel={(item) => String(item.name)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Выберите достопримечательность"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
isOptionEqualToValue={(option, value) => option.id === value?.id}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
const searchWords = inputValue
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.filter(Boolean);
|
||||
return options.filter((option) => {
|
||||
const optionWords = String(option.name)
|
||||
.toLowerCase()
|
||||
.split(" ");
|
||||
return searchWords.every((searchWord) =>
|
||||
optionWords.some((word) => word.startsWith(searchWord))
|
||||
);
|
||||
});
|
||||
}}
|
||||
renderOption={(props, option) => (
|
||||
<li {...props} key={option.id}>
|
||||
{String(option.name)}
|
||||
</li>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={linkItem}
|
||||
disabled={!selectedItemId}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<Typography color="textSecondary" textAlign="center" py={2}>
|
||||
Загрузка...
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Typography color="error" textAlign="center" py={2}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const LinkedSightsContents = observer(
|
||||
LinkedSightsContentsInner
|
||||
) as typeof LinkedSightsContentsInner;
|
||||
261
src/pages/Station/StationCreatePage/index.tsx
Normal file
261
src/pages/Station/StationCreatePage/index.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
} 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 {
|
||||
stationsStore,
|
||||
languageStore,
|
||||
cityStore,
|
||||
useSelectedCity,
|
||||
} from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { SaveWithoutCityAgree } from "@widgets";
|
||||
|
||||
export const StationCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
const {
|
||||
createStationData,
|
||||
setCreateCommonData,
|
||||
createStation,
|
||||
setLanguageCreateStationData,
|
||||
} = stationsStore;
|
||||
const { cities, getCities } = cityStore;
|
||||
const { selectedCityId, selectedCity } = useSelectedCity();
|
||||
const [coordinates, setCoordinates] = useState<string>("");
|
||||
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
|
||||
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
createStationData.common.latitude !== 0 ||
|
||||
createStationData.common.longitude !== 0
|
||||
) {
|
||||
setCoordinates(
|
||||
`${createStationData.common.latitude}, ${createStationData.common.longitude}`
|
||||
);
|
||||
}
|
||||
}, [createStationData.common.latitude, createStationData.common.longitude]);
|
||||
|
||||
// НОВАЯ ФУНКЦИЯ: Фактическое создание (вызывается после подтверждения)
|
||||
const executeCreate = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await createStation();
|
||||
toast.success("Остановка успешно создана");
|
||||
navigate("/station");
|
||||
} catch (error) {
|
||||
console.error("Error creating station:", error);
|
||||
toast.error("Ошибка при создании станции");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или создания
|
||||
const handleCreate = async () => {
|
||||
const isCityMissing = !createStationData.common.city_id;
|
||||
// Проверяем названия на всех языках
|
||||
const isNameMissing = !createStationData.ru.name || !createStationData.en.name || !createStationData.zh.name;
|
||||
|
||||
if (isCityMissing || isNameMissing) {
|
||||
setIsSaveWarningOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await executeCreate();
|
||||
};
|
||||
|
||||
// Обработчик "Да" в предупреждающем окне
|
||||
const handleConfirmCreate = async () => {
|
||||
setIsSaveWarningOpen(false);
|
||||
await executeCreate();
|
||||
};
|
||||
|
||||
// Обработчик "Нет" в предупреждающем окне
|
||||
const handleCancelCreate = () => {
|
||||
setIsSaveWarningOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCities = async () => {
|
||||
await getCities("ru");
|
||||
await getCities("en");
|
||||
await getCities("zh");
|
||||
};
|
||||
|
||||
fetchCities();
|
||||
}, []);
|
||||
|
||||
// Автоматически устанавливаем выбранный город при загрузке страницы
|
||||
useEffect(() => {
|
||||
if (selectedCityId && selectedCity && !createStationData.common.city_id) {
|
||||
setCreateCommonData({
|
||||
city_id: selectedCityId,
|
||||
city: selectedCity.name,
|
||||
});
|
||||
}
|
||||
}, [selectedCityId, selectedCity, createStationData.common.city_id]);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<LanguageSwitcher />
|
||||
<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">
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
|
||||
<h1 className="text-3xl break-words">Создание остановки</h1>
|
||||
</div>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Название"
|
||||
value={createStationData[language].name || ""}
|
||||
required
|
||||
onChange={(e) =>
|
||||
setLanguageCreateStationData(language, {
|
||||
name: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="direction-label">Прямой/обратный маршрут</InputLabel>
|
||||
<Select
|
||||
labelId="direction-label"
|
||||
value={createStationData.common.direction ? "Прямой" : "Обратный"}
|
||||
label="Прямой/обратный маршрут"
|
||||
onChange={(e) =>
|
||||
setCreateCommonData({
|
||||
direction: e.target.value === "Прямой",
|
||||
})
|
||||
}
|
||||
>
|
||||
<MenuItem value="Прямой">Прямой</MenuItem>
|
||||
<MenuItem value="Обратный">Обратный</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Описание"
|
||||
value={createStationData.common.description || ""}
|
||||
onChange={(e) =>
|
||||
setCreateCommonData({
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{/* <TextField
|
||||
fullWidth
|
||||
label="Адрес"
|
||||
value={createStationData[language].address || ""}
|
||||
onChange={(e) =>
|
||||
setLanguageCreateStationData(language, {
|
||||
address: e.target.value,
|
||||
})
|
||||
}
|
||||
/> */}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Координаты"
|
||||
value={coordinates}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setCoordinates(newValue);
|
||||
|
||||
const input = newValue.replace(/,/g, " ").trim();
|
||||
const [latStr, lonStr] = input.split(/\s+/);
|
||||
|
||||
const lat = parseFloat(latStr);
|
||||
const lon = parseFloat(lonStr);
|
||||
|
||||
const isValidLat = !isNaN(lat);
|
||||
const isValidLon = !isNaN(lon);
|
||||
|
||||
if (isValidLat && isValidLon) {
|
||||
setCreateCommonData({
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
});
|
||||
} else {
|
||||
setCreateCommonData({
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="Введите координаты в формате: широта долгота (можно использовать запятые или пробелы)"
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Город</InputLabel>
|
||||
<Select
|
||||
value={createStationData.common.city_id || ""}
|
||||
label="Город"
|
||||
onChange={(e) => {
|
||||
const selectedCity = cities["ru"].data.find(
|
||||
(city) => city.id === e.target.value
|
||||
);
|
||||
setCreateCommonData({
|
||||
city_id: e.target.value as number,
|
||||
city: selectedCity?.name || "",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{cities["ru"].data.map((city) => (
|
||||
<MenuItem key={city.id} value={city.id}>
|
||||
{city.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleCreate}
|
||||
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleCreate
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Создать"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
|
||||
{isSaveWarningOpen && (
|
||||
<SaveWithoutCityAgree
|
||||
blocker={{
|
||||
proceed: handleConfirmCreate,
|
||||
reset: handleCancelCreate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
267
src/pages/Station/StationEditPage/index.tsx
Normal file
267
src/pages/Station/StationEditPage/index.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { stationsStore, languageStore, cityStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { LinkedSights } from "../LinkedSights";
|
||||
import { SaveWithoutCityAgree } from "@widgets";
|
||||
|
||||
export const StationEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
const { id } = useParams();
|
||||
const {
|
||||
editStationData,
|
||||
getEditStation,
|
||||
setEditCommonData,
|
||||
editStation,
|
||||
setLanguageEditStationData,
|
||||
} = stationsStore;
|
||||
const { cities, getCities } = cityStore;
|
||||
const [coordinates, setCoordinates] = useState<string>("");
|
||||
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
|
||||
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
editStationData.common.latitude !== 0 ||
|
||||
editStationData.common.longitude !== 0
|
||||
) {
|
||||
setCoordinates(
|
||||
`${editStationData.common.latitude}, ${editStationData.common.longitude}`
|
||||
);
|
||||
}
|
||||
}, [editStationData.common.latitude, editStationData.common.longitude]);
|
||||
|
||||
// НОВАЯ ФУНКЦИЯ: Фактическое редактирование (вызывается после подтверждения)
|
||||
const executeEdit = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await editStation(Number(id));
|
||||
toast.success("Остановка успешно обновлена");
|
||||
} catch (error) {
|
||||
console.error("Error updating station:", error);
|
||||
toast.error("Ошибка при обновлении станции");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или редактирования
|
||||
const handleEdit = async () => {
|
||||
const isCityMissing = !editStationData.common.city_id;
|
||||
// Проверяем названия на всех языках
|
||||
const isNameMissing =
|
||||
!editStationData.ru.name ||
|
||||
!editStationData.en.name ||
|
||||
!editStationData.zh.name;
|
||||
|
||||
if (isCityMissing || isNameMissing) {
|
||||
setIsSaveWarningOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await executeEdit();
|
||||
};
|
||||
|
||||
// Обработчик "Да" в предупреждающем окне
|
||||
const handleConfirmEdit = async () => {
|
||||
setIsSaveWarningOpen(false);
|
||||
await executeEdit();
|
||||
};
|
||||
|
||||
// Обработчик "Нет" в предупреждающем окне
|
||||
const handleCancelEdit = () => {
|
||||
setIsSaveWarningOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAndSetStationData = async () => {
|
||||
if (!id) return;
|
||||
|
||||
const stationId = Number(id);
|
||||
await getEditStation(stationId);
|
||||
await getCities("ru");
|
||||
await getCities("en");
|
||||
await getCities("zh");
|
||||
};
|
||||
|
||||
fetchAndSetStationData();
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<LanguageSwitcher />
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
|
||||
<h1 className="text-3xl break-words">{editStationData.ru.name}</h1>
|
||||
</div>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Название"
|
||||
value={editStationData[language].name || ""}
|
||||
required
|
||||
onChange={(e) =>
|
||||
setLanguageEditStationData(language, {
|
||||
name: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="direction-label">Прямой/обратный маршрут</InputLabel>
|
||||
<Select
|
||||
labelId="direction-label"
|
||||
value={editStationData.common.direction ? "Прямой" : "Обратный"}
|
||||
label="Прямой/обратный маршрут"
|
||||
onChange={(e) =>
|
||||
setEditCommonData({
|
||||
direction: e.target.value === "Прямой",
|
||||
})
|
||||
}
|
||||
>
|
||||
<MenuItem value="Прямой">Прямой</MenuItem>
|
||||
<MenuItem value="Обратный">Обратный</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Описание"
|
||||
value={editStationData.common.description || ""}
|
||||
onChange={(e) =>
|
||||
setEditCommonData({
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{/* <TextField
|
||||
fullWidth
|
||||
label="Адрес"
|
||||
value={editStationData[language].address || ""}
|
||||
onChange={(e) =>
|
||||
setLanguageEditStationData(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) {
|
||||
setEditCommonData({
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
});
|
||||
} else {
|
||||
setEditCommonData({
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="Введите координаты в формате: широта долгота (можно использовать запятые или пробелы)"
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Город</InputLabel>
|
||||
<Select
|
||||
value={editStationData.common.city_id || ""}
|
||||
label="Город"
|
||||
onChange={(e) => {
|
||||
const selectedCity = cities["ru"].data.find(
|
||||
(city) => city.id === e.target.value
|
||||
);
|
||||
setEditCommonData({
|
||||
city_id: e.target.value as number,
|
||||
city: selectedCity?.name || "",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{cities["ru"].data.map((city) => (
|
||||
<MenuItem key={city.id} value={city.id}>
|
||||
{city.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{id && (
|
||||
<LinkedSights
|
||||
parentId={Number(id)}
|
||||
fields={[{ label: "Название", data: "name" }]}
|
||||
type="edit"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleEdit}
|
||||
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleEdit
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Сохранить"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
|
||||
{isSaveWarningOpen && (
|
||||
<SaveWithoutCityAgree
|
||||
blocker={{
|
||||
proceed: handleConfirmEdit,
|
||||
reset: handleCancelEdit,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
210
src/pages/Station/StationListPage/index.tsx
Normal file
210
src/pages/Station/StationListPage/index.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import {
|
||||
languageStore,
|
||||
stationsStore,
|
||||
selectedCityStore,
|
||||
cityStore,
|
||||
} from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const StationListPage = observer(() => {
|
||||
const { stationLists, getStationList, deleteStation } = stationsStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<number | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStations = async () => {
|
||||
setIsLoading(true);
|
||||
await cityStore.getCities(language);
|
||||
await getStationList();
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchStations();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "name",
|
||||
headerName: "Название",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "system_name",
|
||||
headerName: "Системное название",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "direction",
|
||||
headerName: "Направление",
|
||||
width: 200,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<p
|
||||
className={
|
||||
params.row.direction === true ? "text-green-500" : "text-red-500"
|
||||
}
|
||||
>
|
||||
{params.row.direction ? "Прямой" : "Обратный"}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
width: 140,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
<button onClick={() => navigate(`/station/${params.row.id}/edit`)}>
|
||||
<Pencil size={20} className="text-blue-500" />
|
||||
</button>
|
||||
<button onClick={() => navigate(`/station/${params.row.id}`)}>
|
||||
<Eye size={20} className="text-green-500" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Фильтрация станций по выбранному городу
|
||||
const filteredStations = () => {
|
||||
const { selectedCityId } = selectedCityStore;
|
||||
if (!selectedCityId) {
|
||||
return stationLists[language].data;
|
||||
}
|
||||
return stationLists[language].data.filter(
|
||||
(station: any) => station.city_id === selectedCityId
|
||||
);
|
||||
};
|
||||
|
||||
const rows = filteredStations().map((station: any) => ({
|
||||
id: station.id,
|
||||
name: station.name,
|
||||
system_name: station.system_name,
|
||||
direction: station.direction,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<LanguageSwitcher />
|
||||
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl">Станции</h1>
|
||||
<CreateButton label="Создать остановки" path="/station/create" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as number[]);
|
||||
}}
|
||||
hideFooter
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? <CircularProgress size={20} /> : "Нет станций"}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DeleteModal
|
||||
open={isDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
if (rowId) {
|
||||
deleteStation(rowId);
|
||||
}
|
||||
setIsDeleteModalOpen(false);
|
||||
setRowId(null);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setRowId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isBulkDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
await Promise.all(ids.map((id) => deleteStation(id)));
|
||||
getStationList();
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
setIds([]);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
89
src/pages/Station/StationPreviewPage/index.tsx
Normal file
89
src/pages/Station/StationPreviewPage/index.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Paper } from "@mui/material";
|
||||
import { languageStore, stationsStore } from "@shared";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { LinkedSights } from "../LinkedSights";
|
||||
|
||||
export const StationPreviewPage = observer(() => {
|
||||
const { id } = useParams();
|
||||
const { stationPreview, getStationPreview } = stationsStore;
|
||||
const navigate = useNavigate();
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (id) {
|
||||
await getStationPreview(Number(id));
|
||||
}
|
||||
})();
|
||||
}, [id, language]);
|
||||
|
||||
return (
|
||||
<Paper className="w-full p-3 py-5 flex flex-col gap-10">
|
||||
<LanguageSwitcher />
|
||||
<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>{stationPreview[id!]?.[language]?.data.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-lg font-bold">Системное название</h1>
|
||||
<p>{stationPreview[id!]?.[language]?.data.system_name}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-lg font-bold">Направление</h1>
|
||||
<p
|
||||
className={`${
|
||||
stationPreview[id!]?.[language]?.data.direction
|
||||
? "text-green-500"
|
||||
: "text-red-500"
|
||||
}`}
|
||||
>
|
||||
{stationPreview[id!]?.[language]?.data.direction
|
||||
? "Прямой"
|
||||
: "Обратный"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{stationPreview[id!]?.[language]?.data.address && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-lg font-bold">Адрес</h1>
|
||||
<p>{stationPreview[id!]?.[language]?.data.address}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stationPreview[id!]?.[language]?.data.description && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-lg font-bold">Описание</h1>
|
||||
<p>{stationPreview[id!]?.[language]?.data.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{id && (
|
||||
<LinkedSights
|
||||
parentId={Number(id)}
|
||||
fields={[
|
||||
{ label: "Название", data: "name" },
|
||||
{ label: "Описание", data: "description" },
|
||||
]}
|
||||
type="show"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
5
src/pages/Station/index.ts
Normal file
5
src/pages/Station/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./StationListPage";
|
||||
export * from "./StationCreatePage";
|
||||
export * from "./StationPreviewPage";
|
||||
export * from "./StationEditPage";
|
||||
export * from "./LinkedSights";
|
||||
129
src/pages/User/UserCreatePage/index.tsx
Normal file
129
src/pages/User/UserCreatePage/index.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
} 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 { userStore } from "@shared";
|
||||
import { useState } from "react";
|
||||
|
||||
export const UserCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const { createUserData, setCreateUserData, createUser } = userStore;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await createUser();
|
||||
toast.success("Пользователь успешно создан");
|
||||
navigate("/user");
|
||||
} 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">
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Имя"
|
||||
value={createUserData.name || ""}
|
||||
required
|
||||
onChange={(e) =>
|
||||
setCreateUserData(
|
||||
e.target.value,
|
||||
createUserData.email || "",
|
||||
createUserData.password || "",
|
||||
createUserData.is_admin || false
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email"
|
||||
value={createUserData.email || ""}
|
||||
required
|
||||
onChange={(e) =>
|
||||
setCreateUserData(
|
||||
createUserData.name || "",
|
||||
e.target.value,
|
||||
createUserData.password || "",
|
||||
createUserData.is_admin || false
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Пароль"
|
||||
value={createUserData.password || ""}
|
||||
required
|
||||
onChange={(e) =>
|
||||
setCreateUserData(
|
||||
createUserData.name || "",
|
||||
createUserData.email || "",
|
||||
e.target.value,
|
||||
createUserData.is_admin || false
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="w-full flex flex-col items-start">
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={createUserData.is_admin || false}
|
||||
onChange={(e) => {
|
||||
setCreateUserData(
|
||||
createUserData.name || "",
|
||||
createUserData.email || "",
|
||||
createUserData.password || "",
|
||||
e.target.checked
|
||||
);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Администратор"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleCreate}
|
||||
disabled={
|
||||
isLoading || !createUserData.name || !createUserData.password
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Создать"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
145
src/pages/User/UserEditPage/index.tsx
Normal file
145
src/pages/User/UserEditPage/index.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
Button,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Paper,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { userStore, languageStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const UserEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { id } = useParams();
|
||||
const { editUserData, editUser, getUser, setEditUserData } = userStore;
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
const handleEdit = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await editUser(Number(id));
|
||||
toast.success("Пользователь успешно обновлен");
|
||||
navigate("/user");
|
||||
} catch (error) {
|
||||
toast.error("Ошибка при обновлении пользователя");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (id) {
|
||||
const data = await getUser(Number(id));
|
||||
|
||||
setEditUserData(
|
||||
data?.name || "",
|
||||
data?.email || "",
|
||||
data?.password || "",
|
||||
data?.is_admin || false
|
||||
);
|
||||
}
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
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-start">
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Имя"
|
||||
value={editUserData.name || ""}
|
||||
required
|
||||
onChange={(e) =>
|
||||
setEditUserData(
|
||||
e.target.value,
|
||||
editUserData.email || "",
|
||||
editUserData.password || "",
|
||||
editUserData.is_admin || false
|
||||
)
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email"
|
||||
value={editUserData.email || ""}
|
||||
required
|
||||
onChange={(e) =>
|
||||
setEditUserData(
|
||||
editUserData.name || "",
|
||||
e.target.value,
|
||||
editUserData.password || "",
|
||||
editUserData.is_admin || false
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Пароль"
|
||||
value={editUserData.password || ""}
|
||||
required
|
||||
onChange={(e) =>
|
||||
setEditUserData(
|
||||
editUserData.name || "",
|
||||
editUserData.email || "",
|
||||
e.target.value,
|
||||
editUserData.is_admin || false
|
||||
)
|
||||
}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={editUserData.is_admin || false}
|
||||
onChange={(e) =>
|
||||
setEditUserData(
|
||||
editUserData.name || "",
|
||||
editUserData.email || "",
|
||||
editUserData.password || "",
|
||||
e.target.checked
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
label="Администратор"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center self-end"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleEdit}
|
||||
disabled={isLoading || !editUserData.name || !editUserData.email}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Сохранить"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
196
src/pages/User/UserListPage/index.tsx
Normal file
196
src/pages/User/UserListPage/index.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { userStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateButton, DeleteModal } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const UserListPage = observer(() => {
|
||||
const { users, getUsers, deleteUser } = userStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<number | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
setIsLoading(true);
|
||||
await getUsers();
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "name",
|
||||
headerName: "Имя",
|
||||
width: 400,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "email",
|
||||
headerName: "Email",
|
||||
width: 400,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "is_admin",
|
||||
headerName: "Роль",
|
||||
flex: 1,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<p
|
||||
className={
|
||||
params.row.is_admin === true ? "text-green-500" : "text-red-500"
|
||||
}
|
||||
>
|
||||
{params.row.is_admin ? "Администратор" : "Пользователь"}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
flex: 1,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
<button>
|
||||
<Pencil
|
||||
size={20}
|
||||
className="text-blue-500"
|
||||
onClick={() => {
|
||||
navigate(`/user/${params.row.id}/edit`);
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const rows = users.data?.map((user) => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
is_admin: user.is_admin,
|
||||
name: user.name,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl">Пользователи</h1>
|
||||
<CreateButton label="Создать пользователя" path="/user/create" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as number[]);
|
||||
}}
|
||||
hideFooter
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
"Нет пользователей"
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DeleteModal
|
||||
open={isDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
if (rowId) {
|
||||
await deleteUser(rowId);
|
||||
}
|
||||
setIsDeleteModalOpen(false);
|
||||
setRowId(null);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setRowId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isBulkDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
await Promise.all(ids.map((id) => deleteUser(id)));
|
||||
getUsers();
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
setIds([]);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
3
src/pages/User/index.ts
Normal file
3
src/pages/User/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./UserListPage";
|
||||
export * from "./UserCreatePage";
|
||||
export * from "./UserEditPage";
|
||||
122
src/pages/Vehicle/VehicleCreatePage/index.tsx
Normal file
122
src/pages/Vehicle/VehicleCreatePage/index.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
vehicleStore,
|
||||
VEHICLE_TYPES,
|
||||
carrierStore,
|
||||
languageStore,
|
||||
} from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const VehicleCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const [tailNumber, setTailNumber] = useState("");
|
||||
const [type, setType] = useState("");
|
||||
const [carrierId, setCarrierId] = useState<number | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
carrierStore.getCarriers(language);
|
||||
}, [language]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await vehicleStore.createVehicle(
|
||||
Number(tailNumber),
|
||||
Number(type),
|
||||
carrierStore.carriers[language].data?.find((c) => c.id === carrierId)
|
||||
?.full_name as string,
|
||||
carrierId!
|
||||
);
|
||||
toast.success("Транспорт успешно создан");
|
||||
} 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">
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Бортовой номер"
|
||||
value={tailNumber}
|
||||
required
|
||||
onChange={(e) => setTailNumber(e.target.value)}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Тип</InputLabel>
|
||||
<Select
|
||||
value={type}
|
||||
label="Тип"
|
||||
required
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
>
|
||||
{VEHICLE_TYPES.map((type) => (
|
||||
<MenuItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Перевозчик</InputLabel>
|
||||
<Select
|
||||
value={carrierId || ""}
|
||||
label="Перевозчик"
|
||||
required
|
||||
onChange={(e) => setCarrierId(e.target.value as number)}
|
||||
>
|
||||
{carrierStore.carriers[language].data?.map((carrier) => (
|
||||
<MenuItem key={carrier.id} value={carrier.id}>
|
||||
{carrier.full_name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleCreate}
|
||||
disabled={isLoading || !tailNumber || !type || !carrierId}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Создать"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
152
src/pages/Vehicle/VehicleEditPage/index.tsx
Normal file
152
src/pages/Vehicle/VehicleEditPage/index.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
Paper,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Button,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
carrierStore,
|
||||
languageStore,
|
||||
VEHICLE_TYPES,
|
||||
vehicleStore,
|
||||
} from "@shared";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const VehicleEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const {
|
||||
getVehicle,
|
||||
vehicle,
|
||||
editVehicleData,
|
||||
setEditVehicleData,
|
||||
editVehicle,
|
||||
} = vehicleStore;
|
||||
const { getCarriers } = carrierStore;
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await getVehicle(Number(id));
|
||||
await getCarriers(language);
|
||||
|
||||
setEditVehicleData({
|
||||
tail_number: vehicle[Number(id)]?.vehicle.tail_number,
|
||||
type: vehicle[Number(id)]?.vehicle.type,
|
||||
carrier: vehicle[Number(id)]?.vehicle.carrier,
|
||||
carrier_id: vehicle[Number(id)]?.vehicle.carrier_id,
|
||||
});
|
||||
})();
|
||||
}, [id, language]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const handleEdit = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await editVehicle(Number(id), editVehicleData);
|
||||
toast.success("Транспортное средство успешно обновлено");
|
||||
navigate("/vehicle");
|
||||
} 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">
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Бортовой номер"
|
||||
value={editVehicleData.tail_number}
|
||||
required
|
||||
onChange={(e) =>
|
||||
setEditVehicleData({
|
||||
...editVehicleData,
|
||||
tail_number: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Тип</InputLabel>
|
||||
<Select
|
||||
value={editVehicleData.type}
|
||||
label="Тип"
|
||||
required
|
||||
onChange={(e) =>
|
||||
setEditVehicleData({ ...editVehicleData, type: e.target.value })
|
||||
}
|
||||
>
|
||||
{VEHICLE_TYPES.map((type) => (
|
||||
<MenuItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Перевозчик</InputLabel>
|
||||
<Select
|
||||
value={editVehicleData.carrier_id}
|
||||
label="Перевозчик"
|
||||
required
|
||||
onChange={(e) =>
|
||||
setEditVehicleData({
|
||||
...editVehicleData,
|
||||
carrier_id: e.target.value as number,
|
||||
})
|
||||
}
|
||||
>
|
||||
{carrierStore.carriers[language].data?.map((carrier) => (
|
||||
<MenuItem key={carrier.id} value={carrier.id}>
|
||||
{carrier.full_name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleEdit}
|
||||
disabled={
|
||||
isLoading ||
|
||||
!editVehicleData.tail_number ||
|
||||
!editVehicleData.type ||
|
||||
!editVehicleData.carrier_id
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Сохранить"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
218
src/pages/Vehicle/VehicleListPage/index.tsx
Normal file
218
src/pages/Vehicle/VehicleListPage/index.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { carrierStore, languageStore, vehicleStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateButton, DeleteModal } from "@widgets";
|
||||
import { VEHICLE_TYPES } from "@shared";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const VehicleListPage = observer(() => {
|
||||
const { vehicles, getVehicles, deleteVehicle } = vehicleStore;
|
||||
const { carriers, getCarriers } = carrierStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const [rowId, setRowId] = useState<number | null>(null);
|
||||
const [ids, setIds] = useState<number[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
await getVehicles();
|
||||
await getCarriers(language);
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchData();
|
||||
}, [language]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "tail_number",
|
||||
headerName: "Бортовой номер",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "type",
|
||||
headerName: "Тип",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
VEHICLE_TYPES.find((type) => type.value === params.row.type)
|
||||
?.label || params.row.type
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "carrier",
|
||||
headerName: "Перевозчик",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "city",
|
||||
headerName: "Город",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
width: 200,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
<button onClick={() => navigate(`/vehicle/${params.row.id}/edit`)}>
|
||||
<Pencil size={20} className="text-blue-500" />
|
||||
</button>
|
||||
<button onClick={() => navigate(`/vehicle/${params.row.id}`)}>
|
||||
<Eye size={20} className="text-green-500" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const rows = vehicles.data?.map((vehicle) => ({
|
||||
id: vehicle.vehicle.id,
|
||||
tail_number: vehicle.vehicle.tail_number,
|
||||
type: vehicle.vehicle.type,
|
||||
carrier: vehicle.vehicle.carrier,
|
||||
city: carriers[language].data?.find(
|
||||
(carrier) => carrier.id === vehicle.vehicle.carrier_id
|
||||
)?.city,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ width: "100%" }}>
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl">Транспортные средства</h1>
|
||||
<CreateButton
|
||||
label="Создать транспортное средство"
|
||||
path="/vehicle/create"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-end mb-5 duration-300"
|
||||
style={{ opacity: ids.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
>
|
||||
<Trash2 size={20} className="text-white" /> Удалить выбранные (
|
||||
{ids.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
hideFooterPagination
|
||||
checkboxSelection
|
||||
loading={isLoading}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
setIds(Array.from(newSelection.ids) as number[]);
|
||||
}}
|
||||
hideFooter
|
||||
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
"Нет транспортных средств"
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DeleteModal
|
||||
open={isDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
if (rowId) {
|
||||
await deleteVehicle(rowId);
|
||||
}
|
||||
setIsDeleteModalOpen(false);
|
||||
setRowId(null);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setRowId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteModal
|
||||
open={isBulkDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
await Promise.all(ids.map((id) => deleteVehicle(id)));
|
||||
getVehicles();
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
setIds([]);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
70
src/pages/Vehicle/VehiclePreviewPage/index.tsx
Normal file
70
src/pages/Vehicle/VehiclePreviewPage/index.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Paper } from "@mui/material";
|
||||
import { vehicleStore, VEHICLE_TYPES } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
export const VehiclePreviewPage = observer(() => {
|
||||
const { id } = useParams();
|
||||
const { getVehicle, vehicle } = vehicleStore;
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await getVehicle(Number(id));
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<div className="flex justify-between items-center">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
{/* <div className="flex gap-2">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => navigate(`/vehicle/${id}/edit`)}
|
||||
startIcon={<Pencil size={20} />}
|
||||
>
|
||||
Редактировать
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={() => navigate(`/vehicle/${id}/delete`)}
|
||||
startIcon={<Trash2 size={20} />}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div> */}
|
||||
</div>
|
||||
{vehicle[id!] && (
|
||||
<div className="flex flex-col gap-10 w-full">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-lg font-bold">Системный номер</h1>
|
||||
<p>{vehicle[id!]?.vehicle.tail_number}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-lg font-bold">Тип транспортного средства</h1>
|
||||
<p>
|
||||
{VEHICLE_TYPES.find(
|
||||
(type) => type.value === vehicle[id!]?.vehicle.type
|
||||
)?.label || vehicle[id!]?.vehicle.type}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-lg font-bold">Перевозчик</h1>
|
||||
<p>{vehicle[id!]?.vehicle.carrier}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
4
src/pages/Vehicle/index.ts
Normal file
4
src/pages/Vehicle/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./VehicleListPage";
|
||||
export * from "./VehiclePreviewPage";
|
||||
export * from "./VehicleCreatePage";
|
||||
export * from "./VehicleEditPage";
|
||||
@@ -5,7 +5,15 @@ export * from "./DevicesPage";
|
||||
export * from "./SightPage";
|
||||
export * from "./CreateSightPage";
|
||||
export * from "./MapPage";
|
||||
export * from "./MediaListPage";
|
||||
export * from "./PreviewMediaPage";
|
||||
export * from "./EditMediaPage";
|
||||
export * from "./Media";
|
||||
// export * from "./CreateMediaPage";
|
||||
export * from "./Country";
|
||||
export * from "./City";
|
||||
export * from "./Route";
|
||||
export * from "./User";
|
||||
export * from "./Snapshot";
|
||||
export * from "./Carrier";
|
||||
export * from "./Station";
|
||||
export * from "./Vehicle";
|
||||
export * from "./Article";
|
||||
export * from "./Sight";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { languageStore, Language } from "@shared";
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||
|
||||
const authInstance = axios.create({
|
||||
baseURL: "https://wn.krbl.ru",
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
});
|
||||
|
||||
authInstance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
@@ -24,7 +24,7 @@ authInstance.interceptors.response.use(
|
||||
|
||||
const languageInstance = (language: Language) => {
|
||||
const instance = axios.create({
|
||||
baseURL: "https://wn.krbl.ru",
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
});
|
||||
instance.interceptors.request.use((config) => {
|
||||
config.headers.Authorization = `Bearer ${localStorage.getItem("token")}`;
|
||||
|
||||
62
src/shared/config/CarrierSvg.tsx
Normal file
62
src/shared/config/CarrierSvg.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -3,18 +3,32 @@ import {
|
||||
Power,
|
||||
LucideIcon,
|
||||
Building2,
|
||||
MonitorSmartphone,
|
||||
Map,
|
||||
BookImage,
|
||||
Users,
|
||||
Earth,
|
||||
Landmark,
|
||||
GitBranch,
|
||||
// Car,
|
||||
Table,
|
||||
Split,
|
||||
// Newspaper,
|
||||
PersonStanding,
|
||||
Cpu,
|
||||
// BookImage,
|
||||
} from "lucide-react";
|
||||
import { CarrierSvg } from "./CarrierSvg";
|
||||
|
||||
export const DRAWER_WIDTH = 300;
|
||||
|
||||
interface NavigationItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
icon?: LucideIcon | React.ReactNode;
|
||||
path?: string;
|
||||
for_admin?: boolean;
|
||||
onClick?: () => void;
|
||||
nestedItems?: NavigationItem[];
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export const NAVIGATION_ITEMS: {
|
||||
@@ -23,10 +37,11 @@ export const NAVIGATION_ITEMS: {
|
||||
} = {
|
||||
primary: [
|
||||
{
|
||||
id: "attractions",
|
||||
label: "Достопримечательности",
|
||||
icon: Building2,
|
||||
path: "/sight",
|
||||
id: "snapshots",
|
||||
label: "Снапшоты",
|
||||
icon: GitBranch,
|
||||
path: "/snapshot",
|
||||
for_admin: true,
|
||||
},
|
||||
{
|
||||
id: "map",
|
||||
@@ -37,21 +52,83 @@ export const NAVIGATION_ITEMS: {
|
||||
{
|
||||
id: "devices",
|
||||
label: "Устройства",
|
||||
icon: MonitorSmartphone,
|
||||
icon: Cpu,
|
||||
path: "/devices",
|
||||
},
|
||||
{
|
||||
id: "media",
|
||||
label: "Медиа",
|
||||
icon: BookImage,
|
||||
path: "/media",
|
||||
for_admin: true,
|
||||
},
|
||||
// {
|
||||
// id: "articles",
|
||||
// label: "Статьи",
|
||||
// icon: Newspaper,
|
||||
// path: "/articles",
|
||||
// id: "vehicles",
|
||||
// label: "Транспорт",
|
||||
// icon: Car,
|
||||
// path: "/vehicle",
|
||||
// },
|
||||
{
|
||||
id: "users",
|
||||
label: "Пользователи",
|
||||
icon: Users,
|
||||
path: "/user",
|
||||
for_admin: true,
|
||||
},
|
||||
{
|
||||
id: "all",
|
||||
label: "Справочник",
|
||||
icon: Table,
|
||||
nestedItems: [
|
||||
// {
|
||||
// id: "media",
|
||||
// label: "Медиа",
|
||||
// icon: BookImage,
|
||||
// path: "/media",
|
||||
// },
|
||||
// {
|
||||
// id: "articles",
|
||||
// label: "Статьи",
|
||||
// icon: Newspaper,
|
||||
// path: "/article",
|
||||
// },
|
||||
{
|
||||
id: "attractions",
|
||||
label: "Достопримечательности",
|
||||
icon: Landmark,
|
||||
path: "/sight",
|
||||
},
|
||||
{
|
||||
id: "stations",
|
||||
label: "Остановки",
|
||||
icon: PersonStanding,
|
||||
path: "/station",
|
||||
},
|
||||
{
|
||||
id: "routes",
|
||||
label: "Маршруты",
|
||||
icon: Split,
|
||||
path: "/route",
|
||||
},
|
||||
|
||||
{
|
||||
id: "countries",
|
||||
label: "Страны",
|
||||
icon: Earth,
|
||||
path: "/country",
|
||||
for_admin: true,
|
||||
},
|
||||
{
|
||||
id: "cities",
|
||||
label: "Города",
|
||||
icon: Building2,
|
||||
path: "/city",
|
||||
for_admin: true,
|
||||
},
|
||||
{
|
||||
id: "carriers",
|
||||
label: "Перевозчики",
|
||||
// @ts-ignore
|
||||
icon: CarrierSvg,
|
||||
path: "/carrier",
|
||||
for_admin: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
secondary: [
|
||||
{
|
||||
@@ -65,3 +142,8 @@ export const NAVIGATION_ITEMS: {
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const VEHICLE_TYPES = [
|
||||
{ label: "Трамвай", value: 1 },
|
||||
{ label: "Троллейбус", value: 2 },
|
||||
];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const API_URL = "https://wn.krbl.ru";
|
||||
export const API_URL = import.meta.env.VITE_API_URL;
|
||||
export const MEDIA_TYPE_LABELS = {
|
||||
1: "Фото",
|
||||
2: "Видео",
|
||||
@@ -7,3 +7,766 @@ export const MEDIA_TYPE_LABELS = {
|
||||
5: "Панорама",
|
||||
6: "3Д-модель",
|
||||
};
|
||||
|
||||
export * from "./mediaTypes";
|
||||
|
||||
export const MEDIA_TYPE_VALUES = {
|
||||
image: 1,
|
||||
video: 2,
|
||||
icon: 3,
|
||||
thumbnail: 3,
|
||||
watermark_lu: 4,
|
||||
watermark_rd: 4,
|
||||
panorama: 5,
|
||||
model: 6,
|
||||
video_preview: 2,
|
||||
};
|
||||
|
||||
export const RU_COUNTRIES = [
|
||||
{ code: "AF", name: "Афганистан" },
|
||||
{ code: "AX", name: "Аландские острова" },
|
||||
{ code: "AL", name: "Албания" },
|
||||
{ code: "DZ", name: "Алжир" },
|
||||
{ code: "AS", name: "Американское Самоа" },
|
||||
{ code: "AD", name: "Андорра" },
|
||||
{ code: "AO", name: "Ангола" },
|
||||
{ code: "AI", name: "Ангилья" },
|
||||
{ code: "AQ", name: "Антарктида" },
|
||||
{ code: "AG", name: "Антигуа и Барбуда" },
|
||||
{ code: "AR", name: "Аргентина" },
|
||||
{ code: "AM", name: "Армения" },
|
||||
{ code: "AW", name: "Аруба" },
|
||||
{ code: "AU", name: "Австралия" },
|
||||
{ code: "AT", name: "Австрия" },
|
||||
{ code: "AZ", name: "Азербайджан" },
|
||||
{ code: "BS", name: "Багамы" },
|
||||
{ code: "BH", name: "Бахрейн" },
|
||||
{ code: "BD", name: "Бангладеш" },
|
||||
{ code: "BB", name: "Барбадос" },
|
||||
{ code: "BY", name: "Беларусь" },
|
||||
{ code: "BE", name: "Бельгия" },
|
||||
{ code: "BZ", name: "Белиз" },
|
||||
{ code: "BJ", name: "Бенин" },
|
||||
{ code: "BM", name: "Бермуды" },
|
||||
{ code: "BT", name: "Бутан" },
|
||||
{ code: "BO", name: "Боливия" },
|
||||
{ code: "BA", name: "Босния и Герцеговина" },
|
||||
{ code: "BW", name: "Ботсвана" },
|
||||
{ code: "BV", name: "Остров Буве" },
|
||||
{ code: "BR", name: "Бразилия" },
|
||||
{ code: "IO", name: "Британская территория в Индийском океане" },
|
||||
{ code: "BN", name: "Бруней-Даруссалам" },
|
||||
{ code: "BG", name: "Болгария" },
|
||||
{ code: "BF", name: "Буркина-Фасо" },
|
||||
{ code: "BI", name: "Бурунди" },
|
||||
{ code: "KH", name: "Камбоджа" },
|
||||
{ code: "CM", name: "Камерун" },
|
||||
{ code: "CA", name: "Канада" },
|
||||
{ code: "CV", name: "Кабо-Верде" },
|
||||
{ code: "KY", name: "Каймановы острова" },
|
||||
{ code: "CF", name: "Центральноафриканская Республика" },
|
||||
{ code: "TD", name: "Чад" },
|
||||
{ code: "CL", name: "Чили" },
|
||||
{ code: "CN", name: "Китай" },
|
||||
{ code: "CX", name: "Остров Рождества" },
|
||||
{ code: "CC", name: "Кокосовые (Килинг) острова" },
|
||||
{ code: "CO", name: "Колумбия" },
|
||||
{ code: "KM", name: "Коморы" },
|
||||
{ code: "CG", name: "Конго" },
|
||||
{ code: "CD", name: "Демократическая Республика Конго" },
|
||||
{ code: "CK", name: "Острова Кука" },
|
||||
{ code: "CR", name: "Коста-Рика" },
|
||||
{ code: "CI", name: "Кот-д'Ивуар" },
|
||||
{ code: "HR", name: "Хорватия" },
|
||||
{ code: "CU", name: "Куба" },
|
||||
{ code: "CY", name: "Кипр" },
|
||||
{ code: "CZ", name: "Чехия" },
|
||||
{ code: "DK", name: "Дания" },
|
||||
{ code: "DJ", name: "Джибути" },
|
||||
{ code: "DM", name: "Доминика" },
|
||||
{ code: "DO", name: "Доминиканская Республика" },
|
||||
{ code: "EC", name: "Эквадор" },
|
||||
{ code: "EG", name: "Египет" },
|
||||
{ code: "SV", name: "Сальвадор" },
|
||||
{ code: "GQ", name: "Экваториальная Гвинея" },
|
||||
{ code: "ER", name: "Эритрея" },
|
||||
{ code: "EE", name: "Эстония" },
|
||||
{ code: "ET", name: "Эфиопия" },
|
||||
{ code: "FK", name: "Фолклендские острова (Мальвинские)" },
|
||||
{ code: "FO", name: "Фарерские острова" },
|
||||
{ code: "FJ", name: "Фиджи" },
|
||||
{ code: "FI", name: "Финляндия" },
|
||||
{ code: "FR", name: "Франция" },
|
||||
{ code: "GF", name: "Французская Гвиана" },
|
||||
{ code: "PF", name: "Французская Полинезия" },
|
||||
{ code: "TF", name: "Французские Южные территории" },
|
||||
{ code: "GA", name: "Габон" },
|
||||
{ code: "GM", name: "Гамбия" },
|
||||
{ code: "GE", name: "Грузия" },
|
||||
{ code: "DE", name: "Германия" },
|
||||
{ code: "GH", name: "Гана" },
|
||||
{ code: "GI", name: "Гибралтар" },
|
||||
{ code: "GR", name: "Греция" },
|
||||
{ code: "GL", name: "Гренландия" },
|
||||
{ code: "GD", name: "Гренада" },
|
||||
{ code: "GP", name: "Гваделупа" },
|
||||
{ code: "GU", name: "Гуам" },
|
||||
{ code: "GT", name: "Гватемала" },
|
||||
{ code: "GG", name: "Гернси" },
|
||||
{ code: "GN", name: "Гвинея" },
|
||||
{ code: "GW", name: "Гвинея-Бисау" },
|
||||
{ code: "GY", name: "Гайана" },
|
||||
{ code: "HT", name: "Гаити" },
|
||||
{ code: "HM", name: "Остров Херд и острова Макдональд" },
|
||||
{ code: "VA", name: "Ватикан" },
|
||||
{ code: "HN", name: "Гондурас" },
|
||||
{ code: "HK", name: "Гонконг" },
|
||||
{ code: "HU", name: "Венгрия" },
|
||||
{ code: "IS", name: "Исландия" },
|
||||
{ code: "IN", name: "Индия" },
|
||||
{ code: "ID", name: "Индонезия" },
|
||||
{ code: "IR", name: "Иран" },
|
||||
{ code: "IQ", name: "Ирак" },
|
||||
{ code: "IE", name: "Ирландия" },
|
||||
{ code: "IM", name: "Остров Мэн" },
|
||||
{ code: "IL", name: "Израиль" },
|
||||
{ code: "IT", name: "Италия" },
|
||||
{ code: "JM", name: "Ямайка" },
|
||||
{ code: "JP", name: "Япония" },
|
||||
{ code: "JE", name: "Джерси" },
|
||||
{ code: "JO", name: "Иордания" },
|
||||
{ code: "KZ", name: "Казахстан" },
|
||||
{ code: "KE", name: "Кения" },
|
||||
{ code: "KI", name: "Кирибати" },
|
||||
{ code: "KR", name: "Корея" },
|
||||
{ code: "KP", name: "Северная Корея" },
|
||||
{ code: "KW", name: "Кувейт" },
|
||||
{ code: "KG", name: "Киргизия" },
|
||||
{ code: "LA", name: "Лаос" },
|
||||
{ code: "LV", name: "Латвия" },
|
||||
{ code: "LB", name: "Ливан" },
|
||||
{ code: "LS", name: "Лесото" },
|
||||
{ code: "LR", name: "Либерия" },
|
||||
{ code: "LY", name: "Ливия" },
|
||||
{ code: "LI", name: "Лихтенштейн" },
|
||||
{ code: "LT", name: "Литва" },
|
||||
{ code: "LU", name: "Люксембург" },
|
||||
{ code: "MO", name: "Макао" },
|
||||
{ code: "MK", name: "Северная Македония" },
|
||||
{ code: "MG", name: "Мадагаскар" },
|
||||
{ code: "MW", name: "Малави" },
|
||||
{ code: "MY", name: "Малайзия" },
|
||||
{ code: "MV", name: "Мальдивы" },
|
||||
{ code: "ML", name: "Мали" },
|
||||
{ code: "MT", name: "Мальта" },
|
||||
{ code: "MH", name: "Маршалловы Острова" },
|
||||
{ code: "MQ", name: "Мартиника" },
|
||||
{ code: "MR", name: "Мавритания" },
|
||||
{ code: "MU", name: "Маврикий" },
|
||||
{ code: "YT", name: "Майотта" },
|
||||
{ code: "MX", name: "Мексика" },
|
||||
{ code: "FM", name: "Микронезия" },
|
||||
{ code: "MD", name: "Молдова" },
|
||||
{ code: "MC", name: "Монако" },
|
||||
{ code: "MN", name: "Монголия" },
|
||||
{ code: "ME", name: "Черногория" },
|
||||
{ code: "MS", name: "Монтсеррат" },
|
||||
{ code: "MA", name: "Марокко" },
|
||||
{ code: "MZ", name: "Мозамбик" },
|
||||
{ code: "MM", name: "Мьянма" },
|
||||
{ code: "NA", name: "Намибия" },
|
||||
{ code: "NR", name: "Науру" },
|
||||
{ code: "NP", name: "Непал" },
|
||||
{ code: "NL", name: "Нидерланды" },
|
||||
{ code: "AN", name: "Нидерландские Антильские острова" },
|
||||
{ code: "NC", name: "Новая Каледония" },
|
||||
{ code: "NZ", name: "Новая Зеландия" },
|
||||
{ code: "NI", name: "Никарагуа" },
|
||||
{ code: "NE", name: "Нигер" },
|
||||
{ code: "NG", name: "Нигерия" },
|
||||
{ code: "NU", name: "Ниуэ" },
|
||||
{ code: "NF", name: "Остров Норфолк" },
|
||||
{ code: "MP", name: "Северные Марианские острова" },
|
||||
{ code: "NO", name: "Норвегия" },
|
||||
{ code: "OM", name: "Оман" },
|
||||
{ code: "PK", name: "Пакистан" },
|
||||
{ code: "PW", name: "Палау" },
|
||||
{ code: "PS", name: "Палестинская территория" },
|
||||
{ code: "PA", name: "Панама" },
|
||||
{ code: "PG", name: "Папуа — Новая Гвинея" },
|
||||
{ code: "PY", name: "Парагвай" },
|
||||
{ code: "PE", name: "Перу" },
|
||||
{ code: "PH", name: "Филиппины" },
|
||||
{ code: "PN", name: "Питкэрн" },
|
||||
{ code: "PL", name: "Польша" },
|
||||
{ code: "PT", name: "Португалия" },
|
||||
{ code: "PR", name: "Пуэрто-Рико" },
|
||||
{ code: "QA", name: "Катар" },
|
||||
{ code: "RE", name: "Реюньон" },
|
||||
{ code: "RO", name: "Румыния" },
|
||||
{ code: "RU", name: "Россия" },
|
||||
{ code: "RW", name: "Руанда" },
|
||||
{ code: "BL", name: "Сен-Бартелеми" },
|
||||
{ code: "SH", name: "Остров Святой Елены" },
|
||||
{ code: "KN", name: "Сент-Китс и Невис" },
|
||||
{ code: "LC", name: "Сент-Люсия" },
|
||||
{ code: "MF", name: "Сен-Мартен" },
|
||||
{ code: "PM", name: "Сен-Пьер и Микелон" },
|
||||
{ code: "VC", name: "Сент-Винсент и Гренадины" },
|
||||
{ code: "WS", name: "Самоа" },
|
||||
{ code: "SM", name: "Сан-Марино" },
|
||||
{ code: "ST", name: "Сан-Томе и Принсипи" },
|
||||
{ code: "SA", name: "Саудовская Аравия" },
|
||||
{ code: "SN", name: "Сенегал" },
|
||||
{ code: "RS", name: "Сербия" },
|
||||
{ code: "SC", name: "Сейшельские Острова" },
|
||||
{ code: "SL", name: "Сьерра-Леоне" },
|
||||
{ code: "SG", name: "Сингапур" },
|
||||
{ code: "SK", name: "Словакия" },
|
||||
{ code: "SI", name: "Словения" },
|
||||
{ code: "SB", name: "Соломоновы Острова" },
|
||||
{ code: "SO", name: "Сомали" },
|
||||
{ code: "ZA", name: "Южная Африка" },
|
||||
{ code: "GS", name: "Южная Георгия и Южные Сандвичевы острова" },
|
||||
{ code: "ES", name: "Испания" },
|
||||
{ code: "LK", name: "Шри-Ланка" },
|
||||
{ code: "SD", name: "Судан" },
|
||||
{ code: "SR", name: "Суринам" },
|
||||
{ code: "SJ", name: "Шпицберген и Ян-Майен" },
|
||||
{ code: "SZ", name: "Свазиленд" },
|
||||
{ code: "SE", name: "Швеция" },
|
||||
{ code: "CH", name: "Швейцария" },
|
||||
{ code: "SY", name: "Сирия" },
|
||||
{ code: "TW", name: "Тайвань" },
|
||||
{ code: "TJ", name: "Таджикистан" },
|
||||
{ code: "TZ", name: "Танзания" },
|
||||
{ code: "TH", name: "Таиланд" },
|
||||
{ code: "TL", name: "Восточный Тимор" },
|
||||
{ code: "TG", name: "Того" },
|
||||
{ code: "TK", name: "Токелау" },
|
||||
{ code: "TO", name: "Тонга" },
|
||||
{ code: "TT", name: "Тринидад и Тобаго" },
|
||||
{ code: "TN", name: "Тунис" },
|
||||
{ code: "TR", name: "Турция" },
|
||||
{ code: "TM", name: "Туркмения" },
|
||||
{ code: "TC", name: "Теркс и Кайкос" },
|
||||
{ code: "TV", name: "Тувалу" },
|
||||
{ code: "UG", name: "Уганда" },
|
||||
{ code: "UA", name: "Украина" },
|
||||
{ code: "AE", name: "Объединённые Арабские Эмираты" },
|
||||
{ code: "GB", name: "Великобритания" },
|
||||
{ code: "US", name: "США" },
|
||||
{ code: "UM", name: "Внешние малые острова США" },
|
||||
{ code: "UY", name: "Уругвай" },
|
||||
{ code: "UZ", name: "Узбекистан" },
|
||||
{ code: "VU", name: "Вануату" },
|
||||
{ code: "VE", name: "Венесуэла" },
|
||||
{ code: "VN", name: "Вьетнам" },
|
||||
{ code: "VG", name: "Британские Виргинские острова" },
|
||||
{ code: "VI", name: "Виргинские острова (США)" },
|
||||
{ code: "WF", name: "Уоллис и Футуна" },
|
||||
{ code: "EH", name: "Западная Сахара" },
|
||||
{ code: "YE", name: "Йемен" },
|
||||
{ code: "ZM", name: "Замбия" },
|
||||
{ code: "ZW", name: "Зимбабве" },
|
||||
];
|
||||
|
||||
// countries-en.js
|
||||
export const EN_COUNTRIES = [
|
||||
{ code: "AF", name: "Afghanistan" },
|
||||
{ code: "AX", name: "Aland Islands" },
|
||||
{ code: "AL", name: "Albania" },
|
||||
{ code: "DZ", name: "Algeria" },
|
||||
{ code: "AS", name: "American Samoa" },
|
||||
{ code: "AD", name: "Andorra" },
|
||||
{ code: "AO", name: "Angola" },
|
||||
{ code: "AI", name: "Anguilla" },
|
||||
{ code: "AQ", name: "Antarctica" },
|
||||
{ code: "AG", name: "Antigua And Barbuda" },
|
||||
{ code: "AR", name: "Argentina" },
|
||||
{ code: "AM", name: "Armenia" },
|
||||
{ code: "AW", name: "Aruba" },
|
||||
{ code: "AU", name: "Australia" },
|
||||
{ code: "AT", name: "Austria" },
|
||||
{ code: "AZ", name: "Azerbaijan" },
|
||||
{ code: "BS", name: "Bahamas" },
|
||||
{ code: "BH", name: "Bahrain" },
|
||||
{ code: "BD", name: "Bangladesh" },
|
||||
{ code: "BB", name: "Barbados" },
|
||||
{ code: "BY", name: "Belarus" },
|
||||
{ code: "BE", name: "Belgium" },
|
||||
{ code: "BZ", name: "Belize" },
|
||||
{ code: "BJ", name: "Benin" },
|
||||
{ code: "BM", name: "Bermuda" },
|
||||
{ code: "BT", name: "Bhutan" },
|
||||
{ code: "BO", name: "Bolivia" },
|
||||
{ code: "BA", name: "Bosnia And Herzegovina" },
|
||||
{ code: "BW", name: "Botswana" },
|
||||
{ code: "BV", name: "Bouvet Island" },
|
||||
{ code: "BR", name: "Brazil" },
|
||||
{ code: "IO", name: "British Indian Ocean Territory" },
|
||||
{ code: "BN", name: "Brunei Darussalam" },
|
||||
{ code: "BG", name: "Bulgaria" },
|
||||
{ code: "BF", name: "Burkina Faso" },
|
||||
{ code: "BI", name: "Burundi" },
|
||||
{ code: "KH", name: "Cambodia" },
|
||||
{ code: "CM", name: "Cameroon" },
|
||||
{ code: "CA", name: "Canada" },
|
||||
{ code: "CV", name: "Cape Verde" },
|
||||
{ code: "KY", name: "Cayman Islands" },
|
||||
{ code: "CF", name: "Central African Republic" },
|
||||
{ code: "TD", name: "Chad" },
|
||||
{ code: "CL", name: "Chile" },
|
||||
{ code: "CN", name: "China" },
|
||||
{ code: "CX", name: "Christmas Island" },
|
||||
{ code: "CC", name: "Cocos (Keeling) Islands" },
|
||||
{ code: "CO", name: "Colombia" },
|
||||
{ code: "KM", name: "Comoros" },
|
||||
{ code: "CG", name: "Congo" },
|
||||
{ code: "CD", name: "Congo, Democratic Republic" },
|
||||
{ code: "CK", name: "Cook Islands" },
|
||||
{ code: "CR", name: "Costa Rica" },
|
||||
{ code: "CI", name: "Cote D'Ivoire" },
|
||||
{ code: "HR", name: "Croatia" },
|
||||
{ code: "CU", name: "Cuba" },
|
||||
{ code: "CY", name: "Cyprus" },
|
||||
{ code: "CZ", name: "Czech Republic" },
|
||||
{ code: "DK", name: "Denmark" },
|
||||
{ code: "DJ", name: "Djibouti" },
|
||||
{ code: "DM", name: "Dominica" },
|
||||
{ code: "DO", name: "Dominican Republic" },
|
||||
{ code: "EC", name: "Ecuador" },
|
||||
{ code: "EG", name: "Egypt" },
|
||||
{ code: "SV", name: "El Salvador" },
|
||||
{ code: "GQ", name: "Equatorial Guinea" },
|
||||
{ code: "ER", name: "Eritrea" },
|
||||
{ code: "EE", name: "Estonia" },
|
||||
{ code: "ET", name: "Ethiopia" },
|
||||
{ code: "FK", name: "Falkland Islands (Malvinas)" },
|
||||
{ code: "FO", name: "Faroe Islands" },
|
||||
{ code: "FJ", name: "Fiji" },
|
||||
{ code: "FI", name: "Finland" },
|
||||
{ code: "FR", name: "France" },
|
||||
{ code: "GF", name: "French Guiana" },
|
||||
{ code: "PF", name: "French Polynesia" },
|
||||
{ code: "TF", name: "French Southern Territories" },
|
||||
{ code: "GA", name: "Gabon" },
|
||||
{ code: "GM", name: "Gambia" },
|
||||
{ code: "GE", name: "Georgia" },
|
||||
{ code: "DE", name: "Germany" },
|
||||
{ code: "GH", name: "Ghana" },
|
||||
{ code: "GI", name: "Gibraltar" },
|
||||
{ code: "GR", name: "Greece" },
|
||||
{ code: "GL", name: "Greenland" },
|
||||
{ code: "GD", name: "Grenada" },
|
||||
{ code: "GP", name: "Guadeloupe" },
|
||||
{ code: "GU", name: "Guam" },
|
||||
{ code: "GT", name: "Guatemala" },
|
||||
{ code: "GG", name: "Guernsey" },
|
||||
{ code: "GN", name: "Guinea" },
|
||||
{ code: "GW", name: "Guinea-Bissau" },
|
||||
{ code: "GY", name: "Guyana" },
|
||||
{ code: "HT", name: "Haiti" },
|
||||
{ code: "HM", name: "Heard Island & Mcdonald Islands" },
|
||||
{ code: "VA", name: "Holy See (Vatican City State)" },
|
||||
{ code: "HN", name: "Honduras" },
|
||||
{ code: "HK", name: "Hong Kong" },
|
||||
{ code: "HU", name: "Hungary" },
|
||||
{ code: "IS", name: "Iceland" },
|
||||
{ code: "IN", name: "India" },
|
||||
{ code: "ID", name: "Indonesia" },
|
||||
{ code: "IR", name: "Iran, Islamic Republic Of" },
|
||||
{ code: "IQ", name: "Iraq" },
|
||||
{ code: "IE", name: "Ireland" },
|
||||
{ code: "IM", name: "Isle Of Man" },
|
||||
{ code: "IL", name: "Israel" },
|
||||
{ code: "IT", name: "Italy" },
|
||||
{ code: "JM", name: "Jamaica" },
|
||||
{ code: "JP", name: "Japan" },
|
||||
{ code: "JE", name: "Jersey" },
|
||||
{ code: "JO", name: "Jordan" },
|
||||
{ code: "KZ", name: "Kazakhstan" },
|
||||
{ code: "KE", name: "Kenya" },
|
||||
{ code: "KI", name: "Kiribati" },
|
||||
{ code: "KR", name: "Korea" },
|
||||
{ code: "KP", name: "North Korea" },
|
||||
{ code: "KW", name: "Kuwait" },
|
||||
{ code: "KG", name: "Kyrgyzstan" },
|
||||
{ code: "LA", name: "Lao People's Democratic Republic" },
|
||||
{ code: "LV", name: "Latvia" },
|
||||
{ code: "LB", name: "Lebanon" },
|
||||
{ code: "LS", name: "Lesotho" },
|
||||
{ code: "LR", name: "Liberia" },
|
||||
{ code: "LY", name: "Libyan Arab Jamahiriya" },
|
||||
{ code: "LI", name: "Liechtenstein" },
|
||||
{ code: "LT", name: "Lithuania" },
|
||||
{ code: "LU", name: "Luxembourg" },
|
||||
{ code: "MO", name: "Macao" },
|
||||
{ code: "MK", name: "Macedonia" },
|
||||
{ code: "MG", name: "Madagascar" },
|
||||
{ code: "MW", name: "Malawi" },
|
||||
{ code: "MY", name: "Malaysia" },
|
||||
{ code: "MV", name: "Maldives" },
|
||||
{ code: "ML", name: "Mali" },
|
||||
{ code: "MT", name: "Malta" },
|
||||
{ code: "MH", name: "Marshall Islands" },
|
||||
{ code: "MQ", name: "Martinique" },
|
||||
{ code: "MR", name: "Mauritania" },
|
||||
{ code: "MU", name: "Mauritius" },
|
||||
{ code: "YT", name: "Mayotte" },
|
||||
{ code: "MX", name: "Mexico" },
|
||||
{ code: "FM", name: "Micronesia, Federated States Of" },
|
||||
{ code: "MD", name: "Moldova" },
|
||||
{ code: "MC", name: "Monaco" },
|
||||
{ code: "MN", name: "Mongolia" },
|
||||
{ code: "ME", name: "Montenegro" },
|
||||
{ code: "MS", name: "Montserrat" },
|
||||
{ code: "MA", name: "Morocco" },
|
||||
{ code: "MZ", name: "Mozambique" },
|
||||
{ code: "MM", name: "Myanmar" },
|
||||
{ code: "NA", name: "Namibia" },
|
||||
{ code: "NR", name: "Nauru" },
|
||||
{ code: "NP", name: "Nepal" },
|
||||
{ code: "NL", name: "Netherlands" },
|
||||
{ code: "AN", name: "Netherlands Antilles" },
|
||||
{ code: "NC", name: "New Caledonia" },
|
||||
{ code: "NZ", name: "New Zealand" },
|
||||
{ code: "NI", name: "Nicaragua" },
|
||||
{ code: "NE", name: "Niger" },
|
||||
{ code: "NG", name: "Nigeria" },
|
||||
{ code: "NU", name: "Niue" },
|
||||
{ code: "NF", name: "Norfolk Island" },
|
||||
{ code: "MP", name: "Northern Mariana Islands" },
|
||||
{ code: "NO", name: "Norway" },
|
||||
{ code: "OM", name: "Oman" },
|
||||
{ code: "PK", name: "Pakistan" },
|
||||
{ code: "PW", name: "Palau" },
|
||||
{ code: "PS", name: "Palestinian Territory, Occupied" },
|
||||
{ code: "PA", name: "Panama" },
|
||||
{ code: "PG", name: "Papua New Guinea" },
|
||||
{ code: "PY", name: "Paraguay" },
|
||||
{ code: "PE", name: "Peru" },
|
||||
{ code: "PH", name: "Philippines" },
|
||||
{ code: "PN", name: "Pitcairn" },
|
||||
{ code: "PL", name: "Poland" },
|
||||
{ code: "PT", name: "Portugal" },
|
||||
{ code: "PR", name: "Puerto Rico" },
|
||||
{ code: "QA", name: "Qatar" },
|
||||
{ code: "RE", name: "Reunion" },
|
||||
{ code: "RO", name: "Romania" },
|
||||
{ code: "RU", name: "Russian Federation" },
|
||||
{ code: "RW", name: "Rwanda" },
|
||||
{ code: "BL", name: "Saint Barthelemy" },
|
||||
{ code: "SH", name: "Saint Helena" },
|
||||
{ code: "KN", name: "Saint Kitts And Nevis" },
|
||||
{ code: "LC", name: "Saint Lucia" },
|
||||
{ code: "MF", name: "Saint Martin" },
|
||||
{ code: "PM", name: "Saint Pierre And Miquelon" },
|
||||
{ code: "VC", name: "Saint Vincent And Grenadines" },
|
||||
{ code: "WS", name: "Samoa" },
|
||||
{ code: "SM", name: "San Marino" },
|
||||
{ code: "ST", name: "Sao Tome And Principe" },
|
||||
{ code: "SA", name: "Saudi Arabia" },
|
||||
{ code: "SN", name: "Senegal" },
|
||||
{ code: "RS", name: "Serbia" },
|
||||
{ code: "SC", name: "Seychelles" },
|
||||
{ code: "SL", name: "Sierra Leone" },
|
||||
{ code: "SG", name: "Singapore" },
|
||||
{ code: "SK", name: "Slovakia" },
|
||||
{ code: "SI", name: "Slovenia" },
|
||||
{ code: "SB", name: "Solomon Islands" },
|
||||
{ code: "SO", name: "Somalia" },
|
||||
{ code: "ZA", name: "South Africa" },
|
||||
{ code: "GS", name: "South Georgia And Sandwich Isl." },
|
||||
{ code: "ES", name: "Spain" },
|
||||
{ code: "LK", name: "Sri Lanka" },
|
||||
{ code: "SD", name: "Sudan" },
|
||||
{ code: "SR", name: "Suriname" },
|
||||
{ code: "SJ", name: "Svalbard And Jan Mayen" },
|
||||
{ code: "SZ", name: "Swaziland" },
|
||||
{ code: "SE", name: "Sweden" },
|
||||
{ code: "CH", name: "Switzerland" },
|
||||
{ code: "SY", name: "Syrian Arab Republic" },
|
||||
{ code: "TW", name: "Taiwan" },
|
||||
{ code: "TJ", name: "Tajikistan" },
|
||||
{ code: "TZ", name: "Tanzania" },
|
||||
{ code: "TH", name: "Thailand" },
|
||||
{ code: "TL", name: "Timor-Leste" },
|
||||
{ code: "TG", name: "Togo" },
|
||||
{ code: "TK", name: "Tokelau" },
|
||||
{ code: "TO", name: "Tonga" },
|
||||
{ code: "TT", name: "Trinidad And Tobago" },
|
||||
{ code: "TN", name: "Tunisia" },
|
||||
{ code: "TR", name: "Turkey" },
|
||||
{ code: "TM", name: "Turkmenistan" },
|
||||
{ code: "TC", name: "Turks And Caicos Islands" },
|
||||
{ code: "TV", name: "Tuvalu" },
|
||||
{ code: "UG", name: "Uganda" },
|
||||
{ code: "UA", name: "Ukraine" },
|
||||
{ code: "AE", name: "United Arab Emirates" },
|
||||
{ code: "GB", name: "United Kingdom" },
|
||||
{ code: "US", name: "United States" },
|
||||
{ code: "UM", name: "United States Outlying Islands" },
|
||||
{ code: "UY", name: "Uruguay" },
|
||||
{ code: "UZ", name: "Uzbekistan" },
|
||||
{ code: "VU", name: "Vanuatu" },
|
||||
{ code: "VE", name: "Venezuela" },
|
||||
{ code: "VN", name: "Vietnam" },
|
||||
{ code: "VG", name: "Virgin Islands, British" },
|
||||
{ code: "VI", name: "Virgin Islands, U.S." },
|
||||
{ code: "WF", name: "Wallis And Futuna" },
|
||||
{ code: "EH", name: "Western Sahara" },
|
||||
{ code: "YE", name: "Yemen" },
|
||||
{ code: "ZM", name: "Zambia" },
|
||||
{ code: "ZW", name: "Zimbabwe" },
|
||||
];
|
||||
|
||||
// countries-zh.js
|
||||
export const ZH_COUNTRIES = [
|
||||
{ code: "AF", name: "阿富汗" },
|
||||
{ code: "AX", name: "奥兰群岛" },
|
||||
{ code: "AL", name: "阿尔巴尼亚" },
|
||||
{ code: "DZ", name: "阿尔及利亚" },
|
||||
{ code: "AS", name: "美属萨摩亚" },
|
||||
{ code: "AD", name: "安道尔" },
|
||||
{ code: "AO", name: "安哥拉" },
|
||||
{ code: "AI", name: "安圭拉" },
|
||||
{ code: "AQ", name: "南极洲" },
|
||||
{ code: "AG", name: "安提瓜和巴布达" },
|
||||
{ code: "AR", name: "阿根廷" },
|
||||
{ code: "AM", name: "亚美尼亚" },
|
||||
{ code: "AW", name: "阿鲁巴" },
|
||||
{ code: "AU", name: "澳大利亚" },
|
||||
{ code: "AT", name: "奥地利" },
|
||||
{ code: "AZ", name: "阿塞拜疆" },
|
||||
{ code: "BS", name: "巴哈马" },
|
||||
{ code: "BH", name: "巴林" },
|
||||
{ code: "BD", name: "孟加拉国" },
|
||||
{ code: "BB", name: "巴巴多斯" },
|
||||
{ code: "BY", name: "白俄罗斯" },
|
||||
{ code: "BE", name: "比利时" },
|
||||
{ code: "BZ", name: "伯利兹" },
|
||||
{ code: "BJ", name: "贝宁" },
|
||||
{ code: "BM", name: "百慕大" },
|
||||
{ code: "BT", name: "不丹" },
|
||||
{ code: "BO", name: "玻利维亚" },
|
||||
{ code: "BA", name: "波斯尼亚和黑塞哥维那" },
|
||||
{ code: "BW", name: "博茨瓦纳" },
|
||||
{ code: "BV", name: "布韦岛" },
|
||||
{ code: "BR", name: "巴西" },
|
||||
{ code: "IO", name: "英属印度洋领地" },
|
||||
{ code: "BN", name: "文莱" },
|
||||
{ code: "BG", name: "保加利亚" },
|
||||
{ code: "BF", name: "布基纳法索" },
|
||||
{ code: "BI", name: "布隆迪" },
|
||||
{ code: "KH", name: "柬埔寨" },
|
||||
{ code: "CM", name: "喀麦隆" },
|
||||
{ code: "CA", name: "加拿大" },
|
||||
{ code: "CV", name: "佛得角" },
|
||||
{ code: "KY", name: "开曼群岛" },
|
||||
{ code: "CF", name: "中非共和国" },
|
||||
{ code: "TD", name: "乍得" },
|
||||
{ code: "CL", name: "智利" },
|
||||
{ code: "CN", name: "中国" },
|
||||
{ code: "CX", name: "圣诞岛" },
|
||||
{ code: "CC", name: "科科斯(基林)群岛" },
|
||||
{ code: "CO", name: "哥伦比亚" },
|
||||
{ code: "KM", name: "科摩罗" },
|
||||
{ code: "CG", name: "刚果" },
|
||||
{ code: "CD", name: "刚果(金)" },
|
||||
{ code: "CK", name: "库克群岛" },
|
||||
{ code: "CR", name: "哥斯达黎加" },
|
||||
{ code: "CI", name: "科特迪瓦" },
|
||||
{ code: "HR", name: "克罗地亚" },
|
||||
{ code: "CU", name: "古巴" },
|
||||
{ code: "CY", name: "塞浦路斯" },
|
||||
{ code: "CZ", name: "捷克" },
|
||||
{ code: "DK", name: "丹麦" },
|
||||
{ code: "DJ", name: "吉布提" },
|
||||
{ code: "DM", name: "多米尼克" },
|
||||
{ code: "DO", name: "多米尼加共和国" },
|
||||
{ code: "EC", name: "厄瓜多尔" },
|
||||
{ code: "EG", name: "埃及" },
|
||||
{ code: "SV", name: "萨尔瓦多" },
|
||||
{ code: "GQ", name: "赤道几内亚" },
|
||||
{ code: "ER", name: "厄立特里亚" },
|
||||
{ code: "EE", name: "爱沙尼亚" },
|
||||
{ code: "ET", name: "埃塞俄比亚" },
|
||||
{ code: "FK", name: "福克兰群岛" },
|
||||
{ code: "FO", name: "法罗群岛" },
|
||||
{ code: "FJ", name: "斐济" },
|
||||
{ code: "FI", name: "芬兰" },
|
||||
{ code: "FR", name: "法国" },
|
||||
{ code: "GF", name: "法属圭亚那" },
|
||||
{ code: "PF", name: "法属波利尼西亚" },
|
||||
{ code: "TF", name: "法属南部领地" },
|
||||
{ code: "GA", name: "加蓬" },
|
||||
{ code: "GM", name: "冈比亚" },
|
||||
{ code: "GE", name: "格鲁吉亚" },
|
||||
{ code: "DE", name: "德国" },
|
||||
{ code: "GH", name: "加纳" },
|
||||
{ code: "GI", name: "直布罗陀" },
|
||||
{ code: "GR", name: "希腊" },
|
||||
{ code: "GL", name: "格陵兰" },
|
||||
{ code: "GD", name: "格林纳达" },
|
||||
{ code: "GP", name: "瓜德罗普" },
|
||||
{ code: "GU", name: "关岛" },
|
||||
{ code: "GT", name: "危地马拉" },
|
||||
{ code: "GG", name: "根西岛" },
|
||||
{ code: "GN", name: "几内亚" },
|
||||
{ code: "GW", name: "几内亚比绍" },
|
||||
{ code: "GY", name: "圭亚那" },
|
||||
{ code: "HT", name: "海地" },
|
||||
{ code: "HM", name: "赫德岛和麦克唐纳群岛" },
|
||||
{ code: "VA", name: "梵蒂冈" },
|
||||
{ code: "HN", name: "洪都拉斯" },
|
||||
{ code: "HK", name: "中国香港" },
|
||||
{ code: "HU", name: "匈牙利" },
|
||||
{ code: "IS", name: "冰岛" },
|
||||
{ code: "IN", name: "印度" },
|
||||
{ code: "ID", name: "印度尼西亚" },
|
||||
{ code: "IR", name: "伊朗" },
|
||||
{ code: "IQ", name: "伊拉克" },
|
||||
{ code: "IE", name: "爱尔兰" },
|
||||
{ code: "IM", name: "马恩岛" },
|
||||
{ code: "IL", name: "以色列" },
|
||||
{ code: "IT", name: "意大利" },
|
||||
{ code: "JM", name: "牙买加" },
|
||||
{ code: "JP", name: "日本" },
|
||||
{ code: "JE", name: "泽西岛" },
|
||||
{ code: "JO", name: "约旦" },
|
||||
{ code: "KZ", name: "哈萨克斯坦" },
|
||||
{ code: "KE", name: "肯尼亚" },
|
||||
{ code: "KI", name: "基里巴斯" },
|
||||
{ code: "KR", name: "韩国" },
|
||||
{ code: "KP", name: "朝鲜" },
|
||||
{ code: "KW", name: "科威特" },
|
||||
{ code: "KG", name: "吉尔吉斯斯坦" },
|
||||
{ code: "LA", name: "老挝" },
|
||||
{ code: "LV", name: "拉脱维亚" },
|
||||
{ code: "LB", name: "黎巴嫩" },
|
||||
{ code: "LS", name: "莱索托" },
|
||||
{ code: "LR", name: "利比里亚" },
|
||||
{ code: "LY", name: "利比亚" },
|
||||
{ code: "LI", name: "列支敦士登" },
|
||||
{ code: "LT", name: "立陶宛" },
|
||||
{ code: "LU", name: "卢森堡" },
|
||||
{ code: "MO", name: "中国澳门" },
|
||||
{ code: "MK", name: "北马其顿" },
|
||||
{ code: "MG", name: "马达加斯加" },
|
||||
{ code: "MW", name: "马拉维" },
|
||||
{ code: "MY", name: "马来西亚" },
|
||||
{ code: "MV", name: "马尔代夫" },
|
||||
{ code: "ML", name: "马里" },
|
||||
{ code: "MT", name: "马耳他" },
|
||||
{ code: "MH", name: "马绍尔群岛" },
|
||||
{ code: "MQ", name: "马提尼克" },
|
||||
{ code: "MR", name: "毛里塔尼亚" },
|
||||
{ code: "MU", name: "毛里求斯" },
|
||||
{ code: "YT", name: "马约特" },
|
||||
{ code: "MX", name: "墨西哥" },
|
||||
{ code: "FM", name: "密克罗尼西亚" },
|
||||
{ code: "MD", name: "摩尔多瓦" },
|
||||
{ code: "MC", name: "摩纳哥" },
|
||||
{ code: "MN", name: "蒙古" },
|
||||
{ code: "ME", name: "黑山" },
|
||||
{ code: "MS", name: "蒙特塞拉特" },
|
||||
{ code: "MA", name: "摩洛哥" },
|
||||
{ code: "MZ", name: "莫桑比克" },
|
||||
{ code: "MM", name: "缅甸" },
|
||||
{ code: "NA", name: "纳米比亚" },
|
||||
{ code: "NR", name: "瑙鲁" },
|
||||
{ code: "NP", name: "尼泊尔" },
|
||||
{ code: "NL", name: "荷兰" },
|
||||
{ code: "AN", name: "荷属安的列斯" },
|
||||
{ code: "NC", name: "新喀里多尼亚" },
|
||||
{ code: "NZ", name: "新西兰" },
|
||||
{ code: "NI", name: "尼加拉瓜" },
|
||||
{ code: "NE", name: "尼日尔" },
|
||||
{ code: "NG", name: "尼日利亚" },
|
||||
{ code: "NU", name: "纽埃" },
|
||||
{ code: "NF", name: "诺福克岛" },
|
||||
{ code: "MP", name: "北马里亚纳群岛" },
|
||||
{ code: "NO", name: "挪威" },
|
||||
{ code: "OM", name: "阿曼" },
|
||||
{ code: "PK", name: "巴基斯坦" },
|
||||
{ code: "PW", name: "帕劳" },
|
||||
{ code: "PS", name: "巴勒斯坦" },
|
||||
{ code: "PA", name: "巴拿马" },
|
||||
{ code: "PG", name: "巴布亚新几内亚" },
|
||||
{ code: "PY", name: "巴拉圭" },
|
||||
{ code: "PE", name: "秘鲁" },
|
||||
{ code: "PH", name: "菲律宾" },
|
||||
{ code: "PN", name: "皮特凯恩群岛" },
|
||||
{ code: "PL", name: "波兰" },
|
||||
{ code: "PT", name: "葡萄牙" },
|
||||
{ code: "PR", name: "波多黎各" },
|
||||
{ code: "QA", name: "卡塔尔" },
|
||||
{ code: "RE", name: "留尼汪" },
|
||||
{ code: "RO", name: "罗马尼亚" },
|
||||
{ code: "RU", name: "俄罗斯" },
|
||||
{ code: "RW", name: "卢旺达" },
|
||||
{ code: "BL", name: "圣巴泰勒米" },
|
||||
{ code: "SH", name: "圣赫勒拿" },
|
||||
{ code: "KN", name: "圣基茨和尼维斯" },
|
||||
{ code: "LC", name: "圣卢西亚" },
|
||||
{ code: "MF", name: "法属圣马丁" },
|
||||
{ code: "PM", name: "圣皮埃尔和密克隆" },
|
||||
{ code: "VC", name: "圣文森特和格林纳丁斯" },
|
||||
{ code: "WS", name: "萨摩亚" },
|
||||
{ code: "SM", name: "圣马力诺" },
|
||||
{ code: "ST", name: "圣多美和普林西比" },
|
||||
{ code: "SA", name: "沙特阿拉伯" },
|
||||
{ code: "SN", name: "塞内加尔" },
|
||||
{ code: "RS", name: "塞尔维亚" },
|
||||
{ code: "SC", name: "塞舌尔" },
|
||||
{ code: "SL", name: "塞拉利昂" },
|
||||
{ code: "SG", name: "新加坡" },
|
||||
{ code: "SK", name: "斯洛伐克" },
|
||||
{ code: "SI", name: "斯洛文尼亚" },
|
||||
{ code: "SB", name: "所罗门群岛" },
|
||||
{ code: "SO", name: "索马里" },
|
||||
{ code: "ZA", name: "南非" },
|
||||
{ code: "GS", name: "南乔治亚和南桑威奇群岛" },
|
||||
{ code: "ES", name: "西班牙" },
|
||||
{ code: "LK", name: "斯里兰卡" },
|
||||
{ code: "SD", name: "苏丹" },
|
||||
{ code: "SR", name: "苏里南" },
|
||||
{ code: "SJ", name: "斯瓦尔巴和扬马延" },
|
||||
{ code: "SZ", name: "斯威士兰" },
|
||||
{ code: "SE", name: "瑞典" },
|
||||
{ code: "CH", name: "瑞士" },
|
||||
{ code: "SY", name: "叙利亚" },
|
||||
{ code: "TW", name: "中国台湾" },
|
||||
{ code: "TJ", name: "塔吉克斯坦" },
|
||||
{ code: "TZ", name: "坦桑尼亚" },
|
||||
{ code: "TH", name: "泰国" },
|
||||
{ code: "TL", name: "东帝汶" },
|
||||
{ code: "TG", name: "多哥" },
|
||||
{ code: "TK", name: "托克劳" },
|
||||
{ code: "TO", name: "汤加" },
|
||||
{ code: "TT", name: "特立尼达和多巴哥" },
|
||||
{ code: "TN", name: "突尼斯" },
|
||||
{ code: "TR", name: "土耳其" },
|
||||
{ code: "TM", name: "土库曼斯坦" },
|
||||
{ code: "TC", name: "特克斯和凯科斯群岛" },
|
||||
{ code: "TV", name: "图瓦卢" },
|
||||
{ code: "UG", name: "乌干达" },
|
||||
{ code: "UA", name: "乌克兰" },
|
||||
{ code: "AE", name: "阿联酋" },
|
||||
{ code: "GB", name: "英国" },
|
||||
{ code: "US", name: "美国" },
|
||||
{ code: "UM", name: "美国本土外小岛屿" },
|
||||
{ code: "UY", name: "乌拉圭" },
|
||||
{ code: "UZ", name: "乌兹别克斯坦" },
|
||||
{ code: "VU", name: "瓦努阿图" },
|
||||
{ code: "VE", name: "委内瑞拉" },
|
||||
{ code: "VN", name: "越南" },
|
||||
{ code: "VG", name: "英属维尔京群岛" },
|
||||
{ code: "VI", name: "美属维尔京群岛" },
|
||||
{ code: "WF", name: "瓦利斯和富图纳" },
|
||||
{ code: "EH", name: "西撒哈拉" },
|
||||
{ code: "YE", name: "也门" },
|
||||
{ code: "ZM", name: "赞比亚" },
|
||||
{ code: "ZW", name: "津巴布韦" },
|
||||
];
|
||||
|
||||
85
src/shared/const/mediaTypes.ts
Normal file
85
src/shared/const/mediaTypes.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// Допустимые типы и расширения файлов для медиа
|
||||
export const ALLOWED_MEDIA_TYPES = {
|
||||
image: {
|
||||
extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"],
|
||||
mimeTypes: [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
"image/svg+xml",
|
||||
],
|
||||
accept: "image/*",
|
||||
},
|
||||
video: {
|
||||
extensions: [".mp4", ".webm", ".ogg", ".mov", ".avi"],
|
||||
mimeTypes: [
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/ogg",
|
||||
"video/quicktime",
|
||||
"video/x-msvideo",
|
||||
],
|
||||
accept: "video/*",
|
||||
},
|
||||
model3d: {
|
||||
extensions: [".glb", ".gltf"],
|
||||
mimeTypes: ["model/gltf-binary", "model/gltf+json"],
|
||||
accept: ".glb,.gltf",
|
||||
},
|
||||
panorama: {
|
||||
extensions: [".jpg", ".jpeg", ".png"],
|
||||
mimeTypes: ["image/jpeg", "image/png"],
|
||||
accept: "image/*",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const getAllAllowedExtensions = (): string[] => {
|
||||
return [
|
||||
...ALLOWED_MEDIA_TYPES.image.extensions,
|
||||
...ALLOWED_MEDIA_TYPES.video.extensions,
|
||||
...ALLOWED_MEDIA_TYPES.model3d.extensions,
|
||||
];
|
||||
};
|
||||
|
||||
export const getAllAcceptString = (): string => {
|
||||
return `${ALLOWED_MEDIA_TYPES.image.accept},${ALLOWED_MEDIA_TYPES.video.accept},${ALLOWED_MEDIA_TYPES.model3d.accept}`;
|
||||
};
|
||||
|
||||
export const validateFileExtension = (
|
||||
file: File
|
||||
): { valid: boolean; error?: string } => {
|
||||
const fileName = file.name.toLowerCase();
|
||||
const extension = fileName.substring(fileName.lastIndexOf("."));
|
||||
const allowedExtensions = getAllAllowedExtensions();
|
||||
|
||||
if (!allowedExtensions.includes(extension)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Неподдерживаемый формат файла "${extension}". Допустимые форматы: ${allowedExtensions.join(
|
||||
", "
|
||||
)}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
export const filterValidFiles = (
|
||||
files: File[]
|
||||
): { validFiles: File[]; errors: string[] } => {
|
||||
const validFiles: File[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
files.forEach((file) => {
|
||||
const validation = validateFileExtension(file);
|
||||
if (validation.valid) {
|
||||
validFiles.push(file);
|
||||
} else {
|
||||
errors.push(`${file.name}: ${validation.error}`);
|
||||
}
|
||||
});
|
||||
|
||||
return { validFiles, errors };
|
||||
};
|
||||
1
src/shared/hooks/index.ts
Normal file
1
src/shared/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./useSelectedCity";
|
||||
12
src/shared/hooks/useSelectedCity.ts
Normal file
12
src/shared/hooks/useSelectedCity.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { selectedCityStore } from "@shared";
|
||||
|
||||
export const useSelectedCity = () => {
|
||||
const { selectedCity, selectedCityId, selectedCityName } = selectedCityStore;
|
||||
|
||||
return {
|
||||
selectedCity,
|
||||
selectedCityId,
|
||||
selectedCityName,
|
||||
hasSelectedCity: !!selectedCity,
|
||||
};
|
||||
};
|
||||
@@ -5,3 +5,4 @@ export * from "./store";
|
||||
export * from "./const";
|
||||
export * from "./api";
|
||||
export * from "./modals";
|
||||
export * from "./hooks";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user