Compare commits
18 Commits
bf117ef048
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 50ad374cf5 | |||
| 9e47ab667f | |||
| 1b8fc3d215 | |||
| f5142ec95d | |||
| cdb96dfb8b | |||
| c50ccb3a0c | |||
| 4bcc2e2cca | |||
| 26e4d70b95 | |||
| a357994025 | |||
| 7382a85082 | |||
| db64beb3ee | |||
|
|
1abd6b30a4 | ||
|
|
b25df42960 | ||
|
34ba3c1db0
|
|||
| 4f038551a2 | |||
| 470a58a3fa | |||
| 89d7fc2748 | |||
| 97f95fc394 |
1
.env
1
.env
@@ -1,2 +1,3 @@
|
||||
VITE_API_URL='https://wn.krbl.ru'
|
||||
VITE_REACT_APP ='https://wn.krbl.ru/'
|
||||
VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
|
||||
32
Dockerfile
Normal file
32
Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
# Stage 1: Build the application
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and yarn.lock
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
# Install dependencies
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
# Copy the rest of the application code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN yarn build
|
||||
|
||||
# Stage 2: Serve the application with Nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy the built application from the build stage
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration (optional, can be added later if needed)
|
||||
# COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Start Nginx server
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
38
Makefile
Normal file
38
Makefile
Normal file
@@ -0,0 +1,38 @@
|
||||
# Variables
|
||||
IMAGE_NAME = white-nights-admin-panel
|
||||
IMAGE_TAG = latest
|
||||
FULL_IMAGE_NAME = $(IMAGE_NAME):$(IMAGE_TAG)
|
||||
ARCHIVE_NAME = white-nights-admin-panel-image.zip
|
||||
|
||||
# Default target
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "Available commands:"
|
||||
@echo " make build-image - Build Docker image"
|
||||
@echo " make export-image - Build Docker image and export it to a zip archive"
|
||||
@echo " make clean - Remove Docker image and zip archive"
|
||||
@echo " make help - Show this help message"
|
||||
|
||||
# Build Docker image
|
||||
.PHONY: build-image
|
||||
build-image:
|
||||
@echo "Building Docker image: $(FULL_IMAGE_NAME)"
|
||||
docker build -t $(FULL_IMAGE_NAME) .
|
||||
|
||||
# Export Docker image to zip archive
|
||||
.PHONY: export-image
|
||||
export-image: build-image
|
||||
@echo "Exporting Docker image to $(ARCHIVE_NAME)"
|
||||
docker save $(FULL_IMAGE_NAME) | gzip > $(ARCHIVE_NAME)
|
||||
@echo "Image exported successfully to $(ARCHIVE_NAME)"
|
||||
|
||||
# Clean up
|
||||
.PHONY: clean
|
||||
clean:
|
||||
@echo "Removing Docker image and zip archive"
|
||||
-docker rmi $(FULL_IMAGE_NAME) 2>/dev/null || true
|
||||
-rm -f $(ARCHIVE_NAME) 2>/dev/null || true
|
||||
@echo "Clean up completed"
|
||||
|
||||
# Default target when no arguments provided
|
||||
.DEFAULT_GOAL := help
|
||||
14
package-lock.json
generated
14
package-lock.json
generated
@@ -6592,20 +6592,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
|
||||
"integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
158
src/app/GlobalErrorBoundary.tsx
Normal file
158
src/app/GlobalErrorBoundary.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React, { Component, ReactNode } from "react";
|
||||
import { Box, Button, Typography, Paper, Container } from "@mui/material";
|
||||
import { RefreshCw, Home, AlertTriangle } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class GlobalErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error("❌ GlobalErrorBoundary: Критическая ошибка приложения", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
componentStack: errorInfo.componentStack,
|
||||
});
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
});
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
handleGoHome = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
});
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "background.default",
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="sm">
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={64} color="#f44336" />
|
||||
</Box>
|
||||
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Упс! Что-то пошло не так
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Приложение столкнулось с неожиданной ошибкой. Попробуйте
|
||||
перезагрузить страницу или вернуться на главную.
|
||||
</Typography>
|
||||
|
||||
{this.state.error?.message && (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 3,
|
||||
backgroundColor: "error.light",
|
||||
color: "error.contrastText",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: "bold", display: "block", mb: 1 }}
|
||||
>
|
||||
Информация об ошибке:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.75rem",
|
||||
wordBreak: "break-word",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{this.state.error.message}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 2,
|
||||
justifyContent: "center",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Home size={16} />}
|
||||
onClick={this.handleGoHome}
|
||||
size="large"
|
||||
>
|
||||
На главную
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<RefreshCw size={16} />}
|
||||
onClick={this.handleReset}
|
||||
size="large"
|
||||
>
|
||||
Перезагрузить
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface NavigationItem {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
path?: string;
|
||||
for_admin?: boolean;
|
||||
onClick?: () => void;
|
||||
nestedItems?: NavigationItem[];
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import type { NavigationItem } from "../model";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { Plus } from "lucide-react";
|
||||
import { authStore } from "@shared";
|
||||
|
||||
interface NavigationItemProps {
|
||||
item: NavigationItem;
|
||||
@@ -30,9 +31,21 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const { payload } = authStore;
|
||||
|
||||
// @ts-ignore
|
||||
const isAdmin = payload?.is_admin || false;
|
||||
|
||||
const isActive = item.path ? location.pathname.startsWith(item.path) : false;
|
||||
|
||||
const filteredNestedItems = item.nestedItems?.filter((nestedItem) => {
|
||||
if (nestedItem.for_admin) {
|
||||
return isAdmin;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleClick = () => {
|
||||
if (item.id === "all" && !open) {
|
||||
onDrawerOpen?.();
|
||||
@@ -108,15 +121,16 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{item.nestedItems &&
|
||||
{filteredNestedItems &&
|
||||
filteredNestedItems.length > 0 &&
|
||||
open &&
|
||||
(isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />)}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
{item.nestedItems && (
|
||||
{filteredNestedItems && filteredNestedItems.length > 0 && (
|
||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
{item.nestedItems.map((nestedItem) => (
|
||||
{filteredNestedItems.map((nestedItem) => (
|
||||
<NavigationItemComponent
|
||||
key={nestedItem.id}
|
||||
item={nestedItem}
|
||||
|
||||
@@ -1,41 +1,62 @@
|
||||
import List from "@mui/material/List";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import { NAVIGATION_ITEMS } from "@shared";
|
||||
import { authStore, NAVIGATION_ITEMS } from "@shared";
|
||||
import { NavigationItem, NavigationItemComponent } from "@entities";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
interface NavigationListProps {
|
||||
open: boolean;
|
||||
onDrawerOpen?: () => void;
|
||||
}
|
||||
|
||||
export const NavigationList = ({ open, onDrawerOpen }: NavigationListProps) => {
|
||||
const primaryItems = NAVIGATION_ITEMS.primary;
|
||||
const secondaryItems = NAVIGATION_ITEMS.secondary;
|
||||
export const NavigationList = observer(
|
||||
({ open, onDrawerOpen }: NavigationListProps) => {
|
||||
const { payload } = authStore;
|
||||
// @ts-ignore
|
||||
const isAdmin = Boolean(payload?.is_admin) || false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
{primaryItems.map((item) => (
|
||||
<NavigationItemComponent
|
||||
key={item.id}
|
||||
item={item as NavigationItem}
|
||||
open={open}
|
||||
onDrawerOpen={onDrawerOpen}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
{secondaryItems.map((item) => (
|
||||
<NavigationItemComponent
|
||||
key={item.id}
|
||||
item={item as NavigationItem}
|
||||
open={open}
|
||||
onClick={item.onClick ? item.onClick : undefined}
|
||||
onDrawerOpen={onDrawerOpen}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
};
|
||||
const primaryItems = NAVIGATION_ITEMS.primary.filter((item) => {
|
||||
if (item.for_admin) {
|
||||
return isAdmin;
|
||||
}
|
||||
|
||||
if (item.nestedItems && item.nestedItems.length > 0) {
|
||||
return item.nestedItems.some((nestedItem) => {
|
||||
if (nestedItem.for_admin) {
|
||||
return isAdmin;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
{primaryItems.map((item) => (
|
||||
<NavigationItemComponent
|
||||
key={item.id}
|
||||
item={item as NavigationItem}
|
||||
open={open}
|
||||
onDrawerOpen={onDrawerOpen}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
{NAVIGATION_ITEMS.secondary.map((item) => (
|
||||
<NavigationItemComponent
|
||||
key={item.id}
|
||||
item={item as NavigationItem}
|
||||
open={open}
|
||||
onClick={item.onClick ? item.onClick : undefined}
|
||||
onDrawerOpen={onDrawerOpen}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -46,6 +46,7 @@ export const ArticleListPage = observer(() => {
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -88,6 +88,7 @@ export const CarrierListPage = observer(() => {
|
||||
headerName: "Действия",
|
||||
headerAlign: "center",
|
||||
width: 200,
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
|
||||
@@ -93,6 +93,7 @@ export const CityListPage = observer(() => {
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
width: 200,
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -69,6 +69,7 @@ export const MediaListPage = observer(() => {
|
||||
width: 200,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Tab,
|
||||
Box,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||
import {
|
||||
@@ -33,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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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("Ошибка при сохранении изменений");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Сохранить изменения
|
||||
|
||||
@@ -82,11 +82,7 @@ export const Sight = ({ sight, id }: Readonly<SightProps>) => {
|
||||
Assets.load("/SightIcon.png").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");
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { cityStore, languageStore, sightsStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
cityStore,
|
||||
languageStore,
|
||||
sightsStore,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -68,6 +73,7 @@ export const SightListPage = observer(() => {
|
||||
headerName: "Действия",
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
@@ -92,7 +98,16 @@ export const SightListPage = observer(() => {
|
||||
},
|
||||
];
|
||||
|
||||
const rows = sights.map((sight) => ({
|
||||
// Фильтрация достопримечательностей по выбранному городу
|
||||
const filteredSights = useMemo(() => {
|
||||
const { selectedCityId } = selectedCityStore;
|
||||
if (!selectedCityId) {
|
||||
return sights;
|
||||
}
|
||||
return sights.filter((sight: any) => sight.city_id === selectedCityId);
|
||||
}, [sights, selectedCityStore.selectedCityId]);
|
||||
|
||||
const rows = filteredSights.map((sight) => ({
|
||||
id: sight.id,
|
||||
name: sight.name,
|
||||
city_id: sight.city_id,
|
||||
@@ -132,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={{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./SnapshotListPage";
|
||||
|
||||
export * from "./SnapshotCreatePage";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { languageStore, stationsStore } from "@shared";
|
||||
import {
|
||||
languageStore,
|
||||
stationsStore,
|
||||
selectedCityStore,
|
||||
cityStore,
|
||||
} from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
|
||||
@@ -21,6 +26,7 @@ export const StationListPage = observer(() => {
|
||||
useEffect(() => {
|
||||
const fetchStations = async () => {
|
||||
setIsLoading(true);
|
||||
await cityStore.getCities(language);
|
||||
await getStationList();
|
||||
setIsLoading(false);
|
||||
};
|
||||
@@ -85,6 +91,7 @@ export const StationListPage = observer(() => {
|
||||
width: 140,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
@@ -109,7 +116,18 @@ export const StationListPage = observer(() => {
|
||||
},
|
||||
];
|
||||
|
||||
const rows = stationLists[language].data.map((station: any) => ({
|
||||
// Фильтрация станций по выбранному городу
|
||||
const filteredStations = () => {
|
||||
const { selectedCityId } = selectedCityStore;
|
||||
if (!selectedCityId) {
|
||||
return stationLists[language].data;
|
||||
}
|
||||
return stationLists[language].data.filter(
|
||||
(station: any) => station.city_id === selectedCityId
|
||||
);
|
||||
};
|
||||
|
||||
const rows = filteredStations().map((station: any) => ({
|
||||
id: station.id,
|
||||
name: station.name,
|
||||
system_name: station.system_name,
|
||||
|
||||
@@ -83,6 +83,7 @@ export const UserListPage = observer(() => {
|
||||
flex: 1,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
|
||||
@@ -102,6 +102,7 @@ export const VehicleListPage = observer(() => {
|
||||
width: 200,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { languageStore, Language } from "@shared";
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||
|
||||
const authInstance = axios.create({
|
||||
baseURL: "https://wn.krbl.ru",
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
});
|
||||
|
||||
authInstance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
@@ -24,7 +24,7 @@ authInstance.interceptors.response.use(
|
||||
|
||||
const languageInstance = (language: Language) => {
|
||||
const instance = axios.create({
|
||||
baseURL: "https://wn.krbl.ru",
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
});
|
||||
instance.interceptors.request.use((config) => {
|
||||
config.headers.Authorization = `Bearer ${localStorage.getItem("token")}`;
|
||||
|
||||
@@ -25,6 +25,7 @@ interface NavigationItem {
|
||||
label: string;
|
||||
icon?: LucideIcon | React.ReactNode;
|
||||
path?: string;
|
||||
for_admin?: boolean;
|
||||
onClick?: () => void;
|
||||
nestedItems?: NavigationItem[];
|
||||
isActive?: boolean;
|
||||
@@ -40,6 +41,7 @@ export const NAVIGATION_ITEMS: {
|
||||
label: "Снапшоты",
|
||||
icon: GitBranch,
|
||||
path: "/snapshot",
|
||||
for_admin: true,
|
||||
},
|
||||
{
|
||||
id: "map",
|
||||
@@ -52,6 +54,7 @@ export const NAVIGATION_ITEMS: {
|
||||
label: "Устройства",
|
||||
icon: Cpu,
|
||||
path: "/devices",
|
||||
for_admin: true,
|
||||
},
|
||||
// {
|
||||
// id: "vehicles",
|
||||
@@ -64,6 +67,7 @@ export const NAVIGATION_ITEMS: {
|
||||
label: "Пользователи",
|
||||
icon: Users,
|
||||
path: "/user",
|
||||
for_admin: true,
|
||||
},
|
||||
{
|
||||
id: "all",
|
||||
@@ -106,12 +110,14 @@ export const NAVIGATION_ITEMS: {
|
||||
label: "Страны",
|
||||
icon: Earth,
|
||||
path: "/country",
|
||||
for_admin: true,
|
||||
},
|
||||
{
|
||||
id: "cities",
|
||||
label: "Города",
|
||||
icon: Building2,
|
||||
path: "/city",
|
||||
for_admin: true,
|
||||
},
|
||||
{
|
||||
id: "carriers",
|
||||
@@ -119,6 +125,7 @@ export const NAVIGATION_ITEMS: {
|
||||
// @ts-ignore
|
||||
icon: CarrierSvg,
|
||||
path: "/carrier",
|
||||
for_admin: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const API_URL = "https://wn.krbl.ru";
|
||||
export const API_URL = import.meta.env.VITE_API_URL;
|
||||
export const MEDIA_TYPE_LABELS = {
|
||||
1: "Фото",
|
||||
2: "Видео",
|
||||
@@ -8,6 +8,8 @@ export const MEDIA_TYPE_LABELS = {
|
||||
6: "3Д-модель",
|
||||
};
|
||||
|
||||
export * from "./mediaTypes";
|
||||
|
||||
export const MEDIA_TYPE_VALUES = {
|
||||
image: 1,
|
||||
video: 2,
|
||||
|
||||
85
src/shared/const/mediaTypes.ts
Normal file
85
src/shared/const/mediaTypes.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// Допустимые типы и расширения файлов для медиа
|
||||
export const ALLOWED_MEDIA_TYPES = {
|
||||
image: {
|
||||
extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"],
|
||||
mimeTypes: [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
"image/svg+xml",
|
||||
],
|
||||
accept: "image/*",
|
||||
},
|
||||
video: {
|
||||
extensions: [".mp4", ".webm", ".ogg", ".mov", ".avi"],
|
||||
mimeTypes: [
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/ogg",
|
||||
"video/quicktime",
|
||||
"video/x-msvideo",
|
||||
],
|
||||
accept: "video/*",
|
||||
},
|
||||
model3d: {
|
||||
extensions: [".glb", ".gltf"],
|
||||
mimeTypes: ["model/gltf-binary", "model/gltf+json"],
|
||||
accept: ".glb,.gltf",
|
||||
},
|
||||
panorama: {
|
||||
extensions: [".jpg", ".jpeg", ".png"],
|
||||
mimeTypes: ["image/jpeg", "image/png"],
|
||||
accept: "image/*",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const getAllAllowedExtensions = (): string[] => {
|
||||
return [
|
||||
...ALLOWED_MEDIA_TYPES.image.extensions,
|
||||
...ALLOWED_MEDIA_TYPES.video.extensions,
|
||||
...ALLOWED_MEDIA_TYPES.model3d.extensions,
|
||||
];
|
||||
};
|
||||
|
||||
export const getAllAcceptString = (): string => {
|
||||
return `${ALLOWED_MEDIA_TYPES.image.accept},${ALLOWED_MEDIA_TYPES.video.accept},${ALLOWED_MEDIA_TYPES.model3d.accept}`;
|
||||
};
|
||||
|
||||
export const validateFileExtension = (
|
||||
file: File
|
||||
): { valid: boolean; error?: string } => {
|
||||
const fileName = file.name.toLowerCase();
|
||||
const extension = fileName.substring(fileName.lastIndexOf("."));
|
||||
const allowedExtensions = getAllAllowedExtensions();
|
||||
|
||||
if (!allowedExtensions.includes(extension)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Неподдерживаемый формат файла "${extension}". Допустимые форматы: ${allowedExtensions.join(
|
||||
", "
|
||||
)}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
export const filterValidFiles = (
|
||||
files: File[]
|
||||
): { validFiles: File[]; errors: string[] } => {
|
||||
const validFiles: File[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
files.forEach((file) => {
|
||||
const validation = validateFileExtension(file);
|
||||
if (validation.valid) {
|
||||
validFiles.push(file);
|
||||
} else {
|
||||
errors.push(`${file.name}: ${validation.error}`);
|
||||
}
|
||||
});
|
||||
|
||||
return { validFiles, errors };
|
||||
};
|
||||
1
src/shared/hooks/index.ts
Normal file
1
src/shared/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./useSelectedCity";
|
||||
12
src/shared/hooks/useSelectedCity.ts
Normal file
12
src/shared/hooks/useSelectedCity.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { selectedCityStore } from "@shared";
|
||||
|
||||
export const useSelectedCity = () => {
|
||||
const { selectedCity, selectedCityId, selectedCityName } = selectedCityStore;
|
||||
|
||||
return {
|
||||
selectedCity,
|
||||
selectedCityId,
|
||||
selectedCityName,
|
||||
hasSelectedCity: !!selectedCity,
|
||||
};
|
||||
};
|
||||
@@ -5,3 +5,4 @@ export * from "./store";
|
||||
export * from "./const";
|
||||
export * from "./api";
|
||||
export * from "./modals";
|
||||
export * from "./hooks";
|
||||
|
||||
82
src/shared/lib/gltfCacheManager.ts
Normal file
82
src/shared/lib/gltfCacheManager.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./mui/theme";
|
||||
export * from "./DecodeJWT";
|
||||
export * from "./gltfCacheManager";
|
||||
|
||||
/**
|
||||
* Генерирует название медиа по умолчанию в разных форматах
|
||||
|
||||
@@ -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,
|
||||
@@ -81,18 +82,42 @@ 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);
|
||||
setMediaUrl(URL.createObjectURL(initialFile));
|
||||
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) {
|
||||
setMediaFile(fileToUpload);
|
||||
@@ -104,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); // По умолчанию Фото
|
||||
@@ -206,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) => {
|
||||
@@ -257,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();
|
||||
};
|
||||
|
||||
@@ -326,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}
|
||||
@@ -336,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
|
||||
@@ -349,6 +421,8 @@ export const UploadMediaDialog = observer(
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
onLoad={() => setIsPreviewLoaded(true)}
|
||||
onError={() => setIsPreviewLoaded(true)}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
@@ -370,9 +444,17 @@ export const UploadMediaDialog = observer(
|
||||
)
|
||||
}
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || (!mediaName && !mediaFilename)}
|
||||
disabled={
|
||||
isLoading ||
|
||||
(!mediaName && !mediaFilename) ||
|
||||
!isPreviewLoaded
|
||||
}
|
||||
>
|
||||
{isLoading ? "Сохранение..." : "Сохранить"}
|
||||
{isLoading
|
||||
? "Сохранение..."
|
||||
: !isPreviewLoaded
|
||||
? "Загрузка превью..."
|
||||
: "Сохранить"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -497,9 +497,7 @@ class EditSightStore {
|
||||
media_name: media_name,
|
||||
media_type: type,
|
||||
};
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
createLinkWithArticle = async (media: {
|
||||
|
||||
15
src/shared/store/MenuStore/index.ts
Normal file
15
src/shared/store/MenuStore/index.ts
Normal 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();
|
||||
101
src/shared/store/ModelLoadingStore/index.ts
Normal file
101
src/shared/store/ModelLoadingStore/index.ts
Normal 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();
|
||||
@@ -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];
|
||||
|
||||
48
src/shared/store/SelectedCityStore/index.ts
Normal file
48
src/shared/store/SelectedCityStore/index.ts
Normal 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();
|
||||
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
};
|
||||
|
||||
|
||||
@@ -14,3 +14,5 @@ export * from "./RouteStore";
|
||||
export * from "./UserStore";
|
||||
export * from "./CarrierStore";
|
||||
export * from "./StationsStore";
|
||||
export * from "./MenuStore";
|
||||
export * from "./SelectedCityStore";
|
||||
|
||||
143
src/shared/ui/LoadingSpinner/index.tsx
Normal file
143
src/shared/ui/LoadingSpinner/index.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
CircularProgress,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
} from "@mui/material";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
progress?: number;
|
||||
message?: string;
|
||||
size?: number;
|
||||
color?: "primary" | "secondary" | "error" | "info" | "success" | "warning";
|
||||
variant?: "circular" | "linear";
|
||||
showPercentage?: boolean;
|
||||
thickness?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
progress,
|
||||
message = "Загрузка...",
|
||||
size = 40,
|
||||
color = "primary",
|
||||
variant = "circular",
|
||||
showPercentage = true,
|
||||
thickness = 4,
|
||||
className,
|
||||
}) => {
|
||||
if (variant === "linear") {
|
||||
return (
|
||||
<Box
|
||||
className={className}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 2,
|
||||
padding: 3,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: "100%", maxWidth: 300 }}>
|
||||
<LinearProgress
|
||||
variant={progress !== undefined ? "determinate" : "indeterminate"}
|
||||
value={progress}
|
||||
color={color}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.1)",
|
||||
"& .MuiLinearProgress-bar": {
|
||||
borderRadius: 4,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{showPercentage && progress !== undefined && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
display: "block",
|
||||
textAlign: "center",
|
||||
mt: 1,
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{`${Math.round(progress)}%`}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{message && (
|
||||
<Typography variant="body2" color="text.secondary" textAlign="center">
|
||||
{message}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={className}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 2,
|
||||
padding: 3,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: "relative", display: "inline-flex" }}>
|
||||
<CircularProgress
|
||||
size={size}
|
||||
color={color}
|
||||
variant={progress !== undefined ? "determinate" : "indeterminate"}
|
||||
value={progress}
|
||||
thickness={thickness}
|
||||
sx={{
|
||||
"& .MuiCircularProgress-circle": {
|
||||
strokeLinecap: "round",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{showPercentage && progress !== undefined && (
|
||||
<Box
|
||||
sx={{
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
position: "absolute",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="div"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontSize: size * 0.25,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{`${Math.round(progress)}%`}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{message && (
|
||||
<Typography variant="body2" color="text.secondary" textAlign="center">
|
||||
{message}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
196
src/shared/ui/ModelLoadingIndicator/index.tsx
Normal file
196
src/shared/ui/ModelLoadingIndicator/index.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
CircularProgress,
|
||||
} from "@mui/material";
|
||||
|
||||
interface ModelLoadingIndicatorProps {
|
||||
progress?: number;
|
||||
message?: string;
|
||||
isVisible?: boolean;
|
||||
variant?: "overlay" | "inline";
|
||||
size?: "small" | "medium" | "large";
|
||||
showDetails?: boolean;
|
||||
}
|
||||
|
||||
export const ModelLoadingIndicator: React.FC<ModelLoadingIndicatorProps> = ({
|
||||
progress = 0,
|
||||
message = "Загрузка 3D модели...",
|
||||
isVisible = true,
|
||||
variant = "overlay",
|
||||
size = "medium",
|
||||
showDetails = true,
|
||||
}) => {
|
||||
const sizeConfig = {
|
||||
small: {
|
||||
spinnerSize: 32,
|
||||
fontSize: "0.75rem",
|
||||
progressBarWidth: 150,
|
||||
padding: 2,
|
||||
},
|
||||
medium: {
|
||||
spinnerSize: 48,
|
||||
fontSize: "0.875rem",
|
||||
progressBarWidth: 200,
|
||||
padding: 3,
|
||||
},
|
||||
large: {
|
||||
spinnerSize: 64,
|
||||
fontSize: "1rem",
|
||||
progressBarWidth: 250,
|
||||
padding: 4,
|
||||
},
|
||||
};
|
||||
|
||||
const currentSize = sizeConfig[size];
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const getProgressStage = (progress: number): string => {
|
||||
if (progress < 20) return "Инициализация...";
|
||||
if (progress < 40) return "Загрузка геометрии...";
|
||||
if (progress < 60) return "Обработка материалов...";
|
||||
if (progress < 80) return "Загрузка текстур...";
|
||||
if (progress < 95) return "Финализация...";
|
||||
if (progress === 100) return "Готово!";
|
||||
return "Загрузка...";
|
||||
};
|
||||
|
||||
const content = (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 2,
|
||||
padding: currentSize.padding,
|
||||
textAlign: "center",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{/* Крутяшка с процентами */}
|
||||
<Box sx={{ position: "relative", display: "inline-flex" }}>
|
||||
<CircularProgress
|
||||
size={currentSize.spinnerSize}
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
thickness={4}
|
||||
sx={{
|
||||
color: "primary.main",
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
position: "absolute",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="div"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontSize: currentSize.spinnerSize * 0.25,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{`${Math.round(progress)}%`}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Линейный прогресс бар */}
|
||||
<Box sx={{ width: "100%", maxWidth: currentSize.progressBarWidth }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
color="primary"
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.1)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Основное сообщение */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontSize: currentSize.fontSize,
|
||||
fontWeight: 500,
|
||||
maxWidth: 280,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</Typography>
|
||||
|
||||
{/* Детальная информация о прогрессе */}
|
||||
{showDetails && progress > 0 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.disabled"
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
opacity: 0.8,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{getProgressStage(progress)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (variant === "overlay") {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.95)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
borderRadius: 1,
|
||||
border: "1px solid rgba(0, 0, 0, 0.05)",
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: 200,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.02)",
|
||||
borderRadius: 2,
|
||||
border: "1px dashed",
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
77
src/widgets/CitySelector/index.tsx
Normal file
77
src/widgets/CitySelector/index.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -202,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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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(() => {
|
||||
@@ -55,7 +62,7 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
|
||||
>
|
||||
<Menu />
|
||||
</IconButton>
|
||||
<div></div>
|
||||
<CitySelector />
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex flex-col gap-1">
|
||||
{(() => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { Box, Button } from "@mui/material";
|
||||
import { editSightStore, SelectMediaDialog, UploadMediaDialog } from "@shared";
|
||||
import {
|
||||
editSightStore,
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
filterValidFiles,
|
||||
getAllAcceptString,
|
||||
} from "@shared";
|
||||
import { Upload } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useState, DragEvent, useRef } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const MediaAreaForSight = observer(
|
||||
({
|
||||
@@ -38,11 +45,18 @@ export const MediaAreaForSight = observer(
|
||||
setIsDragging(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length && onFilesDrop) {
|
||||
setFileToUpload(files[0]);
|
||||
}
|
||||
if (files.length) {
|
||||
const { validFiles, errors } = filterValidFiles(files);
|
||||
|
||||
setUploadMediaDialogOpen(true);
|
||||
if (errors.length > 0) {
|
||||
errors.forEach((error: string) => toast.error(error));
|
||||
}
|
||||
|
||||
if (validFiles.length > 0 && onFilesDrop) {
|
||||
setFileToUpload(validFiles[0]);
|
||||
setUploadMediaDialogOpen(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
@@ -60,10 +74,18 @@ export const MediaAreaForSight = observer(
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length && onFilesDrop) {
|
||||
setFileToUpload(files[0]);
|
||||
onFilesDrop(files);
|
||||
setUploadMediaDialogOpen(true);
|
||||
if (files.length) {
|
||||
const { validFiles, errors } = filterValidFiles(files);
|
||||
|
||||
if (errors.length > 0) {
|
||||
errors.forEach((error: string) => toast.error(error));
|
||||
}
|
||||
|
||||
if (validFiles.length > 0 && onFilesDrop) {
|
||||
setFileToUpload(validFiles[0]);
|
||||
onFilesDrop(validFiles);
|
||||
setUploadMediaDialogOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
|
||||
@@ -76,7 +98,7 @@ export const MediaAreaForSight = observer(
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
accept="image/*,video/*,.glb,.gltf"
|
||||
accept={getAllAcceptString()}
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,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>
|
||||
);
|
||||
};
|
||||
|
||||
240
src/widgets/MediaViewer/ThreeViewErrorBoundary.tsx
Normal file
240
src/widgets/MediaViewer/ThreeViewErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 />
|
||||
|
||||
24
src/widgets/SaveWithoutCityAgree/index.tsx
Normal file
24
src/widgets/SaveWithoutCityAgree/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -35,8 +35,7 @@ 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 }) => {
|
||||
@@ -51,7 +50,6 @@ 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" | "video_preview" | null
|
||||
@@ -62,21 +60,15 @@ export const CreateInformationTab = observer(
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
|
||||
>(null);
|
||||
|
||||
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
|
||||
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
|
||||
|
||||
useEffect(() => {}, [hardcodeType]);
|
||||
// const handleMenuOpen = (
|
||||
// event: React.MouseEvent<HTMLElement>,
|
||||
// type: "thumbnail" | "watermark_lu" | "watermark_rd"
|
||||
// ) => {
|
||||
// setMenuAnchorEl(event.currentTarget);
|
||||
// setActiveMenuType(type);
|
||||
// };
|
||||
|
||||
useEffect(() => {
|
||||
// Показывать только при инициализации (не менять при ошибках пользователя)
|
||||
if (sight.latitude !== 0 || sight.longitude !== 0) {
|
||||
setCoordinates(`${sight.latitude}, ${sight.longitude}`);
|
||||
}
|
||||
// если координаты обнулились — оставить поле как есть
|
||||
}, [sight.latitude, sight.longitude]);
|
||||
|
||||
const handleMenuClose = () => {
|
||||
@@ -125,6 +117,30 @@ export const CreateInformationTab = observer(
|
||||
}
|
||||
};
|
||||
|
||||
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}>
|
||||
@@ -134,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%]">
|
||||
@@ -146,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,
|
||||
@@ -215,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);
|
||||
|
||||
@@ -260,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
|
||||
@@ -348,7 +362,7 @@ export const CreateInformationTab = observer(
|
||||
/>
|
||||
|
||||
<VideoPreviewCard
|
||||
title="Видео превью"
|
||||
title="Видеозаставка"
|
||||
videoId={sight.video_preview}
|
||||
onVideoClick={handleVideoPreviewClick}
|
||||
onDeleteVideoClick={() => {
|
||||
@@ -358,12 +372,10 @@ export const CreateInformationTab = observer(
|
||||
}}
|
||||
onSelectVideoClick={(file) => {
|
||||
if (file) {
|
||||
// Если передан файл, открываем диалог загрузки медиа
|
||||
createSightStore.setFileToUpload(file);
|
||||
setActiveMenuType("video_preview");
|
||||
setIsUploadMediaOpen(true);
|
||||
} else {
|
||||
// Если файл не передан, открываем диалог выбора существующих медиа
|
||||
setActiveMenuType("video_preview");
|
||||
setIsAddMediaOpen(true);
|
||||
}
|
||||
@@ -373,31 +385,25 @@ export const CreateInformationTab = observer(
|
||||
</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>
|
||||
@@ -405,7 +411,6 @@ export const CreateInformationTab = observer(
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* Media Menu */}
|
||||
<MuiMenu
|
||||
anchorEl={menuAnchorEl}
|
||||
open={Boolean(menuAnchorEl)}
|
||||
@@ -471,7 +476,6 @@ export const CreateInformationTab = observer(
|
||||
initialFile={createSightStore.fileToUpload || undefined}
|
||||
/>
|
||||
|
||||
{/* Модальное окно предпросмотра видео */}
|
||||
{sight.video_preview && sight.video_preview !== "" && (
|
||||
<Dialog
|
||||
open={isVideoPreviewOpen}
|
||||
@@ -498,6 +502,16 @@ export const CreateInformationTab = observer(
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
|
||||
{isSaveWarningOpen && (
|
||||
<SaveWithoutCityAgree
|
||||
blocker={{
|
||||
proceed: handleConfirmSave,
|
||||
reset: handleCancelSave,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,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 }) => {
|
||||
@@ -51,7 +52,6 @@ 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" | "video_preview" | null
|
||||
@@ -62,15 +62,15 @@ export const InformationTab = observer(
|
||||
"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 = () => {
|
||||
@@ -119,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}>
|
||||
@@ -128,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%]">
|
||||
@@ -141,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,
|
||||
@@ -208,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);
|
||||
|
||||
@@ -260,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
|
||||
@@ -358,7 +386,7 @@ export const InformationTab = observer(
|
||||
/>
|
||||
|
||||
<VideoPreviewCard
|
||||
title="Видео превью"
|
||||
title="Видеозаставка"
|
||||
videoId={sight.common.video_preview}
|
||||
onVideoClick={handleVideoPreviewClick}
|
||||
onDeleteVideoClick={() => {
|
||||
@@ -372,12 +400,10 @@ export const InformationTab = observer(
|
||||
}}
|
||||
onSelectVideoClick={(file) => {
|
||||
if (file) {
|
||||
// Если передан файл, открываем диалог загрузки медиа
|
||||
editSightStore.setFileToUpload(file);
|
||||
setActiveMenuType("video_preview");
|
||||
setIsUploadMediaOpen(true);
|
||||
} else {
|
||||
// Если файл не передан, открываем диалог выбора существующих медиа
|
||||
setActiveMenuType("video_preview");
|
||||
setIsAddMediaOpen(true);
|
||||
}
|
||||
@@ -387,31 +413,25 @@ export const InformationTab = observer(
|
||||
</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>
|
||||
@@ -419,7 +439,6 @@ export const InformationTab = observer(
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* Media Menu */}
|
||||
<MuiMenu
|
||||
anchorEl={menuAnchorEl}
|
||||
open={Boolean(menuAnchorEl)}
|
||||
@@ -492,7 +511,6 @@ export const InformationTab = observer(
|
||||
mediaId={mediaId}
|
||||
/>
|
||||
|
||||
{/* Модальное окно предпросмотра видео */}
|
||||
{sight.common.video_preview && sight.common.video_preview !== "" && (
|
||||
<Dialog
|
||||
open={isVideoPreviewOpen}
|
||||
@@ -519,7 +537,17 @@ export const InformationTab = observer(
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
|
||||
{isSaveWarningOpen && (
|
||||
<SaveWithoutCityAgree
|
||||
blocker={{
|
||||
proceed: handleConfirmSave,
|
||||
reset: handleCancelSave,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
);
|
||||
@@ -316,31 +316,35 @@ export const LeftWidgetTab = observer(
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
<img
|
||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
sight.common.watermark_lu
|
||||
}/download?token=${token}`}
|
||||
alt="preview"
|
||||
className="absolute top-4 left-4 z-10"
|
||||
style={{
|
||||
width: "30px",
|
||||
height: "30px",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
{sight.common.watermark_lu && (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
sight.common.watermark_lu
|
||||
}/download?token=${token}`}
|
||||
alt="preview"
|
||||
className="absolute top-4 left-4 z-10"
|
||||
style={{
|
||||
width: "30px",
|
||||
height: "30px",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<img
|
||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
sight.common.watermark_rd
|
||||
}/download?token=${token}`}
|
||||
alt="preview"
|
||||
className="absolute bottom-4 right-4 z-10"
|
||||
style={{
|
||||
width: "30px",
|
||||
height: "30px",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
{sight.common.watermark_rd && (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
sight.common.watermark_rd
|
||||
}/download?token=${token}`}
|
||||
alt="preview"
|
||||
className="absolute bottom-4 right-4 z-10"
|
||||
style={{
|
||||
width: "30px",
|
||||
height: "30px",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<ImagePlus size={48} color="white" />
|
||||
|
||||
@@ -87,7 +87,6 @@ export const RightWidgetTab = observer(
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
console.log(sight[language].right);
|
||||
}, [sight.common.id]);
|
||||
|
||||
const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>(
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -17,4 +17,6 @@ export * from "./LeaveAgree";
|
||||
export * from "./DeleteModal";
|
||||
export * from "./SnapshotRestore";
|
||||
export * from "./CreateButton";
|
||||
export * from "./SaveWithoutCityAgree";
|
||||
export * from "./CitySelector";
|
||||
export * from "./modals";
|
||||
|
||||
@@ -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
28
yarn.lock
28
yarn.lock
@@ -174,24 +174,24 @@
|
||||
integrity sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==
|
||||
|
||||
"@emnapi/core@^1.4.3":
|
||||
version "1.4.4"
|
||||
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.4.4.tgz#76620673f3033626c6d79b1420d69f06a6bb153c"
|
||||
integrity sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.6.0.tgz#517f65d1c8270d5d5aa1aad660d5acb897430dca"
|
||||
integrity sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg==
|
||||
dependencies:
|
||||
"@emnapi/wasi-threads" "1.0.3"
|
||||
"@emnapi/wasi-threads" "1.1.0"
|
||||
tslib "^2.4.0"
|
||||
|
||||
"@emnapi/runtime@^1.4.3":
|
||||
version "1.4.4"
|
||||
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.4.4.tgz#19a8f00719c51124e2d0fbf4aaad3fa7b0c92524"
|
||||
integrity sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg==
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.6.0.tgz#8fe297e0090f6e89a57a1f31f1c440bdbc3c01d8"
|
||||
integrity sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==
|
||||
dependencies:
|
||||
tslib "^2.4.0"
|
||||
|
||||
"@emnapi/wasi-threads@1.0.3", "@emnapi/wasi-threads@^1.0.2":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.0.3.tgz#83fa228bde0e71668aad6db1af4937473d1d3ab1"
|
||||
integrity sha512-8K5IFFsQqF9wQNJptGbS6FNKgUTsSRYnTqNCG1vPP8jFdjSv18n2mQfJpkt2Oibo9iBEzcDnDxNwKTzC7svlJw==
|
||||
"@emnapi/wasi-threads@1.1.0", "@emnapi/wasi-threads@^1.0.2":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf"
|
||||
integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==
|
||||
dependencies:
|
||||
tslib "^2.4.0"
|
||||
|
||||
@@ -1024,9 +1024,9 @@
|
||||
integrity sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==
|
||||
|
||||
"@tybys/wasm-util@^0.10.0":
|
||||
version "0.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.0.tgz#2fd3cd754b94b378734ce17058d0507c45c88369"
|
||||
integrity sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==
|
||||
version "0.10.1"
|
||||
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414"
|
||||
integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==
|
||||
dependencies:
|
||||
tslib "^2.4.0"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user