Compare commits

...

23 Commits

Author SHA1 Message Date
2b48ade2f1 #13 Чистка, исправление кода
Reviewed-on: #15
Reviewed-by: Илья Куприец <kkzemeow@gmail.com>
2025-10-29 11:21:55 +00:00
b0fdf03cc6 fix: Fix icons in `top map sightbar 2025-10-29 14:19:00 +03:00
Микаэл Оганесян
349c7009c6 закрыл задачу 13 2025-10-29 02:32:14 +03:00
50ad374cf5 feat: Add selected city functional with some debugging 2025-10-22 03:04:58 +03:00
9e47ab667f feat: Update admin panel 2025-10-22 02:55:04 +03:00
1b8fc3d215 fix: fix upload bug 3d 2025-10-20 20:00:28 +03:00
f5142ec95d fix: Env 2025-10-13 14:34:56 +03:00
cdb96dfb8b fix: Fix errors 2025-10-10 08:40:39 +03:00
c50ccb3a0c fix: Add more filters by city for station list and sight list 2025-10-06 10:35:15 +03:00
4bcc2e2cca fix: Fix webp + delete ctrl z + filter by city 2025-10-06 10:03:41 +03:00
26e4d70b95 fix: Fix 3d models 2025-10-02 22:20:37 +03:00
a357994025 feat: Update pop-up logic 2025-10-02 04:45:43 +03:00
7382a85082 feat: Update city logic in map page with scrollbar 2025-10-02 04:38:15 +03:00
db64beb3ee feat: Select city in top of the page for next usage in create/edit pages 2025-09-28 10:41:13 +03:00
Микаэл Оганесян
1abd6b30a4 build fix 2025-09-27 22:31:14 -07:00
Микаэл Оганесян
b25df42960 hotfix admin panel 2025-09-27 22:29:13 -07:00
34ba3c1db0 Add Dockerfile and Makefile for containerization and build automation
- Created a Dockerfile with a multi-stage build process to containerize the application.
- Added Makefile for managing build, export, and cleanup tasks.
2025-07-29 17:39:21 +03:00
4f038551a2 fix: Fix problems and bugs 2025-07-28 08:18:21 +03:00
470a58a3fa fix: Fix panorama + route scale data 2025-07-26 11:48:41 +03:00
89d7fc2748 feat: Add scale on group click, add cache for map entities, fix map preview loading 2025-07-15 05:29:27 +03:00
97f95fc394 feat: Group map entities + delete useless logs 2025-07-13 20:56:25 +03:00
bf117ef048 feat: Add preview_video for sights 2025-07-13 20:26:45 +03:00
ced3067915 fix: Fix name on map and fix city name in sight list 2025-07-13 14:36:57 +03:00
96 changed files with 8092 additions and 4159 deletions

1
.env
View File

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

32
Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
# Stage 1: Build the application
FROM node:20-alpine AS build
# Set working directory
WORKDIR /app
# Copy package.json and yarn.lock
COPY package.json yarn.lock ./
# Install dependencies
RUN yarn install --frozen-lockfile
# Copy the rest of the application code
COPY . .
# Build the application
RUN yarn build
# Stage 2: Serve the application with Nginx
FROM nginx:alpine
# Copy the built application from the build stage
COPY --from=build /app/dist /usr/share/nginx/html
# Copy nginx configuration (optional, can be added later if needed)
# COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port 80
EXPOSE 80
# Start Nginx server
CMD ["nginx", "-g", "daemon off;"]

38
Makefile Normal file
View File

@@ -0,0 +1,38 @@
# Variables
IMAGE_NAME = white-nights-admin-panel
IMAGE_TAG = latest
FULL_IMAGE_NAME = $(IMAGE_NAME):$(IMAGE_TAG)
ARCHIVE_NAME = white-nights-admin-panel-image.zip
# Default target
.PHONY: help
help:
@echo "Available commands:"
@echo " make build-image - Build Docker image"
@echo " make export-image - Build Docker image and export it to a zip archive"
@echo " make clean - Remove Docker image and zip archive"
@echo " make help - Show this help message"
# Build Docker image
.PHONY: build-image
build-image:
@echo "Building Docker image: $(FULL_IMAGE_NAME)"
docker build -t $(FULL_IMAGE_NAME) .
# Export Docker image to zip archive
.PHONY: export-image
export-image: build-image
@echo "Exporting Docker image to $(ARCHIVE_NAME)"
docker save $(FULL_IMAGE_NAME) | gzip > $(ARCHIVE_NAME)
@echo "Image exported successfully to $(ARCHIVE_NAME)"
# Clean up
.PHONY: clean
clean:
@echo "Removing Docker image and zip archive"
-docker rmi $(FULL_IMAGE_NAME) 2>/dev/null || true
-rm -f $(ARCHIVE_NAME) 2>/dev/null || true
@echo "Clean up completed"
# Default target when no arguments provided
.DEFAULT_GOAL := help

View File

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

3415
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
"private": true,
"version": "0.0.0",
"type": "module",
"license": "UNLICENSED",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
@@ -23,6 +24,7 @@
"@tailwindcss/vite": "^4.1.8",
"axios": "^1.9.0",
"easymde": "^2.20.0",
"i18n-iso-countries": "^7.14.0",
"lucide-react": "^0.511.0",
"mobx": "^6.13.7",
"mobx-react-lite": "^4.1.0",
@@ -30,11 +32,10 @@
"path": "^0.12.7",
"pixi.js": "^8.10.1",
"react": "^19.1.0",
"react-colorful": "^5.6.1",
"react-dom": "^19.1.0",
"react-dropzone": "^14.3.8",
"react-markdown": "^10.1.0",
"react-photo-sphere-viewer": "^6.2.3",
"react-router": "^7.9.4",
"react-router-dom": "^7.6.1",
"react-simplemde-editor": "^5.2.0",
"react-toastify": "^11.0.5",
@@ -53,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"
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 750 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

3
public/favicon_ship.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.2 KiB

21
public/sight_icon.svg Normal file
View File

@@ -0,0 +1,21 @@
<svg width="53" height="49" viewBox="0 0 53 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 19.9296H52.7174V15.9968L26.3662 0L0 15.9968V19.9296ZM26.3659 3.75713L4.75331 16.8616H47.9636L26.3659 3.75713Z" fill="#A6A6A6"/>
<path d="M52.7174 45.4072H0V48.3587H52.7174V45.4072Z" fill="#A6A6A6"/>
<path d="M50.0742 41.4756H2.64355V44.427H50.0742V41.4756Z" fill="#A6A6A6"/>
<path d="M9.46312 21.6035H5.49805V39.0244H9.46312V21.6035Z" fill="#A6A6A6"/>
<path d="M11.4448 39.4316H3.51465V40.5827H11.4448V39.4316Z" fill="#A6A6A6"/>
<path d="M4.40104 20.6592C4.10066 20.6592 3.86035 20.8953 3.86035 21.1904C3.86035 21.4856 4.10066 21.7217 4.40104 21.7217C4.70143 21.7217 4.94173 21.4856 4.94173 21.1904H10.0182C10.0182 21.4856 10.2585 21.7217 10.5589 21.7217C10.8593 21.7217 11.0996 21.4856 11.0996 21.1904C11.0996 20.8953 10.8593 20.6592 10.5589 20.6592H4.40104Z" fill="#A6A6A6"/>
<path d="M22.0979 21.6035H18.1328V39.0244H22.0979V21.6035Z" fill="#A6A6A6"/>
<path d="M24.0815 39.4316H16.1514V40.5827H24.0815V39.4316Z" fill="#A6A6A6"/>
<path d="M17.0358 20.6592C16.7354 20.6592 16.4951 20.8953 16.4951 21.1904C16.4951 21.4856 16.7354 21.7217 17.0358 21.7217C17.3362 21.7217 17.5765 21.4856 17.5765 21.1904H22.653C22.653 21.4856 22.8933 21.7217 23.1937 21.7217C23.4941 21.7217 23.7344 21.4856 23.7344 21.1904C23.7344 20.8953 23.4941 20.6592 23.1937 20.6592H17.0358Z" fill="#A6A6A6"/>
<path d="M34.7414 21.6035H30.7764V39.0244H34.7414V21.6035Z" fill="#A6A6A6"/>
<path d="M36.7231 39.4316H28.793V40.5827H36.7231V39.4316Z" fill="#A6A6A6"/>
<path d="M29.6794 20.6592C29.379 20.6592 29.1387 20.8953 29.1387 21.1904C29.1387 21.4856 29.379 21.7217 29.6794 21.7217C29.9797 21.7217 30.2201 21.4856 30.2201 21.1904H35.2965C35.2965 21.4856 35.5369 21.7217 35.8372 21.7217C36.1376 21.7217 36.3779 21.4856 36.3779 21.1904C36.3779 20.8953 36.1376 20.6592 35.8372 20.6592H29.6794Z" fill="#A6A6A6"/>
<path d="M47.3762 21.6045H43.4111V39.0254H47.3762V21.6045Z" fill="#A6A6A6"/>
<path d="M49.3598 39.4316H41.4297V40.5827H49.3598V39.4316Z" fill="#A6A6A6"/>
<path d="M42.3141 20.6592C42.0137 20.6592 41.7734 20.8953 41.7734 21.1904C41.7734 21.4856 42.0137 21.7217 42.3141 21.7217C42.6145 21.7217 42.8548 21.4856 42.8548 21.1904H47.9313C47.9313 21.4856 48.1716 21.7217 48.472 21.7217C48.7724 21.7217 49.0127 21.4856 49.0127 21.1904C49.0127 20.8953 48.7724 20.6592 48.472 20.6592H42.3141Z" fill="#A6A6A6"/>
<path d="M26.8478 9.42308C26.8478 9.18696 26.6151 8.99512 26.3297 8.99512C26.0443 8.99512 25.8115 9.18696 25.8115 9.42308V9.76249C25.8115 10.0429 26.0443 10.2716 26.3297 10.2716C26.6151 10.2716 26.8478 10.0429 26.8478 9.76249V9.42308Z" fill="#A6A6A6"/>
<path d="M19.5098 11.6514C19.2695 11.6514 19.0742 11.8801 19.0742 12.1679C19.0742 12.4483 19.2695 12.6844 19.5098 12.6844H20.4109C20.6963 12.6844 20.9366 12.4556 20.9366 12.1679C20.9366 11.8875 20.7038 11.6514 20.4109 11.6514H19.5098Z" fill="#A6A6A6"/>
<path d="M32.2022 11.6514C31.9619 11.6514 31.7666 11.8801 31.7666 12.1679C31.7666 12.4483 31.9619 12.6844 32.2022 12.6844H33.1033C33.3887 12.6844 33.629 12.4556 33.629 12.1679C33.629 11.8875 33.3962 11.6514 33.1033 11.6514H32.2022Z" fill="#A6A6A6"/>
<path d="M27.6188 11.1644L26.973 10.7586C26.973 10.7586 26.8979 10.7217 26.8528 10.7217H25.8015C25.7564 10.7217 25.7189 10.7364 25.6813 10.7586L25.0355 11.1644C24.9679 11.2087 24.9304 11.2751 24.9304 11.3489V11.8211C24.9304 11.8654 24.9454 11.9096 24.9679 11.9465L25.5311 12.7656C25.5762 12.832 25.5837 12.9057 25.5537 12.9795L24.9454 14.3372C24.9454 14.3372 24.9229 14.3962 24.9229 14.4257V15.776C24.9229 15.9015 25.0205 15.9974 25.1481 15.9974H27.4911C27.6188 15.9974 27.7164 15.9015 27.7164 15.776V14.4257C27.7164 14.4257 27.7164 14.3667 27.6939 14.3372L27.0856 12.9795C27.0556 12.9131 27.0631 12.832 27.1081 12.7656L27.6714 11.9465C27.6714 11.9465 27.7089 11.8654 27.7089 11.8211V11.3489C27.7089 11.2751 27.6714 11.2013 27.6038 11.1644H27.6188Z" fill="#A6A6A6"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,158 @@
import React, { Component, ReactNode } from "react";
import { Box, Button, Typography, Paper, Container } from "@mui/material";
import { RefreshCw, Home, AlertTriangle } from "lucide-react";
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class GlobalErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
};
}
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error,
};
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("❌ GlobalErrorBoundary: Критическая ошибка приложения", {
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
});
}
handleReset = () => {
this.setState({
hasError: false,
error: null,
});
window.location.reload();
};
handleGoHome = () => {
this.setState({
hasError: false,
error: null,
});
window.location.href = "/";
};
render() {
if (this.state.hasError) {
return (
<Box
sx={{
minHeight: "100vh",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: "background.default",
p: 2,
}}
>
<Container maxWidth="sm">
<Paper
elevation={3}
sx={{
p: 4,
textAlign: "center",
}}
>
<Box
sx={{
display: "flex",
justifyContent: "center",
mb: 3,
}}
>
<AlertTriangle size={64} color="#f44336" />
</Box>
<Typography variant="h4" component="h1" gutterBottom>
Упс! Что-то пошло не так
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Приложение столкнулось с неожиданной ошибкой. Попробуйте
перезагрузить страницу или вернуться на главную.
</Typography>
{this.state.error?.message && (
<Paper
variant="outlined"
sx={{
p: 2,
mb: 3,
backgroundColor: "error.light",
color: "error.contrastText",
textAlign: "left",
}}
>
<Typography
variant="caption"
sx={{ fontWeight: "bold", display: "block", mb: 1 }}
>
Информация об ошибке:
</Typography>
<Typography
variant="caption"
sx={{
fontFamily: "monospace",
fontSize: "0.75rem",
wordBreak: "break-word",
display: "block",
}}
>
{this.state.error.message}
</Typography>
</Paper>
)}
<Box
sx={{
display: "flex",
gap: 2,
justifyContent: "center",
flexWrap: "wrap",
}}
>
<Button
variant="outlined"
startIcon={<Home size={16} />}
onClick={this.handleGoHome}
size="large"
>
На главную
</Button>
<Button
variant="contained"
startIcon={<RefreshCw size={16} />}
onClick={this.handleReset}
size="large"
>
Перезагрузить
</Button>
</Box>
</Paper>
</Container>
</Box>
);
}
return this.props.children;
}
}

View File

@@ -4,10 +4,13 @@ import { Router } from "./router";
import { CustomTheme } from "@shared";
import { ThemeProvider } from "@mui/material/styles";
import { ToastContainer } from "react-toastify";
import { GlobalErrorBoundary } from "./GlobalErrorBoundary";
export const App: React.FC = () => (
<ThemeProvider theme={CustomTheme.Light}>
<ToastContainer />
<Router />
</ThemeProvider>
<GlobalErrorBoundary>
<ThemeProvider theme={CustomTheme.Light}>
<ToastContainer />
<Router />
</ThemeProvider>
</GlobalErrorBoundary>
);

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

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

View File

@@ -10,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}

View File

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

View File

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

View File

@@ -46,6 +46,7 @@ export const ArticleListPage = observer(() => {
{
field: "actions",
headerName: "Действия",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (

View File

@@ -12,7 +12,13 @@ import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { carrierStore, cityStore, mediaStore, languageStore } from "@shared";
import {
carrierStore,
cityStore,
mediaStore,
languageStore,
useSelectedCity,
} from "@shared";
import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
import {
@@ -25,6 +31,7 @@ export const CarrierCreatePage = observer(() => {
const navigate = useNavigate();
const { createCarrierData, setCreateCarrierData } = carrierStore;
const { language } = languageStore;
const { selectedCityId } = useSelectedCity();
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
@@ -41,6 +48,20 @@ export const CarrierCreatePage = observer(() => {
languageStore.setLanguage("ru");
}, []);
// Автоматически устанавливаем выбранный город при загрузке страницы
useEffect(() => {
if (selectedCityId && !createCarrierData.city_id) {
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
selectedCityId,
createCarrierData[language].slogan,
selectedMediaId || "",
language
);
}
}, [selectedCityId, createCarrierData.city_id]);
const handleCreate = async () => {
try {
setIsLoading(true);

View File

@@ -88,6 +88,7 @@ export const CarrierListPage = observer(() => {
headerName: "Действия",
headerAlign: "center",
width: 200,
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (

View File

@@ -50,7 +50,6 @@ export const CityListPage = observer(() => {
}
setRows(newRows2 || []);
console.log(newRows2);
}, [cities, countryStore.countries, language, isLoading]);
const columns: GridColDef[] = [
@@ -94,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">

View File

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

View File

@@ -52,7 +52,12 @@ export const LoginPage = () => {
}
navigate("/map");
await getUsers();
try {
await getUsers();
} catch (err) {
console.error(err);
}
toast.success("Вход в систему выполнен успешно");
} catch (err) {
setError(

File diff suppressed because it is too large Load Diff

View File

@@ -45,10 +45,10 @@ export const MediaEditPage = observer(() => {
if (id) {
mediaStore.getOneMedia(id);
}
console.log(newFile);
console.log(uploadDialogOpen);
}, [id]);
useEffect(() => {}, [newFile, uploadDialogOpen]);
useEffect(() => {
if (media) {
setMediaName(media.media_name);
@@ -60,7 +60,11 @@ export const MediaEditPage = observer(() => {
if (extension) {
if (["glb", "gltf"].includes(extension)) {
setAvailableMediaTypes([6]); // 3D model
} else if (["jpg", "jpeg", "png", "gif"].includes(extension)) {
} else if (
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
extension
)
) {
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
} else if (["mp4", "webm", "mov"].includes(extension)) {
setAvailableMediaTypes([2]); // Video
@@ -109,7 +113,11 @@ export const MediaEditPage = observer(() => {
if (["glb", "gltf"].includes(extension)) {
setAvailableMediaTypes([6]); // 3D model
setMediaType(6);
} else if (["jpg", "jpeg", "png", "gif"].includes(extension)) {
} else if (
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
extension
)
) {
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
setMediaType(1); // Default to Photo
} else if (["mp4", "webm", "mov"].includes(extension)) {

View File

@@ -69,6 +69,7 @@ export const MediaListPage = observer(() => {
width: 200,
align: "center",
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (

View File

@@ -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,7 +34,12 @@ 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)
@@ -73,7 +79,6 @@ type LinkedItemsProps<T> = {
disableCreation?: boolean;
updatedLinkedItems?: T[];
refresh?: number;
cityId?: number;
routeDirection?: boolean;
};
@@ -112,7 +117,7 @@ export const LinkedItems = <
);
};
export const LinkedItemsContents = <
const LinkedItemsContentsInner = <
T extends { id: number; name: string; [key: string]: any }
>({
parentId,
@@ -124,7 +129,6 @@ export const LinkedItemsContents = <
disableCreation = false,
updatedLinkedItems,
refresh,
cityId,
routeDirection,
}: LinkedItemsProps<T>) => {
const { language } = languageStore;
@@ -140,9 +144,7 @@ export const LinkedItemsContents = <
const [activeTab, setActiveTab] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
console.log(error);
}, [error]);
useEffect(() => {}, [error]);
const parentResource = "route";
const childResource = "station";
@@ -155,17 +157,20 @@ export const LinkedItemsContents = <
// Фильтруем станции по направлению маршрута
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(() => {
@@ -227,7 +232,7 @@ export const LinkedItemsContents = <
if (type === "edit") {
setError(null);
authInstance
.get(`/${childResource}/`)
.get(`/${childResource}`)
.then((response) => {
setAllItems(response?.data || []);
})
@@ -462,9 +467,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
@@ -599,3 +602,7 @@ export const LinkedItemsContents = <
</>
);
};
export const LinkedItemsContents = observer(
LinkedItemsContentsInner
) as typeof LinkedItemsContentsInner;

View File

@@ -16,13 +16,18 @@ import {
import { MediaViewer } 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,
SelectArticleModal,
SelectMediaDialog,
selectedCityStore,
} from "@shared";
export const RouteCreatePage = observer(() => {
const navigate = useNavigate();
@@ -50,6 +55,21 @@ 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");
@@ -194,16 +214,10 @@ export const RouteCreatePage = observer(() => {
value={carrier}
label="Выберите перевозчика"
onChange={(e) => setCarrier(e.target.value as string)}
disabled={
carrierStore.carriers[
language as keyof typeof carrierStore.carriers
].data?.length === 0
}
disabled={filteredCarriers.length === 0}
>
<MenuItem value="">Не выбрано</MenuItem>
{carrierStore.carriers[
language as keyof typeof carrierStore.carriers
].data?.map((carrier) => (
{filteredCarriers.map((carrier: any) => (
<MenuItem key={carrier.id} value={carrier.id}>
{carrier.full_name}
</MenuItem>
@@ -301,10 +315,10 @@ export const RouteCreatePage = observer(() => {
</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

View File

@@ -307,10 +307,10 @@ export const RouteEditPage = observer(() => {
</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

View File

@@ -90,6 +90,7 @@ export const RouteListPage = observer(() => {
width: 250,
align: "center",
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">

View File

@@ -1,7 +1,7 @@
export const UP_SCALE = 30000;
export const PATH_WIDTH = 15;
export const STATION_RADIUS = 20;
export const STATION_OUTLINE_WIDTH = 10;
export const UP_SCALE = 10000;
export const PATH_WIDTH = 5;
export const STATION_RADIUS = 8;
export const STATION_OUTLINE_WIDTH = 4;
export const SIGHT_SIZE = 40;
export const SCALE_FACTOR = 50;

View File

@@ -54,16 +54,16 @@ export function InfiniteCanvas({
const lastOriginalRotation = useRef<number | undefined>(undefined);
useEffect(() => {
const canvas = applicationRef?.app?.canvas;
if (!canvas) return;
if (!applicationRef?.app?.canvas) return;
const canvas = applicationRef.app.canvas;
const canvasRect = canvas.getBoundingClientRect();
const canvasLeft = canvasRect.left;
const canvasTop = canvasRect.top;
const centerX = window.innerWidth / 2 - canvasLeft;
const centerY = window.innerHeight / 2 - canvasTop;
setScreenCenter({ x: centerX, y: centerY });
}, [applicationRef?.app?.canvas, setScreenCenter]);
}, [applicationRef?.app, setScreenCenter]);
const handlePointerDown = (e: FederatedMouseEvent) => {
setIsPointerDown(true);

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useTransform } from "./TransformContext";
import { coordinatesToLocal, localToCoordinates } from "./utils";
import { SCALE_FACTOR } from "./Constants";
import { toast } from "react-toastify";
export function RightSidebar() {
const {
@@ -100,7 +101,6 @@ export function RightSidebar() {
}
if (!routeData) {
console.error("routeData is null");
return null;
}
@@ -360,8 +360,14 @@ export function RightSidebar() {
variant="contained"
color="secondary"
sx={{ mt: 2 }}
onClick={() => {
saveChanges();
onClick={async () => {
try {
await saveChanges();
toast.success("Изменения сохранены");
} catch (error) {
console.error(error);
toast.error("Ошибка при сохранении изменений");
}
}}
>
Сохранить изменения

View File

@@ -79,14 +79,10 @@ 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(() => {
console.log(
`Rendering Sight ${id + 1} at [${sight.latitude}, ${sight.longitude}]`
);
}, [id, sight.latitude, sight.longitude]);
useEffect(() => {}, [id, sight.latitude, sight.longitude]);
if (!sight) {
console.error("sight is null");

View File

@@ -57,8 +57,8 @@ export function Widgets() {
mb: 1,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<Landmark size={16} />
<Box sx={{ display: "flex", gap: 0.5 }}>
<Landmark size={16} className="shrink-0" />
<Typography
variant="subtitle2"
sx={{ color: "#fff", fontWeight: "bold" }}

View File

@@ -26,6 +26,7 @@ import { Sight } from "./Sight";
import { SightData } from "./types";
import { Station } from "./Station";
import { UP_SCALE } from "./Constants";
import CircularProgress from "@mui/material/CircularProgress";
extend({
Container,
@@ -36,13 +37,27 @@ extend({
Text,
});
const Loading = () => {
const { isRouteLoading, isStationLoading, isSightLoading } = useMapData();
if (isRouteLoading || isStationLoading || isSightLoading) {
return (
<div className="fixed flex z-1 items-center justify-center h-screen w-screen bg-[#111]">
<CircularProgress />
</div>
);
}
return null;
};
export const RoutePreview = () => {
const { routeData, stationData, sightData } = useMapData();
return (
<MapDataProvider>
<TransformProvider>
<Stack direction="row" height="100vh" width="100vw" overflow="hidden">
<LanguageSwitcher />
{routeData && stationData && sightData ? <LanguageSwitcher /> : null}
<Loading />
<LeftSidebar />
<Stack direction="row" flex={1} position="relative" height="100%">
<RouteMap />
@@ -145,13 +160,13 @@ export const RouteMap = observer(() => {
]);
if (!routeData || !stationData || !sightData) {
console.error("routeData, stationData or sightData is null");
return <div>Loading...</div>;
return null;
}
return (
<div style={{ width: "100%", height: "100%" }} ref={parentRef}>
<Application resizeTo={parentRef} background="#fff">
<LanguageSwitcher />
<Application resizeTo={parentRef} background="#fff" preference="webgl">
<InfiniteCanvas>
<TravelPath points={points} />
{stationData[language].map((obj, index) => (

View File

@@ -1,7 +1,12 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { 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";
@@ -10,6 +15,7 @@ import { Box, CircularProgress } from "@mui/material";
export const SightListPage = observer(() => {
const { sights, getSights, deleteListSight } = sightsStore;
const { cities, getCities } = cityStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
@@ -21,7 +27,9 @@ export const SightListPage = observer(() => {
useEffect(() => {
const fetchSights = async () => {
setIsLoading(true);
await getCities(language);
await getSights();
setIsLoading(false);
};
fetchSights();
@@ -45,14 +53,14 @@ export const SightListPage = observer(() => {
},
},
{
field: "city",
field: "city_id",
headerName: "Город",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
cities[language].data.find((el) => el.id == params.value)?.name
) : (
<Minus size={20} className="text-red-500" />
)}
@@ -65,6 +73,7 @@ export const SightListPage = observer(() => {
headerName: "Действия",
align: "center",
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
@@ -89,10 +98,19 @@ export const SightListPage = observer(() => {
},
];
const rows = sights.map((sight) => ({
// Фильтрация достопримечательностей по выбранному городу
const filteredSights = useMemo(() => {
const { selectedCityId } = selectedCityStore;
if (!selectedCityId) {
return sights;
}
return sights.filter((sight: any) => sight.city_id === selectedCityId);
}, [sights, selectedCityStore.selectedCityId]);
const rows = filteredSights.map((sight) => ({
id: sight.id,
name: sight.name,
city: sight.city,
city_id: sight.city_id,
}));
return (
@@ -129,7 +147,6 @@ export const SightListPage = observer(() => {
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => {
console.log(newSelection);
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{

View File

@@ -1,69 +1,68 @@
import { Button, Paper, TextField } from "@mui/material";
import { Button, TextField } from "@mui/material";
import { snapshotStore } from "@shared";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
export const SnapshotCreatePage = observer(() => {
const { id } = useParams();
const { getSnapshot, createSnapshot } = snapshotStore;
const { createSnapshot } = snapshotStore;
const navigate = useNavigate();
const [name, setName] = useState("");
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
(async () => {
await getSnapshot(id as string);
})();
}, [id]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<h1 className="text-2xl font-bold">Создание снапшота</h1>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
className="w-full"
label="Название"
required
value={name}
onChange={(e) => setName(e.target.value)}
/>
<div className="w-full h-[400px] flex justify-center items-center">
<div className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<h1 className="text-2xl font-bold">Создание снапшота</h1>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
className="w-full"
label="Название"
required
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={async () => {
try {
setIsLoading(true);
await createSnapshot(name);
setIsLoading(false);
toast.success("Снапшот успешно создан");
} catch (error) {
console.error(error);
}
}}
disabled={isLoading}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Сохранить"
)}
</Button>
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={async () => {
try {
setIsLoading(true);
await createSnapshot(name);
setIsLoading(false);
toast.success("Снапшот успешно создан");
navigate(-1);
} catch (error) {
console.error(error);
toast.error("Ошибка при создании снапшота");
} finally {
setIsLoading(false);
}
}}
disabled={isLoading || !name.trim()}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Сохранить"
)}
</Button>
</div>
</div>
</Paper>
</div>
);
});

View File

@@ -43,6 +43,7 @@ export const SnapshotListPage = observer(() => {
headerName: "Действия",
width: 300,
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
@@ -117,12 +118,15 @@ export const SnapshotListPage = observer(() => {
<SnapshotRestore
open={isRestoreModalOpen}
loading={isLoading}
onDelete={async () => {
setIsLoading(true);
if (rowId) {
await restoreSnapshot(rowId);
}
setIsRestoreModalOpen(false);
setRowId(null);
setIsLoading(false);
}}
onCancel={() => {
setIsRestoreModalOpen(false);

View File

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

View File

@@ -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,
@@ -93,15 +94,21 @@ export const LinkedSightsContents = <
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
console.log(error);
}, [error]);
useEffect(() => {}, [error]);
const parentResource = "station";
const childResource = "sight";
const availableItems = allItems
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
.filter((item) => {
// Фильтруем по городу из навбара
const selectedCityId = selectedCityStore.selectedCityId;
if (selectedCityId && "city_id" in item) {
return item.city_id === selectedCityId;
}
return true;
})
.sort((a, b) => a.name.localeCompare(b.name));
useEffect(() => {
@@ -178,7 +185,7 @@ export const LinkedSightsContents = <
if (type === "edit") {
setError(null);
authInstance
.get(`/${childResource}/`)
.get(`/${childResource}`)
.then((response) => {
setAllItems(response?.data || []);
})
@@ -315,3 +322,7 @@ export const LinkedSightsContents = <
</>
);
};
export const LinkedSightsContents = observer(
LinkedSightsContentsInner
) as typeof LinkedSightsContentsInner;

View File

@@ -12,9 +12,15 @@ import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { stationsStore, languageStore, cityStore } from "@shared";
import {
stationsStore,
languageStore,
cityStore,
useSelectedCity,
} from "@shared";
import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets";
import { SaveWithoutCityAgree } from "@widgets";
export const StationCreatePage = observer(() => {
const navigate = useNavigate();
@@ -27,7 +33,10 @@ export const StationCreatePage = observer(() => {
setLanguageCreateStationData,
} = stationsStore;
const { cities, getCities } = cityStore;
const { selectedCityId, selectedCity } = useSelectedCity();
const [coordinates, setCoordinates] = useState<string>("");
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => {
if (
@@ -40,7 +49,8 @@ export const StationCreatePage = observer(() => {
}
}, [createStationData.common.latitude, createStationData.common.longitude]);
const handleCreate = async () => {
// НОВАЯ ФУНКЦИЯ: Фактическое создание (вызывается после подтверждения)
const executeCreate = async () => {
try {
setIsLoading(true);
await createStation();
@@ -54,6 +64,31 @@ export const StationCreatePage = observer(() => {
}
};
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или создания
const handleCreate = async () => {
const isCityMissing = !createStationData.common.city_id;
// Проверяем названия на всех языках
const isNameMissing = !createStationData.ru.name || !createStationData.en.name || !createStationData.zh.name;
if (isCityMissing || isNameMissing) {
setIsSaveWarningOpen(true);
return;
}
await executeCreate();
};
// Обработчик "Да" в предупреждающем окне
const handleConfirmCreate = async () => {
setIsSaveWarningOpen(false);
await executeCreate();
};
// Обработчик "Нет" в предупреждающем окне
const handleCancelCreate = () => {
setIsSaveWarningOpen(false);
};
useEffect(() => {
const fetchCities = async () => {
await getCities("ru");
@@ -64,6 +99,16 @@ export const StationCreatePage = observer(() => {
fetchCities();
}, []);
// Автоматически устанавливаем выбранный город при загрузке страницы
useEffect(() => {
if (selectedCityId && selectedCity && !createStationData.common.city_id) {
setCreateCommonData({
city_id: selectedCityId,
city: selectedCity.name,
});
}
}, [selectedCityId, selectedCity, createStationData.common.city_id]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />
@@ -192,7 +237,7 @@ export const StationCreatePage = observer(() => {
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={isLoading || !createStationData[language]?.name}
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleCreate
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
@@ -201,6 +246,16 @@ export const StationCreatePage = observer(() => {
)}
</Button>
</div>
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
{isSaveWarningOpen && (
<SaveWithoutCityAgree
blocker={{
proceed: handleConfirmCreate,
reset: handleCancelCreate,
}}
/>
)}
</Paper>
);
});

View File

@@ -16,6 +16,7 @@ import { stationsStore, languageStore, cityStore } from "@shared";
import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets";
import { LinkedSights } from "../LinkedSights";
import { SaveWithoutCityAgree } from "@widgets";
export const StationEditPage = observer(() => {
const navigate = useNavigate();
@@ -31,9 +32,10 @@ export const StationEditPage = observer(() => {
} = stationsStore;
const { cities, getCities } = cityStore;
const [coordinates, setCoordinates] = useState<string>("");
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
@@ -48,7 +50,8 @@ export const StationEditPage = observer(() => {
}
}, [editStationData.common.latitude, editStationData.common.longitude]);
const handleEdit = async () => {
// НОВАЯ ФУНКЦИЯ: Фактическое редактирование (вызывается после подтверждения)
const executeEdit = async () => {
try {
setIsLoading(true);
await editStation(Number(id));
@@ -61,6 +64,34 @@ export const StationEditPage = observer(() => {
}
};
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или редактирования
const handleEdit = async () => {
const isCityMissing = !editStationData.common.city_id;
// Проверяем названия на всех языках
const isNameMissing =
!editStationData.ru.name ||
!editStationData.en.name ||
!editStationData.zh.name;
if (isCityMissing || isNameMissing) {
setIsSaveWarningOpen(true);
return;
}
await executeEdit();
};
// Обработчик "Да" в предупреждающем окне
const handleConfirmEdit = async () => {
setIsSaveWarningOpen(false);
await executeEdit();
};
// Обработчик "Нет" в предупреждающем окне
const handleCancelEdit = () => {
setIsSaveWarningOpen(false);
};
useEffect(() => {
const fetchAndSetStationData = async () => {
if (!id) return;
@@ -78,6 +109,7 @@ export const StationEditPage = observer(() => {
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
@@ -211,7 +243,7 @@ export const StationEditPage = observer(() => {
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleEdit}
disabled={isLoading || !editStationData[language]?.name}
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleEdit
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
@@ -220,6 +252,16 @@ export const StationEditPage = observer(() => {
)}
</Button>
</div>
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
{isSaveWarningOpen && (
<SaveWithoutCityAgree
blocker={{
proceed: handleConfirmEdit,
reset: handleCancelEdit,
}}
/>
)}
</Paper>
);
});

View File

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

View File

@@ -83,6 +83,7 @@ export const UserListPage = observer(() => {
flex: 1,
align: "center",
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (

View File

@@ -102,6 +102,7 @@ export const VehicleListPage = observer(() => {
width: 200,
align: "center",
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -16,7 +16,8 @@ import {
Cpu,
// BookImage,
} from "lucide-react";
import { CarrierSvg } from "./CarrierSvg";
import carrierIcon from "./carrier.svg";
export const DRAWER_WIDTH = 300;
@@ -25,6 +26,7 @@ interface NavigationItem {
label: string;
icon?: LucideIcon | React.ReactNode;
path?: string;
for_admin?: boolean;
onClick?: () => void;
nestedItems?: NavigationItem[];
isActive?: boolean;
@@ -40,6 +42,7 @@ export const NAVIGATION_ITEMS: {
label: "Снапшоты",
icon: GitBranch,
path: "/snapshot",
for_admin: true,
},
{
id: "map",
@@ -52,6 +55,7 @@ export const NAVIGATION_ITEMS: {
label: "Устройства",
icon: Cpu,
path: "/devices",
for_admin: true,
},
// {
// id: "vehicles",
@@ -64,6 +68,7 @@ export const NAVIGATION_ITEMS: {
label: "Пользователи",
icon: Users,
path: "/user",
for_admin: true,
},
{
id: "all",
@@ -106,19 +111,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,
},
],
},

View File

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

View File

@@ -0,0 +1,85 @@
// Допустимые типы и расширения файлов для медиа
export const ALLOWED_MEDIA_TYPES = {
image: {
extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"],
mimeTypes: [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/bmp",
"image/svg+xml",
],
accept: "image/*",
},
video: {
extensions: [".mp4", ".webm", ".ogg", ".mov", ".avi"],
mimeTypes: [
"video/mp4",
"video/webm",
"video/ogg",
"video/quicktime",
"video/x-msvideo",
],
accept: "video/*",
},
model3d: {
extensions: [".glb", ".gltf"],
mimeTypes: ["model/gltf-binary", "model/gltf+json"],
accept: ".glb,.gltf",
},
panorama: {
extensions: [".jpg", ".jpeg", ".png"],
mimeTypes: ["image/jpeg", "image/png"],
accept: "image/*",
},
} as const;
export const getAllAllowedExtensions = (): string[] => {
return [
...ALLOWED_MEDIA_TYPES.image.extensions,
...ALLOWED_MEDIA_TYPES.video.extensions,
...ALLOWED_MEDIA_TYPES.model3d.extensions,
];
};
export const getAllAcceptString = (): string => {
return `${ALLOWED_MEDIA_TYPES.image.accept},${ALLOWED_MEDIA_TYPES.video.accept},${ALLOWED_MEDIA_TYPES.model3d.accept}`;
};
export const validateFileExtension = (
file: File
): { valid: boolean; error?: string } => {
const fileName = file.name.toLowerCase();
const extension = fileName.substring(fileName.lastIndexOf("."));
const allowedExtensions = getAllAllowedExtensions();
if (!allowedExtensions.includes(extension)) {
return {
valid: false,
error: `Неподдерживаемый формат файла "${extension}". Допустимые форматы: ${allowedExtensions.join(
", "
)}`,
};
}
return { valid: true };
};
export const filterValidFiles = (
files: File[]
): { validFiles: File[]; errors: string[] } => {
const validFiles: File[] = [];
const errors: string[] = [];
files.forEach((file) => {
const validation = validateFileExtension(file);
if (validation.valid) {
validFiles.push(file);
} else {
errors.push(`${file.name}: ${validation.error}`);
}
});
return { validFiles, errors };
};

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,82 @@
/**
* Утилита для управления кешем GLTF и blob URL
*/
// Динамический импорт useGLTF для избежания проблем с SSR
let useGLTF: any = null;
const initializeUseGLTF = async () => {
if (!useGLTF) {
try {
const drei = await import("@react-three/drei");
useGLTF = drei.useGLTF;
} catch (error) {
console.warn(
"⚠️ GLTFCacheManager: Не удалось импортировать useGLTF",
error
);
}
}
return useGLTF;
};
/**
* Очищает кеш GLTF для конкретного URL
*/
export const clearGLTFCacheForUrl = async (url: string) => {
try {
const gltf = await initializeUseGLTF();
if (gltf && gltf.clear) {
gltf.clear(url);
}
} catch (error) {}
};
/**
* Очищает весь кеш GLTF
*/
export const clearAllGLTFCache = async () => {
try {
const gltf = await initializeUseGLTF();
if (gltf && gltf.clear) {
gltf.clear();
}
} catch (error) {}
};
/**
* Очищает blob URL из памяти браузера
*/
export const revokeBlobURL = (url: string) => {
if (url && url.startsWith("blob:")) {
try {
URL.revokeObjectURL(url);
} catch (error) {}
}
};
/**
* Комплексная очистка: blob URL + кеш GLTF
*/
export const clearBlobAndGLTFCache = async (url: string) => {
// Сначала отзываем blob URL
revokeBlobURL(url);
// Затем очищаем кеш GLTF
await clearGLTFCacheForUrl(url);
};
/**
* Очистка при смене медиа (для предотвращения конфликтов)
*/
export const clearMediaTransitionCache = async (
previousMediaId: string | number | null,
newMediaId: string | number | null,
newMediaType?: number
) => {
console.log(newMediaId, newMediaType);
// Если переключаемся с/на 3D модель, очищаем весь кеш
if (newMediaType === 6 || previousMediaId) {
await clearAllGLTFCache();
}
};

View File

@@ -1,5 +1,6 @@
export * from "./mui/theme";
export * from "./DecodeJWT";
export * from "./gltfCacheManager";
/**
* Генерирует название медиа по умолчанию в разных форматах

View 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,
@@ -36,7 +37,13 @@ interface UploadMediaDialogProps {
media_type: number;
}) => void;
afterUploadSight?: (id: string) => void;
hardcodeType?: "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null;
hardcodeType?:
| "thumbnail"
| "watermark_lu"
| "watermark_rd"
| "image"
| "video_preview"
| null;
contextObjectName?: string;
contextType?:
| "sight"
@@ -47,6 +54,7 @@ interface UploadMediaDialogProps {
| "station";
isArticle?: boolean;
articleName?: string;
initialFile?: File; // <--- добавлено
}
export const UploadMediaDialog = observer(
@@ -60,6 +68,7 @@ export const UploadMediaDialog = observer(
isArticle,
articleName,
initialFile, // <--- добавлено
}: UploadMediaDialogProps) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -73,6 +82,41 @@ export const UploadMediaDialog = observer(
const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>(
[]
);
const [isPreviewLoaded, setIsPreviewLoaded] = useState(false);
const previousMediaUrlRef = useRef<string | null>(null);
useEffect(() => {
if (initialFile) {
// Очищаем предыдущий blob URL если он существует
if (
previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:")
) {
clearBlobAndGLTFCache(previousMediaUrlRef.current);
}
setMediaFile(initialFile);
setMediaFilename(initialFile.name);
setAvailableMediaTypes([2]);
setMediaType(2);
const newBlobUrl = URL.createObjectURL(initialFile);
setMediaUrl(newBlobUrl);
previousMediaUrlRef.current = newBlobUrl;
setMediaName(initialFile.name.replace(/\.[^/.]+$/, ""));
}
}, [initialFile]);
// Очистка blob URL при размонтировании компонента
useEffect(() => {
return () => {
if (
previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:")
) {
clearBlobAndGLTFCache(previousMediaUrlRef.current);
}
};
}, []); // Пустой массив зависимостей - выполняется только при размонтировании
useEffect(() => {
if (fileToUpload) {
@@ -85,7 +129,11 @@ export const UploadMediaDialog = observer(
setAvailableMediaTypes([6]);
setMediaType(6);
}
if (["jpg", "jpeg", "png", "gif", "svg"].includes(extension)) {
if (
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
extension
)
) {
// Для изображений доступны все типы кроме видео
setAvailableMediaTypes([1, 3, 4, 5]); // Фото, Иконка, Водяной знак, Панорама, 3Д-модель
setMediaType(1); // По умолчанию Фото
@@ -187,9 +235,20 @@ export const UploadMediaDialog = observer(
useEffect(() => {
if (mediaFile) {
setMediaUrl(URL.createObjectURL(mediaFile as Blob));
// Очищаем предыдущий blob URL и кеш GLTF если он существует
if (
previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:")
) {
clearBlobAndGLTFCache(previousMediaUrlRef.current);
}
const newBlobUrl = URL.createObjectURL(mediaFile as Blob);
setMediaUrl(newBlobUrl);
previousMediaUrlRef.current = newBlobUrl; // Сохраняем новый URL в ref
setIsPreviewLoaded(false); // Сбрасываем состояние загрузки при смене файла
}
}, [mediaFile]);
}, [mediaFile]); // Убираем mediaUrl из зависимостей чтобы избежать зацикливания
// const fileFormat = useEffect(() => {
// const handleKeyPress = (event: KeyboardEvent) => {
@@ -226,6 +285,10 @@ export const UploadMediaDialog = observer(
}
}
setSuccess(true);
// Закрываем модальное окно после успешного сохранения
setTimeout(() => {
handleClose();
}, 1000); // Небольшая задержка, чтобы пользователь увидел сообщение об успехе
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save media");
} finally {
@@ -234,8 +297,20 @@ export const UploadMediaDialog = observer(
};
const handleClose = () => {
// Очищаем blob URL и кеш GLTF при закрытии диалога
if (
previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:")
) {
clearBlobAndGLTFCache(previousMediaUrlRef.current);
}
setError(null);
setSuccess(false);
setMediaUrl(null);
setMediaFile(null);
setIsPreviewLoaded(false);
previousMediaUrlRef.current = null; // Очищаем ref
onClose();
};
@@ -303,8 +378,22 @@ export const UploadMediaDialog = observer(
alignItems: "center",
justifyContent: "center",
height: "100%",
position: "relative",
}}
>
{!isPreviewLoaded && mediaUrl && (
<Box
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: 1,
}}
>
<CircularProgress />
</Box>
)}
{mediaType == 2 && mediaUrl && (
<video
src={mediaUrl}
@@ -313,10 +402,16 @@ export const UploadMediaDialog = observer(
loop
controls
style={{ maxWidth: "100%", maxHeight: "100%" }}
onLoadedData={() => setIsPreviewLoaded(true)}
onError={() => setIsPreviewLoaded(true)}
/>
)}
{mediaType === 6 && mediaUrl && (
<ModelViewer3D fileUrl={mediaUrl} height="100%" />
<ModelViewer3D
fileUrl={mediaUrl}
height="100%"
onLoad={() => setIsPreviewLoaded(true)}
/>
)}
{mediaType !== 6 && mediaType !== 2 && mediaUrl && (
<img
@@ -326,6 +421,8 @@ export const UploadMediaDialog = observer(
height: "100%",
objectFit: "contain",
}}
onLoad={() => setIsPreviewLoaded(true)}
onError={() => setIsPreviewLoaded(true)}
/>
)}
</Paper>
@@ -333,18 +430,31 @@ export const UploadMediaDialog = observer(
<Box className="flex flex-col gap-2 self-end">
<Button
variant="contained"
color="success"
sx={{
backgroundColor: isLoading ? "#9e9e9e" : "#4caf50",
"&:hover": {
backgroundColor: isLoading ? "#9e9e9e" : "#45a049",
},
}}
startIcon={
isLoading ? (
<CircularProgress size={16} />
<CircularProgress size={16} color="inherit" />
) : (
<Save size={16} />
)
}
onClick={handleSave}
disabled={isLoading || (!mediaName && !mediaFilename)}
disabled={
isLoading ||
(!mediaName && !mediaFilename) ||
!isPreviewLoaded
}
>
Сохранить
{isLoading
? "Сохранение..."
: !isPreviewLoaded
? "Загрузка превью..."
: "Сохранить"}
</Button>
</Box>
</Box>

View File

@@ -551,7 +551,6 @@ class CreateSightStore {
});
}
console.log("Sight created with ID:", newSightId);
// Optionally: this.clearCreateSight(); // To reset form after successful creation
this.needLeaveAgree = false;
return newSightId;

View File

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

View File

@@ -497,9 +497,7 @@ class EditSightStore {
media_name: media_name,
media_type: type,
};
} catch (error) {
console.log(error);
}
} catch (error) {}
};
createLinkWithArticle = async (media: {

View File

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

View File

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

View File

@@ -82,11 +82,6 @@ class RouteStore {
};
setRouteStations = (routeId: number, stationId: number, data: any) => {
console.log(
this.routeStations[routeId],
stationId,
this.routeStations[routeId].find((station) => station.id === stationId)
);
this.routeStations[routeId] = this.routeStations[routeId]?.map((station) =>
station.id === stationId ? { ...station, ...data } : station
);
@@ -139,7 +134,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];

View File

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

View File

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

View File

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

View File

@@ -14,3 +14,5 @@ export * from "./RouteStore";
export * from "./UserStore";
export * from "./CarrierStore";
export * from "./StationsStore";
export * from "./MenuStore";
export * from "./SelectedCityStore";

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

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

View File

@@ -0,0 +1,77 @@
import React, { useEffect } from "react";
import {
FormControl,
Select,
MenuItem,
SelectChangeEvent,
Typography,
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { cityStore, selectedCityStore } from "@shared";
import { MapPin } from "lucide-react";
export const CitySelector: React.FC = observer(() => {
const { getCities, cities } = cityStore;
const { selectedCity, setSelectedCity } = selectedCityStore;
useEffect(() => {
getCities("ru");
}, []);
const handleCityChange = (event: SelectChangeEvent<string>) => {
const cityId = event.target.value;
if (cityId === "") {
setSelectedCity(null);
return;
}
const city = cities["ru"].data.find((c) => c.id === Number(cityId));
if (city) {
setSelectedCity(city);
}
};
const currentCities = cities["ru"].data;
return (
<Box className="flex items-center gap-2">
<MapPin size={16} className="text-white" />
<FormControl size="medium" sx={{ minWidth: 120 }}>
<Select
value={selectedCity?.id?.toString() || ""}
onChange={handleCityChange}
displayEmpty
sx={{
height: "40px",
color: "white",
"& .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255, 255, 255, 0.3)",
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255, 255, 255, 0.5)",
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: "white",
},
"& .MuiSvgIcon-root": {
color: "white",
},
}}
MenuProps={{
PaperProps: {},
}}
>
<MenuItem value="">
<Typography variant="body2">Выберите город</Typography>
</MenuItem>
{currentCities.map((city) => (
<MenuItem key={city.id} value={city.id?.toString()}>
<Typography variant="body2">{city.name}</Typography>
</MenuItem>
))}
</Select>
</FormControl>
</Box>
);
});

View File

@@ -174,6 +174,7 @@ export const DevicesTable = observer(() => {
setSelectedDevice(uuid); // Sets the device in the store, useful for context elsewhere
try {
await authInstance.post(`/devices/${uuid}/request-status`);
await getVehicles();
await getDevices(); // Refresh devices to show updated status
} catch (error) {
console.error(`Error requesting status for device ${uuid}:`, error);
@@ -201,7 +202,6 @@ export const DevicesTable = observer(() => {
try {
// Create an array of promises for all snapshot requests
const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => {
console.log(`Sending snapshot ${snapshotId} to device ${deviceUuid}`);
return send(deviceUuid);
});
@@ -398,7 +398,6 @@ export const DevicesTable = observer(() => {
devices.find((device) => device === row.device_uuid)
) {
await handleReloadStatus(row.device_uuid);
await getDevices();
toast.success("Статус устройства обновлен");
} else {
toast.error("Нет связи с устройством");

View File

@@ -35,6 +35,7 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
console.log("isDragOver");
}
}, [isDragOver]);
// --- Click to select file ---
const handleZoneClick = () => {
// Trigger the hidden file input click

View File

@@ -51,7 +51,7 @@ export const LanguageSwitcher = observer(() => {
key={lang}
onClick={() => handleLanguageChange(lang)}
variant={"contained"} // Highlight the active language
color={language === lang ? "primary" : "secondary"}
color={language === lang ? "primary" : "inherit"}
sx={{ minWidth: "60px" }} // Give buttons a consistent width
>
{getLanguageLabel(lang)}

View File

@@ -8,10 +8,11 @@ import { AppBar } from "./ui/AppBar";
import { Drawer } from "./ui/Drawer";
import { DrawerHeader } from "./ui/DrawerHeader";
import { NavigationList } from "@features";
import { authStore, userStore } from "@shared";
import { authStore, userStore, menuStore } from "@shared";
import { observer } from "mobx-react-lite";
import { useEffect } from "react";
import { Typography } from "@mui/material";
import { CitySelector } from "@widgets";
interface LayoutProps {
children: React.ReactNode;
@@ -20,6 +21,12 @@ interface LayoutProps {
export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
const theme = useTheme();
const [open, setOpen] = React.useState(true);
const { setIsMenuOpen } = menuStore;
React.useEffect(() => {
setIsMenuOpen(open);
}, [open]);
const { getUsers, users } = userStore;
useEffect(() => {
@@ -41,25 +48,27 @@ 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>
<div></div>
<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">
{(() => {
console.log(authStore.payload);
return (
<>
<p className=" text-white">
@@ -108,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}

View File

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

View File

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

View File

@@ -1,5 +1,60 @@
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";
// Утилита для очистки кеша GLTF
const clearGLTFCache = (url?: string) => {
try {
if (url) {
// Если это blob 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());
// Если это blob URL, считаем его валидным (пользователь выбрал файл)
const isBlobUrl = url.startsWith("blob:");
// Если это URL с токеном и нет явного расширения, считаем валидным
// (предполагаем что сервер вернет правильный файл)
const hasToken = searchParams.has("token");
const isServerUrl = hasToken && !hasValidExtension;
const isValid =
hasValidExtension || hasValidType || isBlobUrl || isServerUrl;
return isValid;
} catch (error) {
console.warn("⚠️ isValid3DFile: Ошибка при проверке URL", error);
// В случае ошибки парсинга URL, считаем валидным (пусть useGLTF сам разберется)
return true;
}
};
type ModelViewerProps = {
width?: string;
@@ -7,21 +62,93 @@ type ModelViewerProps = {
height?: string;
};
const Model = ({ fileUrl }: { fileUrl: string }) => {
// Очищаем кеш перед загрузкой новой модели
useEffect(() => {
// Очищаем кеш для текущего URL
clearGLTFCache(fileUrl);
}, [fileUrl]);
// Проверяем валидность файла перед загрузкой (только для blob URL)
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);
// Проверяем валидность файла (только для blob URL)
useEffect(() => {
if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) {
console.warn("⚠️ ThreeView: Невалидный 3D файл", { fileUrl });
}
}, [fileUrl]);
// Очищаем кеш при размонтировании и при смене URL
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>
);
};

View File

@@ -0,0 +1,240 @@
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 {
// Сбрасываем ошибку ТОЛЬКО при смене медиа (когда меняется ID в resetKey)
if (
props.resetKey !== state.lastResetKey &&
state.lastResetKey !== undefined
) {
const oldMediaId = String(state.lastResetKey).split("-")[0];
const newMediaId = String(props.resetKey).split("-")[0];
// Сбрасываем ошибку только если изменился ID медиа (пользователь выбрал другую модель)
if (oldMediaId !== newMediaId) {
return {
hasError: false,
error: null,
lastResetKey: props.resetKey,
};
}
// Если изменился только счетчик (нажата кнопка "Попробовать снова"), обновляем lastResetKey
// но не сбрасываем ошибку автоматически - ждем результата загрузки
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,
},
() => {
// После того как состояние обновилось, вызываем callback для изменения resetKey
// Это приведет к пересозданию компонента и новой попытке загрузки
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;
}
}

View File

@@ -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;
@@ -12,15 +15,46 @@ export interface MediaData {
export function MediaViewer({
media,
className,
height,
width,
fullWidth,
fullHeight,
}: Readonly<{
media?: MediaData;
className?: string;
height?: string;
width?: string;
fullWidth?: boolean;
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?.id || null,
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}
@@ -42,13 +76,8 @@ export function MediaViewer({
}/download?token=${token}`}
alt={media?.filename}
style={{
height: fullHeight ? "100%" : "auto",
width: fullWidth ? "100%" : "auto",
...(media?.filename?.toLowerCase().endsWith(".webp") && {
maxWidth: "300px",
maxHeight: "300px",
objectFit: "contain",
}),
height: fullHeight ? "100%" : height ? height : "auto",
width: fullWidth ? "100%" : width ? width : "auto",
}}
/>
)}
@@ -59,8 +88,8 @@ export function MediaViewer({
media?.id
}/download?token=${token}`}
style={{
width: "100%",
height: "100%",
width: width ? width : "100%",
height: height ? height : "100%",
objectFit: "cover",
borderRadius: 8,
}}
@@ -76,8 +105,8 @@ export function MediaViewer({
}/download?token=${token}`}
alt={media?.filename}
style={{
height: fullHeight ? "100%" : "auto",
width: fullWidth ? "100%" : "auto",
height: fullHeight ? "100%" : height ? height : "auto",
width: fullWidth ? "100%" : width ? width : "auto",
}}
/>
)}
@@ -98,19 +127,25 @@ export function MediaViewer({
src={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id
}/download?token=${token}`}
width={"100%"}
height={"100%"}
width={width ? width : "500px"}
height={height ? height : "300px"}
/>
)}
{media?.media_type === 6 && (
<ThreeView
fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id
}/download?token=${token}`}
height="500px"
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>
);

