Compare commits
16 Commits
a357994025
...
#18
| Author | SHA1 | Date | |
|---|---|---|---|
| 1917b2cf5a | |||
| 5298fb9f60 | |||
| c95a6517e9 | |||
| 79f523e9cb | |||
| 90f3d66b22 | |||
| 2b48ade2f1 | |||
| b0fdf03cc6 | |||
|
|
349c7009c6 | ||
| 50ad374cf5 | |||
| 9e47ab667f | |||
| 1b8fc3d215 | |||
| f5142ec95d | |||
| cdb96dfb8b | |||
| c50ccb3a0c | |||
| 4bcc2e2cca | |||
| 26e4d70b95 |
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/'
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon_ship.png" />
|
||||
<link rel="icon" type="image/svg" href="/favicon_ship.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Белые ночи</title>
|
||||
</head>
|
||||
|
||||
3409
package-lock.json
generated
@@ -3,6 +3,7 @@
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
@@ -23,6 +24,7 @@
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
"axios": "^1.9.0",
|
||||
"easymde": "^2.20.0",
|
||||
"i18n-iso-countries": "^7.14.0",
|
||||
"lucide-react": "^0.511.0",
|
||||
"mobx": "^6.13.7",
|
||||
"mobx-react-lite": "^4.1.0",
|
||||
@@ -30,11 +32,10 @@
|
||||
"path": "^0.12.7",
|
||||
"pixi.js": "^8.10.1",
|
||||
"react": "^19.1.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-photo-sphere-viewer": "^6.2.3",
|
||||
"react-router": "^7.9.4",
|
||||
"react-router-dom": "^7.6.1",
|
||||
"react-simplemde-editor": "^5.2.0",
|
||||
"react-toastify": "^11.0.5",
|
||||
@@ -53,9 +54,11 @@
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vite": "^6.3.5"
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-svgr": "^4.5.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 176 KiB |
BIN
public/GET.png
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 750 B |
|
Before Width: | Height: | Size: 2.3 KiB |
3
public/favicon_ship.svg
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
21
public/sight_icon.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<svg width="53" height="49" viewBox="0 0 53 49" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 19.9296H52.7174V15.9968L26.3662 0L0 15.9968V19.9296ZM26.3659 3.75713L4.75331 16.8616H47.9636L26.3659 3.75713Z" fill="#A6A6A6"/>
|
||||
<path d="M52.7174 45.4072H0V48.3587H52.7174V45.4072Z" fill="#A6A6A6"/>
|
||||
<path d="M50.0742 41.4756H2.64355V44.427H50.0742V41.4756Z" fill="#A6A6A6"/>
|
||||
<path d="M9.46312 21.6035H5.49805V39.0244H9.46312V21.6035Z" fill="#A6A6A6"/>
|
||||
<path d="M11.4448 39.4316H3.51465V40.5827H11.4448V39.4316Z" fill="#A6A6A6"/>
|
||||
<path d="M4.40104 20.6592C4.10066 20.6592 3.86035 20.8953 3.86035 21.1904C3.86035 21.4856 4.10066 21.7217 4.40104 21.7217C4.70143 21.7217 4.94173 21.4856 4.94173 21.1904H10.0182C10.0182 21.4856 10.2585 21.7217 10.5589 21.7217C10.8593 21.7217 11.0996 21.4856 11.0996 21.1904C11.0996 20.8953 10.8593 20.6592 10.5589 20.6592H4.40104Z" fill="#A6A6A6"/>
|
||||
<path d="M22.0979 21.6035H18.1328V39.0244H22.0979V21.6035Z" fill="#A6A6A6"/>
|
||||
<path d="M24.0815 39.4316H16.1514V40.5827H24.0815V39.4316Z" fill="#A6A6A6"/>
|
||||
<path d="M17.0358 20.6592C16.7354 20.6592 16.4951 20.8953 16.4951 21.1904C16.4951 21.4856 16.7354 21.7217 17.0358 21.7217C17.3362 21.7217 17.5765 21.4856 17.5765 21.1904H22.653C22.653 21.4856 22.8933 21.7217 23.1937 21.7217C23.4941 21.7217 23.7344 21.4856 23.7344 21.1904C23.7344 20.8953 23.4941 20.6592 23.1937 20.6592H17.0358Z" fill="#A6A6A6"/>
|
||||
<path d="M34.7414 21.6035H30.7764V39.0244H34.7414V21.6035Z" fill="#A6A6A6"/>
|
||||
<path d="M36.7231 39.4316H28.793V40.5827H36.7231V39.4316Z" fill="#A6A6A6"/>
|
||||
<path d="M29.6794 20.6592C29.379 20.6592 29.1387 20.8953 29.1387 21.1904C29.1387 21.4856 29.379 21.7217 29.6794 21.7217C29.9797 21.7217 30.2201 21.4856 30.2201 21.1904H35.2965C35.2965 21.4856 35.5369 21.7217 35.8372 21.7217C36.1376 21.7217 36.3779 21.4856 36.3779 21.1904C36.3779 20.8953 36.1376 20.6592 35.8372 20.6592H29.6794Z" fill="#A6A6A6"/>
|
||||
<path d="M47.3762 21.6045H43.4111V39.0254H47.3762V21.6045Z" fill="#A6A6A6"/>
|
||||
<path d="M49.3598 39.4316H41.4297V40.5827H49.3598V39.4316Z" fill="#A6A6A6"/>
|
||||
<path d="M42.3141 20.6592C42.0137 20.6592 41.7734 20.8953 41.7734 21.1904C41.7734 21.4856 42.0137 21.7217 42.3141 21.7217C42.6145 21.7217 42.8548 21.4856 42.8548 21.1904H47.9313C47.9313 21.4856 48.1716 21.7217 48.472 21.7217C48.7724 21.7217 49.0127 21.4856 49.0127 21.1904C49.0127 20.8953 48.7724 20.6592 48.472 20.6592H42.3141Z" fill="#A6A6A6"/>
|
||||
<path d="M26.8478 9.42308C26.8478 9.18696 26.6151 8.99512 26.3297 8.99512C26.0443 8.99512 25.8115 9.18696 25.8115 9.42308V9.76249C25.8115 10.0429 26.0443 10.2716 26.3297 10.2716C26.6151 10.2716 26.8478 10.0429 26.8478 9.76249V9.42308Z" fill="#A6A6A6"/>
|
||||
<path d="M19.5098 11.6514C19.2695 11.6514 19.0742 11.8801 19.0742 12.1679C19.0742 12.4483 19.2695 12.6844 19.5098 12.6844H20.4109C20.6963 12.6844 20.9366 12.4556 20.9366 12.1679C20.9366 11.8875 20.7038 11.6514 20.4109 11.6514H19.5098Z" fill="#A6A6A6"/>
|
||||
<path d="M32.2022 11.6514C31.9619 11.6514 31.7666 11.8801 31.7666 12.1679C31.7666 12.4483 31.9619 12.6844 32.2022 12.6844H33.1033C33.3887 12.6844 33.629 12.4556 33.629 12.1679C33.629 11.8875 33.3962 11.6514 33.1033 11.6514H32.2022Z" fill="#A6A6A6"/>
|
||||
<path d="M27.6188 11.1644L26.973 10.7586C26.973 10.7586 26.8979 10.7217 26.8528 10.7217H25.8015C25.7564 10.7217 25.7189 10.7364 25.6813 10.7586L25.0355 11.1644C24.9679 11.2087 24.9304 11.2751 24.9304 11.3489V11.8211C24.9304 11.8654 24.9454 11.9096 24.9679 11.9465L25.5311 12.7656C25.5762 12.832 25.5837 12.9057 25.5537 12.9795L24.9454 14.3372C24.9454 14.3372 24.9229 14.3962 24.9229 14.4257V15.776C24.9229 15.9015 25.0205 15.9974 25.1481 15.9974H27.4911C27.6188 15.9974 27.7164 15.9015 27.7164 15.776V14.4257C27.7164 14.4257 27.7164 14.3667 27.6939 14.3372L27.0856 12.9795C27.0556 12.9131 27.0631 12.832 27.1081 12.7656L27.6714 11.9465C27.6714 11.9465 27.7089 11.8654 27.7089 11.8211V11.3489C27.7089 11.2751 27.6714 11.2013 27.6038 11.1644H27.6188Z" fill="#A6A6A6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
158
src/app/GlobalErrorBoundary.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React, { Component, ReactNode } from "react";
|
||||
import { Box, Button, Typography, Paper, Container } from "@mui/material";
|
||||
import { RefreshCw, Home, AlertTriangle } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class GlobalErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error("❌ GlobalErrorBoundary: Критическая ошибка приложения", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
componentStack: errorInfo.componentStack,
|
||||
});
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
});
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
handleGoHome = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
});
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "background.default",
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="sm">
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={64} color="#f44336" />
|
||||
</Box>
|
||||
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Упс! Что-то пошло не так
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Приложение столкнулось с неожиданной ошибкой. Попробуйте
|
||||
перезагрузить страницу или вернуться на главную.
|
||||
</Typography>
|
||||
|
||||
{this.state.error?.message && (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 3,
|
||||
backgroundColor: "error.light",
|
||||
color: "error.contrastText",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: "bold", display: "block", mb: 1 }}
|
||||
>
|
||||
Информация об ошибке:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.75rem",
|
||||
wordBreak: "break-word",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{this.state.error.message}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 2,
|
||||
justifyContent: "center",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Home size={16} />}
|
||||
onClick={this.handleGoHome}
|
||||
size="large"
|
||||
>
|
||||
На главную
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<RefreshCw size={16} />}
|
||||
onClick={this.handleReset}
|
||||
size="large"
|
||||
>
|
||||
Перезагрузить
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,13 @@ import { Router } from "./router";
|
||||
import { CustomTheme } from "@shared";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { GlobalErrorBoundary } from "./GlobalErrorBoundary";
|
||||
|
||||
export const App: React.FC = () => (
|
||||
<ThemeProvider theme={CustomTheme.Light}>
|
||||
<ToastContainer />
|
||||
<Router />
|
||||
</ThemeProvider>
|
||||
<GlobalErrorBoundary>
|
||||
<ThemeProvider theme={CustomTheme.Light}>
|
||||
<ToastContainer />
|
||||
<Router />
|
||||
</ThemeProvider>
|
||||
</GlobalErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -16,12 +16,7 @@ import {
|
||||
SnapshotListPage,
|
||||
CarrierListPage,
|
||||
StationListPage,
|
||||
// VehicleListPage,
|
||||
ArticleListPage,
|
||||
|
||||
// CountryPreviewPage,
|
||||
// VehiclePreviewPage,
|
||||
// CarrierPreviewPage,
|
||||
SnapshotCreatePage,
|
||||
CountryCreatePage,
|
||||
CityCreatePage,
|
||||
@@ -31,7 +26,6 @@ import {
|
||||
CityEditPage,
|
||||
UserCreatePage,
|
||||
UserEditPage,
|
||||
// VehicleEditPage,
|
||||
CarrierEditPage,
|
||||
StationCreatePage,
|
||||
StationPreviewPage,
|
||||
@@ -75,7 +69,6 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// Чтобы очистка сторов происходила при смене локации
|
||||
const ClearStoresWrapper: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
@@ -116,65 +109,45 @@ const router = createBrowserRouter([
|
||||
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 /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -5,6 +5,7 @@ export interface NavigationItem {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
path?: string;
|
||||
for_admin?: boolean;
|
||||
onClick?: () => void;
|
||||
nestedItems?: NavigationItem[];
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import type { NavigationItem } from "../model";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { Plus } from "lucide-react";
|
||||
import { authStore } from "@shared";
|
||||
|
||||
interface NavigationItemProps {
|
||||
item: NavigationItem;
|
||||
@@ -30,9 +31,21 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
|
||||
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?.();
|
||||
@@ -108,15 +121,16 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{item.nestedItems &&
|
||||
{filteredNestedItems &&
|
||||
filteredNestedItems.length > 0 &&
|
||||
open &&
|
||||
(isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />)}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
{item.nestedItems && (
|
||||
{filteredNestedItems && filteredNestedItems.length > 0 && (
|
||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
{item.nestedItems.map((nestedItem) => (
|
||||
{filteredNestedItems.map((nestedItem) => (
|
||||
<NavigationItemComponent
|
||||
key={nestedItem.id}
|
||||
item={nestedItem}
|
||||
|
||||
@@ -1,41 +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";
|
||||
|
||||
interface NavigationListProps {
|
||||
open: boolean;
|
||||
onDrawerOpen?: () => void;
|
||||
}
|
||||
|
||||
export const NavigationList = ({ open, onDrawerOpen }: NavigationListProps) => {
|
||||
const primaryItems = NAVIGATION_ITEMS.primary;
|
||||
const secondaryItems = NAVIGATION_ITEMS.secondary;
|
||||
export const NavigationList = observer(
|
||||
({ open, onDrawerOpen }: NavigationListProps) => {
|
||||
const { payload } = authStore;
|
||||
// @ts-ignore
|
||||
const isAdmin = Boolean(payload?.is_admin) || false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
{primaryItems.map((item) => (
|
||||
<NavigationItemComponent
|
||||
key={item.id}
|
||||
item={item as NavigationItem}
|
||||
open={open}
|
||||
onDrawerOpen={onDrawerOpen}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
{secondaryItems.map((item) => (
|
||||
<NavigationItemComponent
|
||||
key={item.id}
|
||||
item={item as NavigationItem}
|
||||
open={open}
|
||||
onClick={item.onClick ? item.onClick : undefined}
|
||||
onDrawerOpen={onDrawerOpen}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
};
|
||||
const primaryItems = NAVIGATION_ITEMS.primary.filter((item) => {
|
||||
if (item.for_admin) {
|
||||
return isAdmin;
|
||||
}
|
||||
|
||||
if (item.nestedItems && item.nestedItems.length > 0) {
|
||||
return item.nestedItems.some((nestedItem) => {
|
||||
if (nestedItem.for_admin) {
|
||||
return isAdmin;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
{primaryItems.map((item) => (
|
||||
<NavigationItemComponent
|
||||
key={item.id}
|
||||
item={item as NavigationItem}
|
||||
open={open}
|
||||
onDrawerOpen={onDrawerOpen}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
{NAVIGATION_ITEMS.secondary.map((item) => (
|
||||
<NavigationItemComponent
|
||||
key={item.id}
|
||||
item={item as NavigationItem}
|
||||
open={open}
|
||||
onClick={item.onClick ? item.onClick : undefined}
|
||||
onDrawerOpen={onDrawerOpen}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export const ArticleListPage = observer(() => {
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
|
||||
@@ -48,7 +48,6 @@ export const CarrierCreatePage = observer(() => {
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
// Автоматически устанавливаем выбранный город при загрузке страницы
|
||||
useEffect(() => {
|
||||
if (selectedCityId && !createCarrierData.city_id) {
|
||||
setCreateCarrierData(
|
||||
|
||||
@@ -74,7 +74,6 @@ export const CarrierEditPage = observer(() => {
|
||||
mediaStore.getMedia();
|
||||
})();
|
||||
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, [id]);
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ export const CarrierListPage = observer(() => {
|
||||
headerName: "Действия",
|
||||
headerAlign: "center",
|
||||
width: 200,
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
|
||||
@@ -44,7 +44,6 @@ export const CityEditPage = observer(() => {
|
||||
const { getMedia, getOneMedia } = mediaStore;
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
@@ -64,12 +63,11 @@ export const CityEditPage = observer(() => {
|
||||
(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");
|
||||
@@ -207,7 +205,7 @@ export const CityEditPage = observer(() => {
|
||||
open={isSelectMediaOpen}
|
||||
onClose={() => setIsSelectMediaOpen(false)}
|
||||
onSelectMedia={handleMediaSelect}
|
||||
mediaType={1} // Тип медиа для иконок
|
||||
mediaType={1}
|
||||
/>
|
||||
|
||||
<UploadMediaDialog
|
||||
|
||||
@@ -93,6 +93,7 @@ export const CityListPage = observer(() => {
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
width: 200,
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
|
||||
@@ -17,7 +17,6 @@ export const CountryEditPage = observer(() => {
|
||||
countryStore;
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
@@ -36,12 +35,10 @@ export const CountryEditPage = observer(() => {
|
||||
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");
|
||||
|
||||
@@ -50,6 +50,7 @@ export const CountryListPage = observer(() => {
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
width: 200,
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
|
||||
@@ -24,7 +24,6 @@ export const LoginPage = () => {
|
||||
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) {
|
||||
@@ -42,7 +41,6 @@ export const LoginPage = () => {
|
||||
try {
|
||||
await login(email, password);
|
||||
|
||||
// Save or clear credentials based on remember me checkbox
|
||||
if (rememberMe) {
|
||||
localStorage.setItem("rememberedEmail", email);
|
||||
localStorage.setItem("rememberedPassword", password);
|
||||
@@ -52,7 +50,12 @@ export const LoginPage = () => {
|
||||
}
|
||||
|
||||
navigate("/map");
|
||||
await getUsers();
|
||||
try {
|
||||
await getUsers();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
toast.success("Вход в систему выполнен успешно");
|
||||
} catch (err) {
|
||||
setError(
|
||||
|
||||
@@ -22,10 +22,8 @@ interface ApiSight {
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
// Допуск для сравнения координат, чтобы избежать ошибок с точностью чисел.
|
||||
const COORDINATE_PRECISION_TOLERANCE = 1e-9;
|
||||
|
||||
// Вспомогательная функция, обновленная для сравнения с допуском.
|
||||
const arePathsEqual = (
|
||||
path1: [number, number][],
|
||||
path2: [number, number][]
|
||||
@@ -136,7 +134,6 @@ class MapStore {
|
||||
longitude: geometry.coordinates[0],
|
||||
};
|
||||
|
||||
// ИЗМЕНЕНИЕ: Сравнение координат с допуском
|
||||
if (
|
||||
originalStation.name !== currentStation.name ||
|
||||
Math.abs(originalStation.latitude - currentStation.latitude) >
|
||||
@@ -155,7 +152,6 @@ class MapStore {
|
||||
path: geometry.coordinates,
|
||||
};
|
||||
|
||||
// ИЗМЕНЕНИЕ: Используется новая функция arePathsEqual с допуском
|
||||
if (
|
||||
originalRoute.route_number !== currentRoute.route_number ||
|
||||
!arePathsEqual(originalRoute.path, currentRoute.path)
|
||||
@@ -173,7 +169,6 @@ class MapStore {
|
||||
longitude: geometry.coordinates[0],
|
||||
};
|
||||
|
||||
// ИЗМЕНЕНИЕ: Сравнение координат с допуском
|
||||
if (
|
||||
originalSight.name !== currentSight.name ||
|
||||
originalSight.description !== currentSight.description ||
|
||||
|
||||
@@ -33,7 +33,7 @@ export const MediaEditPage = observer(() => {
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [newFile, setNewFile] = useState<File | null>(null);
|
||||
const [uploadDialogOpen, setUploadDialogOpen] = useState(false); // State for the upload dialog
|
||||
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
|
||||
|
||||
const media = id ? mediaStore.media.find((m) => m.id === id) : null;
|
||||
const [mediaName, setMediaName] = useState(media?.media_name ?? "");
|
||||
@@ -55,47 +55,27 @@ export const MediaEditPage = observer(() => {
|
||||
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"].includes(extension)) {
|
||||
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
|
||||
setAvailableMediaTypes([6]);
|
||||
} else if (
|
||||
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
|
||||
extension
|
||||
)
|
||||
) {
|
||||
setAvailableMediaTypes([1, 3, 4, 5]);
|
||||
} else if (["mp4", "webm", "mov"].includes(extension)) {
|
||||
setAvailableMediaTypes([2]); // Video
|
||||
setAvailableMediaTypes([2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [media]);
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
// const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
// e.preventDefault();
|
||||
// e.stopPropagation();
|
||||
// setIsDragging(false);
|
||||
|
||||
// const files = Array.from(e.dataTransfer.files);
|
||||
// if (files.length > 0) {
|
||||
// setNewFile(files[0]);
|
||||
// setMediaFilename(files[0].name);
|
||||
// setUploadDialogOpen(true); // Open dialog on file drop
|
||||
// }
|
||||
// };
|
||||
|
||||
// const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
// e.preventDefault();
|
||||
// setIsDragging(true);
|
||||
// };
|
||||
|
||||
// const handleDragLeave = () => {
|
||||
// setIsDragging(false);
|
||||
// };
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
@@ -103,22 +83,25 @@ export const MediaEditPage = observer(() => {
|
||||
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
|
||||
setAvailableMediaTypes([6]);
|
||||
setMediaType(6);
|
||||
} else if (["jpg", "jpeg", "png", "gif"].includes(extension)) {
|
||||
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
|
||||
setMediaType(1); // Default to Photo
|
||||
} else if (
|
||||
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
|
||||
extension
|
||||
)
|
||||
) {
|
||||
setAvailableMediaTypes([1, 3, 4, 5]);
|
||||
setMediaType(1);
|
||||
} else if (["mp4", "webm", "mov"].includes(extension)) {
|
||||
setAvailableMediaTypes([2]); // Video
|
||||
setAvailableMediaTypes([2]);
|
||||
setMediaType(2);
|
||||
}
|
||||
}
|
||||
|
||||
setUploadDialogOpen(true); // Open dialog on file selection
|
||||
setUploadDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -135,11 +118,6 @@ export const MediaEditPage = observer(() => {
|
||||
type: mediaType,
|
||||
});
|
||||
|
||||
// If a new file was selected, the actual file upload will happen
|
||||
// via the UploadMediaDialog. We just need to make sure the metadata
|
||||
// is updated correctly before or after.
|
||||
// Since the dialog handles the actual upload, we don't call updateMediaFile here.
|
||||
|
||||
setSuccess(true);
|
||||
handleUploadSuccess();
|
||||
} catch (err) {
|
||||
@@ -150,17 +128,15 @@ export const MediaEditPage = observer(() => {
|
||||
};
|
||||
|
||||
const handleUploadSuccess = () => {
|
||||
// After successful upload in the dialog, refresh media data if needed
|
||||
if (id) {
|
||||
mediaStore.getOneMedia(id);
|
||||
}
|
||||
setNewFile(null); // Clear the new file state after successful upload
|
||||
setNewFile(null);
|
||||
setUploadDialogOpen(false);
|
||||
setSuccess(true);
|
||||
};
|
||||
|
||||
if (!media && id) {
|
||||
// Only show loading if an ID is present and media is not yet loaded
|
||||
return (
|
||||
<Box className="flex justify-center items-center h-screen">
|
||||
<CircularProgress />
|
||||
|
||||
@@ -69,6 +69,7 @@ export const MediaListPage = observer(() => {
|
||||
width: 200,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
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 {
|
||||
@@ -33,10 +34,14 @@ import {
|
||||
DropResult,
|
||||
} from "@hello-pangea/dnd";
|
||||
|
||||
import { authInstance, languageStore, routeStore } from "@shared";
|
||||
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];
|
||||
@@ -48,7 +53,6 @@ function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] {
|
||||
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);
|
||||
@@ -73,7 +77,6 @@ type LinkedItemsProps<T> = {
|
||||
disableCreation?: boolean;
|
||||
updatedLinkedItems?: T[];
|
||||
refresh?: number;
|
||||
cityId?: number;
|
||||
routeDirection?: boolean;
|
||||
};
|
||||
|
||||
@@ -112,7 +115,7 @@ export const LinkedItems = <
|
||||
);
|
||||
};
|
||||
|
||||
export const LinkedItemsContents = <
|
||||
const LinkedItemsContentsInner = <
|
||||
T extends { id: number; name: string; [key: string]: any }
|
||||
>({
|
||||
parentId,
|
||||
@@ -124,7 +127,6 @@ export const LinkedItemsContents = <
|
||||
disableCreation = false,
|
||||
updatedLinkedItems,
|
||||
refresh,
|
||||
cityId,
|
||||
routeDirection,
|
||||
}: LinkedItemsProps<T>) => {
|
||||
const { language } = languageStore;
|
||||
@@ -148,22 +150,22 @@ export const LinkedItemsContents = <
|
||||
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 (!cityId || item.city_id == cityId) {
|
||||
if (!searchQuery.trim()) return true;
|
||||
return String(item.name)
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase());
|
||||
}
|
||||
return false;
|
||||
if (!searchQuery.trim()) return true;
|
||||
return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -460,9 +462,7 @@ export const LinkedItemsContents = <
|
||||
onChange={(_, newValue) =>
|
||||
setSelectedItemId(newValue?.id || null)
|
||||
}
|
||||
options={availableItems.filter(
|
||||
(item) => !cityId || item.city_id == cityId
|
||||
)}
|
||||
options={availableItems}
|
||||
getOptionLabel={(item) => String(item.name)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
@@ -557,7 +557,14 @@ export const LinkedItemsContents = <
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={String(item.name)}
|
||||
label={
|
||||
<div className="flex justify-between items-center w-full gap-10">
|
||||
<p>{String(item.name)}</p>
|
||||
<p className="text-xs text-gray-500 max-w-[300px] truncate text-ellipsis">
|
||||
{String(item.description)}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
sx={{
|
||||
margin: 0,
|
||||
"& .MuiFormControlLabel-label": {
|
||||
@@ -597,3 +604,7 @@ export const LinkedItemsContents = <
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const LinkedItemsContents = observer(
|
||||
LinkedItemsContentsInner
|
||||
) as typeof LinkedItemsContentsInner;
|
||||
|
||||
@@ -13,16 +13,22 @@ import {
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from "@mui/material";
|
||||
import { MediaViewer } from "@widgets";
|
||||
import { MediaViewer, VideoPreviewCard } from "@widgets";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save, Plus } from "lucide-react";
|
||||
import { useEffect, useState } from "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 } from "@shared";
|
||||
import {
|
||||
languageStore,
|
||||
ArticleSelectOrCreateDialog,
|
||||
SelectMediaDialog,
|
||||
selectedCityStore,
|
||||
UploadMediaDialog,
|
||||
} from "@shared";
|
||||
|
||||
export const RouteCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
@@ -32,8 +38,9 @@ export const RouteCreatePage = observer(() => {
|
||||
const [govRouteNumber, setGovRouteNumber] = useState("");
|
||||
const [governorAppeal, setGovernorAppeal] = useState<string>("");
|
||||
const [direction, setDirection] = useState("backward");
|
||||
const [scaleMin, setScaleMin] = useState("");
|
||||
const [scaleMax, setScaleMax] = useState("");
|
||||
const [scaleMin, setScaleMin] = useState("10");
|
||||
const [scaleMax, setScaleMax] = useState("100");
|
||||
const [routeName, setRouteName] = useState("");
|
||||
const [turn, setTurn] = useState("");
|
||||
const [centerLat, setCenterLat] = useState("");
|
||||
const [centerLng, setCenterLng] = useState("");
|
||||
@@ -43,6 +50,8 @@ export const RouteCreatePage = observer(() => {
|
||||
useState(false);
|
||||
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
|
||||
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
|
||||
const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
|
||||
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,6 +59,20 @@ export const RouteCreatePage = observer(() => {
|
||||
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");
|
||||
@@ -90,6 +113,7 @@ export const RouteCreatePage = observer(() => {
|
||||
const handleArticleSelect = (articleId: number) => {
|
||||
setGovernorAppeal(articleId.toString());
|
||||
setIsSelectArticleDialogOpen(false);
|
||||
articlesStore.getArticleList();
|
||||
};
|
||||
|
||||
const handleVideoSelect = (media: {
|
||||
@@ -102,6 +126,26 @@ export const RouteCreatePage = observer(() => {
|
||||
setIsSelectVideoDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleVideoFileSelect = (file?: File) => {
|
||||
if (file) {
|
||||
setFileToUpload(file);
|
||||
setIsUploadVideoDialogOpen(true);
|
||||
} else {
|
||||
setIsSelectVideoDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVideoUpload = (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
setVideoPreview(media.id);
|
||||
setIsUploadVideoDialogOpen(false);
|
||||
setFileToUpload(null);
|
||||
};
|
||||
|
||||
const handleVideoPreviewClick = () => {
|
||||
setIsVideoPreviewOpen(true);
|
||||
};
|
||||
@@ -109,23 +153,72 @@ export const RouteCreatePage = observer(() => {
|
||||
const handleCreateRoute = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
// Преобразуем значения в нужные типы
|
||||
|
||||
if (!routeName.trim()) {
|
||||
toast.error("Заполните название маршрута");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!carrier) {
|
||||
toast.error("Выберите перевозчика");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!routeNumber.trim()) {
|
||||
toast.error("Заполните номер маршрута");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!govRouteNumber.trim()) {
|
||||
toast.error("Заполните номер маршрута в Говорящем Городе");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!governorAppeal) {
|
||||
toast.error("Выберите статью для обращения к пассажирам");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const validationResult = validateCoordinates(routeCoords);
|
||||
if (validationResult !== true) {
|
||||
toast.error(validationResult);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const scale_min = scaleMin ? Number(scaleMin) : null;
|
||||
const scale_max = scaleMax ? Number(scaleMax) : null;
|
||||
|
||||
if (
|
||||
scale_min === 0 ||
|
||||
scale_max === 0 ||
|
||||
scale_min === null ||
|
||||
scale_max === null
|
||||
) {
|
||||
toast.error("Масштабы не могут быть равны 0");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
scale_min !== null &&
|
||||
scale_max !== null &&
|
||||
scale_max !== undefined &&
|
||||
scale_min > scale_max
|
||||
) {
|
||||
toast.error("Максимальный масштаб не может быть меньше минимального");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const carrier_id = Number(carrier);
|
||||
const 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")
|
||||
@@ -137,7 +230,6 @@ export const RouteCreatePage = observer(() => {
|
||||
return [lat, lon];
|
||||
});
|
||||
|
||||
// Собираем объект маршрута
|
||||
const newRoute: Partial<Route> = {
|
||||
carrier:
|
||||
carrierStore.carriers[
|
||||
@@ -147,9 +239,10 @@ export const RouteCreatePage = observer(() => {
|
||||
route_number: routeNumber,
|
||||
route_sys_number: govRouteNumber,
|
||||
governor_appeal,
|
||||
route_name: routeName,
|
||||
route_direction,
|
||||
scale_min,
|
||||
scale_max,
|
||||
scale_min: scale_min !== null ? scale_min : 0,
|
||||
scale_max: scale_max !== null ? scale_max : 0,
|
||||
rotate,
|
||||
center_latitude,
|
||||
center_longitude,
|
||||
@@ -169,7 +262,6 @@ export const RouteCreatePage = observer(() => {
|
||||
}
|
||||
};
|
||||
|
||||
// Получаем название выбранной статьи для отображения
|
||||
const selectedArticle = articlesStore.articleList.ru.data.find(
|
||||
(article) => article.id === Number(governorAppeal)
|
||||
);
|
||||
@@ -188,22 +280,23 @@ export const RouteCreatePage = observer(() => {
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<Box className="flex flex-col gap-6 w-full">
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Название маршрута"
|
||||
required
|
||||
value={routeName}
|
||||
onChange={(e) => setRouteName(e.target.value)}
|
||||
/>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Выберите перевозчика</InputLabel>
|
||||
<Select
|
||||
value={carrier}
|
||||
label="Выберите перевозчика"
|
||||
onChange={(e) => setCarrier(e.target.value as string)}
|
||||
disabled={
|
||||
carrierStore.carriers[
|
||||
language as keyof typeof carrierStore.carriers
|
||||
].data?.length === 0
|
||||
}
|
||||
disabled={filteredCarriers.length === 0}
|
||||
>
|
||||
<MenuItem value="">Не выбрано</MenuItem>
|
||||
{carrierStore.carriers[
|
||||
language as keyof typeof carrierStore.carriers
|
||||
].data?.map((carrier) => (
|
||||
{filteredCarriers.map((carrier: any) => (
|
||||
<MenuItem key={carrier.id} value={carrier.id}>
|
||||
{carrier.full_name}
|
||||
</MenuItem>
|
||||
@@ -233,7 +326,6 @@ export const RouteCreatePage = observer(() => {
|
||||
const lines = routeCoords.split("\n");
|
||||
const lastLine = lines[lines.length - 1];
|
||||
|
||||
// Если мы на последней строке и она не пустая
|
||||
if (lastLine && lastLine.trim()) {
|
||||
e.preventDefault();
|
||||
const newValue = routeCoords + "\n";
|
||||
@@ -265,6 +357,7 @@ export const RouteCreatePage = observer(() => {
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Номер маршрута в Говорящем Городе"
|
||||
@@ -273,99 +366,42 @@ export const RouteCreatePage = observer(() => {
|
||||
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>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
Обращение к пассажирам
|
||||
</Typography>
|
||||
<Box className="flex gap-2">
|
||||
<TextField
|
||||
className="flex-1"
|
||||
value={selectedArticle?.heading || "Статья не выбрана"}
|
||||
placeholder="Выберите статью"
|
||||
disabled
|
||||
fullWidth
|
||||
sx={{
|
||||
"& .MuiInputBase-input": {
|
||||
color: selectedArticle ? "inherit" : "#999",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setIsSelectArticleDialogOpen(true)}
|
||||
startIcon={<Plus size={16} />}
|
||||
sx={{ minWidth: "auto", px: 2 }}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</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>
|
||||
<VideoPreviewCard
|
||||
title="Видеозаставка"
|
||||
videoId={videoPreview}
|
||||
onVideoClick={handleVideoPreviewClick}
|
||||
onDeleteVideoClick={() => {
|
||||
setVideoPreview("");
|
||||
}}
|
||||
onSelectVideoClick={handleVideoFileSelect}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Прямой/обратный маршрут</InputLabel>
|
||||
@@ -381,15 +417,41 @@ export const RouteCreatePage = observer(() => {
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Масштаб (мин)"
|
||||
type="number"
|
||||
value={scaleMin}
|
||||
onChange={(e) => setScaleMin(e.target.value)}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setScaleMin(value);
|
||||
if (value && scaleMax && Number(value) > Number(scaleMax)) {
|
||||
setScaleMax(value);
|
||||
}
|
||||
}}
|
||||
error={
|
||||
scaleMin !== "" &&
|
||||
scaleMax !== "" &&
|
||||
Number(scaleMin) > Number(scaleMax)
|
||||
}
|
||||
required
|
||||
helperText={
|
||||
scaleMin !== "" &&
|
||||
scaleMax !== "" &&
|
||||
Number(scaleMin) > Number(scaleMax)
|
||||
? "Минимальный масштаб не может быть больше максимального"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Масштаб (макс)"
|
||||
type="number"
|
||||
value={scaleMax}
|
||||
onChange={(e) => setScaleMax(e.target.value)}
|
||||
required
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setScaleMax(value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Поворот"
|
||||
@@ -426,23 +488,17 @@ export const RouteCreatePage = observer(() => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модальное окно выбора статьи */}
|
||||
<SelectArticleModal
|
||||
<ArticleSelectOrCreateDialog
|
||||
open={isSelectArticleDialogOpen}
|
||||
onClose={() => setIsSelectArticleDialogOpen(false)}
|
||||
onSelectArticle={handleArticleSelect}
|
||||
/>
|
||||
|
||||
{/* Модальное окно выбора видео */}
|
||||
<SelectMediaDialog
|
||||
open={isSelectVideoDialogOpen}
|
||||
onClose={() => setIsSelectVideoDialogOpen(false)}
|
||||
onSelectMedia={handleVideoSelect}
|
||||
mediaType={2}
|
||||
/>
|
||||
|
||||
{/* Модальное окно предпросмотра видео */}
|
||||
{videoPreview && videoPreview !== "" && (
|
||||
<Dialog
|
||||
open={isVideoPreviewOpen}
|
||||
@@ -469,6 +525,18 @@ export const RouteCreatePage = observer(() => {
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)}
|
||||
<UploadMediaDialog
|
||||
open={isUploadVideoDialogOpen}
|
||||
onClose={() => {
|
||||
setIsUploadVideoDialogOpen(false);
|
||||
setFileToUpload(null);
|
||||
}}
|
||||
hardcodeType="video_preview"
|
||||
contextObjectName={routeName || "Маршрут"}
|
||||
contextType="sight"
|
||||
initialFile={fileToUpload || undefined}
|
||||
afterUpload={handleVideoUpload}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from "@mui/material";
|
||||
import { MediaViewer } from "@widgets";
|
||||
import { MediaViewer, VideoPreviewCard } from "@widgets";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Copy, Save, Plus } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -24,8 +24,9 @@ import { articlesStore } from "../../../shared/store/ArticlesStore";
|
||||
import {
|
||||
routeStore,
|
||||
languageStore,
|
||||
SelectArticleModal,
|
||||
ArticleSelectOrCreateDialog,
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
} from "@shared";
|
||||
import { toast } from "react-toastify";
|
||||
import { stationsStore } from "@shared";
|
||||
@@ -40,12 +41,13 @@ export const RouteEditPage = observer(() => {
|
||||
useState(false);
|
||||
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
|
||||
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
|
||||
const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
|
||||
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
|
||||
const { language } = languageStore;
|
||||
const [coordinates, setCoordinates] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
const response = await routeStore.getRoute(Number(id));
|
||||
routeStore.setEditRouteData(response);
|
||||
languageStore.setLanguage("ru");
|
||||
@@ -72,10 +74,67 @@ export const RouteEditPage = observer(() => {
|
||||
}, [editRouteData.path]);
|
||||
|
||||
const handleSave = async () => {
|
||||
// Валидация обязательных полей
|
||||
if (!editRouteData.route_name?.trim()) {
|
||||
toast.error("Заполните название маршрута");
|
||||
return;
|
||||
}
|
||||
if (!editRouteData.carrier_id) {
|
||||
toast.error("Выберите перевозчика");
|
||||
return;
|
||||
}
|
||||
if (!editRouteData.route_number?.trim()) {
|
||||
toast.error("Заполните номер маршрута");
|
||||
return;
|
||||
}
|
||||
if (!editRouteData.route_sys_number?.trim()) {
|
||||
toast.error("Заполните номер маршрута в Говорящем Городе");
|
||||
return;
|
||||
}
|
||||
if (!editRouteData.governor_appeal) {
|
||||
toast.error("Выберите статью для обращения к пассажирам");
|
||||
return;
|
||||
}
|
||||
|
||||
const validationResult = validateCoordinates(coordinates);
|
||||
if (validationResult !== true) {
|
||||
toast.error(validationResult);
|
||||
return;
|
||||
}
|
||||
|
||||
// Валидация масштабов
|
||||
if (
|
||||
editRouteData.scale_min !== null &&
|
||||
editRouteData.scale_min !== undefined &&
|
||||
editRouteData.scale_max !== null &&
|
||||
editRouteData.scale_max !== undefined &&
|
||||
editRouteData.scale_min > editRouteData.scale_max
|
||||
) {
|
||||
toast.error("Максимальный масштаб не может быть меньше минимального");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
editRouteData.scale_min === 0 ||
|
||||
editRouteData.scale_max === 0 ||
|
||||
editRouteData.scale_min === null ||
|
||||
editRouteData.scale_max === null
|
||||
) {
|
||||
toast.error("Масштабы не могут быть равны 0");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
await routeStore.editRoute(Number(id));
|
||||
toast.success("Маршрут успешно сохранен");
|
||||
setIsLoading(false);
|
||||
try {
|
||||
await routeStore.editRoute(Number(id));
|
||||
toast.success("Маршрут успешно сохранен");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Произошла ошибка при сохранении маршрута");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validateCoordinates = (value: string) => {
|
||||
@@ -125,6 +184,8 @@ export const RouteEditPage = observer(() => {
|
||||
governor_appeal: articleId,
|
||||
});
|
||||
setIsSelectArticleDialogOpen(false);
|
||||
// Обновляем список статей после создания новой
|
||||
articlesStore.getArticleList();
|
||||
};
|
||||
|
||||
const handleVideoSelect = (media: {
|
||||
@@ -139,6 +200,28 @@ export const RouteEditPage = observer(() => {
|
||||
setIsSelectVideoDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleVideoFileSelect = (file?: File) => {
|
||||
if (file) {
|
||||
setFileToUpload(file);
|
||||
setIsUploadVideoDialogOpen(true);
|
||||
} else {
|
||||
setIsSelectVideoDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVideoUpload = (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name?: string;
|
||||
media_type: number;
|
||||
}) => {
|
||||
routeStore.setEditRouteData({
|
||||
video_preview: media.id,
|
||||
});
|
||||
setIsUploadVideoDialogOpen(false);
|
||||
setFileToUpload(null);
|
||||
};
|
||||
|
||||
const handleVideoPreviewClick = () => {
|
||||
if (editRouteData.video_preview && editRouteData.video_preview !== "") {
|
||||
setIsVideoPreviewOpen(true);
|
||||
@@ -164,6 +247,17 @@ export const RouteEditPage = observer(() => {
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<Box className="flex flex-col gap-6 w-full">
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Название маршрута"
|
||||
required
|
||||
value={editRouteData.route_name || ""}
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
route_name: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Выберите перевозчика</InputLabel>
|
||||
<Select
|
||||
@@ -235,7 +329,6 @@ export const RouteEditPage = observer(() => {
|
||||
const lines = coordinates.split("\n");
|
||||
const lastLine = lines[lines.length - 1];
|
||||
|
||||
// Если мы на последней строке и она не пустая
|
||||
if (lastLine && lastLine.trim()) {
|
||||
e.preventDefault();
|
||||
const newValue = coordinates + "\n";
|
||||
@@ -279,110 +372,6 @@ export const RouteEditPage = observer(() => {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Заменяем 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
|
||||
@@ -401,17 +390,33 @@ export const RouteEditPage = observer(() => {
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Масштаб (мин)"
|
||||
type="number"
|
||||
value={editRouteData.scale_min ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const value =
|
||||
e.target.value === "" ? null : parseFloat(e.target.value);
|
||||
routeStore.setEditRouteData({
|
||||
scale_min:
|
||||
e.target.value === "" ? null : parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
scale_min: value,
|
||||
});
|
||||
// Если максимальный масштаб стал меньше минимального, обновляем его
|
||||
if (
|
||||
value !== null &&
|
||||
editRouteData.scale_max !== null &&
|
||||
editRouteData.scale_max !== undefined &&
|
||||
value > editRouteData.scale_max
|
||||
) {
|
||||
routeStore.setEditRouteData({
|
||||
scale_max: value,
|
||||
});
|
||||
}
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
required
|
||||
label="Масштаб (макс)"
|
||||
type="number"
|
||||
value={editRouteData.scale_max ?? ""}
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
@@ -419,6 +424,22 @@ export const RouteEditPage = observer(() => {
|
||||
e.target.value === "" ? null : parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
error={
|
||||
editRouteData.scale_min !== null &&
|
||||
editRouteData.scale_min !== undefined &&
|
||||
editRouteData.scale_max !== null &&
|
||||
editRouteData.scale_max !== undefined &&
|
||||
editRouteData.scale_max < editRouteData.scale_min
|
||||
}
|
||||
helperText={
|
||||
editRouteData.scale_min !== null &&
|
||||
editRouteData.scale_min !== undefined &&
|
||||
editRouteData.scale_max !== null &&
|
||||
editRouteData.scale_max !== undefined &&
|
||||
editRouteData.scale_max < editRouteData.scale_min
|
||||
? "Максимальный масштаб не может быть меньше минимального"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
@@ -453,6 +474,43 @@ export const RouteEditPage = observer(() => {
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
Обращение к пассажирам
|
||||
</Typography>
|
||||
<Box className="flex gap-2">
|
||||
<TextField
|
||||
className="flex-1"
|
||||
value={selectedArticle?.heading || "Статья не выбрана"}
|
||||
placeholder="Выберите статью"
|
||||
disabled
|
||||
fullWidth
|
||||
sx={{
|
||||
"& .MuiInputBase-input": {
|
||||
color: selectedArticle ? "inherit" : "#999",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setIsSelectArticleDialogOpen(true)}
|
||||
startIcon={<Plus size={16} />}
|
||||
sx={{ minWidth: "auto", px: 2 }}
|
||||
>
|
||||
Выбрать
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<VideoPreviewCard
|
||||
title="Видеозаставка"
|
||||
videoId={editRouteData.video_preview}
|
||||
onVideoClick={handleVideoPreviewClick}
|
||||
onDeleteVideoClick={() => {
|
||||
routeStore.setEditRouteData({ video_preview: "" });
|
||||
}}
|
||||
onSelectVideoClick={handleVideoFileSelect}
|
||||
className="w-full"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<LinkedItems
|
||||
@@ -493,23 +551,17 @@ export const RouteEditPage = observer(() => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модальное окно выбора статьи */}
|
||||
<SelectArticleModal
|
||||
<ArticleSelectOrCreateDialog
|
||||
open={isSelectArticleDialogOpen}
|
||||
onClose={() => setIsSelectArticleDialogOpen(false)}
|
||||
onSelectArticle={handleArticleSelect}
|
||||
/>
|
||||
|
||||
{/* Модальное окно выбора видео */}
|
||||
<SelectMediaDialog
|
||||
open={isSelectVideoDialogOpen}
|
||||
onClose={() => setIsSelectVideoDialogOpen(false)}
|
||||
onSelectMedia={handleVideoSelect}
|
||||
mediaType={2}
|
||||
/>
|
||||
|
||||
{/* Модальное окно предпросмотра видео */}
|
||||
<Dialog
|
||||
open={isVideoPreviewOpen}
|
||||
onClose={() => setIsVideoPreviewOpen(false)}
|
||||
@@ -519,19 +571,33 @@ export const RouteEditPage = observer(() => {
|
||||
<DialogTitle>Предпросмотр видео</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box className="flex justify-center items-center p-4">
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: editRouteData.video_preview,
|
||||
media_type: 2,
|
||||
filename: "video_preview",
|
||||
}}
|
||||
/>
|
||||
{editRouteData.video_preview && (
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: editRouteData.video_preview,
|
||||
media_type: 2,
|
||||
filename: "video_preview",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setIsVideoPreviewOpen(false)}>Закрыть</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<UploadMediaDialog
|
||||
open={isUploadVideoDialogOpen}
|
||||
onClose={() => {
|
||||
setIsUploadVideoDialogOpen(false);
|
||||
setFileToUpload(null);
|
||||
}}
|
||||
hardcodeType="video_preview"
|
||||
contextObjectName={editRouteData.route_name || "Маршрут"}
|
||||
contextType="sight"
|
||||
initialFile={fileToUpload || undefined}
|
||||
afterUpload={handleVideoUpload}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -50,6 +50,22 @@ export const RouteListPage = observer(() => {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "route_name",
|
||||
headerName: "Название маршрута",
|
||||
flex: 1,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center">
|
||||
{params.value ? (
|
||||
params.value
|
||||
) : (
|
||||
<Minus size={20} className="text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "route_number",
|
||||
headerName: "Номер маршрута",
|
||||
@@ -90,6 +106,7 @@ export const RouteListPage = observer(() => {
|
||||
width: 250,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
@@ -99,9 +116,7 @@ export const RouteListPage = observer(() => {
|
||||
<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);
|
||||
@@ -121,6 +136,7 @@ export const RouteListPage = observer(() => {
|
||||
carrier_id: route.carrier_id,
|
||||
route_number: route.route_number,
|
||||
route_direction: route.route_direction ? "Прямой" : "Обратный",
|
||||
route_name: route.route_name,
|
||||
}));
|
||||
|
||||
return (
|
||||
|
||||
@@ -47,28 +47,26 @@ export function InfiniteCanvas({
|
||||
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(() => {
|
||||
const canvas = applicationRef?.app?.canvas;
|
||||
if (!canvas) return;
|
||||
if (!applicationRef?.app?.canvas) return;
|
||||
|
||||
const canvas = applicationRef.app.canvas;
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
const canvasLeft = canvasRect.left;
|
||||
const canvasTop = canvasRect.top;
|
||||
const centerX = window.innerWidth / 2 - canvasLeft;
|
||||
const centerY = window.innerHeight / 2 - canvasTop;
|
||||
setScreenCenter({ x: centerX, y: centerY });
|
||||
}, [applicationRef?.app?.canvas, setScreenCenter]);
|
||||
}, [applicationRef?.app, setScreenCenter]);
|
||||
|
||||
const handlePointerDown = (e: FederatedMouseEvent) => {
|
||||
setIsPointerDown(true);
|
||||
setIsDragging(false);
|
||||
setIsUserInteracting(true); // Устанавливаем флаг взаимодействия пользователя
|
||||
setIsUserInteracting(true);
|
||||
setStartPosition({
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
@@ -81,13 +79,9 @@ export function InfiniteCanvas({
|
||||
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;
|
||||
@@ -97,7 +91,6 @@ export function InfiniteCanvas({
|
||||
const handlePointerMove = (e: FederatedMouseEvent) => {
|
||||
if (!isPointerDown) return;
|
||||
|
||||
// Проверяем, началось ли перетаскивание
|
||||
if (!isDragging) {
|
||||
const dx = e.globalX - startMousePosition.x;
|
||||
const dy = e.globalY - startMousePosition.y;
|
||||
@@ -119,10 +112,8 @@ export function InfiniteCanvas({
|
||||
e.globalX - center.x
|
||||
);
|
||||
|
||||
// Calculate rotation difference in radians
|
||||
const rotationDiff = currentAngle - startAngle;
|
||||
|
||||
// Update rotation
|
||||
setRotation(startRotation + rotationDiff);
|
||||
|
||||
const cosDelta = Math.cos(rotationDiff);
|
||||
@@ -149,15 +140,13 @@ export function InfiniteCanvas({
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: FederatedMouseEvent) => {
|
||||
// Если не было перетаскивания, то это простой клик - закрываем виджет
|
||||
if (!isDragging) {
|
||||
setSelectedSight(undefined);
|
||||
}
|
||||
|
||||
setIsPointerDown(false);
|
||||
setIsDragging(false);
|
||||
// Сбрасываем флаг взаимодействия через небольшую задержку
|
||||
// чтобы избежать немедленного срабатывания useEffect
|
||||
|
||||
setTimeout(() => {
|
||||
setIsUserInteracting(false);
|
||||
}, 100);
|
||||
@@ -166,29 +155,25 @@ export function InfiniteCanvas({
|
||||
|
||||
const handleWheel = (e: FederatedWheelEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsUserInteracting(true); // Устанавливаем флаг при зуме
|
||||
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 zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
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),
|
||||
@@ -196,7 +181,6 @@ export function InfiniteCanvas({
|
||||
|
||||
setScale(newScale);
|
||||
|
||||
// Сбрасываем флаг взаимодействия через задержку
|
||||
setTimeout(() => {
|
||||
setIsUserInteracting(false);
|
||||
}, 100);
|
||||
|
||||
@@ -141,7 +141,6 @@ export const MapDataProvider = observer(
|
||||
}, [routeId]);
|
||||
|
||||
useEffect(() => {
|
||||
// combine changes with original data
|
||||
if (originalRouteData)
|
||||
setRouteData({ ...originalRouteData, ...routeChanges });
|
||||
if (originalSightData) setSightData(originalSightData);
|
||||
|
||||
@@ -37,11 +37,9 @@ export function RightSidebar() {
|
||||
|
||||
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;
|
||||
|
||||
@@ -101,7 +99,6 @@ export function RightSidebar() {
|
||||
}
|
||||
|
||||
if (!routeData) {
|
||||
console.error("routeData is null");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -119,7 +116,7 @@ export function RightSidebar() {
|
||||
borderRadius={2}
|
||||
>
|
||||
<Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center">
|
||||
Детали о достопримечательностях
|
||||
Настройка маршрута
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={2} direction="row" alignItems="center">
|
||||
@@ -131,7 +128,6 @@ export function RightSidebar() {
|
||||
onChange={(e) => {
|
||||
let newMinScale = Number(e.target.value);
|
||||
|
||||
// Сбрасываем к 1 если меньше
|
||||
if (newMinScale < 1) {
|
||||
newMinScale = 1;
|
||||
}
|
||||
@@ -140,10 +136,10 @@ export function RightSidebar() {
|
||||
|
||||
if (maxScale - newMinScale < 2) {
|
||||
let newMaxScale = newMinScale + 2;
|
||||
// Сбрасываем максимальный к 3 если меньше минимального
|
||||
|
||||
if (newMaxScale < 3) {
|
||||
newMaxScale = 3;
|
||||
setMinScale(1); // Сбрасываем минимальный к 1
|
||||
setMinScale(1);
|
||||
}
|
||||
setMaxScale(newMaxScale);
|
||||
}
|
||||
@@ -176,7 +172,6 @@ export function RightSidebar() {
|
||||
onChange={(e) => {
|
||||
let newMaxScale = Number(e.target.value);
|
||||
|
||||
// Сбрасываем к 3 если меньше минимального
|
||||
if (newMaxScale < 3) {
|
||||
newMaxScale = 3;
|
||||
}
|
||||
@@ -185,10 +180,10 @@ export function RightSidebar() {
|
||||
|
||||
if (newMaxScale - minScale < 2) {
|
||||
let newMinScale = newMaxScale - 2;
|
||||
// Сбрасываем минимальный к 1 если меньше
|
||||
|
||||
if (newMinScale < 1) {
|
||||
newMinScale = 1;
|
||||
setMaxScale(3); // Сбрасываем максимальный к минимальному значению
|
||||
setMaxScale(3);
|
||||
}
|
||||
setMinScale(newMinScale);
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export const Sight = ({ sight, id }: Readonly<SightProps>) => {
|
||||
|
||||
const [texture, setTexture] = useState(Texture.EMPTY);
|
||||
useEffect(() => {
|
||||
Assets.load("/SightIcon.png").then(setTexture);
|
||||
Assets.load("/sight_icon.svg").then(setTexture);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {}, [id, sight.latitude, sight.longitude]);
|
||||
|
||||
@@ -2,7 +2,6 @@ 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,
|
||||
@@ -15,22 +14,16 @@ 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 координаты.
|
||||
*/
|
||||
@@ -39,8 +32,6 @@ type LabelAlign = "left" | "center" | "right";
|
||||
* Получает координату anchor.x из типа выравнивания.
|
||||
*/
|
||||
|
||||
// --- Интерфейсы пропсов ---
|
||||
|
||||
interface StationProps {
|
||||
station: StationData;
|
||||
ruLabel: string | null;
|
||||
@@ -83,10 +74,6 @@ const getAnchorFromOffset = (
|
||||
return { x: (1 - nx) / 2, y: (1 - ny) / 2 };
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// Компонент: Панель управления выравниванием в стиле УрФУ
|
||||
// =========================================================================
|
||||
|
||||
const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
|
||||
scale,
|
||||
currentAlign,
|
||||
@@ -107,7 +94,6 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
|
||||
(g: Graphics) => {
|
||||
g.clear();
|
||||
|
||||
// Основной фон с градиентом
|
||||
g.roundRect(
|
||||
-controlWidth / 2,
|
||||
0,
|
||||
@@ -115,9 +101,8 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
|
||||
controlHeight,
|
||||
borderRadius
|
||||
);
|
||||
g.fill({ color: "#1a1a1a" }); // Темный фон как у УрФУ
|
||||
g.fill({ color: "#1a1a1a" });
|
||||
|
||||
// Тонкая рамка
|
||||
g.roundRect(
|
||||
-controlWidth / 2,
|
||||
0,
|
||||
@@ -127,7 +112,6 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
|
||||
);
|
||||
g.stroke({ color: "#333333", width: strokeWidth });
|
||||
|
||||
// Разделители между кнопками
|
||||
for (let i = 1; i < 3; i++) {
|
||||
const x = -controlWidth / 2 + buttonWidth * i;
|
||||
g.moveTo(x, strokeWidth);
|
||||
@@ -151,7 +135,7 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
|
||||
controlHeight - strokeWidth * 2,
|
||||
borderRadius / 2
|
||||
);
|
||||
g.fill({ color: "#0066cc", alpha: 0.8 }); // Синий акцент УрФУ
|
||||
g.fill({ color: "#0066cc", alpha: 0.8 });
|
||||
}
|
||||
},
|
||||
[controlWidth, controlHeight, buttonWidth, strokeWidth, borderRadius]
|
||||
@@ -230,10 +214,6 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// Компонент: Метка Станции (с логикой)
|
||||
// =========================================================================
|
||||
|
||||
const StationLabel = observer(
|
||||
({
|
||||
station,
|
||||
@@ -274,48 +254,45 @@ const StationLabel = observer(
|
||||
hideTimer.current = null;
|
||||
}
|
||||
setIsHovered(true);
|
||||
onTextHover?.(true); // Call the callback to indicate text is hovered
|
||||
onTextHover?.(true);
|
||||
};
|
||||
|
||||
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
|
||||
onTextHover?.(true);
|
||||
};
|
||||
|
||||
const handleControlPointerLeave = () => {
|
||||
setIsControlHovered(false);
|
||||
// Если курсор не над основным контейнером, скрываем панель через некоторое время
|
||||
|
||||
if (!isHovered) {
|
||||
hideTimer.current = setTimeout(() => {
|
||||
setIsHovered(false);
|
||||
onTextHover?.(false); // Call the callback to indicate neither text nor control is hovered
|
||||
onTextHover?.(false);
|
||||
}, 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); // Увеличиваем время до скрытия панели
|
||||
onTextHover?.(false);
|
||||
}, 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:
|
||||
@@ -329,7 +306,6 @@ const StationLabel = observer(
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для конвертации строкового align в числовой
|
||||
const convertStringAlign = (align: LabelAlign): number => {
|
||||
switch (align) {
|
||||
case "left":
|
||||
@@ -353,7 +329,6 @@ const StationLabel = observer(
|
||||
const compensatedRuFontSize = (26 * 0.75) / scale;
|
||||
const compensatedNameFontSize = (16 * 0.75) / scale;
|
||||
|
||||
// Измеряем ширину верхнего лейбла
|
||||
useEffect(() => {
|
||||
if (ruLabelRef.current && ruLabel) {
|
||||
setRuLabelWidth(ruLabelRef.current.width);
|
||||
@@ -386,7 +361,6 @@ const StationLabel = observer(
|
||||
y: dragStartPos.current.y + dy_screen,
|
||||
};
|
||||
|
||||
// Проверяем, изменилась ли позиция
|
||||
if (
|
||||
Math.abs(newPosition.x - position.x) > 0.01 ||
|
||||
Math.abs(newPosition.y - position.y) > 0.01
|
||||
@@ -406,7 +380,7 @@ const StationLabel = observer(
|
||||
const handleAlignChange = async (align: LabelAlign) => {
|
||||
setCurrentLabelAlign(align);
|
||||
onLabelAlignChange?.(align);
|
||||
// Сохраняем в стор
|
||||
|
||||
const numericAlign = convertStringAlign(align);
|
||||
setStationAlign(station.id, numericAlign);
|
||||
};
|
||||
@@ -416,34 +390,29 @@ const StationLabel = observer(
|
||||
[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 (левый край)
|
||||
return 0;
|
||||
case "center":
|
||||
return 0.5; // anchor.x = 0.5 (центр)
|
||||
return 0.5;
|
||||
case "right":
|
||||
return 1; // anchor.x = 1 (правый край)
|
||||
return 1;
|
||||
default:
|
||||
return 0.5;
|
||||
}
|
||||
@@ -522,10 +491,6 @@ const StationLabel = observer(
|
||||
}
|
||||
);
|
||||
|
||||
// =========================================================================
|
||||
// Главный экспортируемый компонент: Станция
|
||||
// =========================================================================
|
||||
|
||||
export const Station = ({
|
||||
station,
|
||||
ruLabel,
|
||||
@@ -548,10 +513,9 @@ export const Station = ({
|
||||
|
||||
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
|
||||
g.fill({ color: 0x00aaff });
|
||||
g.stroke({ color: 0xffffff, width: strokeWidth + 1 });
|
||||
} else {
|
||||
g.fill({ color: PATH_COLOR });
|
||||
g.stroke({ color: BACKGROUND_COLOR, width: strokeWidth });
|
||||
|
||||
@@ -50,7 +50,6 @@ const TransformContext = createContext<{
|
||||
setScaleAtCenter: () => {},
|
||||
});
|
||||
|
||||
// Provider component
|
||||
export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [scale, setScale] = useState(1);
|
||||
@@ -59,12 +58,10 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
||||
|
||||
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 cosRotation = Math.cos(-rotation);
|
||||
const sinRotation = Math.sin(-rotation);
|
||||
const rotatedX = translatedX * cosRotation - translatedY * sinRotation;
|
||||
const rotatedY = translatedX * sinRotation + translatedY * cosRotation;
|
||||
@@ -77,7 +74,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
||||
[position.x, position.y, scale, rotation]
|
||||
);
|
||||
|
||||
// Inverse of screenToLocal
|
||||
const localToScreen = useCallback(
|
||||
(localX: number, localY: number) => {
|
||||
const upscaledX = localX * UP_SCALE;
|
||||
@@ -120,7 +116,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
||||
(currentFromPosition.x - center.x) * sinDelta,
|
||||
};
|
||||
|
||||
// Update both rotation and position in a single batch to avoid stale closure
|
||||
setRotation(to);
|
||||
setPosition(newPosition);
|
||||
},
|
||||
@@ -150,13 +145,11 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
||||
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);
|
||||
@@ -184,7 +177,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
||||
);
|
||||
|
||||
const setScaleOnly = useCallback((newScale: number) => {
|
||||
// Изменяем только масштаб, не трогая позицию и поворот
|
||||
setScale(newScale);
|
||||
}, []);
|
||||
|
||||
@@ -237,7 +229,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// Custom hook for easy access to transform values
|
||||
export const useTransform = () => {
|
||||
const context = useContext(TransformContext);
|
||||
if (!context) {
|
||||
|
||||
@@ -166,7 +166,7 @@ export const RouteMap = observer(() => {
|
||||
return (
|
||||
<div style={{ width: "100%", height: "100%" }} ref={parentRef}>
|
||||
<LanguageSwitcher />
|
||||
<Application resizeTo={parentRef} background="#fff">
|
||||
<Application resizeTo={parentRef} background="#fff" preference="webgl">
|
||||
<InfiniteCanvas>
|
||||
<TravelPath points={points} />
|
||||
{stationData[language].map((obj, index) => (
|
||||
|
||||
1725
src/pages/Route/route-preview/web-gl/web-gl-version.tsx
Normal file
321
src/pages/Sight/LinkedStations.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
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 LinkedStationsProps<T> = {
|
||||
parentId: string | number;
|
||||
fields: Field<T>[];
|
||||
setItemsParent?: (items: T[]) => void;
|
||||
type: "show" | "edit";
|
||||
onUpdate?: () => void;
|
||||
disableCreation?: boolean;
|
||||
updatedLinkedItems?: T[];
|
||||
refresh?: number;
|
||||
};
|
||||
|
||||
export const LinkedStations = <
|
||||
T extends { id: number; name: string; [key: string]: any }
|
||||
>(
|
||||
props: LinkedStationsProps<T>
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Accordion sx={{ width: "100%" }}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
sx={{
|
||||
background: theme.palette.background.paper,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight="bold">
|
||||
Привязанные остановки
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails
|
||||
sx={{ background: theme.palette.background.paper, width: "100%" }}
|
||||
>
|
||||
<Stack gap={2} width="100%">
|
||||
<LinkedStationsContents {...props} />
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const LinkedStationsContentsInner = <
|
||||
T extends { id: number; name: string; [key: string]: any }
|
||||
>({
|
||||
parentId,
|
||||
setItemsParent,
|
||||
fields,
|
||||
type,
|
||||
onUpdate,
|
||||
disableCreation = false,
|
||||
updatedLinkedItems,
|
||||
refresh,
|
||||
}: LinkedStationsProps<T>) => {
|
||||
const { language } = languageStore;
|
||||
|
||||
const [allItems, setAllItems] = useState<T[]>([]);
|
||||
const [linkedItems, setLinkedItems] = useState<T[]>([]);
|
||||
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {}, [error]);
|
||||
|
||||
const parentResource = "sight";
|
||||
const childResource = "station";
|
||||
|
||||
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 = {
|
||||
station_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 station:", 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 station:", error);
|
||||
setError("Failed to delete station");
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (parentId) {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
authInstance
|
||||
.get(`/${parentResource}/${parentId}/${childResource}`)
|
||||
.then((response) => {
|
||||
setLinkedItems(response?.data || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching linked stations:", error);
|
||||
setError("Failed to load linked stations");
|
||||
setLinkedItems([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [parentId, language, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (type === "edit") {
|
||||
setError(null);
|
||||
authInstance
|
||||
.get(`/${childResource}`)
|
||||
.then((response) => {
|
||||
setAllItems(response?.data || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching all stations:", error);
|
||||
setError("Failed to load available stations");
|
||||
setAllItems([]);
|
||||
});
|
||||
}
|
||||
}, [type]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{linkedItems?.length > 0 && (
|
||||
<TableContainer component={Paper} sx={{ width: "100%" }}>
|
||||
<Table sx={{ width: "100%" }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<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 LinkedStationsContents = observer(
|
||||
LinkedStationsContentsInner
|
||||
) as typeof LinkedStationsContentsInner;
|
||||
@@ -1,7 +1,12 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { cityStore, languageStore, sightsStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
cityStore,
|
||||
languageStore,
|
||||
sightsStore,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -68,6 +73,7 @@ export const SightListPage = observer(() => {
|
||||
headerName: "Действия",
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
@@ -92,7 +98,16 @@ export const SightListPage = observer(() => {
|
||||
},
|
||||
];
|
||||
|
||||
const rows = sights.map((sight) => ({
|
||||
// Фильтрация достопримечательностей по выбранному городу
|
||||
const filteredSights = useMemo(() => {
|
||||
const { selectedCityId } = selectedCityStore;
|
||||
if (!selectedCityId) {
|
||||
return sights;
|
||||
}
|
||||
return sights.filter((sight: any) => sight.city_id === selectedCityId);
|
||||
}, [sights, selectedCityStore.selectedCityId]);
|
||||
|
||||
const rows = filteredSights.map((sight) => ({
|
||||
id: sight.id,
|
||||
name: sight.name,
|
||||
city_id: sight.city_id,
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./SightListPage";
|
||||
export { LinkedStations } from "./LinkedStations";
|
||||
|
||||
@@ -2,23 +2,16 @@ import { Button, TextField } from "@mui/material";
|
||||
import { snapshotStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const SnapshotCreatePage = observer(() => {
|
||||
const { id } = useParams();
|
||||
const { getSnapshot, createSnapshot } = snapshotStore;
|
||||
const { createSnapshot } = snapshotStore;
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await getSnapshot(id as string);
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-[400px] flex justify-center items-center">
|
||||
<div className="w-full h-full p-3 flex flex-col gap-10">
|
||||
|
||||
@@ -43,6 +43,7 @@ export const SnapshotListPage = observer(() => {
|
||||
headerName: "Действия",
|
||||
width: 300,
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
|
||||
@@ -17,9 +17,10 @@ import {
|
||||
Paper,
|
||||
TableBody,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
|
||||
import { authInstance, languageStore } from "@shared";
|
||||
import { authInstance, languageStore, selectedCityStore } from "@shared";
|
||||
|
||||
type Field<T> = {
|
||||
label: string;
|
||||
@@ -73,7 +74,7 @@ export const LinkedSights = <
|
||||
);
|
||||
};
|
||||
|
||||
export const LinkedSightsContents = <
|
||||
const LinkedSightsContentsInner = <
|
||||
T extends { id: number; name: string; [key: string]: any }
|
||||
>({
|
||||
parentId,
|
||||
@@ -100,6 +101,14 @@ export const LinkedSightsContents = <
|
||||
|
||||
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(() => {
|
||||
@@ -313,3 +322,7 @@ export const LinkedSightsContents = <
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const LinkedSightsContents = observer(
|
||||
LinkedSightsContentsInner
|
||||
) as typeof LinkedSightsContentsInner;
|
||||
|
||||
@@ -35,7 +35,7 @@ export const StationCreatePage = observer(() => {
|
||||
const { cities, getCities } = cityStore;
|
||||
const { selectedCityId, selectedCity } = useSelectedCity();
|
||||
const [coordinates, setCoordinates] = useState<string>("");
|
||||
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
|
||||
|
||||
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -49,7 +49,6 @@ export const StationCreatePage = observer(() => {
|
||||
}
|
||||
}, [createStationData.common.latitude, createStationData.common.longitude]);
|
||||
|
||||
// НОВАЯ ФУНКЦИЯ: Фактическое создание (вызывается после подтверждения)
|
||||
const executeCreate = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@@ -64,11 +63,13 @@ export const StationCreatePage = observer(() => {
|
||||
}
|
||||
};
|
||||
|
||||
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или создания
|
||||
const handleCreate = async () => {
|
||||
const isCityMissing = !createStationData.common.city_id;
|
||||
// Проверяем названия на всех языках
|
||||
const isNameMissing = !createStationData.ru.name || !createStationData.en.name || !createStationData.zh.name;
|
||||
|
||||
const isNameMissing =
|
||||
!createStationData.ru.name ||
|
||||
!createStationData.en.name ||
|
||||
!createStationData.zh.name;
|
||||
|
||||
if (isCityMissing || isNameMissing) {
|
||||
setIsSaveWarningOpen(true);
|
||||
@@ -78,13 +79,11 @@ export const StationCreatePage = observer(() => {
|
||||
await executeCreate();
|
||||
};
|
||||
|
||||
// Обработчик "Да" в предупреждающем окне
|
||||
const handleConfirmCreate = async () => {
|
||||
setIsSaveWarningOpen(false);
|
||||
await executeCreate();
|
||||
};
|
||||
|
||||
// Обработчик "Нет" в предупреждающем окне
|
||||
const handleCancelCreate = () => {
|
||||
setIsSaveWarningOpen(false);
|
||||
};
|
||||
@@ -99,7 +98,6 @@ export const StationCreatePage = observer(() => {
|
||||
fetchCities();
|
||||
}, []);
|
||||
|
||||
// Автоматически устанавливаем выбранный город при загрузке страницы
|
||||
useEffect(() => {
|
||||
if (selectedCityId && selectedCity && !createStationData.common.city_id) {
|
||||
setCreateCommonData({
|
||||
@@ -237,7 +235,7 @@ export const StationCreatePage = observer(() => {
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleCreate}
|
||||
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleCreate
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
|
||||
@@ -32,7 +32,7 @@ export const StationEditPage = observer(() => {
|
||||
} = stationsStore;
|
||||
const { cities, getCities } = cityStore;
|
||||
const [coordinates, setCoordinates] = useState<string>("");
|
||||
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
|
||||
|
||||
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,7 +50,6 @@ export const StationEditPage = observer(() => {
|
||||
}
|
||||
}, [editStationData.common.latitude, editStationData.common.longitude]);
|
||||
|
||||
// НОВАЯ ФУНКЦИЯ: Фактическое редактирование (вызывается после подтверждения)
|
||||
const executeEdit = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@@ -64,11 +63,13 @@ export const StationEditPage = observer(() => {
|
||||
}
|
||||
};
|
||||
|
||||
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или редактирования
|
||||
const handleEdit = async () => {
|
||||
const isCityMissing = !editStationData.common.city_id;
|
||||
// Проверяем названия на всех языках
|
||||
const isNameMissing = !editStationData.ru.name || !editStationData.en.name || !editStationData.zh.name;
|
||||
|
||||
const isNameMissing =
|
||||
!editStationData.ru.name ||
|
||||
!editStationData.en.name ||
|
||||
!editStationData.zh.name;
|
||||
|
||||
if (isCityMissing || isNameMissing) {
|
||||
setIsSaveWarningOpen(true);
|
||||
@@ -78,13 +79,11 @@ export const StationEditPage = observer(() => {
|
||||
await executeEdit();
|
||||
};
|
||||
|
||||
// Обработчик "Да" в предупреждающем окне
|
||||
const handleConfirmEdit = async () => {
|
||||
setIsSaveWarningOpen(false);
|
||||
await executeEdit();
|
||||
};
|
||||
|
||||
// Обработчик "Нет" в предупреждающем окне
|
||||
const handleCancelEdit = () => {
|
||||
setIsSaveWarningOpen(false);
|
||||
};
|
||||
@@ -106,6 +105,7 @@ export const StationEditPage = observer(() => {
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<LanguageSwitcher />
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
@@ -239,7 +239,7 @@ export const StationEditPage = observer(() => {
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleEdit}
|
||||
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleEdit
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { languageStore, stationsStore } from "@shared";
|
||||
import {
|
||||
languageStore,
|
||||
stationsStore,
|
||||
selectedCityStore,
|
||||
cityStore,
|
||||
} from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
|
||||
@@ -21,6 +26,7 @@ export const StationListPage = observer(() => {
|
||||
useEffect(() => {
|
||||
const fetchStations = async () => {
|
||||
setIsLoading(true);
|
||||
await cityStore.getCities(language);
|
||||
await getStationList();
|
||||
setIsLoading(false);
|
||||
};
|
||||
@@ -85,6 +91,7 @@ export const StationListPage = observer(() => {
|
||||
width: 140,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
@@ -109,7 +116,18 @@ export const StationListPage = observer(() => {
|
||||
},
|
||||
];
|
||||
|
||||
const rows = stationLists[language].data.map((station: any) => ({
|
||||
// Фильтрация станций по выбранному городу
|
||||
const filteredStations = () => {
|
||||
const { selectedCityId } = selectedCityStore;
|
||||
if (!selectedCityId) {
|
||||
return stationLists[language].data;
|
||||
}
|
||||
return stationLists[language].data.filter(
|
||||
(station: any) => station.city_id === selectedCityId
|
||||
);
|
||||
};
|
||||
|
||||
const rows = filteredStations().map((station: any) => ({
|
||||
id: station.id,
|
||||
name: station.name,
|
||||
system_name: station.system_name,
|
||||
|
||||
@@ -83,6 +83,7 @@ export const UserListPage = observer(() => {
|
||||
flex: 1,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
|
||||
@@ -102,6 +102,7 @@ export const VehicleListPage = observer(() => {
|
||||
width: 200,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
|
||||
@@ -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")}`;
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
export const CarrierSvg = () => {
|
||||
return (
|
||||
<svg
|
||||
fill="#000000"
|
||||
height="26px"
|
||||
width="26px"
|
||||
version="1.1"
|
||||
id="Capa_1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 489.785 489.785"
|
||||
>
|
||||
<g id="XMLID_196_">
|
||||
<path
|
||||
id="XMLID_203_"
|
||||
d="M409.772,379.327l-81.359-124.975c-5.884-9.054-15.925-13.119-25.987-13.119
|
||||
c-2.082,0-6.392,0.05-11.051,0.115c-0.363-0.61-0.742-1.215-1.355-1.627l-20.492-13.609c-2.364-1.569-5.434-1.486-7.701,0.182
|
||||
l-16.948,12.508l-16.959-12.508c-2.285-1.668-5.337-1.751-7.72-0.182l-20.455,13.609c-0.578,0.377-0.945,0.907-1.282,1.461
|
||||
c-4.828,0.031-9.327,0.057-11.222,0.057c-10.016,0-20.011,4.119-25.859,13.113L80.022,379.327
|
||||
c-8.65,13.267-5.149,31.008,7.896,39.992l18.06,12.449c10.887-25.926,28.868-48.094,51.45-64.279l4.657-7.162v3.861
|
||||
c16.364-10.811,34.941-18.477,54.885-22.234c-5.926-13.152-10.899-28.819-14.546-43.586c-4.249-17.232-6.741-33.201-6.741-42.245
|
||||
c0-3.351,0.433-6.579,1.09-9.727l14.8,48.975c0.766,2.565,2.984,4.417,5.641,4.73c0.268,0.03,0.529,0.046,0.784,0.046
|
||||
c2.365,0,4.602-1.25,5.818-3.34l11.538-19.873l3.246,3.235c-7.768,10.276-10.82,39.199-12.005,60.314
|
||||
c5.994-0.734,12.066-1.222,18.254-1.222c6.201,0,12.292,0.497,18.304,1.23c-1.186-21.114-4.237-50.037-12.024-60.322l3.246-3.255
|
||||
l11.574,19.892c1.216,2.09,3.422,3.34,5.805,3.34c0.255,0,0.522-0.016,0.779-0.046c2.655-0.314,4.874-2.166,5.659-4.73
|
||||
l14.791-48.872c0.634,3.116,1.051,6.313,1.051,9.624c0,16.806-8.425,57.342-21.276,85.831
|
||||
c19.981,3.768,38.588,11.453,54.953,22.291v-3.899l4.735,7.256c22.504,16.193,40.436,38.324,51.293,64.206l18.139-12.488
|
||||
C414.919,410.335,418.403,392.594,409.772,379.327z M219.962,276.685l-8.613-28.53l12.388-8.24l12.322,9.088L219.962,276.685z
|
||||
M269.783,276.685l-16.079-27.683l12.31-9.088l12.401,8.24L269.783,276.685z"
|
||||
/>
|
||||
<path
|
||||
id="XMLID_202_"
|
||||
d="M202.716,424.721l14.705,19.349c8.151-4.914,17.598-7.607,27.427-7.607c9.848,0,19.313,2.692,27.464,7.615
|
||||
l14.705-19.363c-11.465-10.799-26.346-16.721-42.15-16.721C229.055,407.994,214.156,413.925,202.716,424.721z"
|
||||
/>
|
||||
<path
|
||||
id="XMLID_201_"
|
||||
d="M176.693,160.576c0.499,25.456,14.96,47.266,36.03,58.591c9.622,5.18,20.473,8.384,32.174,8.384
|
||||
c11.683,0,22.503-3.198,32.114-8.368c21.063-11.311,35.579-33.117,36.06-58.582c-17.379,12.075-41.896,19.923-68.174,19.923
|
||||
S194.096,172.676,176.693,160.576z"
|
||||
/>
|
||||
<path
|
||||
id="XMLID_200_"
|
||||
d="M174.741,100.132l-0.225,20.205c0.037,15.991,31.524,36.82,70.38,36.82
|
||||
c38.855,0,70.314-20.829,70.331-36.82l-0.207-20.195c10.224-2.662,18.158-6.617,23.239-12.301
|
||||
c3.981-4.434,6.267-9.902,6.267-16.783C344.528,39.883,299.879,0,244.897,0c-55.031,0-99.631,39.883-99.631,71.058
|
||||
c0,6.881,2.273,12.34,6.236,16.783C156.585,93.524,164.529,97.479,174.741,100.132z"
|
||||
/>
|
||||
<path
|
||||
id="XMLID_197_"
|
||||
d="M244.848,356.925c-73.255,0-132.858,59.605-132.858,132.86h33.47c0-0.048,0-0.114,0-0.161v-0.031
|
||||
c1.088-6.557,6.711-11.334,13.313-11.334c0.115,0,0.243,0.01,0.37,0.01l51.707,1.341c-0.973,3.247-1.648,6.619-1.648,10.176h71.322
|
||||
c0-3.557-0.669-6.929-1.66-10.176l51.724-1.341c0.109,0,0.219-0.01,0.353-0.01c6.595,0,12.243,4.777,13.324,11.334v0.031
|
||||
c0,0.047,0,0.113,0,0.161h33.44C377.706,416.53,318.122,356.925,244.848,356.925z M302.201,433.91l-27.562,36.317
|
||||
c-6.389-9.687-17.325-16.104-29.792-16.104c-12.437,0-23.385,6.411-29.762,16.098l-27.555-36.3
|
||||
c-4.699-6.194-4.11-14.923,1.392-20.424c15.452-15.443,35.689-23.166,55.943-23.166c20.249,0,40.484,7.723,55.961,23.179
|
||||
C306.322,419.007,306.901,427.719,302.201,433.91z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
58
src/shared/config/carrier.svg
Normal file
@@ -0,0 +1,58 @@
|
||||
<svg
|
||||
fill="#000000"
|
||||
height="26px"
|
||||
width="26px"
|
||||
version="1.1"
|
||||
id="Capa_1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 489.785 489.785"
|
||||
>
|
||||
<g id="XMLID_196_">
|
||||
<path
|
||||
id="XMLID_203_"
|
||||
d="M409.772,379.327l-81.359-124.975c-5.884-9.054-15.925-13.119-25.987-13.119
|
||||
c-2.082,0-6.392,0.05-11.051,0.115c-0.363-0.61-0.742-1.215-1.355-1.627l-20.492-13.609c-2.364-1.569-5.434-1.486-7.701,0.182
|
||||
l-16.948,12.508l-16.959-12.508c-2.285-1.668-5.337-1.751-7.72-0.182l-20.455,13.609c-0.578,0.377-0.945,0.907-1.282,1.461
|
||||
c-4.828,0.031-9.327,0.057-11.222,0.057c-10.016,0-20.011,4.119-25.859,13.113L80.022,379.327
|
||||
c-8.65,13.267-5.149,31.008,7.896,39.992l18.06,12.449c10.887-25.926,28.868-48.094,51.45-64.279l4.657-7.162v3.861
|
||||
c16.364-10.811,34.941-18.477,54.885-22.234c-5.926-13.152-10.899-28.819-14.546-43.586c-4.249-17.232-6.741-33.201-6.741-42.245
|
||||
c0-3.351,0.433-6.579,1.09-9.727l14.8,48.975c0.766,2.565,2.984,4.417,5.641,4.73c0.268,0.03,0.529,0.046,0.784,0.046
|
||||
c2.365,0,4.602-1.25,5.818-3.34l11.538-19.873l3.246,3.235c-7.768,10.276-10.82,39.199-12.005,60.314
|
||||
c5.994-0.734,12.066-1.222,18.254-1.222c6.201,0,12.292,0.497,18.304,1.23c-1.186-21.114-4.237-50.037-12.024-60.322l3.246-3.255
|
||||
l11.574,19.892c1.216,2.09,3.422,3.34,5.805,3.34c0.255,0,0.522-0.016,0.779-0.046c2.655-0.314,4.874-2.166,5.659-4.73
|
||||
l14.791-48.872c0.634,3.116,1.051,6.313,1.051,9.624c0,16.806-8.425,57.342-21.276,85.831
|
||||
c19.981,3.768,38.588,11.453,54.953,22.291v-3.899l4.735,7.256c22.504,16.193,40.436,38.324,51.293,64.206l18.139-12.488
|
||||
C414.919,410.335,418.403,392.594,409.772,379.327z M219.962,276.685l-8.613-28.53l12.388-8.24l12.322,9.088L219.962,276.685z
|
||||
M269.783,276.685l-16.079-27.683l12.31-9.088l12.401,8.24L269.783,276.685z"
|
||||
/>
|
||||
<path
|
||||
id="XMLID_202_"
|
||||
d="M202.716,424.721l14.705,19.349c8.151-4.914,17.598-7.607,27.427-7.607c9.848,0,19.313,2.692,27.464,7.615
|
||||
l14.705-19.363c-11.465-10.799-26.346-16.721-42.15-16.721C229.055,407.994,214.156,413.925,202.716,424.721z"
|
||||
/>
|
||||
<path
|
||||
id="XMLID_201_"
|
||||
d="M176.693,160.576c0.499,25.456,14.96,47.266,36.03,58.591c9.622,5.18,20.473,8.384,32.174,8.384
|
||||
c11.683,0,22.503-3.198,32.114-8.368c21.063-11.311,35.579-33.117,36.06-58.582c-17.379,12.075-41.896,19.923-68.174,19.923
|
||||
S194.096,172.676,176.693,160.576z"
|
||||
/>
|
||||
<path
|
||||
id="XMLID_200_"
|
||||
d="M174.741,100.132l-0.225,20.205c0.037,15.991,31.524,36.82,70.38,36.82
|
||||
c38.855,0,70.314-20.829,70.331-36.82l-0.207-20.195c10.224-2.662,18.158-6.617,23.239-12.301
|
||||
c3.981-4.434,6.267-9.902,6.267-16.783C344.528,39.883,299.879,0,244.897,0c-55.031,0-99.631,39.883-99.631,71.058
|
||||
c0,6.881,2.273,12.34,6.236,16.783C156.585,93.524,164.529,97.479,174.741,100.132z"
|
||||
/>
|
||||
<path
|
||||
id="XMLID_197_"
|
||||
d="M244.848,356.925c-73.255,0-132.858,59.605-132.858,132.86h33.47c0-0.048,0-0.114,0-0.161v-0.031
|
||||
c1.088-6.557,6.711-11.334,13.313-11.334c0.115,0,0.243,0.01,0.37,0.01l51.707,1.341c-0.973,3.247-1.648,6.619-1.648,10.176h71.322
|
||||
c0-3.557-0.669-6.929-1.66-10.176l51.724-1.341c0.109,0,0.219-0.01,0.353-0.01c6.595,0,12.243,4.777,13.324,11.334v0.031
|
||||
c0,0.047,0,0.113,0,0.161h33.44C377.706,416.53,318.122,356.925,244.848,356.925z M302.201,433.91l-27.562,36.317
|
||||
c-6.389-9.687-17.325-16.104-29.792-16.104c-12.437,0-23.385,6.411-29.762,16.098l-27.555-36.3
|
||||
c-4.699-6.194-4.11-14.923,1.392-20.424c15.452-15.443,35.689-23.166,55.943-23.166c20.249,0,40.484,7.723,55.961,23.179
|
||||
C306.322,419.007,306.901,427.719,302.201,433.91z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -8,15 +8,13 @@ import {
|
||||
Earth,
|
||||
Landmark,
|
||||
GitBranch,
|
||||
// Car,
|
||||
Table,
|
||||
Split,
|
||||
// Newspaper,
|
||||
PersonStanding,
|
||||
Cpu,
|
||||
// BookImage,
|
||||
} from "lucide-react";
|
||||
import { CarrierSvg } from "./CarrierSvg";
|
||||
|
||||
import carrierIcon from "./carrier.svg";
|
||||
|
||||
export const DRAWER_WIDTH = 300;
|
||||
|
||||
@@ -25,6 +23,7 @@ interface NavigationItem {
|
||||
label: string;
|
||||
icon?: LucideIcon | React.ReactNode;
|
||||
path?: string;
|
||||
for_admin?: boolean;
|
||||
onClick?: () => void;
|
||||
nestedItems?: NavigationItem[];
|
||||
isActive?: boolean;
|
||||
@@ -40,6 +39,7 @@ export const NAVIGATION_ITEMS: {
|
||||
label: "Снапшоты",
|
||||
icon: GitBranch,
|
||||
path: "/snapshot",
|
||||
for_admin: true,
|
||||
},
|
||||
{
|
||||
id: "map",
|
||||
@@ -52,36 +52,20 @@ export const NAVIGATION_ITEMS: {
|
||||
label: "Устройства",
|
||||
icon: Cpu,
|
||||
path: "/devices",
|
||||
for_admin: true,
|
||||
},
|
||||
// {
|
||||
// id: "vehicles",
|
||||
// label: "Транспорт",
|
||||
// icon: Car,
|
||||
// path: "/vehicle",
|
||||
// },
|
||||
{
|
||||
id: "users",
|
||||
label: "Пользователи",
|
||||
icon: Users,
|
||||
path: "/user",
|
||||
for_admin: true,
|
||||
},
|
||||
{
|
||||
id: "all",
|
||||
label: "Справочник",
|
||||
icon: Table,
|
||||
nestedItems: [
|
||||
// {
|
||||
// id: "media",
|
||||
// label: "Медиа",
|
||||
// icon: BookImage,
|
||||
// path: "/media",
|
||||
// },
|
||||
// {
|
||||
// id: "articles",
|
||||
// label: "Статьи",
|
||||
// icon: Newspaper,
|
||||
// path: "/article",
|
||||
// },
|
||||
{
|
||||
id: "attractions",
|
||||
label: "Достопримечательности",
|
||||
@@ -106,19 +90,22 @@ export const NAVIGATION_ITEMS: {
|
||||
label: "Страны",
|
||||
icon: Earth,
|
||||
path: "/country",
|
||||
for_admin: true,
|
||||
},
|
||||
{
|
||||
id: "cities",
|
||||
label: "Города",
|
||||
icon: Building2,
|
||||
path: "/city",
|
||||
for_admin: true,
|
||||
},
|
||||
{
|
||||
id: "carriers",
|
||||
label: "Перевозчики",
|
||||
// @ts-ignore
|
||||
icon: CarrierSvg,
|
||||
icon: () => <img src={carrierIcon} alt="Перевозчики" />,
|
||||
path: "/carrier",
|
||||
for_admin: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
export const API_URL = "https://wn.krbl.ru";
|
||||
import * as countries from "i18n-iso-countries";
|
||||
import * as ru from "i18n-iso-countries/langs/ru.json";
|
||||
import * as en from "i18n-iso-countries/langs/en.json";
|
||||
import * as zh from "i18n-iso-countries/langs/zh.json";
|
||||
|
||||
countries.registerLocale(ru);
|
||||
countries.registerLocale(en);
|
||||
countries.registerLocale(zh);
|
||||
|
||||
const generateCountriesList = (locale: string) => {
|
||||
const names = countries.getNames(locale);
|
||||
return Object.entries(names).map(([code, name]) => ({
|
||||
code: code,
|
||||
name: name,
|
||||
}));
|
||||
};
|
||||
|
||||
export const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
export const MEDIA_TYPE_LABELS = {
|
||||
1: "Фото",
|
||||
2: "Видео",
|
||||
@@ -8,6 +26,8 @@ export const MEDIA_TYPE_LABELS = {
|
||||
6: "3Д-модель",
|
||||
};
|
||||
|
||||
export * from "./mediaTypes";
|
||||
|
||||
export const MEDIA_TYPE_VALUES = {
|
||||
image: 1,
|
||||
video: 2,
|
||||
@@ -20,751 +40,6 @@ export const MEDIA_TYPE_VALUES = {
|
||||
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: "津巴布韦" },
|
||||
];
|
||||
export const RU_COUNTRIES = generateCountriesList("ru");
|
||||
export const EN_COUNTRIES = generateCountriesList("en");
|
||||
export const ZH_COUNTRIES = generateCountriesList("zh");
|
||||
85
src/shared/const/mediaTypes.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// Допустимые типы и расширения файлов для медиа
|
||||
export const ALLOWED_MEDIA_TYPES = {
|
||||
image: {
|
||||
extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"],
|
||||
mimeTypes: [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
"image/svg+xml",
|
||||
],
|
||||
accept: "image/*",
|
||||
},
|
||||
video: {
|
||||
extensions: [".mp4", ".webm", ".ogg", ".mov", ".avi"],
|
||||
mimeTypes: [
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/ogg",
|
||||
"video/quicktime",
|
||||
"video/x-msvideo",
|
||||
],
|
||||
accept: "video/*",
|
||||
},
|
||||
model3d: {
|
||||
extensions: [".glb", ".gltf"],
|
||||
mimeTypes: ["model/gltf-binary", "model/gltf+json"],
|
||||
accept: ".glb,.gltf",
|
||||
},
|
||||
panorama: {
|
||||
extensions: [".jpg", ".jpeg", ".png"],
|
||||
mimeTypes: ["image/jpeg", "image/png"],
|
||||
accept: "image/*",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const getAllAllowedExtensions = (): string[] => {
|
||||
return [
|
||||
...ALLOWED_MEDIA_TYPES.image.extensions,
|
||||
...ALLOWED_MEDIA_TYPES.video.extensions,
|
||||
...ALLOWED_MEDIA_TYPES.model3d.extensions,
|
||||
];
|
||||
};
|
||||
|
||||
export const getAllAcceptString = (): string => {
|
||||
return `${ALLOWED_MEDIA_TYPES.image.accept},${ALLOWED_MEDIA_TYPES.video.accept},${ALLOWED_MEDIA_TYPES.model3d.accept}`;
|
||||
};
|
||||
|
||||
export const validateFileExtension = (
|
||||
file: File
|
||||
): { valid: boolean; error?: string } => {
|
||||
const fileName = file.name.toLowerCase();
|
||||
const extension = fileName.substring(fileName.lastIndexOf("."));
|
||||
const allowedExtensions = getAllAllowedExtensions();
|
||||
|
||||
if (!allowedExtensions.includes(extension)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Неподдерживаемый формат файла "${extension}". Допустимые форматы: ${allowedExtensions.join(
|
||||
", "
|
||||
)}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
export const filterValidFiles = (
|
||||
files: File[]
|
||||
): { validFiles: File[]; errors: string[] } => {
|
||||
const validFiles: File[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
files.forEach((file) => {
|
||||
const validation = validateFileExtension(file);
|
||||
if (validation.valid) {
|
||||
validFiles.push(file);
|
||||
} else {
|
||||
errors.push(`${file.name}: ${validation.error}`);
|
||||
}
|
||||
});
|
||||
|
||||
return { validFiles, errors };
|
||||
};
|
||||
57
src/shared/lib/gltfCacheManager.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
let useGLTF: any = null;
|
||||
|
||||
const initializeUseGLTF = async () => {
|
||||
if (!useGLTF) {
|
||||
try {
|
||||
const drei = await import("@react-three/drei");
|
||||
useGLTF = drei.useGLTF;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"⚠️ GLTFCacheManager: Не удалось импортировать useGLTF",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
return useGLTF;
|
||||
};
|
||||
|
||||
export const clearGLTFCacheForUrl = async (url: string) => {
|
||||
try {
|
||||
const gltf = await initializeUseGLTF();
|
||||
if (gltf && gltf.clear) {
|
||||
gltf.clear(url);
|
||||
}
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
export const clearAllGLTFCache = async () => {
|
||||
try {
|
||||
const gltf = await initializeUseGLTF();
|
||||
if (gltf && gltf.clear) {
|
||||
gltf.clear();
|
||||
}
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
export const revokeBlobURL = (url: string) => {
|
||||
if (url && url.startsWith("blob:")) {
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {}
|
||||
}
|
||||
};
|
||||
|
||||
export const clearBlobAndGLTFCache = async (url: string) => {
|
||||
revokeBlobURL(url);
|
||||
|
||||
await clearGLTFCacheForUrl(url);
|
||||
};
|
||||
|
||||
export const clearMediaTransitionCache = async (
|
||||
previousMediaId: string | number | null,
|
||||
newMediaType?: number
|
||||
) => {
|
||||
if (newMediaType === 6 || previousMediaId) {
|
||||
await clearAllGLTFCache();
|
||||
}
|
||||
};
|
||||
@@ -1,34 +1,18 @@
|
||||
export * from "./mui/theme";
|
||||
export * from "./DecodeJWT";
|
||||
export * from "./gltfCacheManager";
|
||||
|
||||
/**
|
||||
* Генерирует название медиа по умолчанию в разных форматах
|
||||
*
|
||||
* Примеры использования:
|
||||
* - Для достопримечательности с названием: "Михайловский замок_mikhail-zamok_Фото"
|
||||
* - Для достопримечательности без названия: "Название_mikhail-zamok_Фото"
|
||||
* - Для статьи: "Михайловский замок_mikhail-zamok_Факты" (где "Факты" - название статьи)
|
||||
*
|
||||
* @param objectName - Название объекта (достопримечательности, города и т.д.)
|
||||
* @param fileName - Название файла
|
||||
* @param mediaType - Тип медиа (число) или название статьи
|
||||
* @param isArticle - Флаг, указывающий что медиа добавляется к статье
|
||||
* @returns Строка в нужном формате
|
||||
*/
|
||||
export const generateDefaultMediaName = (
|
||||
objectName: string,
|
||||
fileName: string,
|
||||
mediaType: number | string,
|
||||
isArticle: boolean = false
|
||||
): string => {
|
||||
// Убираем расширение из названия файла
|
||||
const fileNameWithoutExtension = fileName.split(".").slice(0, -1).join(".");
|
||||
|
||||
if (isArticle && typeof mediaType === "string") {
|
||||
// Для статей: "Название достопримечательности_название файла_название статьи"
|
||||
return `${objectName}_${fileNameWithoutExtension}_${mediaType}`;
|
||||
} else if (typeof mediaType === "number") {
|
||||
// Получаем название типа медиа
|
||||
const mediaTypeLabels: Record<number, string> = {
|
||||
1: "Фото",
|
||||
2: "Видео",
|
||||
@@ -41,14 +25,11 @@ export const generateDefaultMediaName = (
|
||||
const mediaTypeLabel = mediaTypeLabels[mediaType] || "Медиа";
|
||||
|
||||
if (objectName && objectName.trim() !== "") {
|
||||
// Если есть название объекта: "Название объекта_название файла_тип медиа"
|
||||
return `${objectName}_${fileNameWithoutExtension}_${mediaTypeLabel}`;
|
||||
} else {
|
||||
// Если нет названия объекта: "Название_название файла_тип медиа"
|
||||
return `Название_${fileNameWithoutExtension}_${mediaTypeLabel}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return `${objectName || "Название"}_${fileNameWithoutExtension}_Медиа`;
|
||||
};
|
||||
|
||||
1067
src/shared/modals/ArticleSelectOrCreateDialog/index.tsx
Normal file
@@ -3,9 +3,10 @@ import {
|
||||
MEDIA_TYPE_VALUES,
|
||||
editSightStore,
|
||||
generateDefaultMediaName,
|
||||
clearBlobAndGLTFCache,
|
||||
} from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
@@ -53,7 +54,7 @@ interface UploadMediaDialogProps {
|
||||
| "station";
|
||||
isArticle?: boolean;
|
||||
articleName?: string;
|
||||
initialFile?: File; // <--- добавлено
|
||||
initialFile?: File;
|
||||
}
|
||||
|
||||
export const UploadMediaDialog = observer(
|
||||
@@ -67,7 +68,7 @@ export const UploadMediaDialog = observer(
|
||||
|
||||
isArticle,
|
||||
articleName,
|
||||
initialFile, // <--- добавлено
|
||||
initialFile,
|
||||
}: UploadMediaDialogProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -82,46 +83,67 @@ export const UploadMediaDialog = observer(
|
||||
[]
|
||||
);
|
||||
const [isPreviewLoaded, setIsPreviewLoaded] = useState(false);
|
||||
const previousMediaUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialFile) {
|
||||
if (
|
||||
previousMediaUrlRef.current &&
|
||||
previousMediaUrlRef.current.startsWith("blob:")
|
||||
) {
|
||||
clearBlobAndGLTFCache(previousMediaUrlRef.current);
|
||||
}
|
||||
|
||||
setMediaFile(initialFile);
|
||||
setMediaFilename(initialFile.name);
|
||||
setAvailableMediaTypes([2]);
|
||||
setMediaType(2);
|
||||
setMediaUrl(URL.createObjectURL(initialFile));
|
||||
const newBlobUrl = URL.createObjectURL(initialFile);
|
||||
setMediaUrl(newBlobUrl);
|
||||
previousMediaUrlRef.current = newBlobUrl;
|
||||
setMediaName(initialFile.name.replace(/\.[^/.]+$/, ""));
|
||||
}
|
||||
}, [initialFile]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (
|
||||
previousMediaUrlRef.current &&
|
||||
previousMediaUrlRef.current.startsWith("blob:")
|
||||
) {
|
||||
clearBlobAndGLTFCache(previousMediaUrlRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (fileToUpload) {
|
||||
setMediaFile(fileToUpload);
|
||||
setMediaFilename(fileToUpload.name);
|
||||
// Try to determine media type from file extension
|
||||
|
||||
const extension = fileToUpload.name.split(".").pop()?.toLowerCase();
|
||||
if (extension) {
|
||||
if (["glb", "gltf"].includes(extension)) {
|
||||
setAvailableMediaTypes([6]);
|
||||
setMediaType(6);
|
||||
}
|
||||
if (["jpg", "jpeg", "png", "gif", "svg"].includes(extension)) {
|
||||
// Для изображений доступны все типы кроме видео
|
||||
setAvailableMediaTypes([1, 3, 4, 5]); // Фото, Иконка, Водяной знак, Панорама, 3Д-модель
|
||||
setMediaType(1); // По умолчанию Фото
|
||||
if (
|
||||
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
|
||||
extension
|
||||
)
|
||||
) {
|
||||
setAvailableMediaTypes([1, 3, 4, 5]);
|
||||
setMediaType(1);
|
||||
} else if (["mp4", "webm", "mov"].includes(extension)) {
|
||||
// Для видео только тип Видео
|
||||
setAvailableMediaTypes([2]);
|
||||
setMediaType(2);
|
||||
}
|
||||
}
|
||||
|
||||
// Генерируем название по умолчанию если есть контекст
|
||||
if (fileToUpload.name) {
|
||||
let defaultName = "";
|
||||
|
||||
if (isArticle && articleName && contextObjectName) {
|
||||
// Для статей: "Название достопримечательности_название файла_название статьи"
|
||||
defaultName = generateDefaultMediaName(
|
||||
contextObjectName,
|
||||
fileToUpload.name,
|
||||
@@ -129,10 +151,9 @@ export const UploadMediaDialog = observer(
|
||||
true
|
||||
);
|
||||
} else if (contextObjectName && contextObjectName.trim() !== "") {
|
||||
// Для обычных медиа с названием объекта
|
||||
const currentMediaType = hardcodeType
|
||||
? MEDIA_TYPE_VALUES[hardcodeType]
|
||||
: 1; // По умолчанию фото
|
||||
: 1;
|
||||
defaultName = generateDefaultMediaName(
|
||||
contextObjectName,
|
||||
fileToUpload.name,
|
||||
@@ -140,10 +161,9 @@ export const UploadMediaDialog = observer(
|
||||
false
|
||||
);
|
||||
} else {
|
||||
// Для медиа без названия объекта
|
||||
const currentMediaType = hardcodeType
|
||||
? MEDIA_TYPE_VALUES[hardcodeType]
|
||||
: 1; // По умолчанию фото
|
||||
: 1;
|
||||
defaultName = generateDefaultMediaName(
|
||||
"",
|
||||
fileToUpload.name,
|
||||
@@ -157,13 +177,11 @@ export const UploadMediaDialog = observer(
|
||||
}
|
||||
}, [fileToUpload, contextObjectName, hardcodeType, isArticle, articleName]);
|
||||
|
||||
// Обновляем название при изменении типа медиа
|
||||
useEffect(() => {
|
||||
if (mediaFilename && mediaType > 0) {
|
||||
let defaultName = "";
|
||||
|
||||
if (isArticle && articleName && contextObjectName) {
|
||||
// Для статей: "Название достопримечательности_название файла_название статьи"
|
||||
defaultName = generateDefaultMediaName(
|
||||
contextObjectName,
|
||||
mediaFilename,
|
||||
@@ -171,7 +189,6 @@ export const UploadMediaDialog = observer(
|
||||
true
|
||||
);
|
||||
} else if (contextObjectName && contextObjectName.trim() !== "") {
|
||||
// Для обычных медиа с названием объекта
|
||||
const currentMediaType = hardcodeType
|
||||
? MEDIA_TYPE_VALUES[hardcodeType]
|
||||
: mediaType;
|
||||
@@ -182,7 +199,6 @@ export const UploadMediaDialog = observer(
|
||||
false
|
||||
);
|
||||
} else {
|
||||
// Для медиа без названия объекта
|
||||
const currentMediaType = hardcodeType
|
||||
? MEDIA_TYPE_VALUES[hardcodeType]
|
||||
: mediaType;
|
||||
@@ -207,23 +223,20 @@ export const UploadMediaDialog = observer(
|
||||
|
||||
useEffect(() => {
|
||||
if (mediaFile) {
|
||||
setMediaUrl(URL.createObjectURL(mediaFile as Blob));
|
||||
setIsPreviewLoaded(false); // Сбрасываем состояние загрузки при смене файла
|
||||
if (
|
||||
previousMediaUrlRef.current &&
|
||||
previousMediaUrlRef.current.startsWith("blob:")
|
||||
) {
|
||||
clearBlobAndGLTFCache(previousMediaUrlRef.current);
|
||||
}
|
||||
|
||||
const newBlobUrl = URL.createObjectURL(mediaFile as Blob);
|
||||
setMediaUrl(newBlobUrl);
|
||||
previousMediaUrlRef.current = newBlobUrl;
|
||||
setIsPreviewLoaded(false);
|
||||
}
|
||||
}, [mediaFile]);
|
||||
|
||||
// const fileFormat = useEffect(() => {
|
||||
// const handleKeyPress = (event: KeyboardEvent) => {
|
||||
// if (event.key.toLowerCase() === "enter" && !event.ctrlKey) {
|
||||
// event.preventDefault();
|
||||
// onClose();
|
||||
// }
|
||||
// };
|
||||
|
||||
// window.addEventListener("keydown", handleKeyPress);
|
||||
// return () => window.removeEventListener("keydown", handleKeyPress);
|
||||
// }, [onClose]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!mediaFile) return;
|
||||
|
||||
@@ -247,10 +260,10 @@ export const UploadMediaDialog = observer(
|
||||
}
|
||||
}
|
||||
setSuccess(true);
|
||||
// Закрываем модальное окно после успешного сохранения
|
||||
|
||||
setTimeout(() => {
|
||||
handleClose();
|
||||
}, 1000); // Небольшая задержка, чтобы пользователь увидел сообщение об успехе
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save media");
|
||||
} finally {
|
||||
@@ -259,8 +272,19 @@ export const UploadMediaDialog = observer(
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (
|
||||
previousMediaUrlRef.current &&
|
||||
previousMediaUrlRef.current.startsWith("blob:")
|
||||
) {
|
||||
clearBlobAndGLTFCache(previousMediaUrlRef.current);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
setMediaUrl(null);
|
||||
setMediaFile(null);
|
||||
setIsPreviewLoaded(false);
|
||||
previousMediaUrlRef.current = null;
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from "./SelectArticleDialog";
|
||||
export * from "./SelectMediaDialog";
|
||||
export * from "./PreviewMediaDialog";
|
||||
export * from "./UploadMediaDialog";
|
||||
export * from "./ArticleSelectOrCreateDialog";
|
||||
|
||||
@@ -171,7 +171,6 @@ class CarrierStore {
|
||||
this.carriers[language].data.push(response.data);
|
||||
});
|
||||
|
||||
// Create translations for other languages
|
||||
for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) {
|
||||
const patchPayload = {
|
||||
// @ts-ignore
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @shared/stores/createSightStore.ts
|
||||
import {
|
||||
articlesStore,
|
||||
Language,
|
||||
@@ -27,7 +26,6 @@ type SightLanguageInfo = {
|
||||
};
|
||||
|
||||
type SightCommonInfo = {
|
||||
// id: number; // ID is 0 until created
|
||||
city_id: number;
|
||||
city: string;
|
||||
latitude: number;
|
||||
@@ -35,13 +33,11 @@ type SightCommonInfo = {
|
||||
thumbnail: string | null;
|
||||
watermark_lu: string | null;
|
||||
watermark_rd: string | null;
|
||||
left_article: number; // Can be 0 or a real ID, or placeholder like 10000000
|
||||
left_article: number;
|
||||
preview_media: string | null;
|
||||
video_preview: string | null;
|
||||
};
|
||||
|
||||
// SightBaseInfo combines common info with language-specific info
|
||||
// The 'id' for the sight itself will be assigned upon creation by the backend.
|
||||
type SightBaseInfo = SightCommonInfo & {
|
||||
[key in Language]: SightLanguageInfo;
|
||||
};
|
||||
@@ -78,7 +74,7 @@ const initialSightState: SightBaseInfo = {
|
||||
};
|
||||
|
||||
class CreateSightStore {
|
||||
sight: SightBaseInfo = JSON.parse(JSON.stringify(initialSightState)); // Deep copy for reset
|
||||
sight: SightBaseInfo = JSON.parse(JSON.stringify(initialSightState));
|
||||
|
||||
uploadMediaOpen = false;
|
||||
setUploadMediaOpen = (open: boolean) => {
|
||||
@@ -93,9 +89,7 @@ class CreateSightStore {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
// --- Right Article Management ---
|
||||
createNewRightArticle = async () => {
|
||||
// Create article in DB for all languages
|
||||
const articleRuData = {
|
||||
heading: "Новый заголовок (RU)",
|
||||
body: "Новый текст (RU)",
|
||||
@@ -125,7 +119,7 @@ class CreateSightStore {
|
||||
},
|
||||
},
|
||||
});
|
||||
const { id } = articleRes.data; // New article's ID
|
||||
const { id } = articleRes.data;
|
||||
|
||||
runInAction(() => {
|
||||
const newArticleEntry = { id, media: [] };
|
||||
@@ -133,7 +127,7 @@ class CreateSightStore {
|
||||
this.sight.en.right.push({ ...newArticleEntry, ...articleEnData });
|
||||
this.sight.zh.right.push({ ...newArticleEntry, ...articleZhData });
|
||||
});
|
||||
return id; // Return ID for potential immediate use
|
||||
return id;
|
||||
} catch (error) {
|
||||
console.error("Error creating new right article:", error);
|
||||
throw error;
|
||||
@@ -169,7 +163,7 @@ class CreateSightStore {
|
||||
});
|
||||
});
|
||||
|
||||
return articleId; // Return the linked article ID
|
||||
return articleId;
|
||||
} catch (error) {
|
||||
console.error("Error linking existing right article:", error);
|
||||
throw error;
|
||||
@@ -188,9 +182,7 @@ class CreateSightStore {
|
||||
}
|
||||
};
|
||||
|
||||
// "Unlink" in create mode means just removing from the list to be created with the sight
|
||||
unlinkRightAritcle = (articleId: number) => {
|
||||
// Changed from 'unlinkRightAritcle' spelling
|
||||
runInAction(() => {
|
||||
this.sight.ru.right = this.sight.ru.right.filter(
|
||||
(article) => article.id !== articleId
|
||||
@@ -202,16 +194,12 @@ class CreateSightStore {
|
||||
(article) => article.id !== articleId
|
||||
);
|
||||
});
|
||||
// Note: If this article was created via createNewRightArticle, it still exists in the DB.
|
||||
// Consider if an orphaned article should be deleted here or managed separately.
|
||||
// For now, it just removes it from the list associated with *this specific sight creation process*.
|
||||
};
|
||||
|
||||
deleteRightArticle = async (articleId: number) => {
|
||||
try {
|
||||
await authInstance.delete(`/article/${articleId}`); // Delete from backend
|
||||
await authInstance.delete(`/article/${articleId}`);
|
||||
runInAction(() => {
|
||||
// Remove from local store for all languages
|
||||
this.sight.ru.right = this.sight.ru.right.filter(
|
||||
(article) => article.id !== articleId
|
||||
);
|
||||
@@ -228,12 +216,11 @@ class CreateSightStore {
|
||||
}
|
||||
};
|
||||
|
||||
// --- Right Article Media Management ---
|
||||
createLinkWithRightArticle = async (media: MediaItem, articleId: number) => {
|
||||
try {
|
||||
await authInstance.post(`/article/${articleId}/media`, {
|
||||
media_id: media.id,
|
||||
media_order: 1, // Or calculate based on existing media.length + 1
|
||||
media_order: 1,
|
||||
});
|
||||
runInAction(() => {
|
||||
(["ru", "en", "zh"] as Language[]).forEach((lang) => {
|
||||
@@ -242,7 +229,7 @@ class CreateSightStore {
|
||||
);
|
||||
if (article) {
|
||||
if (!article.media) article.media = [];
|
||||
article.media.unshift(media); // Add to the beginning
|
||||
article.media.unshift(media);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -273,7 +260,6 @@ class CreateSightStore {
|
||||
}
|
||||
};
|
||||
|
||||
// --- Left Article Management (largely unchanged from your provided store) ---
|
||||
updateLeftInfo = (language: Language, heading: string, body: string) => {
|
||||
this.sight[language].left.heading = heading;
|
||||
this.sight[language].left.body = body;
|
||||
@@ -323,7 +309,7 @@ class CreateSightStore {
|
||||
deleteLeftArticle = async (articleId: number) => {
|
||||
/* ... your existing logic ... */
|
||||
await authInstance.delete(`/article/${articleId}`);
|
||||
// articlesStore.getArticles(languageStore.language); // If still neede
|
||||
|
||||
runInAction(() => {
|
||||
articlesStore.articles.ru = articlesStore.articles.ru.filter(
|
||||
(article) => article.id !== articleId
|
||||
@@ -340,63 +326,69 @@ class CreateSightStore {
|
||||
|
||||
createLeftArticle = async () => {
|
||||
/* ... your existing logic to create a new left article (placeholder or DB) ... */
|
||||
const ruName = (this.sight.ru.name || "").trim();
|
||||
const enName = (this.sight.en.name || "").trim();
|
||||
const zhName = (this.sight.zh.name || "").trim();
|
||||
|
||||
const hasAnyName = !!(ruName || enName || zhName);
|
||||
|
||||
const response = await languageInstance("ru").post("/article", {
|
||||
heading: "Новая левая статья",
|
||||
body: "Заполните контентом",
|
||||
heading: hasAnyName ? ruName : "",
|
||||
body: "",
|
||||
});
|
||||
const newLeftArticleId = response.data.id;
|
||||
|
||||
await languageInstance("en").patch(`/article/${newLeftArticleId}`, {
|
||||
heading: "New Left Article",
|
||||
body: "Fill with content",
|
||||
heading: hasAnyName ? enName : "",
|
||||
body: "",
|
||||
});
|
||||
await languageInstance("zh").patch(`/article/${newLeftArticleId}`, {
|
||||
heading: "新的左侧文章",
|
||||
body: "填写内容",
|
||||
heading: hasAnyName ? zhName : "",
|
||||
body: "",
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
this.sight.left_article = newLeftArticleId; // Store the actual ID
|
||||
this.sight.left_article = newLeftArticleId;
|
||||
this.sight.ru.left = {
|
||||
heading: "Новая левая статья",
|
||||
body: "Заполните контентом",
|
||||
heading: hasAnyName ? ruName : "",
|
||||
body: "",
|
||||
media: [],
|
||||
};
|
||||
this.sight.en.left = {
|
||||
heading: "New Left Article",
|
||||
body: "Fill with content",
|
||||
heading: hasAnyName ? enName : "",
|
||||
body: "",
|
||||
media: [],
|
||||
};
|
||||
this.sight.zh.left = {
|
||||
heading: "新的左侧文章",
|
||||
body: "填写内容",
|
||||
heading: hasAnyName ? zhName : "",
|
||||
body: "",
|
||||
media: [],
|
||||
};
|
||||
|
||||
articlesStore.articles.ru.push({
|
||||
id: newLeftArticleId,
|
||||
heading: "Новая левая статья",
|
||||
body: "Заполните контентом",
|
||||
service_name: "Новая левая статья",
|
||||
heading: hasAnyName ? ruName : "",
|
||||
body: "",
|
||||
service_name: hasAnyName ? ruName : "",
|
||||
});
|
||||
articlesStore.articles.en.push({
|
||||
id: newLeftArticleId,
|
||||
heading: "New Left Article",
|
||||
body: "Fill with content",
|
||||
service_name: "New Left Article",
|
||||
heading: hasAnyName ? enName : "",
|
||||
body: "",
|
||||
service_name: hasAnyName ? enName : "",
|
||||
});
|
||||
articlesStore.articles.zh.push({
|
||||
id: newLeftArticleId,
|
||||
heading: "新的左侧文章",
|
||||
body: "填写内容",
|
||||
service_name: "新的左侧文章",
|
||||
heading: hasAnyName ? zhName : "",
|
||||
body: "",
|
||||
service_name: hasAnyName ? zhName : "",
|
||||
});
|
||||
});
|
||||
return newLeftArticleId;
|
||||
};
|
||||
|
||||
// Placeholder for a "new" unsaved left article
|
||||
setNewLeftArticlePlaceholder = () => {
|
||||
this.sight.left_article = 10000000; // Special placeholder ID
|
||||
this.sight.left_article = 10000000;
|
||||
this.sight.ru.left = {
|
||||
heading: "Новая левая статья",
|
||||
body: "Заполните контентом",
|
||||
@@ -414,7 +406,6 @@ class CreateSightStore {
|
||||
};
|
||||
};
|
||||
|
||||
// --- Sight Preview Media ---
|
||||
linkPreviewMedia = (mediaId: string) => {
|
||||
this.sight.preview_media = mediaId;
|
||||
};
|
||||
@@ -423,32 +414,27 @@ class CreateSightStore {
|
||||
this.sight.preview_media = null;
|
||||
};
|
||||
|
||||
// --- General Store Methods ---
|
||||
clearCreateSight = () => {
|
||||
this.needLeaveAgree = false;
|
||||
this.sight = JSON.parse(JSON.stringify(initialSightState)); // Reset to initial
|
||||
this.sight = JSON.parse(JSON.stringify(initialSightState));
|
||||
};
|
||||
|
||||
updateSightInfo = (
|
||||
content: Partial<SightLanguageInfo | SightCommonInfo>, // Corrected types
|
||||
content: Partial<SightLanguageInfo | SightCommonInfo>,
|
||||
language?: Language
|
||||
) => {
|
||||
this.needLeaveAgree = true;
|
||||
if (language) {
|
||||
this.sight[language] = { ...this.sight[language], ...content };
|
||||
} else {
|
||||
// Assuming content here is for SightCommonInfo
|
||||
this.sight = { ...this.sight, ...(content as Partial<SightCommonInfo>) };
|
||||
}
|
||||
};
|
||||
|
||||
// --- Main Sight Creation Logic ---
|
||||
createSight = async (primaryLanguage: Language) => {
|
||||
let finalLeftArticleId = this.sight.left_article;
|
||||
|
||||
// 1. Handle Left Article (Create if new, or use existing ID)
|
||||
if (this.sight.left_article === 10000000) {
|
||||
// Placeholder for new
|
||||
const res = await languageInstance("ru").post("/article", {
|
||||
heading: this.sight.ru.left.heading,
|
||||
body: this.sight.ru.left.body,
|
||||
@@ -466,7 +452,6 @@ class CreateSightStore {
|
||||
this.sight.left_article !== 0 &&
|
||||
this.sight.left_article !== null
|
||||
) {
|
||||
// Existing, ensure it's up-to-date
|
||||
await languageInstance("ru").patch(
|
||||
`/article/${this.sight.left_article}`,
|
||||
{ heading: this.sight.ru.left.heading, body: this.sight.ru.left.body }
|
||||
@@ -480,10 +465,7 @@ class CreateSightStore {
|
||||
{ heading: this.sight.zh.left.heading, body: this.sight.zh.left.body }
|
||||
);
|
||||
}
|
||||
// else: left_article is 0, so no left article
|
||||
|
||||
// 2. Right articles are already created in DB and their IDs are in this.sight[lang].right.
|
||||
// We just need to update their content if changed before saving the sight.
|
||||
for (const lang of ["ru", "en", "zh"] as Language[]) {
|
||||
for (const article of this.sight[lang].right) {
|
||||
if (article.id == 0 || article.id == null) {
|
||||
@@ -493,14 +475,12 @@ class CreateSightStore {
|
||||
heading: article.heading,
|
||||
body: article.body,
|
||||
});
|
||||
// Media for these articles are already linked via createLinkWithRightArticle
|
||||
}
|
||||
}
|
||||
const rightArticleIdsForLink = this.sight[primaryLanguage].right.map(
|
||||
(a) => a.id
|
||||
);
|
||||
|
||||
// 3. Create Sight object in DB
|
||||
const sightPayload = {
|
||||
city_id: this.sight.city_id,
|
||||
city: this.sight.city,
|
||||
@@ -520,9 +500,8 @@ class CreateSightStore {
|
||||
"/sight",
|
||||
sightPayload
|
||||
);
|
||||
const newSightId = response.data.id; // ID of the newly created sight
|
||||
const newSightId = response.data.id;
|
||||
|
||||
// 4. Update other languages for the sight
|
||||
const otherLanguages = (["ru", "en", "zh"] as Language[]).filter(
|
||||
(l) => l !== primaryLanguage
|
||||
);
|
||||
@@ -543,21 +522,17 @@ class CreateSightStore {
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Link Right Articles to the new Sight
|
||||
for (let i = 0; i < rightArticleIdsForLink.length; i++) {
|
||||
await authInstance.post(`/sight/${newSightId}/article`, {
|
||||
article_id: rightArticleIdsForLink[i],
|
||||
page_num: i + 1, // Or other logic for page_num
|
||||
page_num: i + 1,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Sight created with ID:", newSightId);
|
||||
// Optionally: this.clearCreateSight(); // To reset form after successful creation
|
||||
this.needLeaveAgree = false;
|
||||
return newSightId;
|
||||
};
|
||||
|
||||
// --- Media Upload (Generic, used by dialogs) ---
|
||||
uploadMedia = async (
|
||||
filename: string,
|
||||
type: number,
|
||||
@@ -576,12 +551,12 @@ class CreateSightStore {
|
||||
this.fileToUpload = null;
|
||||
this.uploadMediaOpen = false;
|
||||
});
|
||||
mediaStore.getMedia(); // Refresh global media list
|
||||
mediaStore.getMedia();
|
||||
return {
|
||||
id: response.data.id,
|
||||
filename: filename, // Or response.data.filename if backend returns it
|
||||
media_name: media_name, // Or response.data.media_name
|
||||
media_type: type, // Or response.data.type
|
||||
filename: filename,
|
||||
media_name: media_name,
|
||||
media_type: type,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error uploading media:", error);
|
||||
@@ -589,15 +564,12 @@ class CreateSightStore {
|
||||
}
|
||||
};
|
||||
|
||||
// For Left Article Media
|
||||
createLinkWithLeftArticle = async (media: MediaItem) => {
|
||||
if (!this.sight.left_article || this.sight.left_article === 10000000) {
|
||||
console.warn(
|
||||
"Left article not selected or is a placeholder. Cannot link media yet."
|
||||
);
|
||||
// If it's a placeholder, we could store the media temporarily and link it after the article is created.
|
||||
// For simplicity, we'll assume the article must exist.
|
||||
// A more robust solution might involve creating the article first if it's a placeholder.
|
||||
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -656,7 +628,7 @@ class CreateSightStore {
|
||||
|
||||
this.sight.ru.right = sortArticles(this.sight.ru.right);
|
||||
this.sight.en.right = sortArticles(this.sight.en.right);
|
||||
this.sight.zh.right = sortArticles(this.sight.zh.right); // теперь zh тоже сортируется одинаково
|
||||
this.sight.zh.right = sortArticles(this.sight.zh.right);
|
||||
|
||||
this.needLeaveAgree = true;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @shared/stores/editSightStore.ts
|
||||
import {
|
||||
articlesStore,
|
||||
authInstance,
|
||||
@@ -96,13 +95,11 @@ class EditSightStore {
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
// Обновляем языковую часть
|
||||
this.sight[language] = {
|
||||
...this.sight[language],
|
||||
...data,
|
||||
};
|
||||
|
||||
// Только при первом запросе обновляем общую часть
|
||||
if (!this.hasLoadedCommon) {
|
||||
this.sight.common = {
|
||||
...this.sight.common,
|
||||
@@ -123,7 +120,6 @@ class EditSightStore {
|
||||
let responseEn = await languageInstance("en").get(`/sight/${id}/article`);
|
||||
let responseZh = await languageInstance("zh").get(`/sight/${id}/article`);
|
||||
|
||||
// Create a map of article IDs to their media
|
||||
const mediaMap = new Map();
|
||||
for (const article of responseRu.data) {
|
||||
const responseMedia = await authInstance.get(
|
||||
@@ -132,7 +128,6 @@ class EditSightStore {
|
||||
mediaMap.set(article.id, responseMedia.data);
|
||||
}
|
||||
|
||||
// Function to add media to articles
|
||||
const addMediaToArticles = (articles: any[]) => {
|
||||
return articles.map((article) => ({
|
||||
...article,
|
||||
@@ -327,28 +322,6 @@ class EditSightStore {
|
||||
articles: articleIdsInObject,
|
||||
});
|
||||
|
||||
// await languageInstance("ru").patch(
|
||||
// `/sight/${this.sight.common.left_article}/article`,
|
||||
// {
|
||||
// heading: this.sight.ru.left.heading,
|
||||
// body: this.sight.ru.left.body,
|
||||
// }
|
||||
// );
|
||||
// await languageInstance("en").patch(
|
||||
// `/sight/${this.sight.common.left_article}/article`,
|
||||
// {
|
||||
// heading: this.sight.en.left.heading,
|
||||
// body: this.sight.en.left.body,
|
||||
// }
|
||||
// );
|
||||
// await languageInstance("zh").patch(
|
||||
// `/sight/${this.sight.common.left_article}/article`,
|
||||
// {
|
||||
// heading: this.sight.zh.left.heading,
|
||||
// body: this.sight.zh.left.body,
|
||||
// }
|
||||
// );
|
||||
|
||||
this.needLeaveAgree = false;
|
||||
};
|
||||
|
||||
@@ -400,16 +373,36 @@ class EditSightStore {
|
||||
};
|
||||
|
||||
createLeftArticle = async () => {
|
||||
const ruName = (this.sight.ru.name || "").trim();
|
||||
const enName = (this.sight.en.name || "").trim();
|
||||
const zhName = (this.sight.zh.name || "").trim();
|
||||
const hasAnyName = !!(ruName || enName || zhName);
|
||||
|
||||
const response = await languageInstance("ru").post(`/article`, {
|
||||
heading: "",
|
||||
heading: hasAnyName ? ruName : "",
|
||||
body: "",
|
||||
});
|
||||
|
||||
this.sight.common.left_article = response.data.id;
|
||||
|
||||
this.sight.ru.left.heading = "";
|
||||
this.sight.en.left.heading = "";
|
||||
this.sight.zh.left.heading = "";
|
||||
await languageInstance("en").patch(
|
||||
`/article/${this.sight.common.left_article}`,
|
||||
{
|
||||
heading: hasAnyName ? enName : "",
|
||||
body: "",
|
||||
}
|
||||
);
|
||||
await languageInstance("zh").patch(
|
||||
`/article/${this.sight.common.left_article}`,
|
||||
{
|
||||
heading: hasAnyName ? zhName : "",
|
||||
body: "",
|
||||
}
|
||||
);
|
||||
|
||||
this.sight.ru.left.heading = hasAnyName ? ruName : "";
|
||||
this.sight.en.left.heading = hasAnyName ? enName : "";
|
||||
this.sight.zh.left.heading = hasAnyName ? zhName : "";
|
||||
this.sight.ru.left.body = "";
|
||||
this.sight.en.left.body = "";
|
||||
this.sight.zh.left.body = "";
|
||||
@@ -569,7 +562,7 @@ class EditSightStore {
|
||||
});
|
||||
});
|
||||
|
||||
return article_id; // Return the linked article ID
|
||||
return article_id;
|
||||
};
|
||||
|
||||
deleteRightArticleMedia = async (article_id: number, media_id: string) => {
|
||||
@@ -675,7 +668,7 @@ class EditSightStore {
|
||||
});
|
||||
});
|
||||
|
||||
return id; // Return the ID of the newly created article
|
||||
return id;
|
||||
};
|
||||
|
||||
createLinkWithRightArticle = async (
|
||||
@@ -750,7 +743,7 @@ class EditSightStore {
|
||||
|
||||
this.sight.ru.right = sortArticles(this.sight.ru.right);
|
||||
this.sight.en.right = sortArticles(this.sight.en.right);
|
||||
this.sight.zh.right = sortArticles(this.sight.zh.right); // теперь zh тоже сортируется одинаково
|
||||
this.sight.zh.right = sortArticles(this.sight.zh.right);
|
||||
|
||||
this.needLeaveAgree = true;
|
||||
};
|
||||
|
||||
@@ -39,12 +39,11 @@ class MediaStore {
|
||||
updateMedia = async (id: string, data: Partial<Media>) => {
|
||||
const response = await authInstance.patch(`/media/${id}`, data);
|
||||
runInAction(() => {
|
||||
// Update in media array
|
||||
const index = this.media.findIndex((m) => m.id === id);
|
||||
if (index !== -1) {
|
||||
this.media[index] = { ...this.media[index], ...response.data };
|
||||
}
|
||||
// Update oneMedia if it's the current media being viewed
|
||||
|
||||
if (this.oneMedia?.id === id) {
|
||||
this.oneMedia = { ...this.oneMedia, ...response.data };
|
||||
}
|
||||
@@ -64,12 +63,11 @@ class MediaStore {
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
// Update in media array
|
||||
const index = this.media.findIndex((m) => m.id === id);
|
||||
if (index !== -1) {
|
||||
this.media[index] = { ...this.media[index], ...response.data };
|
||||
}
|
||||
// Update oneMedia if it's the current media being viewed
|
||||
|
||||
if (this.oneMedia?.id === id) {
|
||||
this.oneMedia = { ...this.oneMedia, ...response.data };
|
||||
}
|
||||
|
||||
90
src/shared/store/ModelLoadingStore/index.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { makeAutoObservable } from "mobx";
|
||||
|
||||
export interface ModelLoadingState {
|
||||
isLoading: boolean;
|
||||
progress: number;
|
||||
modelId: string | null;
|
||||
error?: string;
|
||||
startTime?: number;
|
||||
}
|
||||
|
||||
class ModelLoadingStore {
|
||||
private loadingStates: Map<string, ModelLoadingState> = new Map();
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
startLoading(modelId: string) {
|
||||
this.loadingStates.set(modelId, {
|
||||
isLoading: true,
|
||||
progress: 0,
|
||||
modelId,
|
||||
startTime: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
updateProgress(modelId: string, progress: number) {
|
||||
const state = this.loadingStates.get(modelId);
|
||||
if (state) {
|
||||
state.progress = Math.min(100, Math.max(0, progress));
|
||||
}
|
||||
}
|
||||
|
||||
finishLoading(modelId: string) {
|
||||
const state = this.loadingStates.get(modelId);
|
||||
if (state) {
|
||||
state.isLoading = false;
|
||||
state.progress = 100;
|
||||
}
|
||||
}
|
||||
|
||||
stopLoading(modelId: string) {
|
||||
this.loadingStates.delete(modelId);
|
||||
}
|
||||
|
||||
handleError(modelId: string, error?: string) {
|
||||
const state = this.loadingStates.get(modelId);
|
||||
if (state) {
|
||||
state.isLoading = false;
|
||||
state.error = error || "Ошибка загрузки модели";
|
||||
}
|
||||
}
|
||||
|
||||
getLoadingState(modelId: string): ModelLoadingState | undefined {
|
||||
return this.loadingStates.get(modelId);
|
||||
}
|
||||
|
||||
get isAnyModelLoading(): boolean {
|
||||
return Array.from(this.loadingStates.values()).some(
|
||||
(state) => state.isLoading
|
||||
);
|
||||
}
|
||||
|
||||
get loadingModels(): ModelLoadingState[] {
|
||||
return Array.from(this.loadingStates.values()).filter(
|
||||
(state) => state.isLoading
|
||||
);
|
||||
}
|
||||
|
||||
get overallProgress(): number {
|
||||
const loadingModels = this.loadingModels;
|
||||
if (loadingModels.length === 0) return 100;
|
||||
|
||||
const totalProgress = loadingModels.reduce(
|
||||
(sum, model) => sum + model.progress,
|
||||
0
|
||||
);
|
||||
return Math.round(totalProgress / loadingModels.length);
|
||||
}
|
||||
|
||||
get isSaveBlocked(): boolean {
|
||||
return this.isAnyModelLoading;
|
||||
}
|
||||
|
||||
clearAll() {
|
||||
this.loadingStates.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const modelLoadingStore = new ModelLoadingStore();
|
||||
@@ -2,6 +2,7 @@ import { makeAutoObservable, runInAction } from "mobx";
|
||||
import { authInstance } from "@shared";
|
||||
|
||||
export type Route = {
|
||||
route_name: string;
|
||||
carrier: string;
|
||||
carrier_id: number;
|
||||
center_latitude: number;
|
||||
@@ -97,6 +98,7 @@ class RouteStore {
|
||||
};
|
||||
|
||||
editRouteData = {
|
||||
route_name: "",
|
||||
carrier: "",
|
||||
carrier_id: 0,
|
||||
center_latitude: "",
|
||||
@@ -110,7 +112,7 @@ class RouteStore {
|
||||
route_sys_number: "",
|
||||
scale_max: 0,
|
||||
scale_min: 0,
|
||||
video_preview: "",
|
||||
video_preview: "" as string | undefined,
|
||||
};
|
||||
|
||||
setEditRouteData = (data: any) => {
|
||||
@@ -118,6 +120,9 @@ class RouteStore {
|
||||
};
|
||||
|
||||
editRoute = async (id: number) => {
|
||||
if (!this.editRouteData.video_preview) {
|
||||
delete this.editRouteData.video_preview;
|
||||
}
|
||||
const response = await authInstance.patch(`/route/${id}`, {
|
||||
...this.editRouteData,
|
||||
center_latitude: parseFloat(this.editRouteData.center_latitude),
|
||||
@@ -134,7 +139,6 @@ class RouteStore {
|
||||
|
||||
copyRouteAction = async (id: number) => {
|
||||
const response = await authInstance.post(`/route/${id}/copy`);
|
||||
console.log(response);
|
||||
|
||||
runInAction(() => {
|
||||
this.routes.data = [...this.routes.data, response.data];
|
||||
|
||||
@@ -58,41 +58,6 @@ class SightsStore {
|
||||
});
|
||||
};
|
||||
|
||||
// getSight = async (id: number) => {
|
||||
// const response = await authInstance.get(`/sight/${id}`);
|
||||
|
||||
// runInAction(() => {
|
||||
// this.sight = response.data;
|
||||
// editSightStore.sightInfo = {
|
||||
// ...editSightStore.sightInfo,
|
||||
// id: response.data.id,
|
||||
// city_id: response.data.city_id,
|
||||
// city: response.data.city,
|
||||
// latitude: response.data.latitude,
|
||||
// longitude: response.data.longitude,
|
||||
// thumbnail: response.data.thumbnail,
|
||||
// watermark_lu: response.data.watermark_lu,
|
||||
// watermark_rd: response.data.watermark_rd,
|
||||
// left_article: response.data.left_article,
|
||||
// preview_media: response.data.preview_media,
|
||||
// video_preview: response.data.video_preview,
|
||||
|
||||
// [languageStore.language]: {
|
||||
// info: {
|
||||
// name: response.data.name,
|
||||
// address: response.data.address,
|
||||
// },
|
||||
// left: {
|
||||
// heading: articlesStore.articles[languageStore.language].find(
|
||||
// (article) => article.id === response.data.left_article
|
||||
// )?.heading,
|
||||
// body: articlesStore.articles[languageStore.language].find(
|
||||
// },
|
||||
// },
|
||||
// };
|
||||
// });
|
||||
// };
|
||||
|
||||
createSightAction = async (
|
||||
city: number,
|
||||
coordinates: { latitude: number; longitude: number }
|
||||
@@ -167,16 +132,12 @@ class SightsStore {
|
||||
common: boolean
|
||||
) => {
|
||||
if (common) {
|
||||
// @ts-ignore
|
||||
this.sight!.common = {
|
||||
// @ts-ignore
|
||||
...this.sight!.common,
|
||||
...content,
|
||||
};
|
||||
} else {
|
||||
// @ts-ignore
|
||||
this.sight![language] = {
|
||||
// @ts-ignore
|
||||
...this.sight![language],
|
||||
...content,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { authInstance } from "@shared";
|
||||
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
// Импорт функции сброса кешей карты
|
||||
// import { clearMapCaches } from "../../pages/MapPage";
|
||||
import {
|
||||
articlesStore,
|
||||
cityStore,
|
||||
@@ -35,9 +33,7 @@ class SnapshotStore {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
// Функция для сброса всех кешей в приложении
|
||||
private clearAllCaches = () => {
|
||||
// Сброс кешей статей
|
||||
articlesStore.articleList = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
@@ -47,7 +43,6 @@ class SnapshotStore {
|
||||
articlesStore.articleData = null;
|
||||
articlesStore.articleMedia = null;
|
||||
|
||||
// Сброс кешей городов
|
||||
cityStore.cities = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
@@ -56,21 +51,18 @@ class SnapshotStore {
|
||||
cityStore.ruCities = { data: [], loaded: false };
|
||||
cityStore.city = {};
|
||||
|
||||
// Сброс кешей стран
|
||||
countryStore.countries = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
zh: { data: [], loaded: false },
|
||||
};
|
||||
|
||||
// Сброс кешей перевозчиков
|
||||
carrierStore.carriers = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
zh: { data: [], loaded: false },
|
||||
};
|
||||
|
||||
// Сброс кешей станций
|
||||
stationsStore.stationLists = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
@@ -78,24 +70,18 @@ class SnapshotStore {
|
||||
};
|
||||
stationsStore.stationPreview = {};
|
||||
|
||||
// Сброс кешей достопримечательностей
|
||||
sightsStore.sights = [];
|
||||
sightsStore.sight = null;
|
||||
|
||||
// Сброс кешей маршрутов
|
||||
routeStore.routes = { data: [], loaded: false };
|
||||
|
||||
// Сброс кешей транспорта
|
||||
vehicleStore.vehicles = { data: [], loaded: false };
|
||||
|
||||
// Сброс кешей пользователей
|
||||
userStore.users = { data: [], loaded: false };
|
||||
|
||||
// Сброс кешей медиа
|
||||
mediaStore.media = [];
|
||||
mediaStore.oneMedia = null;
|
||||
|
||||
// Сброс кешей создания и редактирования достопримечательностей
|
||||
createSightStore.sight = JSON.parse(
|
||||
JSON.stringify({
|
||||
city_id: 0,
|
||||
@@ -173,26 +159,21 @@ class SnapshotStore {
|
||||
editSightStore.fileToUpload = null;
|
||||
editSightStore.needLeaveAgree = false;
|
||||
|
||||
// Сброс кешей устройств
|
||||
devicesStore.devices = [];
|
||||
devicesStore.uuid = null;
|
||||
devicesStore.sendSnapshotModalOpen = false;
|
||||
|
||||
// Сброс кешей авторизации (кроме токена)
|
||||
authStore.payload = null;
|
||||
authStore.error = null;
|
||||
authStore.isLoading = false;
|
||||
|
||||
// Сброс кешей карты (если они загружены)
|
||||
try {
|
||||
// Сбрасываем кеши mapStore если он доступен
|
||||
if (typeof window !== "undefined" && (window as any).mapStore) {
|
||||
(window as any).mapStore.routes = [];
|
||||
(window as any).mapStore.stations = [];
|
||||
(window as any).mapStore.sights = [];
|
||||
}
|
||||
|
||||
// Сбрасываем кеши MapService если он доступен
|
||||
if (typeof window !== "undefined" && (window as any).mapServiceInstance) {
|
||||
(window as any).mapServiceInstance.clearCaches();
|
||||
}
|
||||
@@ -200,7 +181,6 @@ class SnapshotStore {
|
||||
console.warn("Не удалось сбросить кеши карты:", error);
|
||||
}
|
||||
|
||||
// Сброс localStorage кешей (кроме токена авторизации)
|
||||
const token = localStorage.getItem("token");
|
||||
const rememberedEmail = localStorage.getItem("rememberedEmail");
|
||||
const rememberedPassword = localStorage.getItem("rememberedPassword");
|
||||
@@ -208,14 +188,12 @@ class SnapshotStore {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
|
||||
// Восстанавливаем важные данные
|
||||
if (token) localStorage.setItem("token", token);
|
||||
if (rememberedEmail)
|
||||
localStorage.setItem("rememberedEmail", rememberedEmail);
|
||||
if (rememberedPassword)
|
||||
localStorage.setItem("rememberedPassword", rememberedPassword);
|
||||
|
||||
// Сброс кешей карты (если они есть)
|
||||
const mapPositionKey = "mapPosition";
|
||||
const activeSectionKey = "mapActiveSection";
|
||||
if (localStorage.getItem(mapPositionKey)) {
|
||||
@@ -225,56 +203,36 @@ class SnapshotStore {
|
||||
localStorage.removeItem(activeSectionKey);
|
||||
}
|
||||
|
||||
// Попытка очистить кеш браузера (если поддерживается)
|
||||
if ("caches" in window) {
|
||||
try {
|
||||
caches
|
||||
.keys()
|
||||
.then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames.map((cacheName) => {
|
||||
return caches.delete(cacheName);
|
||||
})
|
||||
);
|
||||
})
|
||||
.then(() => {
|
||||
console.log("Кеш браузера очищен");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("Не удалось очистить кеш браузера:", error);
|
||||
});
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames.map((cacheName) => {
|
||||
return caches.delete(cacheName);
|
||||
})
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Кеш браузера не поддерживается:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Попытка очистить IndexedDB (если поддерживается)
|
||||
if ("indexedDB" in window) {
|
||||
try {
|
||||
indexedDB
|
||||
.databases()
|
||||
.then((databases) => {
|
||||
return Promise.all(
|
||||
databases.map((db) => {
|
||||
if (db.name) {
|
||||
return indexedDB.deleteDatabase(db.name);
|
||||
}
|
||||
return Promise.resolve();
|
||||
})
|
||||
);
|
||||
})
|
||||
.then(() => {
|
||||
console.log("IndexedDB очищен");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("Не удалось очистить IndexedDB:", error);
|
||||
});
|
||||
indexedDB.databases().then((databases) => {
|
||||
return Promise.all(
|
||||
databases.map((db) => {
|
||||
if (db.name) {
|
||||
return indexedDB.deleteDatabase(db.name);
|
||||
}
|
||||
return Promise.resolve();
|
||||
})
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("IndexedDB не поддерживается:", error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Все кеши приложения сброшены");
|
||||
};
|
||||
|
||||
getSnapshots = async () => {
|
||||
@@ -302,10 +260,8 @@ class SnapshotStore {
|
||||
};
|
||||
|
||||
restoreSnapshot = async (id: string) => {
|
||||
// Сначала сбрасываем все кеши
|
||||
this.clearAllCaches();
|
||||
|
||||
// Затем восстанавливаем снапшот
|
||||
await authInstance.post(`/snapshots/${id}/restore`);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ type StationLanguageData = {
|
||||
name: string;
|
||||
system_name: string;
|
||||
address: string;
|
||||
loaded: boolean; // Indicates if this language's data has been loaded/modified
|
||||
loaded: boolean;
|
||||
};
|
||||
|
||||
type StationCommonData = {
|
||||
@@ -92,7 +92,6 @@ class StationsStore {
|
||||
},
|
||||
};
|
||||
|
||||
// This will store the full station data, keyed by ID and then by language
|
||||
stationPreview: Record<
|
||||
string,
|
||||
Record<string, { loaded: boolean; data: Station }>
|
||||
@@ -264,7 +263,6 @@ class StationsStore {
|
||||
};
|
||||
};
|
||||
|
||||
// Sets language-specific station data
|
||||
setLanguageEditStationData = (
|
||||
language: Language,
|
||||
data: Partial<StationLanguageData>
|
||||
@@ -295,7 +293,7 @@ class StationsStore {
|
||||
`/station/${id}`,
|
||||
{
|
||||
name: name || "",
|
||||
system_name: name || "", // system_name is often derived from name
|
||||
system_name: name || "",
|
||||
description: description || "",
|
||||
address: address || "",
|
||||
...commonDataPayload,
|
||||
@@ -303,7 +301,6 @@ class StationsStore {
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
// Update the cached preview data and station lists after successful patch
|
||||
if (this.stationPreview[id]) {
|
||||
this.stationPreview[id][language] = {
|
||||
loaded: true,
|
||||
@@ -343,11 +340,11 @@ class StationsStore {
|
||||
|
||||
runInAction(() => {
|
||||
this.stations = this.stations.filter((station) => station.id !== id);
|
||||
// Also clear from stationPreview cache
|
||||
|
||||
if (this.stationPreview[id]) {
|
||||
delete this.stationPreview[id];
|
||||
}
|
||||
// Clear from stationLists as well for all languages
|
||||
|
||||
for (const lang of ["ru", "en", "zh"] as const) {
|
||||
if (this.stationLists[lang].data) {
|
||||
this.stationLists[lang].data = this.stationLists[lang].data.filter(
|
||||
@@ -421,12 +418,11 @@ class StationsStore {
|
||||
delete commonDataPayload.icon;
|
||||
}
|
||||
|
||||
// First create station in Russian
|
||||
const { name, address } = this.createStationData[language];
|
||||
const description = this.createStationData.common.description;
|
||||
const response = await languageInstance(language).post("/station", {
|
||||
name: name || "",
|
||||
system_name: name || "", // system_name is often derived from name
|
||||
system_name: name || "",
|
||||
description: description || "",
|
||||
address: address || "",
|
||||
...commonDataPayload,
|
||||
@@ -438,7 +434,6 @@ class StationsStore {
|
||||
|
||||
const stationId = response.data.id;
|
||||
|
||||
// Then update for other languages
|
||||
for (const lang of ["ru", "en", "zh"].filter(
|
||||
(lang) => lang !== language
|
||||
) as Language[]) {
|
||||
@@ -448,7 +443,7 @@ class StationsStore {
|
||||
`/station/${stationId}`,
|
||||
{
|
||||
name: name || "",
|
||||
system_name: name || "", // system_name is often derived from name
|
||||
system_name: name || "",
|
||||
description: description || "",
|
||||
address: address || "",
|
||||
...commonDataPayload,
|
||||
@@ -507,7 +502,6 @@ class StationsStore {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Reset editStationData when navigating away or after saving
|
||||
resetEditStationData = () => {
|
||||
this.editStationData = {
|
||||
ru: {
|
||||
|
||||
143
src/shared/ui/LoadingSpinner/index.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
CircularProgress,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
} from "@mui/material";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
progress?: number;
|
||||
message?: string;
|
||||
size?: number;
|
||||
color?: "primary" | "secondary" | "error" | "info" | "success" | "warning";
|
||||
variant?: "circular" | "linear";
|
||||
showPercentage?: boolean;
|
||||
thickness?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
progress,
|
||||
message = "Загрузка...",
|
||||
size = 40,
|
||||
color = "primary",
|
||||
variant = "circular",
|
||||
showPercentage = true,
|
||||
thickness = 4,
|
||||
className,
|
||||
}) => {
|
||||
if (variant === "linear") {
|
||||
return (
|
||||
<Box
|
||||
className={className}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 2,
|
||||
padding: 3,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: "100%", maxWidth: 300 }}>
|
||||
<LinearProgress
|
||||
variant={progress !== undefined ? "determinate" : "indeterminate"}
|
||||
value={progress}
|
||||
color={color}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.1)",
|
||||
"& .MuiLinearProgress-bar": {
|
||||
borderRadius: 4,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{showPercentage && progress !== undefined && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
display: "block",
|
||||
textAlign: "center",
|
||||
mt: 1,
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{`${Math.round(progress)}%`}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{message && (
|
||||
<Typography variant="body2" color="text.secondary" textAlign="center">
|
||||
{message}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={className}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 2,
|
||||
padding: 3,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: "relative", display: "inline-flex" }}>
|
||||
<CircularProgress
|
||||
size={size}
|
||||
color={color}
|
||||
variant={progress !== undefined ? "determinate" : "indeterminate"}
|
||||
value={progress}
|
||||
thickness={thickness}
|
||||
sx={{
|
||||
"& .MuiCircularProgress-circle": {
|
||||
strokeLinecap: "round",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{showPercentage && progress !== undefined && (
|
||||
<Box
|
||||
sx={{
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
position: "absolute",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="div"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontSize: size * 0.25,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{`${Math.round(progress)}%`}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{message && (
|
||||
<Typography variant="body2" color="text.secondary" textAlign="center">
|
||||
{message}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
196
src/shared/ui/ModelLoadingIndicator/index.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
CircularProgress,
|
||||
} from "@mui/material";
|
||||
|
||||
interface ModelLoadingIndicatorProps {
|
||||
progress?: number;
|
||||
message?: string;
|
||||
isVisible?: boolean;
|
||||
variant?: "overlay" | "inline";
|
||||
size?: "small" | "medium" | "large";
|
||||
showDetails?: boolean;
|
||||
}
|
||||
|
||||
export const ModelLoadingIndicator: React.FC<ModelLoadingIndicatorProps> = ({
|
||||
progress = 0,
|
||||
message = "Загрузка 3D модели...",
|
||||
isVisible = true,
|
||||
variant = "overlay",
|
||||
size = "medium",
|
||||
showDetails = true,
|
||||
}) => {
|
||||
const sizeConfig = {
|
||||
small: {
|
||||
spinnerSize: 32,
|
||||
fontSize: "0.75rem",
|
||||
progressBarWidth: 150,
|
||||
padding: 2,
|
||||
},
|
||||
medium: {
|
||||
spinnerSize: 48,
|
||||
fontSize: "0.875rem",
|
||||
progressBarWidth: 200,
|
||||
padding: 3,
|
||||
},
|
||||
large: {
|
||||
spinnerSize: 64,
|
||||
fontSize: "1rem",
|
||||
progressBarWidth: 250,
|
||||
padding: 4,
|
||||
},
|
||||
};
|
||||
|
||||
const currentSize = sizeConfig[size];
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const getProgressStage = (progress: number): string => {
|
||||
if (progress < 20) return "Инициализация...";
|
||||
if (progress < 40) return "Загрузка геометрии...";
|
||||
if (progress < 60) return "Обработка материалов...";
|
||||
if (progress < 80) return "Загрузка текстур...";
|
||||
if (progress < 95) return "Финализация...";
|
||||
if (progress === 100) return "Готово!";
|
||||
return "Загрузка...";
|
||||
};
|
||||
|
||||
const content = (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 2,
|
||||
padding: currentSize.padding,
|
||||
textAlign: "center",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{/* Крутяшка с процентами */}
|
||||
<Box sx={{ position: "relative", display: "inline-flex" }}>
|
||||
<CircularProgress
|
||||
size={currentSize.spinnerSize}
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
thickness={4}
|
||||
sx={{
|
||||
color: "primary.main",
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
position: "absolute",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="div"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontSize: currentSize.spinnerSize * 0.25,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{`${Math.round(progress)}%`}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Линейный прогресс бар */}
|
||||
<Box sx={{ width: "100%", maxWidth: currentSize.progressBarWidth }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
color="primary"
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.1)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Основное сообщение */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontSize: currentSize.fontSize,
|
||||
fontWeight: 500,
|
||||
maxWidth: 280,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</Typography>
|
||||
|
||||
{/* Детальная информация о прогрессе */}
|
||||
{showDetails && progress > 0 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.disabled"
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
opacity: 0.8,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{getProgressStage(progress)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (variant === "overlay") {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.95)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
borderRadius: 1,
|
||||
border: "1px solid rgba(0, 0, 0, 0.05)",
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: 200,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.02)",
|
||||
borderRadius: 2,
|
||||
border: "1px dashed",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
devicesStore,
|
||||
Modal,
|
||||
snapshotStore,
|
||||
vehicleStore, // Not directly used in this component's rendering logic anymore
|
||||
} from "@shared"; // Assuming @shared exports these
|
||||
vehicleStore,
|
||||
} from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Button, Checkbox, Typography } from "@mui/material";
|
||||
@@ -23,12 +23,10 @@ import { useNavigate } from "react-router-dom";
|
||||
export type ConnectedDevice = string;
|
||||
|
||||
interface Snapshot {
|
||||
ID: string; // Assuming ID is string based on usage
|
||||
ID: string;
|
||||
Name: string;
|
||||
// Add other snapshot properties if needed
|
||||
}
|
||||
|
||||
// --- HELPER FUNCTIONS ---
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return "Нет данных";
|
||||
try {
|
||||
@@ -76,12 +74,7 @@ function createData(
|
||||
};
|
||||
}
|
||||
|
||||
// This function transforms the raw device data (which includes vehicle and device_status)
|
||||
// into the format expected by the table. It now filters for devices that have a UUID.
|
||||
const transformDevicesToRows = (
|
||||
vehicles: Vehicle[]
|
||||
// devices: ConnectedDevice[]
|
||||
): TableRowData[] => {
|
||||
const transformDevicesToRows = (vehicles: Vehicle[]): TableRowData[] => {
|
||||
return vehicles.map((vehicle) => {
|
||||
const uuid = vehicle.vehicle.uuid;
|
||||
if (!uuid)
|
||||
@@ -115,26 +108,21 @@ export const DevicesTable = observer(() => {
|
||||
} = devicesStore;
|
||||
|
||||
const { snapshots, getSnapshots } = snapshotStore;
|
||||
const { getVehicles, vehicles } = vehicleStore; // Removed as devicesStore.devices should be the source of truth
|
||||
const { getVehicles, vehicles } = vehicleStore;
|
||||
const { devices } = devicesStore;
|
||||
const navigate = useNavigate();
|
||||
const [selectedDeviceUuids, setSelectedDeviceUuids] = useState<string[]>([]);
|
||||
|
||||
// Transform the raw devices data into rows suitable for the table
|
||||
// This will also filter out devices without a UUID, as those cannot be acted upon.
|
||||
const currentTableRows = transformDevicesToRows(
|
||||
vehicles.data as Vehicle[]
|
||||
// devices as ConnectedDevice[]
|
||||
);
|
||||
const currentTableRows = transformDevicesToRows(vehicles.data as Vehicle[]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
await getVehicles(); // Not strictly needed if devicesStore.devices is populated correctly by getDevices
|
||||
await getDevices(); // This should fetch the combined vehicle/device_status data
|
||||
await getVehicles();
|
||||
await getDevices();
|
||||
await getSnapshots();
|
||||
};
|
||||
fetchData();
|
||||
}, [getDevices, getSnapshots]); // Added dependencies
|
||||
}, [getDevices, getSnapshots]);
|
||||
|
||||
const isAllSelected =
|
||||
currentTableRows.length > 0 &&
|
||||
@@ -144,7 +132,6 @@ export const DevicesTable = observer(() => {
|
||||
if (isAllSelected) {
|
||||
setSelectedDeviceUuids([]);
|
||||
} else {
|
||||
// Select all device UUIDs from the *currently visible and selectable* rows
|
||||
setSelectedDeviceUuids(
|
||||
currentTableRows.map((row) => row.device_uuid ?? "")
|
||||
);
|
||||
@@ -171,14 +158,13 @@ export const DevicesTable = observer(() => {
|
||||
};
|
||||
|
||||
const handleReloadStatus = async (uuid: string) => {
|
||||
setSelectedDevice(uuid); // Sets the device in the store, useful for context elsewhere
|
||||
setSelectedDevice(uuid);
|
||||
try {
|
||||
await authInstance.post(`/devices/${uuid}/request-status`);
|
||||
await getVehicles();
|
||||
await getDevices(); // Refresh devices to show updated status
|
||||
await getDevices();
|
||||
} catch (error) {
|
||||
console.error(`Error requesting status for device ${uuid}:`, error);
|
||||
// Optionally: show a user-facing error message
|
||||
}
|
||||
};
|
||||
|
||||
@@ -200,22 +186,16 @@ export const DevicesTable = observer(() => {
|
||||
}
|
||||
};
|
||||
try {
|
||||
// Create an array of promises for all snapshot requests
|
||||
const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => {
|
||||
return send(deviceUuid);
|
||||
});
|
||||
|
||||
// Wait for all promises to settle (either resolve or reject)
|
||||
await Promise.allSettled(snapshotPromises);
|
||||
|
||||
// After all requests are attempted
|
||||
await getDevices(); // Refresh the device list
|
||||
setSelectedDeviceUuids([]); // Clear the selection
|
||||
toggleSendSnapshotModal(); // Close the modal
|
||||
await getDevices();
|
||||
setSelectedDeviceUuids([]);
|
||||
toggleSendSnapshotModal();
|
||||
} catch (error) {
|
||||
// This catch block might not be hit if Promise.allSettled is used,
|
||||
// as it doesn't reject on individual promise failures.
|
||||
// Individual errors should be handled if needed within the .map or by checking results.
|
||||
console.error("Error in snapshot sending process:", error);
|
||||
}
|
||||
};
|
||||
@@ -235,7 +215,7 @@ export const DevicesTable = observer(() => {
|
||||
</div>
|
||||
<div className="flex justify-end p-3 gap-2">
|
||||
<Button
|
||||
variant="outlined" // Changed to outlined for distinction
|
||||
variant="outlined"
|
||||
onClick={handleSelectAllDevices}
|
||||
size="small"
|
||||
>
|
||||
@@ -286,7 +266,6 @@ export const DevicesTable = observer(() => {
|
||||
)}
|
||||
selected={selectedDeviceUuids.includes(row.device_uuid ?? "")}
|
||||
onClick={(event) => {
|
||||
// Allow clicking row to toggle checkbox, if not clicking on button
|
||||
if (
|
||||
(event.target as HTMLElement).closest("button") === null &&
|
||||
(event.target as HTMLElement).closest(
|
||||
@@ -308,7 +287,6 @@ export const DevicesTable = observer(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// Only toggle checkbox if Shift key is not pressed
|
||||
if (!event.shiftKey) {
|
||||
handleSelectDevice(
|
||||
{
|
||||
@@ -317,7 +295,7 @@ export const DevicesTable = observer(() => {
|
||||
row.device_uuid ?? ""
|
||||
),
|
||||
},
|
||||
} as React.ChangeEvent<HTMLInputElement>, // Simulate event
|
||||
} as React.ChangeEvent<HTMLInputElement>,
|
||||
row.device_uuid ?? ""
|
||||
);
|
||||
}
|
||||
@@ -445,7 +423,7 @@ export const DevicesTable = observer(() => {
|
||||
</strong>
|
||||
</Typography>
|
||||
<div className="mt-2 flex flex-col gap-2 max-h-[300px] overflow-y-auto pr-2">
|
||||
{snapshots && (snapshots as Snapshot[]).length > 0 ? ( // Cast snapshots
|
||||
{snapshots && (snapshots as Snapshot[]).length > 0 ? (
|
||||
(snapshots as Snapshot[]).map((snapshot) => (
|
||||
<Button
|
||||
variant="outlined"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useRef, useState, DragEvent, useEffect } from "react";
|
||||
import React, { useRef, DragEvent } from "react";
|
||||
import { Paper, Box, Typography, Button, Tooltip } from "@mui/material";
|
||||
import { X, Info, Plus } from "lucide-react"; // Assuming lucide-react for icons
|
||||
import { X, Info, Plus } from "lucide-react";
|
||||
import { editSightStore } from "@shared";
|
||||
import { toast } from "react-toastify";
|
||||
interface ImageUploadCardProps {
|
||||
@@ -27,17 +27,9 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
|
||||
tooltipText,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const { setFileToUpload } = editSightStore;
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragOver) {
|
||||
console.log("isDragOver");
|
||||
}
|
||||
}, [isDragOver]);
|
||||
// --- Click to select file ---
|
||||
const handleZoneClick = () => {
|
||||
// Trigger the hidden file input click
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
@@ -58,28 +50,25 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
|
||||
toast.error("Пожалуйста, выберите изображение");
|
||||
}
|
||||
}
|
||||
// Reset the input value so selecting the same file again triggers change
|
||||
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
const token = localStorage.getItem("token");
|
||||
// --- Drag and Drop Handlers ---
|
||||
|
||||
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault(); // Crucial to allow a drop
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault(); // Crucial to allow a drop
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
const files = event.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
@@ -131,7 +120,6 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
|
||||
cursor: imageUrl ? "pointer" : "default",
|
||||
}}
|
||||
onClick={onImageClick}
|
||||
// Removed onClick on the main Box to avoid conflicts
|
||||
>
|
||||
{imageUrl && (
|
||||
<button
|
||||
@@ -164,7 +152,7 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
|
||||
borderRadius: 1,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={handleZoneClick} // Click handler for the zone
|
||||
onClick={handleZoneClick}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
@@ -178,8 +166,8 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
|
||||
color="primary"
|
||||
startIcon={<Plus color="white" size={18} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent `handleZoneClick` from firing
|
||||
onSelectFileClick(); // This button might trigger a different modal
|
||||
e.stopPropagation();
|
||||
onSelectFileClick();
|
||||
}}
|
||||
>
|
||||
Выбрать файл
|
||||
@@ -190,7 +178,7 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileInputChange}
|
||||
style={{ display: "none" }}
|
||||
accept="image/*" // Accept only image files
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -48,21 +48,24 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
|
||||
<Box sx={{ display: "flex" }}>
|
||||
<AppBar position="fixed" open={open}>
|
||||
<Toolbar className="flex justify-between">
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
onClick={handleDrawerOpen}
|
||||
edge="start"
|
||||
sx={[
|
||||
{
|
||||
marginRight: 5,
|
||||
},
|
||||
open && { display: "none" },
|
||||
]}
|
||||
>
|
||||
<Menu />
|
||||
</IconButton>
|
||||
<CitySelector />
|
||||
<div className="flex items-center">
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
onClick={handleDrawerOpen}
|
||||
edge="start"
|
||||
sx={[
|
||||
{
|
||||
marginRight: 5,
|
||||
},
|
||||
open && { display: "none" },
|
||||
]}
|
||||
>
|
||||
<Menu />
|
||||
</IconButton>
|
||||
<CitySelector />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex flex-col gap-1">
|
||||
{(() => {
|
||||
@@ -114,7 +117,7 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/favicon_ship.png"
|
||||
src="/favicon_ship.svg"
|
||||
alt="logo"
|
||||
width={40}
|
||||
height={40}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Box, Button } from "@mui/material";
|
||||
import { MediaViewer } from "@widgets";
|
||||
import { PreviewMediaDialog } from "@shared";
|
||||
import {
|
||||
PreviewMediaDialog,
|
||||
filterValidFiles,
|
||||
getAllAcceptString,
|
||||
} from "@shared";
|
||||
import { X, Upload } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useState, DragEvent, useRef } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const MediaArea = observer(
|
||||
({
|
||||
@@ -36,7 +41,15 @@ export const MediaArea = observer(
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length && onFilesDrop) {
|
||||
onFilesDrop(files);
|
||||
const { validFiles, errors } = filterValidFiles(files);
|
||||
|
||||
if (errors.length > 0) {
|
||||
errors.forEach((error) => toast.error(error));
|
||||
}
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
onFilesDrop(validFiles);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -56,7 +69,15 @@ export const MediaArea = observer(
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length && onFilesDrop) {
|
||||
onFilesDrop(files);
|
||||
const { validFiles, errors } = filterValidFiles(files);
|
||||
|
||||
if (errors.length > 0) {
|
||||
errors.forEach((error) => toast.error(error));
|
||||
}
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
onFilesDrop(validFiles);
|
||||
}
|
||||
}
|
||||
// Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
|
||||
event.target.value = "";
|
||||
@@ -68,7 +89,7 @@ export const MediaArea = observer(
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
accept="image/*,video/*,.glb,.gltf"
|
||||
accept={getAllAcceptString()}
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { Box, Button } from "@mui/material";
|
||||
import { editSightStore, SelectMediaDialog, UploadMediaDialog } from "@shared";
|
||||
import {
|
||||
editSightStore,
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
filterValidFiles,
|
||||
getAllAcceptString,
|
||||
} from "@shared";
|
||||
import { Upload } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useState, DragEvent, useRef } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const MediaAreaForSight = observer(
|
||||
({
|
||||
@@ -38,11 +45,18 @@ export const MediaAreaForSight = observer(
|
||||
setIsDragging(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length && onFilesDrop) {
|
||||
setFileToUpload(files[0]);
|
||||
}
|
||||
if (files.length) {
|
||||
const { validFiles, errors } = filterValidFiles(files);
|
||||
|
||||
setUploadMediaDialogOpen(true);
|
||||
if (errors.length > 0) {
|
||||
errors.forEach((error: string) => toast.error(error));
|
||||
}
|
||||
|
||||
if (validFiles.length > 0 && onFilesDrop) {
|
||||
setFileToUpload(validFiles[0]);
|
||||
setUploadMediaDialogOpen(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
@@ -60,10 +74,18 @@ export const MediaAreaForSight = observer(
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length && onFilesDrop) {
|
||||
setFileToUpload(files[0]);
|
||||
onFilesDrop(files);
|
||||
setUploadMediaDialogOpen(true);
|
||||
if (files.length) {
|
||||
const { validFiles, errors } = filterValidFiles(files);
|
||||
|
||||
if (errors.length > 0) {
|
||||
errors.forEach((error: string) => toast.error(error));
|
||||
}
|
||||
|
||||
if (validFiles.length > 0 && onFilesDrop) {
|
||||
setFileToUpload(validFiles[0]);
|
||||
onFilesDrop(validFiles);
|
||||
setUploadMediaDialogOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
|
||||
@@ -76,7 +98,7 @@ export const MediaAreaForSight = observer(
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
accept="image/*,video/*,.glb,.gltf"
|
||||
accept={getAllAcceptString()}
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,52 @@
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
|
||||
import { useEffect, Suspense } from "react";
|
||||
import { Box, CircularProgress, Typography } from "@mui/material";
|
||||
|
||||
const clearGLTFCache = (url?: string) => {
|
||||
try {
|
||||
if (url) {
|
||||
if (url.startsWith("blob:")) {
|
||||
useGLTF.clear(url);
|
||||
} else {
|
||||
useGLTF.clear(url);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("⚠️ clearGLTFCache: Ошибка при очистке кеша", error);
|
||||
}
|
||||
};
|
||||
|
||||
const isValid3DFile = (url: string): boolean => {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const pathname = urlObj.pathname.toLowerCase();
|
||||
const searchParams = urlObj.searchParams;
|
||||
|
||||
const validExtensions = [".glb", ".gltf"];
|
||||
const hasValidExtension = validExtensions.some((ext) =>
|
||||
pathname.endsWith(ext)
|
||||
);
|
||||
|
||||
const fileType = searchParams.get("type") || searchParams.get("format");
|
||||
const hasValidType =
|
||||
fileType && ["glb", "gltf"].includes(fileType.toLowerCase());
|
||||
|
||||
const isBlobUrl = url.startsWith("blob:");
|
||||
|
||||
const hasToken = searchParams.has("token");
|
||||
const isServerUrl = hasToken && !hasValidExtension;
|
||||
|
||||
const isValid =
|
||||
hasValidExtension || hasValidType || isBlobUrl || isServerUrl;
|
||||
|
||||
return isValid;
|
||||
} catch (error) {
|
||||
console.warn("⚠️ isValid3DFile: Ошибка при проверке URL", error);
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
type ModelViewerProps = {
|
||||
width?: string;
|
||||
@@ -7,21 +54,87 @@ type ModelViewerProps = {
|
||||
height?: string;
|
||||
};
|
||||
|
||||
const Model = ({ fileUrl }: { fileUrl: string }) => {
|
||||
useEffect(() => {
|
||||
clearGLTFCache(fileUrl);
|
||||
}, [fileUrl]);
|
||||
|
||||
if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) {
|
||||
console.warn("⚠️ Model: Попытка загрузить невалидный 3D файл", { fileUrl });
|
||||
throw new Error(`Файл не является корректной 3D моделью: ${fileUrl}`);
|
||||
}
|
||||
|
||||
const { scene } = useGLTF(fileUrl);
|
||||
return <primitive object={scene} />;
|
||||
};
|
||||
|
||||
const LoadingFallback = () => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
zIndex: 1000,
|
||||
backgroundColor: "background.paper",
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={48} />
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
Загрузка 3D модели...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const ThreeView = ({
|
||||
fileUrl,
|
||||
height = "100%",
|
||||
width = "100%",
|
||||
}: ModelViewerProps) => {
|
||||
const { scene } = useGLTF(fileUrl);
|
||||
useEffect(() => {
|
||||
if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) {
|
||||
console.warn("⚠️ ThreeView: Невалидный 3D файл", { fileUrl });
|
||||
}
|
||||
}, [fileUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
clearGLTFCache(fileUrl);
|
||||
|
||||
return () => {
|
||||
clearGLTFCache(fileUrl);
|
||||
};
|
||||
}, [fileUrl]);
|
||||
|
||||
return (
|
||||
<Canvas style={{ height: height, width: width }}>
|
||||
<ambientLight />
|
||||
<directionalLight />
|
||||
<Stage environment="city" intensity={0.6}>
|
||||
<primitive object={scene} />
|
||||
</Stage>
|
||||
<OrbitControls />
|
||||
</Canvas>
|
||||
<Box sx={{ position: "relative", width, height }}>
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<Canvas
|
||||
style={{ height: height, width: width }}
|
||||
camera={{
|
||||
position: [1, 1, 1],
|
||||
fov: 30,
|
||||
}}
|
||||
>
|
||||
<ambientLight />
|
||||
<directionalLight />
|
||||
<Stage environment="city" intensity={0.6} adjustCamera={false}>
|
||||
<Model fileUrl={fileUrl} />
|
||||
</Stage>
|
||||
<OrbitControls />
|
||||
</Canvas>
|
||||
</Suspense>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
232
src/widgets/MediaViewer/ThreeViewErrorBoundary.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import React, { Component, ReactNode } from "react";
|
||||
import { Box, Button, Typography, Paper } from "@mui/material";
|
||||
import { RefreshCw, AlertTriangle } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
onReset?: () => void;
|
||||
resetKey?: number | string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
lastResetKey?: number | string;
|
||||
}
|
||||
|
||||
export class ThreeViewErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
lastResetKey: props.resetKey,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(
|
||||
props: Props,
|
||||
state: State
|
||||
): Partial<State> | null {
|
||||
if (
|
||||
props.resetKey !== state.lastResetKey &&
|
||||
state.lastResetKey !== undefined
|
||||
) {
|
||||
const oldMediaId = String(state.lastResetKey).split("-")[0];
|
||||
const newMediaId = String(props.resetKey).split("-")[0];
|
||||
|
||||
if (oldMediaId !== newMediaId) {
|
||||
return {
|
||||
hasError: false,
|
||||
error: null,
|
||||
lastResetKey: props.resetKey,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
lastResetKey: props.resetKey,
|
||||
};
|
||||
}
|
||||
|
||||
if (state.lastResetKey === undefined && props.resetKey !== undefined) {
|
||||
return {
|
||||
lastResetKey: props.resetKey,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error("❌ ThreeViewErrorBoundary: Ошибка загрузки 3D модели", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
componentStack: errorInfo.componentStack,
|
||||
});
|
||||
}
|
||||
|
||||
getErrorMessage = () => {
|
||||
const errorMessage = this.state.error?.message || "";
|
||||
|
||||
if (
|
||||
errorMessage.includes("not valid JSON") ||
|
||||
errorMessage.includes("Unexpected token")
|
||||
) {
|
||||
return "Неверный формат файла. Убедитесь, что файл является корректной 3D моделью в формате GLB/GLTF.";
|
||||
}
|
||||
|
||||
if (errorMessage.includes("Could not load")) {
|
||||
return "Не удалось загрузить файл 3D модели. Проверьте, что файл существует и доступен.";
|
||||
}
|
||||
|
||||
if (errorMessage.includes("404") || errorMessage.includes("Not Found")) {
|
||||
return "Файл 3D модели не найден на сервере.";
|
||||
}
|
||||
|
||||
if (errorMessage.includes("Network") || errorMessage.includes("fetch")) {
|
||||
return "Ошибка сети при загрузке 3D модели. Проверьте интернет-соединение.";
|
||||
}
|
||||
|
||||
return (
|
||||
errorMessage || "Произошла неизвестная ошибка при загрузке 3D модели"
|
||||
);
|
||||
};
|
||||
|
||||
getErrorReasons = () => {
|
||||
const errorMessage = this.state.error?.message || "";
|
||||
|
||||
if (
|
||||
errorMessage.includes("not valid JSON") ||
|
||||
errorMessage.includes("Unexpected token")
|
||||
) {
|
||||
return [
|
||||
"Файл не является 3D моделью",
|
||||
"Загружен файл неподдерживаемого формата",
|
||||
"Файл поврежден или не полностью загружен",
|
||||
"Используйте только GLB или GLTF форматы",
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
"Поврежденный файл модели",
|
||||
"Неподдерживаемый формат",
|
||||
"Проблемы с загрузкой файла",
|
||||
];
|
||||
};
|
||||
|
||||
handleReset = () => {
|
||||
this.setState(
|
||||
{
|
||||
hasError: false,
|
||||
error: null,
|
||||
},
|
||||
() => {
|
||||
this.props.onReset?.();
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
handleClose = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 3,
|
||||
maxWidth: 500,
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
backgroundColor: "error.light",
|
||||
color: "error.contrastText",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
|
||||
<AlertTriangle size={32} style={{ marginRight: 12 }} />
|
||||
<Typography variant="h6" component="h2">
|
||||
Ошибка загрузки 3D модели
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
{this.getErrorMessage()}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="caption" sx={{ mb: 2, display: "block" }}>
|
||||
Возможные причины:
|
||||
<ul style={{ margin: "8px 0", paddingLeft: "20px" }}>
|
||||
{this.getErrorReasons().map((reason, index) => (
|
||||
<li key={index}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Typography>
|
||||
|
||||
{this.state.error?.message && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
mb: 2,
|
||||
display: "block",
|
||||
fontFamily: "monospace",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.1)",
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
fontSize: "0.7rem",
|
||||
wordBreak: "break-word",
|
||||
maxHeight: "100px",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{this.state.error.message}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<RefreshCw size={16} />}
|
||||
onClick={() => {
|
||||
this.handleReset();
|
||||
}}
|
||||
sx={{
|
||||
backgroundColor: "error.contrastText",
|
||||
color: "error.main",
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
Попробовать снова
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Box } from "@mui/material";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
|
||||
import { ThreeView } from "./ThreeView";
|
||||
import { ThreeViewErrorBoundary } from "./ThreeViewErrorBoundary";
|
||||
import { clearMediaTransitionCache } from "../../shared/lib/gltfCacheManager";
|
||||
|
||||
export interface MediaData {
|
||||
id: string | number;
|
||||
@@ -25,6 +28,33 @@ export function MediaViewer({
|
||||
fullHeight?: boolean;
|
||||
}>) {
|
||||
const token = localStorage.getItem("token");
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
const [previousMediaId, setPreviousMediaId] = useState<
|
||||
string | number | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (media?.id !== previousMediaId) {
|
||||
// Используем новый cache manager для очистки кеша
|
||||
clearMediaTransitionCache(
|
||||
previousMediaId,
|
||||
|
||||
media?.media_type
|
||||
);
|
||||
|
||||
setResetKey(0);
|
||||
setPreviousMediaId(media?.id || null);
|
||||
}
|
||||
}, [media?.id, media?.media_type, previousMediaId]);
|
||||
|
||||
const handleReset = () => {
|
||||
setResetKey((prev) => {
|
||||
const newKey = prev + 1;
|
||||
|
||||
return newKey;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={className}
|
||||
@@ -48,11 +78,6 @@ export function MediaViewer({
|
||||
style={{
|
||||
height: fullHeight ? "100%" : height ? height : "auto",
|
||||
width: fullWidth ? "100%" : width ? width : "auto",
|
||||
...(media?.filename?.toLowerCase().endsWith(".webp") && {
|
||||
maxWidth: "300px",
|
||||
maxHeight: "300px",
|
||||
objectFit: "contain",
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -108,13 +133,19 @@ export function MediaViewer({
|
||||
)}
|
||||
|
||||
{media?.media_type === 6 && (
|
||||
<ThreeView
|
||||
fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
media?.id
|
||||
}/download?token=${token}`}
|
||||
height={height ? height : "500px"}
|
||||
width={width ? width : "500px"}
|
||||
/>
|
||||
<ThreeViewErrorBoundary
|
||||
onReset={handleReset}
|
||||
resetKey={`${media?.id}-${resetKey}`}
|
||||
>
|
||||
<ThreeView
|
||||
key={`3d-model-${media?.id}-${resetKey}`}
|
||||
fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
media?.id
|
||||
}/download?token=${token}`}
|
||||
height={height ? height : "500px"}
|
||||
width={width ? width : "500px"}
|
||||
/>
|
||||
</ThreeViewErrorBoundary>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
// import { Box, Button, Paper, Typography } from "@mui/material";
|
||||
// import { X, Upload } from "lucide-react";
|
||||
// import { useCallback, useState } from "react";
|
||||
// import { useDropzone } from "react-dropzone";
|
||||
// import { UploadMediaDialog } from "@shared";
|
||||
// import { createSightStore } from "@shared";
|
||||
|
||||
// interface MediaUploadBoxProps {
|
||||
// title: string;
|
||||
// tooltip?: string;
|
||||
// mediaId: string | null;
|
||||
// onMediaSelect: (mediaId: string) => void;
|
||||
// onMediaRemove: () => void;
|
||||
// onPreviewClick: (mediaId: string) => void;
|
||||
// token: string;
|
||||
// type: "thumbnail" | "watermark_lu" | "watermark_rd";
|
||||
// }
|
||||
|
||||
// export const MediaUploadBox = ({
|
||||
// title,
|
||||
// tooltip,
|
||||
// mediaId,
|
||||
// onMediaSelect,
|
||||
// onMediaRemove,
|
||||
// onPreviewClick,
|
||||
// token,
|
||||
// type,
|
||||
// }: MediaUploadBoxProps) => {
|
||||
// const [uploadMediaOpen, setUploadMediaOpen] = useState(false);
|
||||
// const [fileToUpload, setFileToUpload] = useState<File | null>(null);
|
||||
|
||||
// const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
// if (acceptedFiles.length > 0) {
|
||||
// setFileToUpload(acceptedFiles[0]);
|
||||
// setUploadMediaOpen(true);
|
||||
// }
|
||||
// }, []);
|
||||
|
||||
// const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
// onDrop,
|
||||
// accept: {
|
||||
// "image/*": [".png", ".jpg", ".jpeg", ".gif"],
|
||||
// },
|
||||
// multiple: false,
|
||||
// });
|
||||
|
||||
// const handleUploadComplete = async (media: {
|
||||
// id: string;
|
||||
// filename: string;
|
||||
// media_name?: string;
|
||||
// media_type: number;
|
||||
// }) => {
|
||||
// onMediaSelect(media.id);
|
||||
// };
|
||||
|
||||
// return (
|
||||
// <>
|
||||
// <Paper
|
||||
// elevation={2}
|
||||
// sx={{
|
||||
// padding: 2,
|
||||
// display: "flex",
|
||||
// flexDirection: "column",
|
||||
// alignItems: "center",
|
||||
// gap: 1,
|
||||
// flex: 1,
|
||||
// minWidth: 150,
|
||||
// }}
|
||||
// >
|
||||
// <Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
// <Typography variant="subtitle2" gutterBottom sx={{ mb: 0, mr: 0.5 }}>
|
||||
// {title}
|
||||
// </Typography>
|
||||
// </Box>
|
||||
// <Box
|
||||
// {...getRootProps()}
|
||||
// sx={{
|
||||
// position: "relative",
|
||||
// width: "200px",
|
||||
// height: "200px",
|
||||
// display: "flex",
|
||||
// alignItems: "center",
|
||||
// justifyContent: "center",
|
||||
// borderRadius: 1,
|
||||
// mb: 1,
|
||||
// cursor: mediaId ? "pointer" : "default",
|
||||
// border: isDragActive ? "2px dashed #1976d2" : "none",
|
||||
// backgroundColor: isDragActive
|
||||
// ? "rgba(25, 118, 210, 0.04)"
|
||||
// : "transparent",
|
||||
// transition: "all 0.2s ease",
|
||||
// }}
|
||||
// >
|
||||
// <input {...getInputProps()} />
|
||||
// {mediaId && (
|
||||
// <button
|
||||
// className="absolute top-2 right-2 z-10"
|
||||
// onClick={(e) => {
|
||||
// e.stopPropagation();
|
||||
// onMediaRemove();
|
||||
// }}
|
||||
// >
|
||||
// <X color="red" />
|
||||
// </button>
|
||||
// )}
|
||||
// {mediaId ? (
|
||||
// <img
|
||||
// src={`${
|
||||
// import.meta.env.VITE_KRBL_MEDIA
|
||||
// }${mediaId}/download?token=${token}`}
|
||||
// alt={title}
|
||||
// style={{ maxWidth: "100%", maxHeight: "100%" }}
|
||||
// onClick={(e) => {
|
||||
// e.stopPropagation();
|
||||
// onPreviewClick(mediaId);
|
||||
// }}
|
||||
// />
|
||||
// ) : (
|
||||
// <div className="w-full flex flex-col items-center justify-center gap-3">
|
||||
// <div
|
||||
// className={`w-full h-20 border rounded-md flex flex-col items-center transition-all duration-300 justify-center border-dashed ${
|
||||
// isDragActive
|
||||
// ? "border-blue-500 bg-blue-50"
|
||||
// : "border-gray-300"
|
||||
// } cursor-pointer hover:bg-gray-100`}
|
||||
// >
|
||||
// <Upload size={24} className="mb-2" />
|
||||
// <p>
|
||||
// {isDragActive ? "Отпустите файл здесь" : "Перетащите файл"}
|
||||
// </p>
|
||||
// </div>
|
||||
// <p>или</p>
|
||||
// <Button
|
||||
// variant="contained"
|
||||
// color="primary"
|
||||
// onClick={(e) => {
|
||||
// e.stopPropagation();
|
||||
// onMediaSelect("");
|
||||
// }}
|
||||
// >
|
||||
// Выбрать файл
|
||||
// </Button>
|
||||
// </div>
|
||||
// )}
|
||||
// </Box>
|
||||
// </Paper>
|
||||
|
||||
// <UploadMediaDialog
|
||||
// open={uploadMediaOpen}
|
||||
// onClose={() => {
|
||||
// setUploadMediaOpen(false);
|
||||
// setFileToUpload(null);
|
||||
// }}
|
||||
// afterUpload={handleUploadComplete}
|
||||
// />
|
||||
// </>
|
||||
// );
|
||||
// };
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @widgets/LeftWidgetTab.tsx
|
||||
import { Box, Button, TextField, Paper, Typography } from "@mui/material";
|
||||
import {
|
||||
BackButton,
|
||||
@@ -50,17 +49,6 @@ export const CreateLeftTab = observer(
|
||||
const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] =
|
||||
useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
// const handleMediaSelected = useCallback(() => {
|
||||
// // При выборе медиа, обновляем данные для ТЕКУЩЕГО ЯЗЫКА
|
||||
// // сохраняя текущие heading и body.
|
||||
// updateSightInfo(language, {
|
||||
// left: {
|
||||
// heading: data.left.heading,
|
||||
// body: data.left.body,
|
||||
// },
|
||||
// });
|
||||
// setIsSelectMediaDialogOpen(false);
|
||||
// }, [language, data.left.heading, data.left.body]);
|
||||
|
||||
const handleCloseArticleDialog = useCallback(() => {
|
||||
setIsSelectArticleDialogOpen(false);
|
||||
|
||||
@@ -13,28 +13,27 @@ import {
|
||||
languageStore,
|
||||
SelectArticleModal,
|
||||
TabPanel,
|
||||
SelectMediaDialog, // Import
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
Media, // Import
|
||||
Media,
|
||||
} from "@shared";
|
||||
import {
|
||||
LanguageSwitcher,
|
||||
MediaArea, // Import
|
||||
MediaAreaForSight, // Import
|
||||
MediaArea,
|
||||
MediaAreaForSight,
|
||||
ReactMarkdownComponent,
|
||||
ReactMarkdownEditor,
|
||||
DeleteModal,
|
||||
} from "@widgets";
|
||||
import { ImagePlus, Plus, Save, Trash2, Unlink, X } from "lucide-react"; // Import X
|
||||
import { ImagePlus, Plus, Save, Trash2, Unlink, X } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useState, useEffect } from "react"; // Added useEffect
|
||||
import { useState, useEffect } from "react";
|
||||
import { MediaViewer } from "../../MediaViewer/index";
|
||||
import { toast } from "react-toastify";
|
||||
import { authInstance } from "@shared";
|
||||
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
||||
|
||||
type MediaItemShared = {
|
||||
// Define if not already available from @shared
|
||||
id: string;
|
||||
filename: string;
|
||||
media_name?: string;
|
||||
@@ -52,14 +51,14 @@ export const CreateRightTab = observer(
|
||||
unlinkPreviewMedia,
|
||||
createLinkWithRightArticle,
|
||||
deleteRightArticleMedia,
|
||||
setFileToUpload, // From store
|
||||
setUploadMediaOpen, // From store
|
||||
uploadMediaOpen, // From store
|
||||
unlinkRightAritcle, // Corrected spelling
|
||||
setFileToUpload,
|
||||
setUploadMediaOpen,
|
||||
uploadMediaOpen,
|
||||
unlinkRightAritcle,
|
||||
deleteRightArticle,
|
||||
linkExistingRightArticle,
|
||||
createSight,
|
||||
clearCreateSight, // For resetting form
|
||||
clearCreateSight,
|
||||
updateRightArticles,
|
||||
} = createSightStore;
|
||||
const { language } = languageStore;
|
||||
@@ -78,7 +77,7 @@ export const CreateRightTab = observer(
|
||||
>(null);
|
||||
|
||||
const [previewMedia, setPreviewMedia] = useState<Media | null>(null);
|
||||
// Reset activeArticleIndex if language changes and index is out of bounds
|
||||
|
||||
useEffect(() => {
|
||||
if (sight.preview_media) {
|
||||
const fetchMedia = async () => {
|
||||
@@ -97,7 +96,7 @@ export const CreateRightTab = observer(
|
||||
activeArticleIndex >= sight[language].right.length
|
||||
) {
|
||||
setActiveArticleIndex(null);
|
||||
setType("media"); // Default back to media preview if selected article disappears
|
||||
setType("media");
|
||||
}
|
||||
}, [language, sight[language].right, activeArticleIndex]);
|
||||
|
||||
@@ -113,10 +112,9 @@ export const CreateRightTab = observer(
|
||||
try {
|
||||
await createSight(language);
|
||||
toast.success("Достопримечательность успешно создана!");
|
||||
clearCreateSight(); // Reset form
|
||||
clearCreateSight();
|
||||
setActiveArticleIndex(null);
|
||||
setType("media");
|
||||
// Potentially navigate away: history.push('/sights-list');
|
||||
} catch (error) {
|
||||
console.error("Failed to save sight:", error);
|
||||
toast.error("Ошибка при создании достопримечательности.");
|
||||
@@ -132,7 +130,7 @@ export const CreateRightTab = observer(
|
||||
handleCloseMenu();
|
||||
try {
|
||||
const newArticleId = await createNewRightArticle();
|
||||
// Automatically select the new article if ID is returned
|
||||
|
||||
const newIndex = sight[language].right.findIndex(
|
||||
(a) => a.id === newArticleId
|
||||
);
|
||||
@@ -140,7 +138,6 @@ export const CreateRightTab = observer(
|
||||
setActiveArticleIndex(newIndex);
|
||||
setType("article");
|
||||
} else {
|
||||
// Fallback if findIndex fails (should not happen if store updates correctly)
|
||||
setActiveArticleIndex(sight[language].right.length - 1);
|
||||
setType("article");
|
||||
}
|
||||
@@ -156,7 +153,7 @@ export const CreateRightTab = observer(
|
||||
const linkedArticleId = await linkExistingRightArticle(
|
||||
selectedArticleId
|
||||
);
|
||||
setSelectArticleDialogOpen(false); // Close dialog
|
||||
setSelectArticleDialogOpen(false);
|
||||
const newIndex = sight[language].right.findIndex(
|
||||
(a) => a.id === linkedArticleId
|
||||
);
|
||||
@@ -174,7 +171,6 @@ export const CreateRightTab = observer(
|
||||
? sight[language].right[activeArticleIndex]
|
||||
: null;
|
||||
|
||||
// Media Handling for Dialogs
|
||||
const handleOpenUploadMedia = () => {
|
||||
setUploadMediaOpen(true);
|
||||
};
|
||||
@@ -203,7 +199,6 @@ export const CreateRightTab = observer(
|
||||
};
|
||||
|
||||
const handleMediaUploaded = async (media: MediaItemShared) => {
|
||||
// After UploadMediaDialog finishes
|
||||
setUploadMediaOpen(false);
|
||||
setFileToUpload(null);
|
||||
if (mediaTarget === "sightPreview") {
|
||||
@@ -211,36 +206,25 @@ export const CreateRightTab = observer(
|
||||
} else if (mediaTarget === "rightArticle" && currentRightArticle) {
|
||||
await createLinkWithRightArticle(media, currentRightArticle.id);
|
||||
}
|
||||
setMediaTarget(null); // Reset target
|
||||
setMediaTarget(null);
|
||||
};
|
||||
|
||||
const handleDragEnd = (result: any) => {
|
||||
const { source, destination } = result;
|
||||
|
||||
// 1. Guard clause: If dropped outside any droppable area, do nothing.
|
||||
if (!destination) return;
|
||||
|
||||
// Extract source and destination indices
|
||||
const sourceIndex = source.index;
|
||||
const destinationIndex = destination.index;
|
||||
|
||||
// 2. Guard clause: If dropped in the same position, do nothing.
|
||||
if (sourceIndex === destinationIndex) return;
|
||||
|
||||
// 3. Create a new array with reordered articles:
|
||||
// - Create a shallow copy of the current articles array.
|
||||
// This is important for immutability and triggering re-renders.
|
||||
const newRightArticles = [...sight[language].right];
|
||||
|
||||
// - Remove the dragged article from its original position.
|
||||
// `splice` returns an array of removed items, so we destructure the first (and only) one.
|
||||
const [movedArticle] = newRightArticles.splice(sourceIndex, 1);
|
||||
|
||||
// - Insert the moved article into its new position.
|
||||
newRightArticles.splice(destinationIndex, 0, movedArticle);
|
||||
|
||||
// 4. Update the store with the new order:
|
||||
// This will typically trigger a re-render of the component with the updated list.
|
||||
updateRightArticles(newRightArticles);
|
||||
};
|
||||
|
||||
@@ -254,7 +238,7 @@ export const CreateRightTab = observer(
|
||||
height: "100%",
|
||||
minHeight: "calc(100vh - 200px)",
|
||||
gap: 2,
|
||||
paddingBottom: "70px", // Space for the save button
|
||||
paddingBottom: "70px",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
@@ -264,7 +248,6 @@ export const CreateRightTab = observer(
|
||||
</div>
|
||||
|
||||
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}>
|
||||
{/* Left Column: Navigation & Article List */}
|
||||
<Box className="flex flex-col w-[75%] gap-2">
|
||||
<Box className="w-full flex gap-2 ">
|
||||
<Box className="relative w-[20%] h-[70vh] flex flex-col rounded-2xl overflow-y-auto gap-3 border border-gray-300 p-3">
|
||||
@@ -272,7 +255,6 @@ export const CreateRightTab = observer(
|
||||
<Box
|
||||
onClick={() => {
|
||||
setType("media");
|
||||
// setActiveArticleIndex(null); // Optional: deselect article when switching to general media view
|
||||
}}
|
||||
className={`w-full p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-300 ${
|
||||
type === "media"
|
||||
@@ -364,7 +346,6 @@ export const CreateRightTab = observer(
|
||||
</Menu>
|
||||
</Box>
|
||||
|
||||
{/* Main content area: Article Editor or Sight Media Preview */}
|
||||
{type === "article" && currentRightArticle ? (
|
||||
<Box className="w-[80%] border border-gray-300 p-3 flex flex-col gap-2 overflow-hidden">
|
||||
<Box className="flex justify-end gap-2 mb-1 flex-shrink-0">
|
||||
@@ -375,7 +356,7 @@ export const CreateRightTab = observer(
|
||||
startIcon={<Unlink color="white" size={18} />}
|
||||
onClick={() => {
|
||||
if (currentRightArticle) {
|
||||
unlinkRightAritcle(currentRightArticle.id); // Corrected function name
|
||||
unlinkRightAritcle(currentRightArticle.id);
|
||||
setActiveArticleIndex(null);
|
||||
setType("media");
|
||||
}
|
||||
@@ -435,7 +416,7 @@ export const CreateRightTab = observer(
|
||||
/>
|
||||
</Box>
|
||||
<MediaArea
|
||||
articleId={currentRightArticle.id} // Needs a real ID
|
||||
articleId={currentRightArticle.id}
|
||||
mediaIds={currentRightArticle.media || []}
|
||||
onFilesDrop={(files) => {
|
||||
if (files.length > 0) {
|
||||
@@ -507,7 +488,6 @@ export const CreateRightTab = observer(
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Right Column: Live Preview */}
|
||||
<Box className="w-[25%] mr-10">
|
||||
{type === "article" && activeArticleIndex !== null && (
|
||||
<Paper
|
||||
@@ -662,12 +642,11 @@ export const CreateRightTab = observer(
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Sticky Save Button Footer */}
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: "-20px",
|
||||
left: 0, // ensure it spans from left
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: 2,
|
||||
backgroundColor: "background.paper",
|
||||
@@ -689,19 +668,17 @@ export const CreateRightTab = observer(
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Modals */}
|
||||
<SelectArticleModal
|
||||
open={selectArticleDialogOpen}
|
||||
onClose={() => setSelectArticleDialogOpen(false)}
|
||||
onSelectArticle={handleSelectExistingArticleAndLink}
|
||||
// Pass IDs of already linked/added right articles to exclude them from selection
|
||||
linkedArticleIds={sight[language].right.map((article) => article.id)}
|
||||
/>
|
||||
<UploadMediaDialog
|
||||
open={uploadMediaOpen} // From store
|
||||
open={uploadMediaOpen}
|
||||
onClose={() => {
|
||||
setUploadMediaOpen(false);
|
||||
setFileToUpload(null); // Clear file if dialog is closed without upload
|
||||
setFileToUpload(null);
|
||||
setMediaTarget(null);
|
||||
}}
|
||||
contextObjectName={sight[language].name}
|
||||
@@ -712,7 +689,7 @@ export const CreateRightTab = observer(
|
||||
? sight[language].right[activeArticleIndex].heading
|
||||
: undefined
|
||||
}
|
||||
afterUpload={handleMediaUploaded} // This will use the mediaTarget
|
||||
afterUpload={handleMediaUploaded}
|
||||
/>
|
||||
<SelectMediaDialog
|
||||
open={isSelectMediaDialogOpen}
|
||||
|
||||
@@ -37,8 +37,8 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
// Компонент предупреждающего окна (перенесен сюда)
|
||||
import { SaveWithoutCityAgree } from "@widgets";
|
||||
import { LinkedStations } from "@pages";
|
||||
|
||||
export const InformationTab = observer(
|
||||
({ value, index }: { value: number; index: number }) => {
|
||||
@@ -62,7 +62,7 @@ export const InformationTab = observer(
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
|
||||
>(null);
|
||||
const { cities } = cityStore;
|
||||
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
|
||||
|
||||
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
||||
|
||||
useEffect(() => {}, [hardcodeType]);
|
||||
@@ -119,16 +119,14 @@ export const InformationTab = observer(
|
||||
updateSightInfo(language, content, common);
|
||||
};
|
||||
|
||||
// НОВАЯ ФУНКЦИЯ: Фактическое сохранение (вызывается после подтверждения)
|
||||
const executeSave = async () => {
|
||||
await updateSight();
|
||||
toast.success("Достопримечательность сохранена");
|
||||
};
|
||||
|
||||
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или сохранения
|
||||
const handleSave = async () => {
|
||||
const isCityMissing = !sight.common.city_id;
|
||||
// Проверяем названия на всех языках
|
||||
|
||||
const isNameMissing = !sight.ru.name || !sight.en.name || !sight.zh.name;
|
||||
|
||||
if (isCityMissing || isNameMissing) {
|
||||
@@ -139,13 +137,11 @@ export const InformationTab = observer(
|
||||
await executeSave();
|
||||
};
|
||||
|
||||
// Обработчик "Да" в предупреждающем окне
|
||||
const handleConfirmSave = async () => {
|
||||
setIsSaveWarningOpen(false);
|
||||
await executeSave();
|
||||
};
|
||||
|
||||
// Обработчик "Нет" в предупреждающем окне
|
||||
const handleCancelSave = () => {
|
||||
setIsSaveWarningOpen(false);
|
||||
};
|
||||
@@ -275,6 +271,16 @@ export const InformationTab = observer(
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ width: "80%" }}>
|
||||
{sight.common.id !== 0 && (
|
||||
<LinkedStations
|
||||
parentId={sight.common.id}
|
||||
fields={[{ label: "Название", data: "name" }]}
|
||||
type="edit"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
@@ -431,7 +437,7 @@ export const InformationTab = observer(
|
||||
variant="contained"
|
||||
color="success"
|
||||
startIcon={<Save color="white" size={18} />}
|
||||
onClick={handleSave} // Используем новую функцию-обработчик
|
||||
onClick={handleSave}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
@@ -538,7 +544,6 @@ export const InformationTab = observer(
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
|
||||
{isSaveWarningOpen && (
|
||||
<SaveWithoutCityAgree
|
||||
blocker={{
|
||||
|
||||
@@ -316,31 +316,35 @@ export const LeftWidgetTab = observer(
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
<img
|
||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
sight.common.watermark_lu
|
||||
}/download?token=${token}`}
|
||||
alt="preview"
|
||||
className="absolute top-4 left-4 z-10"
|
||||
style={{
|
||||
width: "30px",
|
||||
height: "30px",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
{sight.common.watermark_lu && (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
sight.common.watermark_lu
|
||||
}/download?token=${token}`}
|
||||
alt="preview"
|
||||
className="absolute top-4 left-4 z-10"
|
||||
style={{
|
||||
width: "30px",
|
||||
height: "30px",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<img
|
||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
sight.common.watermark_rd
|
||||
}/download?token=${token}`}
|
||||
alt="preview"
|
||||
className="absolute bottom-4 right-4 z-10"
|
||||
style={{
|
||||
width: "30px",
|
||||
height: "30px",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
{sight.common.watermark_rd && (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
sight.common.watermark_rd
|
||||
}/download?token=${token}`}
|
||||
alt="preview"
|
||||
className="absolute bottom-4 right-4 z-10"
|
||||
style={{
|
||||
width: "30px",
|
||||
height: "30px",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<ImagePlus size={48} color="white" />
|
||||
|
||||
@@ -87,7 +87,6 @@ export const RightWidgetTab = observer(
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
console.log(sight[language].right);
|
||||
}, [sight.common.id]);
|
||||
|
||||
const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>(
|
||||
@@ -119,7 +118,7 @@ export const RightWidgetTab = observer(
|
||||
try {
|
||||
const newArticleId = await createNewRightArticle();
|
||||
handleClose();
|
||||
// Automatically select the newly created article
|
||||
|
||||
const newIndex = sight[language].right.findIndex(
|
||||
(article) => article.id === newArticleId
|
||||
);
|
||||
@@ -145,7 +144,7 @@ export const RightWidgetTab = observer(
|
||||
try {
|
||||
const linkedArticleId = await linkArticle(id);
|
||||
handleCloseSelectModal();
|
||||
// Automatically select the newly linked article
|
||||
|
||||
const newIndex = sight[language].right.findIndex(
|
||||
(article) => article.id === linkedArticleId
|
||||
);
|
||||
@@ -175,37 +174,22 @@ export const RightWidgetTab = observer(
|
||||
toast.success("Достопримечательность сохранена");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log(sight[language].right);
|
||||
}, [sight[language].right]);
|
||||
|
||||
const handleDragEnd = (result: DropResult) => {
|
||||
const { source, destination } = result;
|
||||
|
||||
// 1. Guard clause: If dropped outside any droppable area, do nothing.
|
||||
if (!destination) return;
|
||||
|
||||
// Extract source and destination indices
|
||||
const sourceIndex = source.index;
|
||||
const destinationIndex = destination.index;
|
||||
|
||||
// 2. Guard clause: If dropped in the same position, do nothing.
|
||||
if (sourceIndex === destinationIndex) return;
|
||||
|
||||
// 3. Create a new array with reordered articles:
|
||||
// - Create a shallow copy of the current articles array.
|
||||
// This is important for immutability and triggering re-renders.
|
||||
const newRightArticles = [...sight[language].right];
|
||||
|
||||
// - Remove the dragged article from its original position.
|
||||
// `splice` returns an array of removed items, so we destructure the first (and only) one.
|
||||
const [movedArticle] = newRightArticles.splice(sourceIndex, 1);
|
||||
|
||||
// - Insert the moved article into its new position.
|
||||
newRightArticles.splice(destinationIndex, 0, movedArticle);
|
||||
|
||||
// 4. Update the store with the new order:
|
||||
// This will typically trigger a re-render of the component with the updated list.
|
||||
updateRightArticles(newRightArticles);
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ interface VideoPreviewCardProps {
|
||||
onDeleteVideoClick: () => void;
|
||||
onSelectVideoClick: (file?: File) => void;
|
||||
tooltipText?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
|
||||
@@ -20,15 +21,15 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
|
||||
onDeleteVideoClick,
|
||||
onSelectVideoClick,
|
||||
tooltipText,
|
||||
className,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const token = localStorage.getItem("token");
|
||||
|
||||
useEffect(() => {}, [isDragOver]);
|
||||
// --- Click to select file ---
|
||||
|
||||
const handleZoneClick = () => {
|
||||
// Trigger the hidden file input click
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
@@ -38,19 +39,17 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.type.startsWith("video/")) {
|
||||
// Открываем диалог загрузки медиа с файлом видео
|
||||
onSelectVideoClick(file);
|
||||
} else {
|
||||
toast.error("Пожалуйста, выберите видео файл");
|
||||
}
|
||||
}
|
||||
// Reset the input value so selecting the same file again triggers change
|
||||
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
// --- Drag and Drop Handlers ---
|
||||
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault(); // Crucial to allow a drop
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragOver(true);
|
||||
};
|
||||
@@ -62,7 +61,7 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
|
||||
};
|
||||
|
||||
const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault(); // Crucial to allow a drop
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
@@ -70,7 +69,6 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
if (file.type.startsWith("video/")) {
|
||||
// Открываем диалог загрузки медиа с файлом видео
|
||||
onSelectVideoClick(file);
|
||||
} else {
|
||||
toast.error("Пожалуйста, выберите видео файл");
|
||||
@@ -89,7 +87,10 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
|
||||
gap: 1,
|
||||
flex: 1,
|
||||
minWidth: 150,
|
||||
width: "min-content",
|
||||
mx: "auto",
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ mb: 0, mr: 0.5 }}>
|
||||
@@ -127,7 +128,10 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
|
||||
</button>
|
||||
)}
|
||||
{videoId ? (
|
||||
<Box sx={{ position: "relative", width: "100%", height: "100%" }}>
|
||||
<Box
|
||||
sx={{ position: "relative", width: "100%", height: "100%" }}
|
||||
className={className}
|
||||
>
|
||||
<video
|
||||
src={`${
|
||||
import.meta.env.VITE_KRBL_MEDIA
|
||||
@@ -167,7 +171,7 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
|
||||
borderRadius: 1,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={handleZoneClick} // Click handler for the zone
|
||||
onClick={handleZoneClick}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
@@ -181,8 +185,8 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
|
||||
color="primary"
|
||||
startIcon={<Plus color="white" size={18} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent `handleZoneClick` from firing
|
||||
onSelectVideoClick(); // This button triggers the media selection dialog
|
||||
e.stopPropagation();
|
||||
onSelectVideoClick();
|
||||
}}
|
||||
>
|
||||
Выбрать файл
|
||||
@@ -193,7 +197,7 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileInputChange}
|
||||
style={{ display: "none" }}
|
||||
accept="video/*" // Accept only video files
|
||||
accept="video/*"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -43,8 +43,6 @@ export const EditStationModal = observer(
|
||||
} = routeStore;
|
||||
|
||||
const handleSave = async () => {
|
||||
console.log(routeId, selectedStationId);
|
||||
|
||||
await saveRouteStations(Number(routeId), selectedStationId);
|
||||
toast.success("Успешно сохранено");
|
||||
onClose();
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig, type UserConfigExport } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "path";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@shared": path.resolve(__dirname, "src/shared"),
|
||||
@@ -16,4 +18,9 @@ export default defineConfig({
|
||||
"@app": path.resolve(__dirname, "src/app"),
|
||||
},
|
||||
},
|
||||
|
||||
build: {
|
||||
chunkSizeWarningLimit: 5000,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||