Compare commits

...

18 Commits

Author SHA1 Message Date
50ad374cf5 feat: Add selected city functional with some debugging 2025-10-22 03:04:58 +03:00
9e47ab667f feat: Update admin panel 2025-10-22 02:55:04 +03:00
1b8fc3d215 fix: fix upload bug 3d 2025-10-20 20:00:28 +03:00
f5142ec95d fix: Env 2025-10-13 14:34:56 +03:00
cdb96dfb8b fix: Fix errors 2025-10-10 08:40:39 +03:00
c50ccb3a0c fix: Add more filters by city for station list and sight list 2025-10-06 10:35:15 +03:00
4bcc2e2cca fix: Fix webp + delete ctrl z + filter by city 2025-10-06 10:03:41 +03:00
26e4d70b95 fix: Fix 3d models 2025-10-02 22:20:37 +03:00
a357994025 feat: Update pop-up logic 2025-10-02 04:45:43 +03:00
7382a85082 feat: Update city logic in map page with scrollbar 2025-10-02 04:38:15 +03:00
db64beb3ee feat: Select city in top of the page for next usage in create/edit pages 2025-09-28 10:41:13 +03:00
Микаэл Оганесян
1abd6b30a4 build fix 2025-09-27 22:31:14 -07:00
Микаэл Оганесян
b25df42960 hotfix admin panel 2025-09-27 22:29:13 -07:00
34ba3c1db0 Add Dockerfile and Makefile for containerization and build automation
- Created a Dockerfile with a multi-stage build process to containerize the application.
- Added Makefile for managing build, export, and cleanup tasks.
2025-07-29 17:39:21 +03:00
4f038551a2 fix: Fix problems and bugs 2025-07-28 08:18:21 +03:00
470a58a3fa fix: Fix panorama + route scale data 2025-07-26 11:48:41 +03:00
89d7fc2748 feat: Add scale on group click, add cache for map entities, fix map preview loading 2025-07-15 05:29:27 +03:00
97f95fc394 feat: Group map entities + delete useless logs 2025-07-13 20:56:25 +03:00
80 changed files with 3880 additions and 1246 deletions

1
.env
View File

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

32
Dockerfile Normal file
View File

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

38
Makefile Normal file
View File

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

14
package-lock.json generated
View File

