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_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_modules/yaml": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
"integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,12 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { cityStore, languageStore, sightsStore } from "@shared";
import { useEffect, useState } from "react";
import {
cityStore,
languageStore,
sightsStore,
selectedCityStore,
} from "@shared";
import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
@@ -68,6 +73,7 @@ export const SightListPage = observer(() => {
headerName: "Действия",
align: "center",
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
@@ -92,7 +98,16 @@ export const SightListPage = observer(() => {
},
];
const rows = sights.map((sight) => ({
// Фильтрация достопримечательностей по выбранному городу
const filteredSights = useMemo(() => {
const { selectedCityId } = selectedCityStore;
if (!selectedCityId) {
return sights;
}
return sights.filter((sight: any) => sight.city_id === selectedCityId);
}, [sights, selectedCityStore.selectedCityId]);
const rows = filteredSights.map((sight) => ({
id: sight.id,
name: sight.name,
city_id: sight.city_id,
@@ -132,7 +147,6 @@ export const SightListPage = observer(() => {
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => {
console.log(newSelection);
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,11 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { languageStore, stationsStore } from "@shared";
import {
languageStore,
stationsStore,
selectedCityStore,
cityStore,
} from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
@@ -21,6 +26,7 @@ export const StationListPage = observer(() => {
useEffect(() => {
const fetchStations = async () => {
setIsLoading(true);
await cityStore.getCities(language);
await getStationList();
setIsLoading(false);
};
@@ -85,6 +91,7 @@ export const StationListPage = observer(() => {
width: 140,
align: "center",
headerAlign: "center",
sortable: false,
renderCell: (params: GridRenderCellParams) => {
return (
@@ -109,7 +116,18 @@ export const StationListPage = observer(() => {
},
];
const rows = stationLists[language].data.map((station: any) => ({
// Фильтрация станций по выбранному городу
const filteredStations = () => {
const { selectedCityId } = selectedCityStore;
if (!selectedCityId) {
return stationLists[language].data;
}
return stationLists[language].data.filter(
(station: any) => station.city_id === selectedCityId
);
};
const rows = filteredStations().map((station: any) => ({
id: station.id,
name: station.name,
system_name: station.system_name,

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,60 @@
import { Canvas } from "@react-three/fiber";
import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
import { useEffect, Suspense } from "react";
import { Box, CircularProgress, Typography } from "@mui/material";
// Утилита для очистки кеша GLTF
const clearGLTFCache = (url?: string) => {
try {
if (url) {
// Если это blob URL, очищаем его из кеша
if (url.startsWith("blob:")) {
useGLTF.clear(url);
} else {
useGLTF.clear(url);
}
}
} catch (error) {
console.warn("⚠️ clearGLTFCache: Ошибка при очистке кеша", error);
}
};
// Утилита для проверки типа файла
const isValid3DFile = (url: string): boolean => {
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname.toLowerCase();
const searchParams = urlObj.searchParams;
// Проверяем расширение файла в пути
const validExtensions = [".glb", ".gltf"];
const hasValidExtension = validExtensions.some((ext) =>
pathname.endsWith(ext)
);
// Проверяем параметры запроса на наличие типа файла
const fileType = searchParams.get("type") || searchParams.get("format");
const hasValidType =
fileType && ["glb", "gltf"].includes(fileType.toLowerCase());
// Если это blob URL, считаем его валидным (пользователь выбрал файл)
const isBlobUrl = url.startsWith("blob:");
// Если это URL с токеном и нет явного расширения, считаем валидным
// (предполагаем что сервер вернет правильный файл)
const hasToken = searchParams.has("token");
const isServerUrl = hasToken && !hasValidExtension;
const isValid =
hasValidExtension || hasValidType || isBlobUrl || isServerUrl;
return isValid;
} catch (error) {
console.warn("⚠️ isValid3DFile: Ошибка при проверке URL", error);
// В случае ошибки парсинга URL, считаем валидным (пусть useGLTF сам разберется)
return true;
}
};
type ModelViewerProps = {
width?: string;
@@ -7,21 +62,93 @@ type ModelViewerProps = {
height?: string;
};
const Model = ({ fileUrl }: { fileUrl: string }) => {
// Очищаем кеш перед загрузкой новой модели
useEffect(() => {
// Очищаем кеш для текущего URL
clearGLTFCache(fileUrl);
}, [fileUrl]);
// Проверяем валидность файла перед загрузкой (только для blob URL)
if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) {
console.warn("⚠️ Model: Попытка загрузить невалидный 3D файл", { fileUrl });
throw new Error(`Файл не является корректной 3D моделью: ${fileUrl}`);
}
const { scene } = useGLTF(fileUrl);
return <primitive object={scene} />;
};
const LoadingFallback = () => {
return (
<Box
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 2,
zIndex: 1000,
backgroundColor: "background.paper",
p: 3,
borderRadius: 2,
}}
>
<CircularProgress size={48} />
<Typography
variant="body2"
color="text.secondary"
style={{ whiteSpace: "nowrap" }}
>
Загрузка 3D модели...
</Typography>
</Box>
);
};
export const ThreeView = ({
fileUrl,
height = "100%",
width = "100%",
}: ModelViewerProps) => {
const { scene } = useGLTF(fileUrl);
// Проверяем валидность файла (только для blob URL)
useEffect(() => {
if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) {
console.warn("⚠️ ThreeView: Невалидный 3D файл", { fileUrl });
}
}, [fileUrl]);
// Очищаем кеш при размонтировании и при смене URL
useEffect(() => {
// Очищаем кеш сразу при монтировании компонента
clearGLTFCache(fileUrl);
return () => {
clearGLTFCache(fileUrl);
};
}, [fileUrl]);
return (
<Canvas style={{ height: height, width: width }}>
<ambientLight />
<directionalLight />
<Stage environment="city" intensity={0.6}>
<primitive object={scene} />
</Stage>
<OrbitControls />
</Canvas>
<Box sx={{ position: "relative", width, height }}>
<Suspense fallback={<LoadingFallback />}>
<Canvas
style={{ height: height, width: width }}
camera={{
position: [1, 1, 1],
fov: 30,
}}
>
<ambientLight />
<directionalLight />
<Stage environment="city" intensity={0.6} adjustCamera={false}>
<Model fileUrl={fileUrl} />
</Stage>
<OrbitControls />
</Canvas>
</Suspense>
</Box>
);
};

View File

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

View File

@@ -1,7 +1,10 @@
import { Box } from "@mui/material";
import { useState, useEffect } from "react";
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
import { ThreeView } from "./ThreeView";
import { ThreeViewErrorBoundary } from "./ThreeViewErrorBoundary";
import { clearMediaTransitionCache } from "../../shared/lib/gltfCacheManager";
export interface MediaData {
id: string | number;
@@ -12,15 +15,46 @@ export interface MediaData {
export function MediaViewer({
media,
className,
height,
width,
fullWidth,
fullHeight,
}: Readonly<{
media?: MediaData;
className?: string;
height?: string;
width?: string;
fullWidth?: boolean;
fullHeight?: boolean;
}>) {
const token = localStorage.getItem("token");
const [resetKey, setResetKey] = useState(0);
const [previousMediaId, setPreviousMediaId] = useState<
string | number | null
>(null);
useEffect(() => {
if (media?.id !== previousMediaId) {
// Используем новый cache manager для очистки кеша
clearMediaTransitionCache(
previousMediaId,
media?.id || null,
media?.media_type
);
setResetKey(0);
setPreviousMediaId(media?.id || null);
}
}, [media?.id, media?.media_type, previousMediaId]);
const handleReset = () => {
setResetKey((prev) => {
const newKey = prev + 1;
return newKey;
});
};
return (
<Box
className={className}
@@ -42,13 +76,8 @@ export function MediaViewer({
}/download?token=${token}`}
alt={media?.filename}
style={{
height: fullHeight ? "100%" : "auto",
width: fullWidth ? "100%" : "auto",
...(media?.filename?.toLowerCase().endsWith(".webp") && {
maxWidth: "300px",
maxHeight: "300px",
objectFit: "contain",
}),
height: fullHeight ? "100%" : height ? height : "auto",
width: fullWidth ? "100%" : width ? width : "auto",
}}
/>
)}
@@ -59,8 +88,8 @@ export function MediaViewer({
media?.id
}/download?token=${token}`}
style={{
width: "100%",
height: "100%",
width: width ? width : "100%",
height: height ? height : "100%",
objectFit: "cover",
borderRadius: 8,
}}
@@ -76,8 +105,8 @@ export function MediaViewer({
}/download?token=${token}`}
alt={media?.filename}
style={{
height: fullHeight ? "100%" : "auto",
width: fullWidth ? "100%" : "auto",
height: fullHeight ? "100%" : height ? height : "auto",
width: fullWidth ? "100%" : width ? width : "auto",
}}
/>
)}
@@ -98,19 +127,25 @@ export function MediaViewer({
src={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id
}/download?token=${token}`}
width={"100%"}
height={"100%"}
width={width ? width : "500px"}
height={height ? height : "300px"}
/>
)}
{media?.media_type === 6 && (
<ThreeView
fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id
}/download?token=${token}`}
height="500px"
width="500px"
/>
<ThreeViewErrorBoundary
onReset={handleReset}
resetKey={`${media?.id}-${resetKey}`}
>
<ThreeView
key={`3d-model-${media?.id}-${resetKey}`}
fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id
}/download?token=${token}`}
height={height ? height : "500px"}
width={width ? width : "500px"}
/>
</ThreeViewErrorBoundary>
)}
</Box>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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