View File

@@ -1,3 +1,4 @@
import React from "react";
import { Stage, useGLTF } from "@react-three/drei";
import { Canvas } from "@react-three/fiber";
import { OrbitControls } from "@react-three/drei";
@@ -5,12 +6,21 @@ import { OrbitControls } from "@react-three/drei";
export const ModelViewer3D = ({
fileUrl,
height = "100%",
onLoad,
}: {
fileUrl: string;
height: string;
onLoad?: () => void;
}) => {
const { scene } = useGLTF(fileUrl);
// Вызываем onLoad когда модель загружена
React.useEffect(() => {
if (onLoad) {
onLoad();
}
}, [scene, onLoad]);
return (
<Canvas style={{ width: "100%", height: height }}>
<ambientLight />

View File

@@ -0,0 +1,24 @@
import { Button } from "@mui/material";
export const SaveWithoutCityAgree = ({ blocker }: { blocker: any }) => {
return (
<div className="fixed top-0 left-0 w-screen h-screen flex justify-center items-center z-10000 bg-black/30">
<div className="bg-white p-4 w-140 rounded-lg flex flex-col gap-4 items-center">
<p className="text-black w-140 text-center">
Вы не указали город и/или не заполнили названия на всех языках
(русский, английский, китайский).
<br />
Сохранить без этой информации?
</p>
<div className="flex gap-4 justify-center">
<Button variant="contained" onClick={() => blocker.proceed()}>
Да
</Button>
<Button variant="outlined" onClick={() => blocker.reset()}>
Нет
</Button>
</div>
</div>
</div>
);
};

View File

@@ -5,6 +5,10 @@ import {
Autocomplete,
MenuItem,
Menu as MuiMenu,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import {
BackButton,
@@ -20,14 +24,18 @@ import {
UploadMediaDialog,
MEDIA_TYPE_VALUES,
} from "@shared";
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
import {
ImageUploadCard,
LanguageSwitcher,
VideoPreviewCard,
MediaViewer,
} from "@widgets";
import { Save } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
// Мокап для всплывающей подсказки
import { SaveWithoutCityAgree } from "@widgets";
export const CreateInformationTab = observer(
({ value, index }: { value: number; index: number }) => {
@@ -42,30 +50,25 @@ export const CreateInformationTab = observer(
const [, setCity] = useState<number>(sight.city_id ?? 0);
const [coordinates, setCoordinates] = useState<string>(`0, 0`);
// Menu state for each media button
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
>(null);
const [isAddMediaOpen, setIsAddMediaOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const [hardcodeType, setHardcodeType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
>(null);
// const handleMenuOpen = (
// event: React.MouseEvent<HTMLElement>,
// type: "thumbnail" | "watermark_lu" | "watermark_rd"
// ) => {
// setMenuAnchorEl(event.currentTarget);
// setActiveMenuType(type);
// };
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => {}, [hardcodeType]);
useEffect(() => {
// Показывать только при инициализации (не менять при ошибках пользователя)
if (sight.latitude !== 0 || sight.longitude !== 0) {
setCoordinates(`${sight.latitude}, ${sight.longitude}`);
}
// если координаты обнулились — оставить поле как есть
}, [sight.latitude, sight.longitude]);
const handleMenuClose = () => {
@@ -100,7 +103,7 @@ export const CreateInformationTab = observer(
media_name?: string;
media_type: number;
},
type: "thumbnail" | "watermark_lu" | "watermark_rd"
type: "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview"
) => {
handleChange({
[type]: media.id,
@@ -108,6 +111,36 @@ export const CreateInformationTab = observer(
setActiveMenuType(null);
};
const handleVideoPreviewClick = () => {
if (sight.video_preview && sight.video_preview !== "") {
setIsVideoPreviewOpen(true);
}
};
const handleSave = async () => {
const isCityMissing = !sight.city_id;
// Проверяем названия на всех языках
const isNameMissing = !sight.ru.name || !sight.en.name || !sight.zh.name;
if (isCityMissing || isNameMissing) {
setIsSaveWarningOpen(true);
return;
}
await createSight(language);
toast.success("Достопримечательность создана");
};
const handleConfirmSave = async () => {
setIsSaveWarningOpen(false);
await createSight(language);
toast.success("Достопримечательность создана");
};
const handleCancelSave = () => {
setIsSaveWarningOpen(false);
};
return (
<>
<TabPanel value={value} index={index}>
@@ -117,7 +150,7 @@ export const CreateInformationTab = observer(
flexDirection: "column",
gap: 3,
position: "relative",
paddingBottom: "70px" /* Space for save button */,
paddingBottom: "70px",
}}
>
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
@@ -129,12 +162,11 @@ export const CreateInformationTab = observer(
sx={{
display: "flex",
gap: 4, // Added gap between the two main columns
gap: 4,
width: "100%",
flexDirection: "column",
}}
>
{/* Left column with main fields */}
<Box
sx={{
flexGrow: 1,
@@ -198,14 +230,13 @@ export const CreateInformationTab = observer(
value={coordinates}
onChange={(e) => {
const input = e.target.value;
setCoordinates(input); // показываем как есть
setCoordinates(input);
const [latStr, lonStr] = input.split(/\s+/); // учитываем любые пробелы
const [latStr, lonStr] = input.split(/\s+/);
const lat = parseFloat(latStr);
const lon = parseFloat(lonStr);
// Проверка, что обе координаты валидные числа
const isValidLat = !isNaN(lat);
const isValidLon = !isNaN(lon);
@@ -243,7 +274,7 @@ export const CreateInformationTab = observer(
justifyContent: "space-around",
width: "80%",
gap: 2,
flexDirection: { xs: "column", sm: "row" }, // Stack on extra small, side-by-side on small and up
flexDirection: { xs: "column", sm: "row" },
}}
>
<ImageUploadCard
@@ -329,35 +360,50 @@ export const CreateInformationTab = observer(
setHardcodeType("watermark_rd");
}}
/>
<VideoPreviewCard
title="Видеозаставка"
videoId={sight.video_preview}
onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => {
handleChange({
video_preview: null,
});
}}
onSelectVideoClick={(file) => {
if (file) {
createSightStore.setFileToUpload(file);
setActiveMenuType("video_preview");
setIsUploadMediaOpen(true);
} else {
setActiveMenuType("video_preview");
setIsAddMediaOpen(true);
}
}}
/>
</Box>
</Box>
</Box>
{/* LanguageSwitcher positioned at the top right */}
<LanguageSwitcher />
{/* Save Button fixed at the bottom right */}
<Box
sx={{
position: "absolute",
bottom: 0,
right: 0,
padding: 2,
backgroundColor: "background.paper", // To ensure it stands out over content
width: "100%", // Take full width to cover content below it
backgroundColor: "background.paper",
width: "100%",
display: "flex",
justifyContent: "flex-end", // Align to the right
justifyContent: "flex-end",
}}
>
<Button
variant="contained"
color="success"
startIcon={<Save color="white" size={18} />}
onClick={async () => {
await createSight(language);
toast.success("Достопримечательность создана");
}}
onClick={handleSave}
>
Сохранить
</Button>
@@ -365,7 +411,6 @@ export const CreateInformationTab = observer(
</Box>
</TabPanel>
{/* Media Menu */}
<MuiMenu
anchorEl={menuAnchorEl}
open={Boolean(menuAnchorEl)}
@@ -390,7 +435,9 @@ export const CreateInformationTab = observer(
setActiveMenuType(null);
}}
onSelectMedia={(media) => {
handleMediaSelect(media, activeMenuType ?? "thumbnail");
if (activeMenuType) {
handleMediaSelect(media, activeMenuType);
}
}}
mediaType={
activeMenuType
@@ -413,14 +460,58 @@ export const CreateInformationTab = observer(
contextObjectName={sight[language].name}
contextType="sight"
afterUpload={(media) => {
handleChange({
[activeMenuType ?? "thumbnail"]: media.id,
});
if (activeMenuType === "video_preview") {
handleChange({
video_preview: media.id,
});
} else {
handleChange({
[activeMenuType ?? "thumbnail"]: media.id,
});
}
setActiveMenuType(null);
setIsUploadMediaOpen(false);
}}
hardcodeType={hardcodeType}
hardcodeType={activeMenuType}
initialFile={createSightStore.fileToUpload || undefined}
/>
{sight.video_preview && sight.video_preview !== "" && (
<Dialog
open={isVideoPreviewOpen}
onClose={() => setIsVideoPreviewOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>Предпросмотр видео</DialogTitle>
<DialogContent>
<Box className="flex justify-center items-center p-4">
<MediaViewer
media={{
id: sight.video_preview,
media_type: 2,
filename: "video_preview",
}}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsVideoPreviewOpen(false)}>
Закрыть
</Button>
</DialogActions>
</Dialog>
)}
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
{isSaveWarningOpen && (
<SaveWithoutCityAgree
blocker={{
proceed: handleConfirmSave,
reset: handleCancelSave,
}}
/>
)}
</>
);
}

View File

@@ -5,6 +5,10 @@ import {
Autocomplete,
MenuItem,
Menu as MuiMenu,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import {
BackButton,
@@ -20,7 +24,12 @@ import {
UploadMediaDialog,
MEDIA_TYPE_VALUES,
} from "@shared";
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
import {
ImageUploadCard,
LanguageSwitcher,
VideoPreviewCard,
MediaViewer,
} from "@widgets";
import { Save } from "lucide-react";
import { observer } from "mobx-react-lite";
@@ -28,7 +37,8 @@ import { useEffect, useState } from "react";
import { toast } from "react-toastify";
// Мокап для всплывающей подсказки
// Компонент предупреждающего окна (перенесен сюда)
import { SaveWithoutCityAgree } from "@widgets";
export const InformationTab = observer(
({ value, index }: { value: number; index: number }) => {
@@ -42,22 +52,25 @@ export const InformationTab = observer(
const [, setCity] = useState<number>(sight.common.city_id ?? 0);
const [coordinates, setCoordinates] = useState<string>(`0, 0`);
// Menu state for each media button
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
>(null);
const [isAddMediaOpen, setIsAddMediaOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const [hardcodeType, setHardcodeType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
>(null);
const { cities } = cityStore;
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => {}, [hardcodeType]);
useEffect(() => {
// Показывать только при инициализации (не менять при ошибках пользователя)
if (sight.common.latitude !== 0 || sight.common.longitude !== 0) {
setCoordinates(`${sight.common.latitude}, ${sight.common.longitude}`);
}
// если координаты обнулились — оставить поле как есть
}, [sight.common.latitude, sight.common.longitude]);
const handleMenuClose = () => {
@@ -74,22 +87,28 @@ export const InformationTab = observer(
handleMenuClose();
};
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
if (!activeMenuType) return;
const handleMediaSelect = (
media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
},
type: "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview"
) => {
handleChange(
language as Language,
{
[activeMenuType ?? "thumbnail"]: media.id,
[type]: media.id,
},
true
);
};
setIsUploadMediaOpen(false);
const handleVideoPreviewClick = () => {
if (sight.common.video_preview && sight.common.video_preview !== "") {
setIsVideoPreviewOpen(true);
}
};
const handleChange = (
@@ -100,6 +119,37 @@ 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) {
setIsSaveWarningOpen(true);
return;
}
await executeSave();
};
// Обработчик "Да" в предупреждающем окне
const handleConfirmSave = async () => {
setIsSaveWarningOpen(false);
await executeSave();
};
// Обработчик "Нет" в предупреждающем окне
const handleCancelSave = () => {
setIsSaveWarningOpen(false);
};
return (
<>
<TabPanel value={value} index={index}>
@@ -109,7 +159,7 @@ export const InformationTab = observer(
flexDirection: "column",
gap: 3,
position: "relative",
paddingBottom: "70px" /* Space for save button */,
paddingBottom: "70px",
}}
>
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
@@ -122,12 +172,11 @@ export const InformationTab = observer(
sx={{
display: "flex",
gap: 4, // Added gap between the two main columns
gap: 4,
width: "100%",
flexDirection: "column",
}}
>
{/* Left column with main fields */}
<Box
sx={{
flexGrow: 1,
@@ -189,16 +238,14 @@ export const InformationTab = observer(
value={coordinates}
onChange={(e) => {
const newValue = e.target.value;
setCoordinates(newValue); // сохраняем ввод пользователя как есть
setCoordinates(newValue);
// Обрабатываем значение для сохранения
const input = newValue.replace(/,/g, " ").trim();
const [latStr, lonStr] = input.split(/\s+/);
const lat = parseFloat(latStr);
const lon = parseFloat(lonStr);
// Проверка, что обе координаты валидные числа
const isValidLat = !isNaN(lat);
const isValidLon = !isNaN(lon);
@@ -241,7 +288,7 @@ export const InformationTab = observer(
justifyContent: "space-around",
width: "80%",
gap: 2,
flexDirection: { xs: "column", sm: "row" }, // Stack on extra small, side-by-side on small and up
flexDirection: { xs: "column", sm: "row" },
}}
>
<ImageUploadCard
@@ -337,35 +384,54 @@ export const InformationTab = observer(
setHardcodeType("watermark_rd");
}}
/>
<VideoPreviewCard
title="Видеозаставка"
videoId={sight.common.video_preview}
onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => {
handleChange(
language as Language,
{
video_preview: null,
},
true
);
}}
onSelectVideoClick={(file) => {
if (file) {
editSightStore.setFileToUpload(file);
setActiveMenuType("video_preview");
setIsUploadMediaOpen(true);
} else {
setActiveMenuType("video_preview");
setIsAddMediaOpen(true);
}
}}
/>
</Box>
</Box>
</Box>
{/* LanguageSwitcher positioned at the top right */}
<LanguageSwitcher />
{/* Save Button fixed at the bottom right */}
<Box
sx={{
position: "absolute",
bottom: 0,
right: 0,
padding: 2,
backgroundColor: "background.paper", // To ensure it stands out over content
width: "100%", // Take full width to cover content below it
backgroundColor: "background.paper",
width: "100%",
display: "flex",
justifyContent: "flex-end", // Align to the right
justifyContent: "flex-end",
}}
>
<Button
variant="contained"
color="success"
startIcon={<Save color="white" size={18} />}
onClick={async () => {
await updateSight();
toast.success("Достопримечательность сохранена");
}}
onClick={handleSave} // Используем новую функцию-обработчик
>
Сохранить
</Button>
@@ -373,7 +439,6 @@ export const InformationTab = observer(
</Box>
</TabPanel>
{/* Media Menu */}
<MuiMenu
anchorEl={menuAnchorEl}
open={Boolean(menuAnchorEl)}
@@ -395,8 +460,13 @@ export const InformationTab = observer(
open={isAddMediaOpen}
onClose={() => {
setIsAddMediaOpen(false);
setActiveMenuType(null);
}}
onSelectMedia={(media) => {
if (activeMenuType) {
handleMediaSelect(media, activeMenuType);
}
}}
onSelectMedia={handleMediaSelect}
mediaType={
activeMenuType
? MEDIA_TYPE_VALUES[
@@ -412,23 +482,71 @@ export const InformationTab = observer(
contextObjectName={sight[language].name}
contextType="sight"
afterUpload={(media) => {
handleChange(
language as Language,
{
[activeMenuType ?? "thumbnail"]: media.id,
},
true
);
if (activeMenuType === "video_preview") {
handleChange(
language as Language,
{
video_preview: media.id,
},
true
);
} else {
handleChange(
language as Language,
{
[activeMenuType ?? "thumbnail"]: media.id,
},
true
);
}
setActiveMenuType(null);
setIsUploadMediaOpen(false);
}}
hardcodeType={hardcodeType}
hardcodeType={activeMenuType}
initialFile={editSightStore.fileToUpload || undefined}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
{sight.common.video_preview && sight.common.video_preview !== "" && (
<Dialog
open={isVideoPreviewOpen}
onClose={() => setIsVideoPreviewOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>Предпросмотр видео</DialogTitle>
<DialogContent>
<Box className="flex justify-center items-center p-4">
<MediaViewer
media={{
id: sight.common.video_preview,
media_type: 2,
filename: "video_preview",
}}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsVideoPreviewOpen(false)}>
Закрыть
</Button>
</DialogActions>
</Dialog>
)}
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
{isSaveWarningOpen && (
<SaveWithoutCityAgree
blocker={{
proceed: handleConfirmSave,
reset: handleCancelSave,
}}
/>
)}
</>
);
}

View File

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

View File

@@ -87,7 +87,6 @@ export const RightWidgetTab = observer(
}
};
fetchData();
console.log(sight[language].right);
}, [sight.common.id]);
const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>(
@@ -175,10 +174,6 @@ export const RightWidgetTab = observer(
toast.success("Достопримечательность сохранена");
};
useEffect(() => {
console.log(sight[language].right);
}, [sight[language].right]);
const handleDragEnd = (result: DropResult) => {
const { source, destination } = result;

View File

@@ -1,13 +1,15 @@
import { Button } from "@mui/material";
import { Button, CircularProgress } from "@mui/material";
export const SnapshotRestore = ({
onDelete,
onCancel,
open,
loading = false,
}: {
onDelete: () => void;
onCancel: () => void;
open: boolean;
loading?: boolean;
}) => {
return (
<div
@@ -23,10 +25,22 @@ export const SnapshotRestore = ({
Это действие нельзя будет отменить.
</p>
<div className="flex gap-4 justify-center">
<Button variant="contained" color="primary" onClick={onDelete}>
Да
<Button
variant="contained"
color="primary"
onClick={onDelete}
disabled={loading}
startIcon={
loading ? (
<CircularProgress size={16} color="inherit" />
) : undefined
}
>
{loading ? "Восстановление..." : "Да"}
</Button>
<Button onClick={onCancel} disabled={loading}>
Нет
</Button>
<Button onClick={onCancel}>Нет</Button>
</div>
</div>
</div>

View File

@@ -0,0 +1,203 @@
import React, { useRef, useState, DragEvent, useEffect } from "react";
import { Paper, Box, Typography, Button, Tooltip } from "@mui/material";
import { X, Info, Plus, Play } from "lucide-react";
import { toast } from "react-toastify";
interface VideoPreviewCardProps {
title: string;
videoId: string | null | undefined;
onVideoClick: () => void;
onDeleteVideoClick: () => void;
onSelectVideoClick: (file?: File) => void;
tooltipText?: string;
}
export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
title,
videoId,
onVideoClick,
onDeleteVideoClick,
onSelectVideoClick,
tooltipText,
}) => {
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();
};
const handleFileInputChange = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
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.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.stopPropagation();
setIsDragOver(false);
const files = event.dataTransfer.files;
if (files && files.length > 0) {
const file = files[0];
if (file.type.startsWith("video/")) {
// Открываем диалог загрузки медиа с файлом видео
onSelectVideoClick(file);
} else {
toast.error("Пожалуйста, выберите видео файл");
}
}
};
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>
{tooltipText && (
<Tooltip title={tooltipText}>
<Info size={16} color="gray" style={{ cursor: "pointer" }} />
</Tooltip>
)}
</Box>
<Box
sx={{
position: "relative",
width: "200px",
height: "200px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: videoId ? "pointer" : "default",
}}
onClick={onVideoClick}
>
{videoId && (
<button
className="absolute top-2 right-2 z-10"
onClick={(e) => {
e.stopPropagation();
onDeleteVideoClick();
}}
>
<X color="red" />
</button>
)}
{videoId ? (
<Box sx={{ position: "relative", width: "100%", height: "100%" }}>
<video
src={`${
import.meta.env.VITE_KRBL_MEDIA
}${videoId}/download?token=${token}`}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
borderRadius: 4,
}}
muted
/>
<Box
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
backgroundColor: "rgba(0, 0, 0, 0.5)",
borderRadius: "50%",
width: 40,
height: 40,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Play size={20} color="white" />
</Box>
</Box>
) : (
<div className="w-full flex flex-col items-center justify-center gap-3">
<div
className="flex flex-col p-5 items-center justify-center gap-3"
style={{
border: "2px dashed #ccc",
borderRadius: 1,
cursor: "pointer",
}}
onClick={handleZoneClick} // Click handler for the zone
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<p className="text-center">Перетащите файл</p>
</div>
<p>или</p>
<Button
variant="contained"
color="primary"
startIcon={<Plus color="white" size={18} />}
onClick={(e) => {
e.stopPropagation(); // Prevent `handleZoneClick` from firing
onSelectVideoClick(); // This button triggers the media selection dialog
}}
>
Выбрать файл
</Button>
{/* Hidden file input */}
<input
type="file"
ref={fileInputRef}
onChange={handleFileInputChange}
style={{ display: "none" }}
accept="video/*" // Accept only video files
/>
</div>
)}
</Box>
</Paper>
);
};

View File

@@ -12,8 +12,11 @@ export * from "./MediaArea";
export * from "./ModelViewer3D";
export * from "./MediaAreaForSight";
export * from "./ImageUploadCard";
export * from "./VideoPreviewCard";
export * from "./LeaveAgree";
export * from "./DeleteModal";
export * from "./SnapshotRestore";
export * from "./CreateButton";
export * from "./SaveWithoutCityAgree";
export * from "./CitySelector";
export * from "./modals";

View File

@@ -43,8 +43,6 @@ export const EditStationModal = observer(
} = routeStore;
const handleSave = async () => {
console.log(routeId, selectedStationId);
await saveRouteStations(Number(routeId), selectedStationId);
toast.success("Успешно сохранено");
onClose();

File diff suppressed because one or more lines are too long

View File

@@ -1,11 +1,63 @@
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/
type ManualChunksFn = (id: string, api: { getModuleIds: () => Iterable<string> }) => string | undefined;
const manualChunks: ManualChunksFn = (id) => {
if (id.includes('node_modules')) {
if (
id.includes('three.') ||
id.includes('@react-three') ||
id.includes('ol/') ||
id.includes('mapbox-gl') ||
id.includes('@babel/runtime')
) {
return 'vendor-3d-maps';
}
if (id.includes('codemirror') || id.includes('react-codemirror2')) {
return 'vendor-codemirror';
}
if (id.includes('hls.js')) {
return 'vendor-hls';
}
if (id.includes('pixi.js')) {
return 'vendor-pixijs';
}
if (id.includes('@mui/material') || id.includes('@mui/icons-material') || id.includes('@mui/x-data-grid')) {
return 'vendor-mui-core';
}
if (id.includes('/react/') || id.includes('/react-dom/')) {
return 'vendor-react-core';
}
if (id.includes('react-router') || id.includes('history')) {
return 'vendor-router';
}
return 'vendor-common-remainder';
}
if (id.includes('src/pages/')) {
const pathParts = id.split('src/pages/');
if (pathParts.length > 1) {
return 'page-' + pathParts[1].split('/')[0].toLowerCase();
}
}
};
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [
react(),
tailwindcss(),
],
resolve: {
alias: {
"@shared": path.resolve(__dirname, "src/shared"),
@@ -16,4 +68,18 @@ export default defineConfig({
"@app": path.resolve(__dirname, "src/app"),
},
},
});
build: {
chunkSizeWarningLimit: 2000,
rollupOptions: {
output: {
manualChunks,
entryFileNames: `assets/[name]-[hash].js`,
chunkFileNames: `assets/[name]-[hash].js`,
assetFileNames: `assets/[name]-[hash].[ext]`,
}
}
}
}) as UserConfigExport;

1829
yarn.lock

File diff suppressed because it is too large Load Diff