@@ -6592,20 +6592,6 @@
"node": ">=18" "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": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import type { NavigationItem } from "../model"; import type { NavigationItem } from "../model";
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { authStore } from "@shared";
interface NavigationItemProps { interface NavigationItemProps {
item: NavigationItem; item: NavigationItem;
@@ -30,9 +31,21 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [isExpanded, setIsExpanded] = React.useState(false); 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 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 = () => { const handleClick = () => {
if (item.id === "all" && !open) { if (item.id === "all" && !open) {
onDrawerOpen?.(); onDrawerOpen?.();
@@ -108,15 +121,16 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
}, },
]} ]}
/> />
{item.nestedItems && {filteredNestedItems &&
filteredNestedItems.length > 0 &&
open && open &&
(isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />)} (isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />)}
</ListItemButton> </ListItemButton>
</ListItem> </ListItem>
{item.nestedItems && ( {filteredNestedItems && filteredNestedItems.length > 0 && (
<Collapse in={isExpanded} timeout="auto" unmountOnExit> <Collapse in={isExpanded} timeout="auto" unmountOnExit>
<List component="div" disablePadding> <List component="div" disablePadding>
{item.nestedItems.map((nestedItem) => ( {filteredNestedItems.map((nestedItem) => (
<NavigationItemComponent <NavigationItemComponent
key={nestedItem.id} key={nestedItem.id}
item={nestedItem} item={nestedItem}

View File

@@ -1,41 +1,62 @@
import List from "@mui/material/List"; import List from "@mui/material/List";
import Divider from "@mui/material/Divider"; import Divider from "@mui/material/Divider";
import { NAVIGATION_ITEMS } from "@shared"; import { authStore, NAVIGATION_ITEMS } from "@shared";
import { NavigationItem, NavigationItemComponent } from "@entities"; import { NavigationItem, NavigationItemComponent } from "@entities";
import { observer } from "mobx-react-lite";
interface NavigationListProps { interface NavigationListProps {
open: boolean; open: boolean;
onDrawerOpen?: () => void; onDrawerOpen?: () => void;
} }
export const NavigationList = ({ open, onDrawerOpen }: NavigationListProps) => { export const NavigationList = observer(
const primaryItems = NAVIGATION_ITEMS.primary; ({ open, onDrawerOpen }: NavigationListProps) => {
const secondaryItems = NAVIGATION_ITEMS.secondary; const { payload } = authStore;
// @ts-ignore
const isAdmin = Boolean(payload?.is_admin) || false;
return ( const primaryItems = NAVIGATION_ITEMS.primary.filter((item) => {
<> if (item.for_admin) {
<List> return isAdmin;
{primaryItems.map((item) => ( }
<NavigationItemComponent
key={item.id} if (item.nestedItems && item.nestedItems.length > 0) {
item={item as NavigationItem} return item.nestedItems.some((nestedItem) => {
open={open} if (nestedItem.for_admin) {
onDrawerOpen={onDrawerOpen} return isAdmin;
/> }
))} return true;
</List> });
<Divider /> }
<List>
{secondaryItems.map((item) => ( return true;
<NavigationItemComponent });
key={item.id}
item={item as NavigationItem} return (
open={open} <>
onClick={item.onClick ? item.onClick : undefined} <List>
onDrawerOpen={onDrawerOpen} {primaryItems.map((item) => (
/> <NavigationItemComponent
))} key={item.id}
</List> item={item as NavigationItem}
</> open={open}
); onDrawerOpen={onDrawerOpen}
}; />
))}
</List>
<Divider />
<List>
{NAVIGATION_ITEMS.secondary.map((item) => (
<NavigationItemComponent
key={item.id}
item={item as NavigationItem}
open={open}
onClick={item.onClick ? item.onClick : undefined}
onDrawerOpen={onDrawerOpen}
/>
))}
</List>
</>
);
}
);

View File

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

View File

@@ -12,7 +12,13 @@ import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { carrierStore, cityStore, mediaStore, languageStore } from "@shared"; import {
carrierStore,
cityStore,
mediaStore,
languageStore,
useSelectedCity,
} from "@shared";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher } from "@widgets"; import { ImageUploadCard, LanguageSwitcher } from "@widgets";
import { import {
@@ -25,6 +31,7 @@ export const CarrierCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const { createCarrierData, setCreateCarrierData } = carrierStore; const { createCarrierData, setCreateCarrierData } = carrierStore;
const { language } = languageStore; const { language } = languageStore;
const { selectedCityId } = useSelectedCity();
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null); const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
@@ -41,6 +48,20 @@ export const CarrierCreatePage = observer(() => {
languageStore.setLanguage("ru"); 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 () => { const handleCreate = async () => {
try { try {
setIsLoading(true); setIsLoading(true);

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ import {
Tab, Tab,
Box, Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import { import {
@@ -33,7 +34,12 @@ import {
DropResult, DropResult,
} from "@hello-pangea/dnd"; } from "@hello-pangea/dnd";
import { authInstance, languageStore, routeStore } from "@shared"; import {
authInstance,
languageStore,
routeStore,
selectedCityStore,
} from "@shared";
import { EditStationModal } from "../../widgets/modals/EditStationModal"; import { EditStationModal } from "../../widgets/modals/EditStationModal";
// Helper function to insert an item at a specific position (1-based index) // Helper function to insert an item at a specific position (1-based index)
@@ -73,7 +79,6 @@ type LinkedItemsProps<T> = {
disableCreation?: boolean; disableCreation?: boolean;
updatedLinkedItems?: T[]; updatedLinkedItems?: T[];
refresh?: number; refresh?: number;
cityId?: number;
routeDirection?: boolean; routeDirection?: boolean;
}; };
@@ -112,7 +117,7 @@ export const LinkedItems = <
); );
}; };
export const LinkedItemsContents = < const LinkedItemsContentsInner = <
T extends { id: number; name: string; [key: string]: any } T extends { id: number; name: string; [key: string]: any }
>({ >({
parentId, parentId,
@@ -124,7 +129,6 @@ export const LinkedItemsContents = <
disableCreation = false, disableCreation = false,
updatedLinkedItems, updatedLinkedItems,
refresh, refresh,
cityId,
routeDirection, routeDirection,
}: LinkedItemsProps<T>) => { }: LinkedItemsProps<T>) => {
const { language } = languageStore; const { language } = languageStore;
@@ -140,9 +144,7 @@ export const LinkedItemsContents = <
const [activeTab, setActiveTab] = useState(0); const [activeTab, setActiveTab] = useState(0);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
useEffect(() => { useEffect(() => {}, [error]);
console.log(error);
}, [error]);
const parentResource = "route"; const parentResource = "route";
const childResource = "station"; const childResource = "station";
@@ -155,17 +157,20 @@ export const LinkedItemsContents = <
// Фильтруем станции по направлению маршрута // Фильтруем станции по направлению маршрута
return item.direction === routeDirection; 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)); .sort((a, b) => a.name.localeCompare(b.name));
// Фильтрация по поиску для массового режима // Фильтрация по поиску для массового режима
const filteredAvailableItems = availableItems.filter((item) => { const filteredAvailableItems = availableItems.filter((item) => {
if (!cityId || item.city_id == cityId) { if (!searchQuery.trim()) return true;
if (!searchQuery.trim()) return true; return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());
return String(item.name)
.toLowerCase()
.includes(searchQuery.toLowerCase());
}
return false;
}); });
useEffect(() => { useEffect(() => {
@@ -227,7 +232,7 @@ export const LinkedItemsContents = <
if (type === "edit") { if (type === "edit") {
setError(null); setError(null);
authInstance authInstance
.get(`/${childResource}/`) .get(`/${childResource}`)
.then((response) => { .then((response) => {
setAllItems(response?.data || []); setAllItems(response?.data || []);
}) })
@@ -462,9 +467,7 @@ export const LinkedItemsContents = <
onChange={(_, newValue) => onChange={(_, newValue) =>
setSelectedItemId(newValue?.id || null) setSelectedItemId(newValue?.id || null)
} }
options={availableItems.filter( options={availableItems}
(item) => !cityId || item.city_id == cityId
)}
getOptionLabel={(item) => String(item.name)} getOptionLabel={(item) => String(item.name)}
renderInput={(params) => ( renderInput={(params) => (
<TextField <TextField
@@ -599,3 +602,7 @@ export const LinkedItemsContents = <
</> </>
); );
}; };
export const LinkedItemsContents = observer(
LinkedItemsContentsInner
) as typeof LinkedItemsContentsInner;

View File

@@ -16,13 +16,18 @@ import {
import { MediaViewer } from "@widgets"; import { MediaViewer } from "@widgets";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save, Plus } from "lucide-react"; 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 { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { carrierStore } from "../../../shared/store/CarrierStore"; import { carrierStore } from "../../../shared/store/CarrierStore";
import { articlesStore } from "../../../shared/store/ArticlesStore"; import { articlesStore } from "../../../shared/store/ArticlesStore";
import { Route, routeStore } from "../../../shared/store/RouteStore"; import { Route, routeStore } from "../../../shared/store/RouteStore";
import { languageStore, SelectArticleModal, SelectMediaDialog } from "@shared"; import {
languageStore,
SelectArticleModal,
SelectMediaDialog,
selectedCityStore,
} from "@shared";
export const RouteCreatePage = observer(() => { export const RouteCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -50,6 +55,21 @@ export const RouteCreatePage = observer(() => {
articlesStore.getArticleList(); articlesStore.getArticleList();
}, [language]); }, [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) => { const validateCoordinates = (value: string) => {
try { try {
const lines = value.trim().split("\n"); const lines = value.trim().split("\n");
@@ -194,16 +214,10 @@ export const RouteCreatePage = observer(() => {
value={carrier} value={carrier}
label="Выберите перевозчика" label="Выберите перевозчика"
onChange={(e) => setCarrier(e.target.value as string)} onChange={(e) => setCarrier(e.target.value as string)}
disabled={ disabled={filteredCarriers.length === 0}
carrierStore.carriers[
language as keyof typeof carrierStore.carriers
].data?.length === 0
}
> >
<MenuItem value="">Не выбрано</MenuItem> <MenuItem value="">Не выбрано</MenuItem>
{carrierStore.carriers[ {filteredCarriers.map((carrier: any) => (
language as keyof typeof carrierStore.carriers
].data?.map((carrier) => (
<MenuItem key={carrier.id} value={carrier.id}> <MenuItem key={carrier.id} value={carrier.id}>
{carrier.full_name} {carrier.full_name}
</MenuItem> </MenuItem>
@@ -301,10 +315,10 @@ export const RouteCreatePage = observer(() => {
</Box> </Box>
</Box> </Box>
{/* Селектор видео превью */} {/* Селектор видеозаставки */}
<Box className="flex flex-col gap-2"> <Box className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700"> <label className="text-sm font-medium text-gray-700">
Видео превью Видеозаставка
</label> </label>
<Box className="flex gap-2"> <Box className="flex gap-2">
<Box <Box

View File

@@ -307,10 +307,10 @@ export const RouteEditPage = observer(() => {
</Box> </Box>
</Box> </Box>
{/* Селектор видео превью */} {/* Селектор видеозаставки */}
<Box className="flex flex-col gap-2"> <Box className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700"> <label className="text-sm font-medium text-gray-700">
Видео превью Видеозаставка
</label> </label>
<Box className="flex gap-2"> <Box className="flex gap-2">
<Box <Box

View File

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

View File

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

View File

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

View File

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

View File

@@ -82,11 +82,7 @@ export const Sight = ({ sight, id }: Readonly<SightProps>) => {
Assets.load("/SightIcon.png").then(setTexture); Assets.load("/SightIcon.png").then(setTexture);
}, []); }, []);
useEffect(() => { useEffect(() => {}, [id, sight.latitude, sight.longitude]);
console.log(
`Rendering Sight ${id + 1} at [${sight.latitude}, ${sight.longitude}]`
);
}, [id, sight.latitude, sight.longitude]);
if (!sight) { if (!sight) {
console.error("sight is null"); console.error("sight is null");

View File

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

View File

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

View File

@@ -1,7 +1,12 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales"; import { ruRU } from "@mui/x-data-grid/locales";
import { cityStore, languageStore, sightsStore } from "@shared"; import {
import { useEffect, useState } from "react"; cityStore,
languageStore,
sightsStore,
selectedCityStore,
} from "@shared";
import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react"; import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -68,6 +73,7 @@ export const SightListPage = observer(() => {
headerName: "Действия", headerName: "Действия",
align: "center", align: "center",
headerAlign: "center", headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
return ( 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, id: sight.id,
name: sight.name, name: sight.name,
city_id: sight.city_id, city_id: sight.city_id,
@@ -132,7 +147,6 @@ export const SightListPage = observer(() => {
loading={isLoading} loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => { onRowSelectionModelChange={(newSelection) => {
console.log(newSelection);
setIds(Array.from(newSelection.ids as unknown as number[])); setIds(Array.from(newSelection.ids as unknown as number[]));
}} }}
slots={{ slots={{

View File

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

View File

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

View File

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

View File

@@ -17,9 +17,10 @@ import {
Paper, Paper,
TableBody, TableBody,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { authInstance, languageStore } from "@shared"; import { authInstance, languageStore, selectedCityStore } from "@shared";
type Field<T> = { type Field<T> = {
label: string; label: string;
@@ -73,7 +74,7 @@ export const LinkedSights = <
); );
}; };
export const LinkedSightsContents = < const LinkedSightsContentsInner = <
T extends { id: number; name: string; [key: string]: any } T extends { id: number; name: string; [key: string]: any }
>({ >({
parentId, parentId,
@@ -93,15 +94,21 @@ export const LinkedSightsContents = <
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {}, [error]);
console.log(error);
}, [error]);
const parentResource = "station"; const parentResource = "station";
const childResource = "sight"; const childResource = "sight";
const availableItems = allItems const availableItems = allItems
.filter((item) => !linkedItems.some((linked) => linked.id === item.id)) .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)); .sort((a, b) => a.name.localeCompare(b.name));
useEffect(() => { useEffect(() => {
@@ -178,7 +185,7 @@ export const LinkedSightsContents = <
if (type === "edit") { if (type === "edit") {
setError(null); setError(null);
authInstance authInstance
.get(`/${childResource}/`) .get(`/${childResource}`)
.then((response) => { .then((response) => {
setAllItems(response?.data || []); setAllItems(response?.data || []);
}) })
@@ -315,3 +322,7 @@ export const LinkedSightsContents = <
</> </>
); );
}; };
export const LinkedSightsContents = observer(
LinkedSightsContentsInner
) as typeof LinkedSightsContentsInner;

View File

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

View File

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

View File

@@ -1,6 +1,11 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales"; import { 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 { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Eye, Pencil, Trash2, Minus } from "lucide-react"; import { Eye, Pencil, Trash2, Minus } from "lucide-react";
@@ -21,6 +26,7 @@ export const StationListPage = observer(() => {
useEffect(() => { useEffect(() => {
const fetchStations = async () => { const fetchStations = async () => {
setIsLoading(true); setIsLoading(true);
await cityStore.getCities(language);
await getStationList(); await getStationList();
setIsLoading(false); setIsLoading(false);
}; };
@@ -85,6 +91,7 @@ export const StationListPage = observer(() => {
width: 140, width: 140,
align: "center", align: "center",
headerAlign: "center", headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
return ( 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, id: station.id,
name: station.name, name: station.name,
system_name: station.system_name, system_name: station.system_name,

View File

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

View File

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

View File

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

View File

@@ -25,6 +25,7 @@ interface NavigationItem {
label: string; label: string;
icon?: LucideIcon | React.ReactNode; icon?: LucideIcon | React.ReactNode;
path?: string; path?: string;
for_admin?: boolean;
onClick?: () => void; onClick?: () => void;
nestedItems?: NavigationItem[]; nestedItems?: NavigationItem[];
isActive?: boolean; isActive?: boolean;
@@ -40,6 +41,7 @@ export const NAVIGATION_ITEMS: {
label: "Снапшоты", label: "Снапшоты",
icon: GitBranch, icon: GitBranch,
path: "/snapshot", path: "/snapshot",
for_admin: true,
}, },
{ {
id: "map", id: "map",
@@ -52,6 +54,7 @@ export const NAVIGATION_ITEMS: {
label: "Устройства", label: "Устройства",
icon: Cpu, icon: Cpu,
path: "/devices", path: "/devices",
for_admin: true,
}, },
// { // {
// id: "vehicles", // id: "vehicles",
@@ -64,6 +67,7 @@ export const NAVIGATION_ITEMS: {
label: "Пользователи", label: "Пользователи",
icon: Users, icon: Users,
path: "/user", path: "/user",
for_admin: true,
}, },
{ {
id: "all", id: "all",
@@ -106,12 +110,14 @@ export const NAVIGATION_ITEMS: {
label: "Страны", label: "Страны",
icon: Earth, icon: Earth,
path: "/country", path: "/country",
for_admin: true,
}, },
{ {
id: "cities", id: "cities",
label: "Города", label: "Города",
icon: Building2, icon: Building2,
path: "/city", path: "/city",
for_admin: true,
}, },
{ {
id: "carriers", id: "carriers",
@@ -119,6 +125,7 @@ export const NAVIGATION_ITEMS: {
// @ts-ignore // @ts-ignore
icon: CarrierSvg, icon: CarrierSvg,
path: "/carrier", path: "/carrier",
for_admin: true,
}, },
], ],
}, },

View File

@@ -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 = { export const MEDIA_TYPE_LABELS = {
1: "Фото", 1: "Фото",
2: "Видео", 2: "Видео",
@@ -8,6 +8,8 @@ export const MEDIA_TYPE_LABELS = {
6: "3Д-модель", 6: "3Д-модель",
}; };
export * from "./mediaTypes";
export const MEDIA_TYPE_VALUES = { export const MEDIA_TYPE_VALUES = {
image: 1, image: 1,
video: 2, video: 2,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,9 +3,10 @@ import {
MEDIA_TYPE_VALUES, MEDIA_TYPE_VALUES,
editSightStore, editSightStore,
generateDefaultMediaName, generateDefaultMediaName,
clearBlobAndGLTFCache,
} from "@shared"; } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react"; import { useEffect, useState, useRef } from "react";
import { import {
Dialog, Dialog,
DialogTitle, DialogTitle,
@@ -81,18 +82,42 @@ export const UploadMediaDialog = observer(
const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>( const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>(
[] []
); );
const [isPreviewLoaded, setIsPreviewLoaded] = useState(false);
const previousMediaUrlRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
if (initialFile) { if (initialFile) {
// Очищаем предыдущий blob URL если он существует
if (
previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:")
) {
clearBlobAndGLTFCache(previousMediaUrlRef.current);
}
setMediaFile(initialFile); setMediaFile(initialFile);
setMediaFilename(initialFile.name); setMediaFilename(initialFile.name);
setAvailableMediaTypes([2]); setAvailableMediaTypes([2]);
setMediaType(2); setMediaType(2);
setMediaUrl(URL.createObjectURL(initialFile)); const newBlobUrl = URL.createObjectURL(initialFile);
setMediaUrl(newBlobUrl);
previousMediaUrlRef.current = newBlobUrl;
setMediaName(initialFile.name.replace(/\.[^/.]+$/, "")); setMediaName(initialFile.name.replace(/\.[^/.]+$/, ""));
} }
}, [initialFile]); }, [initialFile]);
// Очистка blob URL при размонтировании компонента
useEffect(() => {
return () => {
if (
previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:")
) {
clearBlobAndGLTFCache(previousMediaUrlRef.current);
}
};
}, []); // Пустой массив зависимостей - выполняется только при размонтировании
useEffect(() => { useEffect(() => {
if (fileToUpload) { if (fileToUpload) {
setMediaFile(fileToUpload); setMediaFile(fileToUpload);
@@ -104,7 +129,11 @@ export const UploadMediaDialog = observer(
setAvailableMediaTypes([6]); setAvailableMediaTypes([6]);
setMediaType(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Д-модель setAvailableMediaTypes([1, 3, 4, 5]); // Фото, Иконка, Водяной знак, Панорама, 3Д-модель
setMediaType(1); // По умолчанию Фото setMediaType(1); // По умолчанию Фото
@@ -206,9 +235,20 @@ export const UploadMediaDialog = observer(
useEffect(() => { useEffect(() => {
if (mediaFile) { 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 fileFormat = useEffect(() => {
// const handleKeyPress = (event: KeyboardEvent) => { // const handleKeyPress = (event: KeyboardEvent) => {
@@ -257,8 +297,20 @@ export const UploadMediaDialog = observer(
}; };
const handleClose = () => { const handleClose = () => {
// Очищаем blob URL и кеш GLTF при закрытии диалога
if (
previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:")
) {
clearBlobAndGLTFCache(previousMediaUrlRef.current);
}
setError(null); setError(null);
setSuccess(false); setSuccess(false);
setMediaUrl(null);
setMediaFile(null);
setIsPreviewLoaded(false);
previousMediaUrlRef.current = null; // Очищаем ref
onClose(); onClose();
}; };
@@ -326,8 +378,22 @@ export const UploadMediaDialog = observer(
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
height: "100%", 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 && ( {mediaType == 2 && mediaUrl && (
<video <video
src={mediaUrl} src={mediaUrl}
@@ -336,10 +402,16 @@ export const UploadMediaDialog = observer(
loop loop
controls controls
style={{ maxWidth: "100%", maxHeight: "100%" }} style={{ maxWidth: "100%", maxHeight: "100%" }}
onLoadedData={() => setIsPreviewLoaded(true)}
onError={() => setIsPreviewLoaded(true)}
/> />
)} )}
{mediaType === 6 && mediaUrl && ( {mediaType === 6 && mediaUrl && (
<ModelViewer3D fileUrl={mediaUrl} height="100%" /> <ModelViewer3D
fileUrl={mediaUrl}
height="100%"
onLoad={() => setIsPreviewLoaded(true)}
/>
)} )}
{mediaType !== 6 && mediaType !== 2 && mediaUrl && ( {mediaType !== 6 && mediaType !== 2 && mediaUrl && (
<img <img
@@ -349,6 +421,8 @@ export const UploadMediaDialog = observer(
height: "100%", height: "100%",
objectFit: "contain", objectFit: "contain",
}} }}
onLoad={() => setIsPreviewLoaded(true)}
onError={() => setIsPreviewLoaded(true)}
/> />
)} )}
</Paper> </Paper>
@@ -370,9 +444,17 @@ export const UploadMediaDialog = observer(
) )
} }
onClick={handleSave} onClick={handleSave}
disabled={isLoading || (!mediaName && !mediaFilename)} disabled={
isLoading ||
(!mediaName && !mediaFilename) ||
!isPreviewLoaded
}
> >
{isLoading ? "Сохранение..." : "Сохранить"} {isLoading
? "Сохранение..."
: !isPreviewLoaded
? "Загрузка превью..."
: "Сохранить"}
</Button> </Button>
</Box> </Box>
</Box> </Box>

View File

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

View File

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

View File

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

View File

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

View File

@@ -82,11 +82,6 @@ class RouteStore {
}; };
setRouteStations = (routeId: number, stationId: number, data: any) => { 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) => this.routeStations[routeId] = this.routeStations[routeId]?.map((station) =>
station.id === stationId ? { ...station, ...data } : station station.id === stationId ? { ...station, ...data } : station
); );
@@ -139,7 +134,6 @@ class RouteStore {
copyRouteAction = async (id: number) => { copyRouteAction = async (id: number) => {
const response = await authInstance.post(`/route/${id}/copy`); const response = await authInstance.post(`/route/${id}/copy`);
console.log(response);
runInAction(() => { runInAction(() => {
this.routes.data = [...this.routes.data, response.data]; this.routes.data = [...this.routes.data, response.data];

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,143 @@
import React from "react";
import {
Box,
CircularProgress,
Typography,
LinearProgress,
} from "@mui/material";
interface LoadingSpinnerProps {
progress?: number;
message?: string;
size?: number;
color?: "primary" | "secondary" | "error" | "info" | "success" | "warning";
variant?: "circular" | "linear";
showPercentage?: boolean;
thickness?: number;
className?: string;
}
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
progress,
message = "Загрузка...",
size = 40,
color = "primary",
variant = "circular",
showPercentage = true,
thickness = 4,
className,
}) => {
if (variant === "linear") {
return (
<Box
className={className}
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 2,
padding: 3,
width: "100%",
}}
>
<Box sx={{ width: "100%", maxWidth: 300 }}>
<LinearProgress
variant={progress !== undefined ? "determinate" : "indeterminate"}
value={progress}
color={color}
sx={{
height: 8,
borderRadius: 4,
backgroundColor: "rgba(0, 0, 0, 0.1)",
"& .MuiLinearProgress-bar": {
borderRadius: 4,
},
}}
/>
{showPercentage && progress !== undefined && (
<Typography
variant="caption"
color="text.secondary"
sx={{
display: "block",
textAlign: "center",
mt: 1,
fontSize: "0.875rem",
fontWeight: 500,
}}
>
{`${Math.round(progress)}%`}
</Typography>
)}
</Box>
{message && (
<Typography variant="body2" color="text.secondary" textAlign="center">
{message}
</Typography>
)}
</Box>
);
}
return (
<Box
className={className}
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 2,
padding: 3,
}}
>
<Box sx={{ position: "relative", display: "inline-flex" }}>
<CircularProgress
size={size}
color={color}
variant={progress !== undefined ? "determinate" : "indeterminate"}
value={progress}
thickness={thickness}
sx={{
"& .MuiCircularProgress-circle": {
strokeLinecap: "round",
},
}}
/>
{showPercentage && progress !== undefined && (
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: "absolute",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Typography
variant="caption"
component="div"
color="text.secondary"
sx={{
fontSize: size * 0.25,
fontWeight: 600,
lineHeight: 1,
}}
>
{`${Math.round(progress)}%`}
</Typography>
</Box>
)}
</Box>
{message && (
<Typography variant="body2" color="text.secondary" textAlign="center">
{message}
</Typography>
)}
</Box>
);
};

View File

@@ -0,0 +1,196 @@
import React from "react";
import {
Box,
Typography,
LinearProgress,
CircularProgress,
} from "@mui/material";
interface ModelLoadingIndicatorProps {
progress?: number;
message?: string;
isVisible?: boolean;
variant?: "overlay" | "inline";
size?: "small" | "medium" | "large";
showDetails?: boolean;
}
export const ModelLoadingIndicator: React.FC<ModelLoadingIndicatorProps> = ({
progress = 0,
message = "Загрузка 3D модели...",
isVisible = true,
variant = "overlay",
size = "medium",
showDetails = true,
}) => {
const sizeConfig = {
small: {
spinnerSize: 32,
fontSize: "0.75rem",
progressBarWidth: 150,
padding: 2,
},
medium: {
spinnerSize: 48,
fontSize: "0.875rem",
progressBarWidth: 200,
padding: 3,
},
large: {
spinnerSize: 64,
fontSize: "1rem",
progressBarWidth: 250,
padding: 4,
},
};
const currentSize = sizeConfig[size];
if (!isVisible) return null;
const getProgressStage = (progress: number): string => {
if (progress < 20) return "Инициализация...";
if (progress < 40) return "Загрузка геометрии...";
if (progress < 60) return "Обработка материалов...";
if (progress < 80) return "Загрузка текстур...";
if (progress < 95) return "Финализация...";
if (progress === 100) return "Готово!";
return "Загрузка...";
};
const content = (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 2,
padding: currentSize.padding,
textAlign: "center",
width: "100%",
}}
>
{/* Крутяшка с процентами */}
<Box sx={{ position: "relative", display: "inline-flex" }}>
<CircularProgress
size={currentSize.spinnerSize}
variant="determinate"
value={progress}
thickness={4}
sx={{
color: "primary.main",
}}
/>
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: "absolute",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Typography
variant="caption"
component="div"
color="text.secondary"
sx={{
fontSize: currentSize.spinnerSize * 0.25,
fontWeight: 600,
lineHeight: 1,
}}
>
{`${Math.round(progress)}%`}
</Typography>
</Box>
</Box>
{/* Линейный прогресс бар */}
<Box sx={{ width: "100%", maxWidth: currentSize.progressBarWidth }}>
<LinearProgress
variant="determinate"
value={progress}
color="primary"
sx={{
height: 8,
borderRadius: 4,
backgroundColor: "rgba(0, 0, 0, 0.1)",
}}
/>
</Box>
{/* Основное сообщение */}
<Typography
variant="body2"
color="text.secondary"
sx={{
fontSize: currentSize.fontSize,
fontWeight: 500,
maxWidth: 280,
lineHeight: 1.4,
}}
>
{message}
</Typography>
{/* Детальная информация о прогрессе */}
{showDetails && progress > 0 && (
<Typography
variant="caption"
color="text.disabled"
sx={{
fontSize: "0.75rem",
opacity: 0.8,
fontWeight: 400,
}}
>
{getProgressStage(progress)}
</Typography>
)}
</Box>
);
if (variant === "overlay") {
return (
<Box
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(255, 255, 255, 0.95)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
borderRadius: 1,
border: "1px solid rgba(0, 0, 0, 0.05)",
}}
>
{content}
</Box>
);
}
return (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 200,
backgroundColor: "rgba(0, 0, 0, 0.02)",
borderRadius: 2,
border: "1px dashed",
borderColor: "divider",
}}
>
{content}
</Box>
);
};

View File

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

View File

@@ -202,7 +202,6 @@ export const DevicesTable = observer(() => {
try { try {
// Create an array of promises for all snapshot requests // Create an array of promises for all snapshot requests
const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => { const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => {
console.log(`Sending snapshot ${snapshotId} to device ${deviceUuid}`);
return send(deviceUuid); return send(deviceUuid);
}); });

View File

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

View File

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

View File

@@ -8,10 +8,11 @@ import { AppBar } from "./ui/AppBar";
import { Drawer } from "./ui/Drawer"; import { Drawer } from "./ui/Drawer";
import { DrawerHeader } from "./ui/DrawerHeader"; import { DrawerHeader } from "./ui/DrawerHeader";
import { NavigationList } from "@features"; import { NavigationList } from "@features";
import { authStore, userStore } from "@shared"; import { authStore, userStore, menuStore } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect } from "react"; import { useEffect } from "react";
import { Typography } from "@mui/material"; import { Typography } from "@mui/material";
import { CitySelector } from "@widgets";
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
@@ -20,6 +21,12 @@ interface LayoutProps {
export const Layout: React.FC<LayoutProps> = observer(({ children }) => { export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
const theme = useTheme(); const theme = useTheme();
const [open, setOpen] = React.useState(true); const [open, setOpen] = React.useState(true);
const { setIsMenuOpen } = menuStore;
React.useEffect(() => {
setIsMenuOpen(open);
}, [open]);
const { getUsers, users } = userStore; const { getUsers, users } = userStore;
useEffect(() => { useEffect(() => {
@@ -55,7 +62,7 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
> >
<Menu /> <Menu />
</IconButton> </IconButton>
<div></div> <CitySelector />
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{(() => { {(() => {

View File

@@ -1,9 +1,14 @@
import { Box, Button } from "@mui/material"; import { Box, Button } from "@mui/material";
import { MediaViewer } from "@widgets"; import { MediaViewer } from "@widgets";
import { PreviewMediaDialog } from "@shared"; import {
PreviewMediaDialog,
filterValidFiles,
getAllAcceptString,
} from "@shared";
import { X, Upload } from "lucide-react"; import { X, Upload } from "lucide-react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useState, DragEvent, useRef } from "react"; import { useState, DragEvent, useRef } from "react";
import { toast } from "react-toastify";
export const MediaArea = observer( export const MediaArea = observer(
({ ({
@@ -36,7 +41,15 @@ export const MediaArea = observer(
const files = Array.from(e.dataTransfer.files); const files = Array.from(e.dataTransfer.files);
if (files.length && onFilesDrop) { 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 handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []); const files = Array.from(event.target.files || []);
if (files.length && onFilesDrop) { 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, чтобы можно было выбрать тот же файл снова // Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
event.target.value = ""; event.target.value = "";
@@ -68,7 +89,7 @@ export const MediaArea = observer(
type="file" type="file"
ref={fileInputRef} ref={fileInputRef}
onChange={handleFileSelect} onChange={handleFileSelect}
accept="image/*,video/*,.glb,.gltf" accept={getAllAcceptString()}
multiple multiple
style={{ display: "none" }} style={{ display: "none" }}
/> />
@@ -109,6 +130,7 @@ export const MediaArea = observer(
media_type: m.media_type, media_type: m.media_type,
filename: m.filename, filename: m.filename,
}} }}
height="40px"
/> />
<button <button
className="absolute top-2 right-2" className="absolute top-2 right-2"

View File

@@ -1,8 +1,15 @@
import { Box, Button } from "@mui/material"; 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 { Upload } from "lucide-react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useState, DragEvent, useRef } from "react"; import { useState, DragEvent, useRef } from "react";
import { toast } from "react-toastify";
export const MediaAreaForSight = observer( export const MediaAreaForSight = observer(
({ ({
@@ -38,11 +45,18 @@ export const MediaAreaForSight = observer(
setIsDragging(false); setIsDragging(false);
const files = Array.from(e.dataTransfer.files); const files = Array.from(e.dataTransfer.files);
if (files.length && onFilesDrop) { if (files.length) {
setFileToUpload(files[0]); 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>) => { const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
@@ -60,10 +74,18 @@ export const MediaAreaForSight = observer(
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []); const files = Array.from(event.target.files || []);
if (files.length && onFilesDrop) { if (files.length) {
setFileToUpload(files[0]); const { validFiles, errors } = filterValidFiles(files);
onFilesDrop(files);
setUploadMediaDialogOpen(true); if (errors.length > 0) {
errors.forEach((error: string) => toast.error(error));
}
if (validFiles.length > 0 && onFilesDrop) {
setFileToUpload(validFiles[0]);
onFilesDrop(validFiles);
setUploadMediaDialogOpen(true);
}
} }
// Сбрасываем значение input, чтобы можно было выбрать тот же файл снова // Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
@@ -76,7 +98,7 @@ export const MediaAreaForSight = observer(
type="file" type="file"
ref={fileInputRef} ref={fileInputRef}
onChange={handleFileSelect} onChange={handleFileSelect}
accept="image/*,video/*,.glb,.gltf" accept={getAllAcceptString()}
multiple multiple
style={{ display: "none" }} style={{ display: "none" }}
/> />

View File

@@ -1,5 +1,60 @@
import { Canvas } from "@react-three/fiber"; import { Canvas } from "@react-three/fiber";
import { OrbitControls, Stage, useGLTF } from "@react-three/drei"; 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 = { type ModelViewerProps = {
width?: string; width?: string;
@@ -7,21 +62,93 @@ type ModelViewerProps = {
height?: string; 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 = ({ export const ThreeView = ({
fileUrl, fileUrl,
height = "100%", height = "100%",
width = "100%", width = "100%",
}: ModelViewerProps) => { }: 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 ( return (
<Canvas style={{ height: height, width: width }}> <Box sx={{ position: "relative", width, height }}>
<ambientLight /> <Suspense fallback={<LoadingFallback />}>
<directionalLight /> <Canvas
<Stage environment="city" intensity={0.6}> style={{ height: height, width: width }}
<primitive object={scene} /> camera={{
</Stage> position: [1, 1, 1],
<OrbitControls /> fov: 30,
</Canvas> }}
>
<ambientLight />
<directionalLight />
<Stage environment="city" intensity={0.6} adjustCamera={false}>
<Model fileUrl={fileUrl} />
</Stage>
<OrbitControls />
</Canvas>
</Suspense>
</Box>
); );
}; };

View File

@@ -0,0 +1,240 @@
import React, { Component, ReactNode } from "react";
import { Box, Button, Typography, Paper } from "@mui/material";
import { RefreshCw, AlertTriangle } from "lucide-react";
interface Props {
children: ReactNode;
onReset?: () => void;
resetKey?: number | string;
}
interface State {
hasError: boolean;
error: Error | null;
lastResetKey?: number | string;
}
export class ThreeViewErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
lastResetKey: props.resetKey,
};
}
static getDerivedStateFromError(error: Error): Partial<State> {
return {
hasError: true,
error,
};
}
static getDerivedStateFromProps(
props: Props,
state: State
): Partial<State> | null {
// Сбрасываем ошибку ТОЛЬКО при смене медиа (когда меняется ID в resetKey)
if (
props.resetKey !== state.lastResetKey &&
state.lastResetKey !== undefined
) {
const oldMediaId = String(state.lastResetKey).split("-")[0];
const newMediaId = String(props.resetKey).split("-")[0];
// Сбрасываем ошибку только если изменился ID медиа (пользователь выбрал другую модель)
if (oldMediaId !== newMediaId) {
return {
hasError: false,
error: null,
lastResetKey: props.resetKey,
};
}
// Если изменился только счетчик (нажата кнопка "Попробовать снова"), обновляем lastResetKey
// но не сбрасываем ошибку автоматически - ждем результата загрузки
return {
lastResetKey: props.resetKey,
};
}
if (state.lastResetKey === undefined && props.resetKey !== undefined) {
return {
lastResetKey: props.resetKey,
};
}
return null;
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("❌ ThreeViewErrorBoundary: Ошибка загрузки 3D модели", {
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
});
}
getErrorMessage = () => {
const errorMessage = this.state.error?.message || "";
if (
errorMessage.includes("not valid JSON") ||
errorMessage.includes("Unexpected token")
) {
return "Неверный формат файла. Убедитесь, что файл является корректной 3D моделью в формате GLB/GLTF.";
}
if (errorMessage.includes("Could not load")) {
return "Не удалось загрузить файл 3D модели. Проверьте, что файл существует и доступен.";
}
if (errorMessage.includes("404") || errorMessage.includes("Not Found")) {
return "Файл 3D модели не найден на сервере.";
}
if (errorMessage.includes("Network") || errorMessage.includes("fetch")) {
return "Ошибка сети при загрузке 3D модели. Проверьте интернет-соединение.";
}
return (
errorMessage || "Произошла неизвестная ошибка при загрузке 3D модели"
);
};
getErrorReasons = () => {
const errorMessage = this.state.error?.message || "";
if (
errorMessage.includes("not valid JSON") ||
errorMessage.includes("Unexpected token")
) {
return [
"Файл не является 3D моделью",
"Загружен файл неподдерживаемого формата",
"Файл поврежден или не полностью загружен",
"Используйте только GLB или GLTF форматы",
];
}
return [
"Поврежденный файл модели",
"Неподдерживаемый формат",
"Проблемы с загрузкой файла",
];
};
handleReset = () => {
// Сначала сбрасываем состояние ошибки
this.setState(
{
hasError: false,
error: null,
},
() => {
// После того как состояние обновилось, вызываем callback для изменения resetKey
// Это приведет к пересозданию компонента и новой попытке загрузки
this.props.onReset?.();
}
);
};
handleClose = () => {
this.setState({
hasError: false,
error: null,
});
};
render() {
if (this.state.hasError) {
return (
<Box
sx={{
width: "100%",
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
p: 2,
}}
>
<Paper
elevation={3}
sx={{
p: 3,
maxWidth: 500,
width: "100%",
position: "relative",
backgroundColor: "error.light",
color: "error.contrastText",
}}
>
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
<AlertTriangle size={32} style={{ marginRight: 12 }} />
<Typography variant="h6" component="h2">
Ошибка загрузки 3D модели
</Typography>
</Box>
<Typography variant="body2" sx={{ mb: 2 }}>
{this.getErrorMessage()}
</Typography>
<Typography variant="caption" sx={{ mb: 2, display: "block" }}>
Возможные причины:
<ul style={{ margin: "8px 0", paddingLeft: "20px" }}>
{this.getErrorReasons().map((reason, index) => (
<li key={index}>{reason}</li>
))}
</ul>
</Typography>
{this.state.error?.message && (
<Typography
variant="caption"
sx={{
mb: 2,
display: "block",
fontFamily: "monospace",
backgroundColor: "rgba(0, 0, 0, 0.1)",
p: 1,
borderRadius: 1,
fontSize: "0.7rem",
wordBreak: "break-word",
maxHeight: "100px",
overflow: "auto",
}}
>
{this.state.error.message}
</Typography>
)}
<Box sx={{ display: "flex", gap: 1 }}>
<Button
variant="contained"
startIcon={<RefreshCw size={16} />}
onClick={() => {
this.handleReset();
}}
sx={{
backgroundColor: "error.contrastText",
color: "error.main",
"&:hover": {
backgroundColor: "rgba(255, 255, 255, 0.9)",
},
}}
>
Попробовать снова
</Button>
</Box>
</Paper>
</Box>
);
}
return this.props.children;
}
}

View File

@@ -1,7 +1,10 @@
import { Box } from "@mui/material"; import { Box } from "@mui/material";
import { useState, useEffect } from "react";
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer"; import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
import { ThreeView } from "./ThreeView"; import { ThreeView } from "./ThreeView";
import { ThreeViewErrorBoundary } from "./ThreeViewErrorBoundary";
import { clearMediaTransitionCache } from "../../shared/lib/gltfCacheManager";
export interface MediaData { export interface MediaData {
id: string | number; id: string | number;
@@ -12,15 +15,46 @@ export interface MediaData {
export function MediaViewer({ export function MediaViewer({
media, media,
className, className,
height,
width,
fullWidth, fullWidth,
fullHeight, fullHeight,
}: Readonly<{ }: Readonly<{
media?: MediaData; media?: MediaData;
className?: string; className?: string;
height?: string;
width?: string;
fullWidth?: boolean; fullWidth?: boolean;
fullHeight?: boolean; fullHeight?: boolean;
}>) { }>) {
const token = localStorage.getItem("token"); 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 ( return (
<Box <Box
className={className} className={className}
@@ -42,13 +76,8 @@ export function MediaViewer({
}/download?token=${token}`} }/download?token=${token}`}
alt={media?.filename} alt={media?.filename}
style={{ style={{
height: fullHeight ? "100%" : "auto", height: fullHeight ? "100%" : height ? height : "auto",
width: fullWidth ? "100%" : "auto", width: fullWidth ? "100%" : width ? width : "auto",
...(media?.filename?.toLowerCase().endsWith(".webp") && {
maxWidth: "300px",
maxHeight: "300px",
objectFit: "contain",
}),
}} }}
/> />
)} )}
@@ -59,8 +88,8 @@ export function MediaViewer({
media?.id media?.id
}/download?token=${token}`} }/download?token=${token}`}
style={{ style={{
width: "100%", width: width ? width : "100%",
height: "100%", height: height ? height : "100%",
objectFit: "cover", objectFit: "cover",
borderRadius: 8, borderRadius: 8,
}} }}
@@ -76,8 +105,8 @@ export function MediaViewer({
}/download?token=${token}`} }/download?token=${token}`}
alt={media?.filename} alt={media?.filename}
style={{ style={{
height: fullHeight ? "100%" : "auto", height: fullHeight ? "100%" : height ? height : "auto",
width: fullWidth ? "100%" : "auto", width: fullWidth ? "100%" : width ? width : "auto",
}} }}
/> />
)} )}
@@ -98,19 +127,25 @@ export function MediaViewer({
src={`${import.meta.env.VITE_KRBL_MEDIA}${ src={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id media?.id
}/download?token=${token}`} }/download?token=${token}`}
width={"100%"} width={width ? width : "500px"}
height={"100%"} height={height ? height : "300px"}
/> />
)} )}
{media?.media_type === 6 && ( {media?.media_type === 6 && (
<ThreeView <ThreeViewErrorBoundary
fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${ onReset={handleReset}
media?.id resetKey={`${media?.id}-${resetKey}`}
}/download?token=${token}`} >
height="500px" <ThreeView
width="500px" 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> </Box>
); );

View File

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

View File

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

View File

@@ -35,8 +35,7 @@ import { Save } from "lucide-react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { SaveWithoutCityAgree } from "@widgets";
// Мокап для всплывающей подсказки
export const CreateInformationTab = observer( export const CreateInformationTab = observer(
({ value, index }: { value: number; index: number }) => { ({ value, index }: { value: number; index: number }) => {
@@ -51,7 +50,6 @@ export const CreateInformationTab = observer(
const [, setCity] = useState<number>(sight.city_id ?? 0); const [, setCity] = useState<number>(sight.city_id ?? 0);
const [coordinates, setCoordinates] = useState<string>(`0, 0`); const [coordinates, setCoordinates] = useState<string>(`0, 0`);
// Menu state for each media button
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null); const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const [activeMenuType, setActiveMenuType] = useState< const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
@@ -62,21 +60,15 @@ export const CreateInformationTab = observer(
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
>(null); >(null);
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => {}, [hardcodeType]); useEffect(() => {}, [hardcodeType]);
// const handleMenuOpen = (
// event: React.MouseEvent<HTMLElement>,
// type: "thumbnail" | "watermark_lu" | "watermark_rd"
// ) => {
// setMenuAnchorEl(event.currentTarget);
// setActiveMenuType(type);
// };
useEffect(() => { useEffect(() => {
// Показывать только при инициализации (не менять при ошибках пользователя)
if (sight.latitude !== 0 || sight.longitude !== 0) { if (sight.latitude !== 0 || sight.longitude !== 0) {
setCoordinates(`${sight.latitude}, ${sight.longitude}`); setCoordinates(`${sight.latitude}, ${sight.longitude}`);
} }
// если координаты обнулились — оставить поле как есть
}, [sight.latitude, sight.longitude]); }, [sight.latitude, sight.longitude]);
const handleMenuClose = () => { 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 ( return (
<> <>
<TabPanel value={value} index={index}> <TabPanel value={value} index={index}>
@@ -134,7 +150,7 @@ export const CreateInformationTab = observer(
flexDirection: "column", flexDirection: "column",
gap: 3, gap: 3,
position: "relative", position: "relative",
paddingBottom: "70px" /* Space for save button */, paddingBottom: "70px",
}} }}
> >
<div className="flex gap-10 items-center mb-5 max-w-[80%]"> <div className="flex gap-10 items-center mb-5 max-w-[80%]">
@@ -146,12 +162,11 @@ export const CreateInformationTab = observer(
sx={{ sx={{
display: "flex", display: "flex",
gap: 4, // Added gap between the two main columns gap: 4,
width: "100%", width: "100%",
flexDirection: "column", flexDirection: "column",
}} }}
> >
{/* Left column with main fields */}
<Box <Box
sx={{ sx={{
flexGrow: 1, flexGrow: 1,
@@ -215,14 +230,13 @@ export const CreateInformationTab = observer(
value={coordinates} value={coordinates}
onChange={(e) => { onChange={(e) => {
const input = e.target.value; 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 lat = parseFloat(latStr);
const lon = parseFloat(lonStr); const lon = parseFloat(lonStr);
// Проверка, что обе координаты валидные числа
const isValidLat = !isNaN(lat); const isValidLat = !isNaN(lat);
const isValidLon = !isNaN(lon); const isValidLon = !isNaN(lon);
@@ -260,7 +274,7 @@ export const CreateInformationTab = observer(
justifyContent: "space-around", justifyContent: "space-around",
width: "80%", width: "80%",
gap: 2, gap: 2,
flexDirection: { xs: "column", sm: "row" }, // Stack on extra small, side-by-side on small and up flexDirection: { xs: "column", sm: "row" },
}} }}
> >
<ImageUploadCard <ImageUploadCard
@@ -348,7 +362,7 @@ export const CreateInformationTab = observer(
/> />
<VideoPreviewCard <VideoPreviewCard
title="Видео превью" title="Видеозаставка"
videoId={sight.video_preview} videoId={sight.video_preview}
onVideoClick={handleVideoPreviewClick} onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => { onDeleteVideoClick={() => {
@@ -358,12 +372,10 @@ export const CreateInformationTab = observer(
}} }}
onSelectVideoClick={(file) => { onSelectVideoClick={(file) => {
if (file) { if (file) {
// Если передан файл, открываем диалог загрузки медиа
createSightStore.setFileToUpload(file); createSightStore.setFileToUpload(file);
setActiveMenuType("video_preview"); setActiveMenuType("video_preview");
setIsUploadMediaOpen(true); setIsUploadMediaOpen(true);
} else { } else {
// Если файл не передан, открываем диалог выбора существующих медиа
setActiveMenuType("video_preview"); setActiveMenuType("video_preview");
setIsAddMediaOpen(true); setIsAddMediaOpen(true);
} }
@@ -373,31 +385,25 @@ export const CreateInformationTab = observer(
</Box> </Box>
</Box> </Box>
{/* LanguageSwitcher positioned at the top right */}
<LanguageSwitcher /> <LanguageSwitcher />
{/* Save Button fixed at the bottom right */}
<Box <Box
sx={{ sx={{
position: "absolute", position: "absolute",
bottom: 0, bottom: 0,
right: 0, right: 0,
padding: 2, padding: 2,
backgroundColor: "background.paper", // To ensure it stands out over content backgroundColor: "background.paper",
width: "100%", // Take full width to cover content below it width: "100%",
display: "flex", display: "flex",
justifyContent: "flex-end", // Align to the right justifyContent: "flex-end",
}} }}
> >
<Button <Button
variant="contained" variant="contained"
color="success" color="success"
startIcon={<Save color="white" size={18} />} startIcon={<Save color="white" size={18} />}
onClick={async () => { onClick={handleSave}
await createSight(language);
toast.success("Достопримечательность создана");
}}
> >
Сохранить Сохранить
</Button> </Button>
@@ -405,7 +411,6 @@ export const CreateInformationTab = observer(
</Box> </Box>
</TabPanel> </TabPanel>
{/* Media Menu */}
<MuiMenu <MuiMenu
anchorEl={menuAnchorEl} anchorEl={menuAnchorEl}
open={Boolean(menuAnchorEl)} open={Boolean(menuAnchorEl)}
@@ -471,7 +476,6 @@ export const CreateInformationTab = observer(
initialFile={createSightStore.fileToUpload || undefined} initialFile={createSightStore.fileToUpload || undefined}
/> />
{/* Модальное окно предпросмотра видео */}
{sight.video_preview && sight.video_preview !== "" && ( {sight.video_preview && sight.video_preview !== "" && (
<Dialog <Dialog
open={isVideoPreviewOpen} open={isVideoPreviewOpen}
@@ -498,6 +502,16 @@ export const CreateInformationTab = observer(
</DialogActions> </DialogActions>
</Dialog> </Dialog>
)} )}
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
{isSaveWarningOpen && (
<SaveWithoutCityAgree
blocker={{
proceed: handleConfirmSave,
reset: handleCancelSave,
}}
/>
)}
</> </>
); );
} }

View File

@@ -37,7 +37,8 @@ import { useEffect, useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
// Мокап для всплывающей подсказки // Компонент предупреждающего окна (перенесен сюда)
import { SaveWithoutCityAgree } from "@widgets";
export const InformationTab = observer( export const InformationTab = observer(
({ value, index }: { value: number; index: number }) => { ({ value, index }: { value: number; index: number }) => {
@@ -51,7 +52,6 @@ export const InformationTab = observer(
const [, setCity] = useState<number>(sight.common.city_id ?? 0); const [, setCity] = useState<number>(sight.common.city_id ?? 0);
const [coordinates, setCoordinates] = useState<string>(`0, 0`); const [coordinates, setCoordinates] = useState<string>(`0, 0`);
// Menu state for each media button
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null); const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const [activeMenuType, setActiveMenuType] = useState< const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
@@ -62,15 +62,15 @@ export const InformationTab = observer(
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
>(null); >(null);
const { cities } = cityStore; const { cities } = cityStore;
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => {}, [hardcodeType]); useEffect(() => {}, [hardcodeType]);
useEffect(() => { useEffect(() => {
// Показывать только при инициализации (не менять при ошибках пользователя)
if (sight.common.latitude !== 0 || sight.common.longitude !== 0) { if (sight.common.latitude !== 0 || sight.common.longitude !== 0) {
setCoordinates(`${sight.common.latitude}, ${sight.common.longitude}`); setCoordinates(`${sight.common.latitude}, ${sight.common.longitude}`);
} }
// если координаты обнулились — оставить поле как есть
}, [sight.common.latitude, sight.common.longitude]); }, [sight.common.latitude, sight.common.longitude]);
const handleMenuClose = () => { const handleMenuClose = () => {
@@ -119,6 +119,37 @@ export const InformationTab = observer(
updateSightInfo(language, content, common); 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 ( return (
<> <>
<TabPanel value={value} index={index}> <TabPanel value={value} index={index}>
@@ -128,7 +159,7 @@ export const InformationTab = observer(
flexDirection: "column", flexDirection: "column",
gap: 3, gap: 3,
position: "relative", position: "relative",
paddingBottom: "70px" /* Space for save button */, paddingBottom: "70px",
}} }}
> >
<div className="flex gap-10 items-center mb-5 max-w-[80%]"> <div className="flex gap-10 items-center mb-5 max-w-[80%]">
@@ -141,12 +172,11 @@ export const InformationTab = observer(
sx={{ sx={{
display: "flex", display: "flex",
gap: 4, // Added gap between the two main columns gap: 4,
width: "100%", width: "100%",
flexDirection: "column", flexDirection: "column",
}} }}
> >
{/* Left column with main fields */}
<Box <Box
sx={{ sx={{
flexGrow: 1, flexGrow: 1,
@@ -208,16 +238,14 @@ export const InformationTab = observer(
value={coordinates} value={coordinates}
onChange={(e) => { onChange={(e) => {
const newValue = e.target.value; const newValue = e.target.value;
setCoordinates(newValue); // сохраняем ввод пользователя как есть setCoordinates(newValue);
// Обрабатываем значение для сохранения
const input = newValue.replace(/,/g, " ").trim(); const input = newValue.replace(/,/g, " ").trim();
const [latStr, lonStr] = input.split(/\s+/); const [latStr, lonStr] = input.split(/\s+/);
const lat = parseFloat(latStr); const lat = parseFloat(latStr);
const lon = parseFloat(lonStr); const lon = parseFloat(lonStr);
// Проверка, что обе координаты валидные числа
const isValidLat = !isNaN(lat); const isValidLat = !isNaN(lat);
const isValidLon = !isNaN(lon); const isValidLon = !isNaN(lon);
@@ -260,7 +288,7 @@ export const InformationTab = observer(
justifyContent: "space-around", justifyContent: "space-around",
width: "80%", width: "80%",
gap: 2, gap: 2,
flexDirection: { xs: "column", sm: "row" }, // Stack on extra small, side-by-side on small and up flexDirection: { xs: "column", sm: "row" },
}} }}
> >
<ImageUploadCard <ImageUploadCard
@@ -358,7 +386,7 @@ export const InformationTab = observer(
/> />
<VideoPreviewCard <VideoPreviewCard
title="Видео превью" title="Видеозаставка"
videoId={sight.common.video_preview} videoId={sight.common.video_preview}
onVideoClick={handleVideoPreviewClick} onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => { onDeleteVideoClick={() => {
@@ -372,12 +400,10 @@ export const InformationTab = observer(
}} }}
onSelectVideoClick={(file) => { onSelectVideoClick={(file) => {
if (file) { if (file) {
// Если передан файл, открываем диалог загрузки медиа
editSightStore.setFileToUpload(file); editSightStore.setFileToUpload(file);
setActiveMenuType("video_preview"); setActiveMenuType("video_preview");
setIsUploadMediaOpen(true); setIsUploadMediaOpen(true);
} else { } else {
// Если файл не передан, открываем диалог выбора существующих медиа
setActiveMenuType("video_preview"); setActiveMenuType("video_preview");
setIsAddMediaOpen(true); setIsAddMediaOpen(true);
} }
@@ -387,31 +413,25 @@ export const InformationTab = observer(
</Box> </Box>
</Box> </Box>
{/* LanguageSwitcher positioned at the top right */}
<LanguageSwitcher /> <LanguageSwitcher />
{/* Save Button fixed at the bottom right */}
<Box <Box
sx={{ sx={{
position: "absolute", position: "absolute",
bottom: 0, bottom: 0,
right: 0, right: 0,
padding: 2, padding: 2,
backgroundColor: "background.paper", // To ensure it stands out over content backgroundColor: "background.paper",
width: "100%", // Take full width to cover content below it width: "100%",
display: "flex", display: "flex",
justifyContent: "flex-end", // Align to the right justifyContent: "flex-end",
}} }}
> >
<Button <Button
variant="contained" variant="contained"
color="success" color="success"
startIcon={<Save color="white" size={18} />} startIcon={<Save color="white" size={18} />}
onClick={async () => { onClick={handleSave} // Используем новую функцию-обработчик
await updateSight();
toast.success("Достопримечательность сохранена");
}}
> >
Сохранить Сохранить
</Button> </Button>
@@ -419,7 +439,6 @@ export const InformationTab = observer(
</Box> </Box>
</TabPanel> </TabPanel>
{/* Media Menu */}
<MuiMenu <MuiMenu
anchorEl={menuAnchorEl} anchorEl={menuAnchorEl}
open={Boolean(menuAnchorEl)} open={Boolean(menuAnchorEl)}
@@ -492,7 +511,6 @@ export const InformationTab = observer(
mediaId={mediaId} mediaId={mediaId}
/> />
{/* Модальное окно предпросмотра видео */}
{sight.common.video_preview && sight.common.video_preview !== "" && ( {sight.common.video_preview && sight.common.video_preview !== "" && (
<Dialog <Dialog
open={isVideoPreviewOpen} open={isVideoPreviewOpen}
@@ -519,7 +537,17 @@ export const InformationTab = observer(
</DialogActions> </DialogActions>
</Dialog> </Dialog>
)} )}
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
{isSaveWarningOpen && (
<SaveWithoutCityAgree
blocker={{
proceed: handleConfirmSave,
reset: handleCancelSave,
}}
/>
)}
</> </>
); );
} }
); );

View File

@@ -316,31 +316,35 @@ export const LeftWidgetTab = observer(
}} }}
fullWidth fullWidth
/> />
<img {sight.common.watermark_lu && (
src={`${import.meta.env.VITE_KRBL_MEDIA}${ <img
sight.common.watermark_lu src={`${import.meta.env.VITE_KRBL_MEDIA}${
}/download?token=${token}`} sight.common.watermark_lu
alt="preview" }/download?token=${token}`}
className="absolute top-4 left-4 z-10" alt="preview"
style={{ className="absolute top-4 left-4 z-10"
width: "30px", style={{
height: "30px", width: "30px",
objectFit: "contain", height: "30px",
}} objectFit: "contain",
/> }}
/>
)}
<img {sight.common.watermark_rd && (
src={`${import.meta.env.VITE_KRBL_MEDIA}${ <img
sight.common.watermark_rd src={`${import.meta.env.VITE_KRBL_MEDIA}${
}/download?token=${token}`} sight.common.watermark_rd
alt="preview" }/download?token=${token}`}
className="absolute bottom-4 right-4 z-10" alt="preview"
style={{ className="absolute bottom-4 right-4 z-10"
width: "30px", style={{
height: "30px", width: "30px",
objectFit: "contain", height: "30px",
}} objectFit: "contain",
/> }}
/>
)}
</> </>
) : ( ) : (
<ImagePlus size={48} color="white" /> <ImagePlus size={48} color="white" />

View File

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

View File

@@ -1,13 +1,15 @@
import { Button } from "@mui/material"; import { Button, CircularProgress } from "@mui/material";
export const SnapshotRestore = ({ export const SnapshotRestore = ({
onDelete, onDelete,
onCancel, onCancel,
open, open,
loading = false,
}: { }: {
onDelete: () => void; onDelete: () => void;
onCancel: () => void; onCancel: () => void;
open: boolean; open: boolean;
loading?: boolean;
}) => { }) => {
return ( return (
<div <div
@@ -23,10 +25,22 @@ export const SnapshotRestore = ({
Это действие нельзя будет отменить. Это действие нельзя будет отменить.
</p> </p>
<div className="flex gap-4 justify-center"> <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>
<Button onClick={onCancel}>Нет</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -17,4 +17,6 @@ export * from "./LeaveAgree";
export * from "./DeleteModal"; export * from "./DeleteModal";
export * from "./SnapshotRestore"; export * from "./SnapshotRestore";
export * from "./CreateButton"; export * from "./CreateButton";
export * from "./SaveWithoutCityAgree";
export * from "./CitySelector";
export * from "./modals"; export * from "./modals";

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -174,24 +174,24 @@
integrity sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow== integrity sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==
"@emnapi/core@^1.4.3": "@emnapi/core@^1.4.3":
version "1.4.4" version "1.6.0"
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.4.4.tgz#76620673f3033626c6d79b1420d69f06a6bb153c" resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.6.0.tgz#517f65d1c8270d5d5aa1aad660d5acb897430dca"
integrity sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g== integrity sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg==
dependencies: dependencies:
"@emnapi/wasi-threads" "1.0.3" "@emnapi/wasi-threads" "1.1.0"
tslib "^2.4.0" tslib "^2.4.0"
"@emnapi/runtime@^1.4.3": "@emnapi/runtime@^1.4.3":
version "1.4.4" version "1.6.0"
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.4.4.tgz#19a8f00719c51124e2d0fbf4aaad3fa7b0c92524" resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.6.0.tgz#8fe297e0090f6e89a57a1f31f1c440bdbc3c01d8"
integrity sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg== integrity sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==
dependencies: dependencies:
tslib "^2.4.0" tslib "^2.4.0"
"@emnapi/wasi-threads@1.0.3", "@emnapi/wasi-threads@^1.0.2": "@emnapi/wasi-threads@1.1.0", "@emnapi/wasi-threads@^1.0.2":
version "1.0.3" version "1.1.0"
resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.0.3.tgz#83fa228bde0e71668aad6db1af4937473d1d3ab1" resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf"
integrity sha512-8K5IFFsQqF9wQNJptGbS6FNKgUTsSRYnTqNCG1vPP8jFdjSv18n2mQfJpkt2Oibo9iBEzcDnDxNwKTzC7svlJw== integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==
dependencies: dependencies:
tslib "^2.4.0" tslib "^2.4.0"
@@ -1024,9 +1024,9 @@
integrity sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA== integrity sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==
"@tybys/wasm-util@^0.10.0": "@tybys/wasm-util@^0.10.0":
version "0.10.0" version "0.10.1"
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.0.tgz#2fd3cd754b94b378734ce17058d0507c45c88369" resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414"
integrity sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ== integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==
dependencies: dependencies:
tslib "^2.4.0" tslib "^2.4.0"