Compare commits

...

27 Commits

Author SHA1 Message Date
a908c63771 fix: Hot fix 3D model view in right preview 2025-07-10 11:53:00 +03:00
06eafee3f4 feat: Add copy device_uuid functionality 2025-07-10 11:40:45 +03:00
717031cd7a fix: Fix video using in route pages 2025-07-10 06:17:43 +03:00
2d4a1e169b Add hover effects and dynamic anchor computation to Station component
- Integrated `onTextHover` callback for handling text hover states.
- Implemented dynamic anchor calculation using `useMemo` for improved positioning.
- Updated visual feedback by highlighting labels on hover.
- Adjusted offset calculations and added interactive pointer movement refinements.
- Added `packageManager` field to `package.json`.
2025-07-09 21:12:54 +03:00
e2547cb571 fix: Update map with tables fixes 2025-07-09 18:56:18 +03:00
78800ee2ae feat: Add vercel.json 2025-06-16 12:37:25 +03:00
d415441af8 fix: Add route-station link area 2025-06-16 12:26:19 +03:00
32a7cb44d1 fix: Hot bug fix 2025-06-15 20:38:48 +03:00
481385c2f4 fix: Fix tables + add open navigation 2025-06-15 15:03:36 +03:00
2117a6836e feat: Add carriers translation on 3 languages 2025-06-13 11:17:18 +03:00
f49caf3ec8 fix: Map page finish 2025-06-13 09:17:24 +03:00
300ff262ce fix: Fix Map page 2025-06-12 22:50:43 +03:00
27cb644242 fix: Fix edit entities on the map 2025-06-09 19:16:30 +03:00
d8302e05b4 fix: Add more height for accordion 2025-06-09 18:48:52 +03:00
e2e750877a feat: Add checkbox for sightbar entity + fix build errors 2025-06-09 18:48:25 +03:00
2ca1f2cba4 feat: Fixed path for routes 2025-06-09 14:07:51 +03:00
64c15b2622 feat: Move route-preview 2025-06-09 09:31:26 +03:00
f4544c1888 feat: Route list page 2025-06-09 09:17:56 +03:00
02a1d2ea74 feat: Worked map prototype 2025-06-08 10:59:26 +03:00
b09c1b3214 feat: Add edit pages with cache 2025-06-08 08:33:43 +03:00
e37f9e14bc fix: Fix bugs 2025-06-07 19:40:54 +03:00
0fe4683683 feat: fix sight edit 2025-06-07 15:16:29 +03:00
1104e94ba8 feat: Delete user preview + snapshot preview + coordinates update 2025-06-06 20:15:02 +03:00
d74789a0d8 feat: Add more pages 2025-06-06 16:08:15 +03:00
f2aab1ab33 fix: Fix build errors 2025-06-04 22:03:03 +03:00
1973ff4304 feat: DND for Articles 2025-06-04 22:01:20 +03:00
89488d9921 feat: Refactor old code with delete modal and icons for buttons 2025-06-04 20:19:06 +03:00
141 changed files with 19542 additions and 2704 deletions

1511
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,8 +12,12 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@hello-pangea/dnd": "^18.0.1",
"@mui/icons-material": "^7.1.1",
"@mui/material": "^7.1.0",
"@mui/x-data-grid": "^8.5.1",
"@photo-sphere-viewer/core": "^5.13.2",
"@pixi/react": "^8.0.2",
"@react-three/drei": "^10.1.2",
"@react-three/fiber": "^9.1.2",
"@tailwindcss/vite": "^4.1.8",
@ -24,7 +28,9 @@
"mobx-react-lite": "^4.1.0",
"ol": "^10.5.0",
"path": "^0.12.7",
"pixi.js": "^8.10.1",
"react": "^19.1.0",
"react-colorful": "^5.6.1",
"react-dom": "^19.1.0",
"react-dropzone": "^14.3.8",
"react-markdown": "^10.1.0",
@ -50,5 +56,6 @@
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"
}
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

373
public/Emblem.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 176 KiB

BIN
public/GET.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

BIN
public/SightIcon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 B

BIN
public/favicon_ship.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -1,7 +1,5 @@
import * as React from "react";
import { BrowserRouter } from "react-router-dom";
import { Router } from "./router";
import { CustomTheme } from "@shared";
import { ThemeProvider } from "@mui/material/styles";
@ -10,8 +8,6 @@ import { ToastContainer } from "react-toastify";
export const App: React.FC = () => (
<ThemeProvider theme={CustomTheme.Light}>
<ToastContainer />
<BrowserRouter>
<Router />
</BrowserRouter>
</ThemeProvider>
);

View File

@ -4,42 +4,82 @@ import {
EditSightPage,
LoginPage,
MainPage,
SightPage,
SightListPage,
MapPage,
MediaListPage,
PreviewMediaPage,
EditMediaPage,
// CreateMediaPage,
MediaPreviewPage,
MediaEditPage,
CountryListPage,
CityListPage,
RouteListPage,
UserListPage,
SnapshotListPage,
CarrierListPage,
StationListPage,
// VehicleListPage,
ArticleListPage,
// CountryPreviewPage,
// VehiclePreviewPage,
// CarrierPreviewPage,
SnapshotCreatePage,
CountryCreatePage,
CityCreatePage,
CarrierCreatePage,
VehicleCreatePage,
CountryEditPage,
CityEditPage,
UserCreatePage,
UserEditPage,
// VehicleEditPage,
CarrierEditPage,
StationCreatePage,
StationPreviewPage,
StationEditPage,
RouteCreatePage,
RoutePreview,
RouteEditPage,
ArticlePreviewPage,
CountryAddPage,
} from "@pages";
import { authStore, createSightStore, editSightStore } from "@shared";
import { Layout } from "@widgets";
import { runInAction } from "mobx";
import { useEffect } from "react";
import React, { useEffect } from "react";
import { Navigate, Outlet, Route, Routes, useLocation } from "react-router-dom";
import {
createBrowserRouter,
RouterProvider,
Navigate,
Outlet,
useLocation,
} from "react-router-dom";
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated } = authStore;
if (isAuthenticated) {
return <Navigate to="/sight" />;
return <Navigate to="/map" replace />;
}
return children;
return <>{children}</>;
};
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated } = authStore;
const pathname = useLocation();
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" />;
return <Navigate to="/login" replace />;
}
if (pathname.pathname === "/") {
return <Navigate to="/sight" />;
if (location.pathname === "/") {
return <Navigate to="/map" replace />;
}
return children;
return <>{children}</>;
};
export const Router = () => {
const pathname = useLocation();
// Чтобы очистка сторов происходила при смене локации
const ClearStoresWrapper: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const location = useLocation();
useEffect(() => {
editSightStore.clearSightInfo();
@ -47,41 +87,98 @@ export const Router = () => {
runInAction(() => {
editSightStore.hasLoadedCommon = false;
});
}, [pathname]);
}, [location]);
return (
<Routes>
<Route
path="/login"
element={
return <>{children}</>;
};
const router = createBrowserRouter([
{
path: "/login",
element: (
<PublicRoute>
<LoginPage />
</PublicRoute>
}
/>
{/* Protected routes with layout */}
<Route
path="/"
element={
),
},
{ path: "route-preview/:id", element: <RoutePreview /> },
{
path: "/",
element: (
<ProtectedRoute>
<Layout>
<ClearStoresWrapper>
<Outlet />
</ClearStoresWrapper>
</Layout>
</ProtectedRoute>
}
>
<Route index element={<MainPage />} />
<Route path="sight" element={<SightPage />} />
<Route path="sight/:id" element={<EditSightPage />} />
<Route path="sight/create" element={<CreateSightPage />} />
<Route path="devices" element={<DevicesPage />} />
<Route path="map" element={<MapPage />} />
<Route path="media" element={<MediaListPage />} />
<Route path="media/:id" element={<PreviewMediaPage />} />
<Route path="media/:id/edit" element={<EditMediaPage />} />
{/* <Route path="media/create" element={<CreateMediaPage />} /> */}
</Route>
</Routes>
);
),
children: [
{ index: true, element: <MainPage /> },
// Sight
{ path: "sight", element: <SightListPage /> },
{ path: "sight/create", element: <CreateSightPage /> },
{ path: "sight/:id/edit", element: <EditSightPage /> },
// Device
{ path: "devices", element: <DevicesPage /> },
// Map
{ path: "map", element: <MapPage /> },
// Media
{ path: "media", element: <MediaListPage /> },
{ path: "media/:id", element: <MediaPreviewPage /> },
{ path: "media/:id/edit", element: <MediaEditPage /> },
// Country
{ path: "country", element: <CountryListPage /> },
{ path: "country/create", element: <CountryCreatePage /> },
{ path: "country/add", element: <CountryAddPage /> },
// { path: "country/:id", element: <CountryPreviewPage /> },
{ path: "country/:id/edit", element: <CountryEditPage /> },
// City
{ path: "city", element: <CityListPage /> },
{ path: "city/create", element: <CityCreatePage /> },
// { path: "city/:id", element: <CityPreviewPage /> },
{ path: "city/:id/edit", element: <CityEditPage /> },
// Route
{ path: "route", element: <RouteListPage /> },
{ path: "route/create", element: <RouteCreatePage /> },
{ path: "route/:id/edit", element: <RouteEditPage /> },
// User
{ path: "user", element: <UserListPage /> },
{ path: "user/create", element: <UserCreatePage /> },
{ path: "user/:id/edit", element: <UserEditPage /> },
// Snapshot
{ path: "snapshot", element: <SnapshotListPage /> },
{ path: "snapshot/create", element: <SnapshotCreatePage /> },
// Carrier
{ path: "carrier", element: <CarrierListPage /> },
{ path: "carrier/create", element: <CarrierCreatePage /> },
// { path: "carrier/:id", element: <CarrierPreviewPage /> },
{ path: "carrier/:id/edit", element: <CarrierEditPage /> },
// Station
{ path: "station", element: <StationListPage /> },
{ path: "station/create", element: <StationCreatePage /> },
{ path: "station/:id", element: <StationPreviewPage /> },
{ path: "station/:id/edit", element: <StationEditPage /> },
// Vehicle
// { path: "vehicle", element: <VehicleListPage /> },
{ path: "vehicle/create", element: <VehicleCreatePage /> },
// { path: "vehicle/:id", element: <VehiclePreviewPage /> },
// { path: "vehicle/:id/edit", element: <VehicleEditPage /> },
// Article
{ path: "article", element: <ArticleListPage /> },
{ path: "article/:id", element: <ArticlePreviewPage /> },
// { path: "media/create", element: <CreateMediaPage /> },
],
},
]);
export const Router = () => {
return <RouterProvider router={router} />;
};

View File

@ -4,7 +4,9 @@ export interface NavigationItem {
id: string;
label: string;
icon: LucideIcon;
path: string;
path?: string;
onClick?: () => void;
nestedItems?: NavigationItem[];
}
export type NavigationSection = "primary" | "secondary";

View File

@ -3,36 +3,54 @@ import ListItem from "@mui/material/ListItem";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Collapse from "@mui/material/Collapse";
import List from "@mui/material/List";
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import type { NavigationItem } from "../model";
import { useNavigate } from "react-router-dom";
import { useNavigate, useLocation } from "react-router-dom";
import { Plus } from "lucide-react";
interface NavigationItemProps {
item: NavigationItem;
open: boolean;
onClick?: () => void;
isNested?: boolean;
onDrawerOpen?: () => void;
}
export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
item,
open,
onClick,
isNested = false,
onDrawerOpen,
}) => {
const Icon = item.icon;
const navigate = useNavigate();
const location = useLocation();
const [isExpanded, setIsExpanded] = React.useState(false);
return (
<ListItem
onClick={() => {
if (onClick) {
const isActive = item.path ? location.pathname.startsWith(item.path) : false;
const handleClick = () => {
if (item.id === "all" && !open) {
onDrawerOpen?.();
}
if (item.nestedItems) {
setIsExpanded(!isExpanded);
} else if (onClick) {
onClick();
} else {
} else if (item.path) {
navigate(item.path);
}
}}
disablePadding
sx={{ display: "block" }}
>
};
return (
<>
<ListItem disablePadding sx={{ display: "block" }}>
<ListItemButton
onClick={handleClick}
sx={[
{
minHeight: 48,
@ -45,6 +63,15 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
: {
justifyContent: "center",
},
isNested && {
pl: open ? 4 : 2.5,
},
isActive && {
backgroundColor: "rgba(0, 0, 0, 0.08)",
"&:hover": {
backgroundColor: "rgba(0, 0, 0, 0.12)",
},
},
]}
>
<ListItemIcon
@ -52,6 +79,7 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
{
minWidth: 0,
justifyContent: "center",
color: isActive ? "primary.main" : "inherit",
},
open
? {
@ -62,7 +90,7 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
},
]}
>
<Icon />
{Icon ? <Icon /> : <Plus />}
</ListItemIcon>
<ListItemText
primary={item.label}
@ -74,9 +102,32 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
: {
opacity: 0,
},
isActive && {
color: "primary.main",
fontWeight: "bold",
},
]}
/>
{item.nestedItems &&
open &&
(isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />)}
</ListItemButton>
</ListItem>
{item.nestedItems && (
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{item.nestedItems.map((nestedItem) => (
<NavigationItemComponent
key={nestedItem.id}
item={nestedItem}
open={open}
onClick={nestedItem.onClick}
isNested={true}
/>
))}
</List>
</Collapse>
)}
</>
);
};

View File

@ -3,7 +3,12 @@ import Divider from "@mui/material/Divider";
import { NAVIGATION_ITEMS } from "@shared";
import { NavigationItem, NavigationItemComponent } from "@entities";
export const NavigationList = ({ open }: { open: boolean }) => {
interface NavigationListProps {
open: boolean;
onDrawerOpen?: () => void;
}
export const NavigationList = ({ open, onDrawerOpen }: NavigationListProps) => {
const primaryItems = NAVIGATION_ITEMS.primary;
const secondaryItems = NAVIGATION_ITEMS.secondary;
@ -15,6 +20,7 @@ export const NavigationList = ({ open }: { open: boolean }) => {
key={item.id}
item={item as NavigationItem}
open={open}
onDrawerOpen={onDrawerOpen}
/>
))}
</List>
@ -26,6 +32,7 @@ export const NavigationList = ({ open }: { open: boolean }) => {
item={item as NavigationItem}
open={open}
onClick={item.onClick ? item.onClick : undefined}
onDrawerOpen={onDrawerOpen}
/>
))}
</List>

View File

@ -3,3 +3,7 @@
button {
cursor: pointer;
}
.mde-preview {
background-color: #f5f5f5;
}

View File

@ -1,6 +1,7 @@
import { createRoot } from "react-dom/client";
import { App } from "./app";
import "./index.css";
import { App } from "./app/index";
const container = document.getElementById("root");
const root = createRoot(container!);

View File

@ -0,0 +1,33 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { LanguageSwitcher } from "@widgets";
import { articlesStore } from "@shared";
const ArticleCreatePage: React.FC = () => {
const navigate = useNavigate();
const { articleData } = articlesStore;
return (
<div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
<h1 className="text-3xl break-words">
{articleData?.ru?.heading || "Создание статьи"}
</h1>
</div>
<LanguageSwitcher />
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
</div>
);
};
export default ArticleCreatePage;

View File

@ -0,0 +1,49 @@
import React, { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { LanguageSwitcher } from "@widgets";
import { articlesStore, languageStore } from "@shared";
import { observer } from "mobx-react-lite";
const ArticleEditPage: React.FC = observer(() => {
const navigate = useNavigate();
const { id } = useParams();
const { articleData, getArticle } = articlesStore;
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
useEffect(() => {
if (id) {
// Fetch data for all languages
getArticle(parseInt(id), "ru");
getArticle(parseInt(id), "en");
getArticle(parseInt(id), "zh");
}
}, [id]);
return (
<div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
<h1 className="text-3xl break-words">
{articleData?.ru?.heading || "Редактирование статьи"}
</h1>
</div>
<LanguageSwitcher />
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
</div>
);
});
export default ArticleEditPage;

View File

@ -0,0 +1,153 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { articlesStore, languageStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Trash2, Eye, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { DeleteModal, LanguageSwitcher } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const ArticleListPage = observer(() => {
const { articleList, getArticleList, deleteArticles } = articlesStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null);
const { language } = languageStore;
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const fetchArticles = async () => {
setIsLoading(true);
await getArticleList();
setIsLoading(false);
};
fetchArticles();
}, [language]);
const columns: GridColDef[] = [
{
field: "heading",
headerName: "Название",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return params.value ? (
params.value
) : (
<div className="flex h-full gap-7 items-center">
<Minus size={20} className="text-red-500" />
</div>
);
},
},
{
field: "actions",
headerName: "Действия",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/article/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = articleList[language].data.map((article) => ({
id: article.id,
heading: article.heading,
body: article.body,
}));
return (
<>
<LanguageSwitcher />
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Статьи</h1>
</div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<div className="w-full">
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
checkboxSelection
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter
slots={{
noRowsOverlay: () => (
<Box
sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}
>
{isLoading ? <CircularProgress size={20} /> : "Нет статей"}
</Box>
),
}}
/>
</div>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
await deleteArticles([parseInt(rowId)]);
getArticleList();
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await deleteArticles(ids);
getArticleList();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</>
);
});

View File

@ -0,0 +1,85 @@
import { Paper, Box, Typography } from "@mui/material";
import { MediaViewer, ReactMarkdownComponent } from "@widgets";
import { articlesStore, languageStore } from "@shared";
import { observer } from "mobx-react-lite";
export const PreviewLeftWidget = observer(() => {
const { articleMedia, articleData } = articlesStore;
const { language } = languageStore;
return (
<Paper
elevation={3}
sx={{
width: "100%",
minWidth: 320,
background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
overflowY: "auto",
display: "flex",
flexDirection: "column",
borderRadius: "10px",
}}
>
<Box
sx={{
overflow: "hidden",
width: "100%",
minHeight: 100,
padding: "3px",
display: "flex",
alignItems: "center",
justifyContent: "center",
"& img": {
borderTopLeftRadius: "10px",
borderTopRightRadius: "10px",
width: "100%",
height: "auto",
objectFit: "contain",
},
}}
>
{articleMedia && <MediaViewer media={articleMedia} fullWidth />}
</Box>
<Box
sx={{
background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
color: "white",
margin: "5px 0px 5px 0px",
display: "flex",
flexDirection: "column",
gap: 1,
padding: 1,
}}
>
<Typography
variant="h5"
component="h2"
sx={{
wordBreak: "break-word",
fontSize: "24px",
fontWeight: 700,
lineHeight: "120%",
}}
>
{articleData?.[language]?.heading || "Название информации"}
</Typography>
</Box>
{articleData?.[language]?.body && (
<Box
sx={{
padding: 1,
maxHeight: "300px",
overflowY: "scroll",
background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
flexGrow: 1,
}}
>
<ReactMarkdownComponent value={articleData?.[language]?.body} />
</Box>
)}
</Paper>
);
});

View File

@ -0,0 +1,139 @@
import { Paper, Box, Typography } from "@mui/material";
import { MediaViewer, ReactMarkdownComponent } from "@widgets";
import { articlesStore, languageStore } from "@shared";
import { observer } from "mobx-react-lite";
import { ImagePlus } from "lucide-react";
export const PreviewRightWidget = observer(() => {
const { articleData, articleMedia } = articlesStore;
const { language } = languageStore;
const article = articleData?.[language];
if (!article) return null;
return (
<Paper
className="flex-1 flex flex-col max-w-[500px]"
sx={{
borderRadius: "10px",
overflow: "hidden",
}}
elevation={2}
>
<Box
className="overflow-hidden"
sx={{
width: "100%",
background: "#877361",
borderColor: "grey.300",
display: "flex",
flexDirection: "column",
}}
>
{articleMedia ? (
<Box
sx={{
overflow: "hidden",
width: "100%",
padding: "2px 2px 0px 2px",
"& img": {
borderTopLeftRadius: "10px",
borderTopRightRadius: "10px",
width: "100%",
height: "auto",
objectFit: "contain",
},
}}
>
<MediaViewer media={articleMedia} fullWidth fullHeight />
</Box>
) : (
<Box
sx={{
width: "100%",
height: 200,
flexShrink: 0,
backgroundColor: "rgba(0,0,0,0.1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<ImagePlus size={48} color="white" />
</Box>
)}
<Box
sx={{
p: 1,
wordBreak: "break-word",
fontSize: "24px",
fontWeight: 700,
lineHeight: "120%",
backdropFilter: "blur(12px)",
borderBottom: "1px solid #A89F90",
boxShadow: "inset 4px 4px 12px 0 rgba(255,255,255,0.12)",
background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
}}
>
<Typography variant="h6" color="white">
{article.heading || "Выберите статью"}
</Typography>
</Box>
<Box
sx={{
padding: 1,
minHeight: "200px",
maxHeight: "300px",
overflowY: "scroll",
background:
"rgba(179, 165, 152, 0.4), linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 100%)",
flexGrow: 1,
}}
>
{article.body ? (
<ReactMarkdownComponent value={article.body} />
) : (
<Typography
color="rgba(255,255,255,0.7)"
sx={{ textAlign: "center", mt: 4 }}
>
Предпросмотр статьи появится здесь
</Typography>
)}
</Box>
{/* @ts-ignore */}
{articleData?.right && articleData?.right.length > 1 && (
<Box
sx={{
p: 2,
display: "flex",
justifyContent: "space-between",
fontSize: "24px",
fontWeight: 700,
lineHeight: "120%",
flexWrap: "wrap",
gap: 1,
backdropFilter: "blur(12px)",
boxShadow: "inset 4px 4px 12px 0 rgba(255,255,255,0.12)",
background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
}}
>
{/* @ts-ignore */}
{articleData.right.map((a, idx) => (
<button
key={idx}
className="inline-block text-left text-xs text-white"
>
{a.heading}
</button>
))}
</Box>
)}
</Box>
</Paper>
);
});

View File

@ -0,0 +1,57 @@
import { useNavigate, useParams } from "react-router-dom";
import { useEffect } from "react";
import { Box } from "@mui/material";
import { PreviewLeftWidget } from "./PreviewLeftWidget";
import { PreviewRightWidget } from "./PreviewRightWidget";
import { articlesStore, languageStore } from "@shared";
import { ArrowLeft } from "lucide-react";
export const ArticlePreviewPage = () => {
const navigate = useNavigate();
const { id } = useParams();
const { getArticle, getArticleMedia, getArticlePreview } = articlesStore;
const { language } = languageStore;
useEffect(() => {
const fetchData = async () => {
if (id) {
await getArticle(Number(id), language);
await getArticleMedia(Number(id));
await getArticlePreview(Number(id));
}
};
fetchData();
}, [id, language]);
return (
<>
<div className="flex items-center gap-4 mb-10">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<Box
sx={{
display: "flex",
gap: 2,
p: 2,
justifyContent: "center",
margin: "0 auto",
}}
>
<Box sx={{ width: "320px" }}>
<PreviewLeftWidget />
</Box>
<Box sx={{ width: "500px" }}>
<PreviewRightWidget />
</Box>
</Box>
</>
);
};

View File

@ -0,0 +1,2 @@
export * from "./ArticleListPage";
export * from "./ArticlePreviewPage";

View File

@ -0,0 +1,247 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
} from "@mui/material";
import { observer } from "mobx-react-lite";
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 { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
import {
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared";
export const CarrierCreatePage = observer(() => {
const navigate = useNavigate();
const { createCarrierData, setCreateCarrierData } = carrierStore;
const { language } = languageStore;
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
useEffect(() => {
cityStore.getCities("ru");
mediaStore.getMedia();
languageStore.setLanguage("ru");
}, []);
const handleCreate = async () => {
try {
setIsLoading(true);
await carrierStore.createCarrier();
toast.success("Перевозчик успешно создан");
navigate("/carrier");
} catch (error) {
toast.error("Ошибка при создании перевозчика");
} finally {
setIsLoading(false);
}
};
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setSelectedMediaId(media.id);
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
createCarrierData[language].slogan,
media.id,
language
);
};
const selectedMedia = selectedMediaId
? mediaStore.media.find((m) => m.id === selectedMediaId)
: null;
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"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
<h1 className="text-3xl break-words">Создание перевозчика</h1>
</div>
<FormControl fullWidth>
<InputLabel>Город</InputLabel>
<Select
value={createCarrierData.city_id || ""}
label="Город"
required
onChange={(e) =>
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
e.target.value as number,
createCarrierData[language].slogan,
selectedMediaId || "",
language
)
}
>
{cityStore.cities["ru"].data.map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
fullWidth
label="Полное название"
value={createCarrierData[language].full_name}
required
onChange={(e) =>
setCreateCarrierData(
e.target.value,
createCarrierData[language].short_name,
createCarrierData.city_id,
createCarrierData[language].slogan,
selectedMediaId || "",
language
)
}
/>
<TextField
fullWidth
label="Короткое название"
value={createCarrierData[language].short_name}
required
onChange={(e) =>
setCreateCarrierData(
createCarrierData[language].full_name,
e.target.value,
createCarrierData.city_id,
createCarrierData[language].slogan,
selectedMediaId || "",
language
)
}
/>
<TextField
fullWidth
label="Слоган"
value={createCarrierData[language].slogan}
onChange={(e) =>
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
e.target.value,
selectedMediaId || "",
language
)
}
/>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard
title="Логотип перевозчика"
imageKey="thumbnail"
imageUrl={selectedMedia?.id}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(selectedMedia?.id ?? "");
}}
onDeleteImageClick={() => {
setSelectedMediaId(null);
setActiveMenuType(null);
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
createCarrierData[language].slogan,
"",
language
);
}}
onSelectFileClick={() => {
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("image");
}}
/>
</div>
<Button
variant="contained"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={
isLoading ||
!createCarrierData[language].full_name ||
!createCarrierData[language].short_name ||
!createCarrierData.city_id
}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Создать"
)}
</Button>
</div>
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={createCarrierData[language].full_name}
contextType="carrier"
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
</Paper>
);
});

View File

@ -0,0 +1,288 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import { carrierStore, cityStore, mediaStore, languageStore } from "@shared";
import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets";
import {
SelectMediaDialog,
PreviewMediaDialog,
UploadMediaDialog,
} from "@shared";
export const CarrierEditPage = observer(() => {
const navigate = useNavigate();
const { id } = useParams();
const { getCarrier, setEditCarrierData, editCarrierData } = carrierStore;
const { language } = languageStore;
const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [isDeleteLogoModalOpen, setIsDeleteLogoModalOpen] = useState(false);
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
useEffect(() => {
(async () => {
await cityStore.getCities("ru");
await cityStore.getCities("en");
await cityStore.getCities("zh");
const carrierData = await getCarrier(Number(id));
if (carrierData) {
setEditCarrierData(
carrierData.ru?.full_name || "",
carrierData.ru?.short_name || "",
carrierData.ru?.city_id || 0,
carrierData.ru?.slogan || "",
carrierData.ru?.logo || "",
"ru"
);
setEditCarrierData(
carrierData.en?.full_name || "",
carrierData.en?.short_name || "",
carrierData.en?.city_id || 0,
carrierData.en?.slogan || "",
carrierData.en?.logo || "",
"en"
);
setEditCarrierData(
carrierData.zh?.full_name || "",
carrierData.zh?.short_name || "",
carrierData.zh?.city_id || 0,
carrierData.zh?.slogan || "",
carrierData.zh?.logo || "",
"zh"
);
}
mediaStore.getMedia();
})();
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, [id]);
const handleEdit = async () => {
try {
setIsLoading(true);
await carrierStore.editCarrier(Number(id));
toast.success("Перевозчик успешно обновлен");
navigate("/carrier");
} catch (error) {
toast.error("Ошибка при обновлении перевозчика");
} finally {
setIsLoading(false);
}
};
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
media.id,
language
);
};
const selectedMedia = editCarrierData.logo
? mediaStore.media.find((m) => m.id === editCarrierData.logo)
: null;
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"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
<h1 className="text-3xl break-words">{editCarrierData.ru.full_name}</h1>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<FormControl fullWidth>
<InputLabel>Город</InputLabel>
<Select
value={editCarrierData.city_id || ""}
label="Город"
required
onChange={(e) =>
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
Number(e.target.value),
editCarrierData[language].slogan,
editCarrierData.logo,
language
)
}
>
{cityStore.cities["ru"].data?.map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
fullWidth
label="Полное название"
value={editCarrierData[language].full_name}
required
onChange={(e) =>
setEditCarrierData(
e.target.value,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
editCarrierData.logo,
language
)
}
/>
<TextField
fullWidth
label="Короткое название"
value={editCarrierData[language].short_name}
required
onChange={(e) =>
setEditCarrierData(
editCarrierData[language].full_name,
e.target.value,
editCarrierData.city_id,
editCarrierData[language].slogan,
editCarrierData.logo,
language
)
}
/>
<TextField
fullWidth
label="Слоган"
value={editCarrierData[language].slogan}
onChange={(e) =>
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
e.target.value,
editCarrierData.logo,
language
)
}
/>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard
title="Логотип перевозчика"
imageKey="thumbnail"
imageUrl={selectedMedia?.id}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(selectedMedia?.id ?? "");
}}
onDeleteImageClick={() => {
setIsDeleteLogoModalOpen(true);
}}
onSelectFileClick={() => {
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("image");
}}
/>
</div>
<Button
variant="contained"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleEdit}
disabled={
isLoading ||
!editCarrierData[language].full_name ||
!editCarrierData.city_id
}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Сохранить"
)}
</Button>
</div>
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={editCarrierData[language].full_name}
contextType="carrier"
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
<DeleteModal
open={isDeleteLogoModalOpen}
onDelete={() => {
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
"",
language
);
setIsDeleteLogoModalOpen(false);
}}
onCancel={() => setIsDeleteLogoModalOpen(false)}
edit
/>
</Paper>
);
});

View File

@ -0,0 +1,197 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { carrierStore, cityStore, languageStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const CarrierListPage = observer(() => {
const { carriers, getCarriers, deleteCarrier } = carrierStore;
const { getCities, cities } = cityStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
await getCities("ru");
await getCities("en");
await getCities("zh");
await getCarriers(language);
setIsLoading(false);
};
fetchData();
}, [language]);
const columns: GridColDef[] = [
{
field: "full_name",
headerName: "Полное имя",
width: 300,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "short_name",
headerName: "Короткое имя",
width: 200,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "city_id",
headerName: "Город",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
const city = cities[language]?.data.find(
(city) => city.id == params.value
);
return (
<div className="w-full h-full flex items-center">
{city && city.name ? (
city.name
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "actions",
headerName: "Действия",
headerAlign: "center",
width: 200,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/carrier/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
{/* <button onClick={() => navigate(`/carrier/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button> */}
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = carriers[language].data?.map((carrier) => ({
id: carrier.id,
full_name: carrier.full_name,
short_name: carrier.short_name,
city_id: carrier.city_id,
}));
return (
<>
<LanguageSwitcher />
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Перевозчики</h1>
<CreateButton label="Создать перевозчика" path="/carrier/create" />
</div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooter
checkboxSelection
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? (
<CircularProgress size={20} />
) : (
"Нет перевозчиков"
)}
</Box>
),
}}
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
await deleteCarrier(rowId);
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteCarrier(id)));
await getCarriers(language);
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</>
);
});

View File

@ -0,0 +1,4 @@
export * from "./CarrierListPage";
export * from "./CarrierCreatePage";
export * from "./CarrierEditPage";

View File

@ -0,0 +1,206 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { cityStore, countryStore, languageStore, mediaStore } from "@shared";
import { useState, useEffect } from "react";
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
import {
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared";
export const CityCreatePage = observer(() => {
const navigate = useNavigate();
const { language } = languageStore;
const { createCityData, setCreateCityData } = cityStore;
const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
const { getCountries } = countryStore;
const { getMedia } = mediaStore;
useEffect(() => {
(async () => {
await getCountries("ru");
await getCountries("en");
await getCountries("zh");
await getMedia();
})();
}, [language]);
const handleCreate = async () => {
try {
setIsLoading(true);
await cityStore.createCity();
toast.success("Город успешно создан");
navigate("/city");
} catch (error) {
toast.error("Ошибка при создании города");
} finally {
setIsLoading(false);
}
};
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setCreateCityData(
createCityData[language].name,
createCityData.country_code,
media.id,
language
);
};
const selectedMedia = createCityData.arms
? mediaStore.media.find((m) => m.id === createCityData.arms)
: null;
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"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
<h1 className="text-3xl break-words">{createCityData.ru.name}</h1>
</div>
<TextField
fullWidth
label="Название города"
value={createCityData[language]?.name || ""}
required
onChange={(e) =>
setCreateCityData(
e.target.value,
createCityData.country_code,
createCityData.arms,
language
)
}
/>
<FormControl fullWidth>
<InputLabel>Страна</InputLabel>
<Select
value={createCityData.country_code || ""}
label="Страна"
required
onChange={(e) => {
setCreateCityData(
createCityData[language].name,
e.target.value,
createCityData.arms,
language
);
}}
>
{countryStore.countries["ru"]?.data?.map((country) => (
<MenuItem key={country.code} value={country.code}>
{country.name}
</MenuItem>
))}
</Select>
</FormControl>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard
title="Герб города"
imageKey="image"
imageUrl={selectedMedia?.id}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(selectedMedia?.id ?? "");
}}
onDeleteImageClick={() => {
setCreateCityData(
createCityData[language].name,
createCityData.country_code,
"",
language
);
setActiveMenuType(null);
}}
onSelectFileClick={() => {
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("image");
}}
setHardcodeType={() => {
setActiveMenuType("image");
}}
/>
</div>
<Button
variant="contained"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={isLoading || !createCityData[language]?.name}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Создать"
)}
</Button>
</div>
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1} // Тип медиа для иконок
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={createCityData[language]?.name}
contextType="city"
afterUpload={handleMediaSelect}
hardcodeType={
activeMenuType as "thumbnail" | "watermark_lu" | "watermark_rd" | null
}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
</Paper>
);
});

View File

@ -0,0 +1,236 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import {
cityStore,
countryStore,
languageStore,
mediaStore,
CashedCities,
} from "@shared";
import { useEffect, useState } from "react";
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
import {
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared";
export const CityEditPage = observer(() => {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
const { language } = languageStore;
const { id } = useParams();
const { editCityData, editCity, getCity, setEditCityData } = cityStore;
const { getCountries } = countryStore;
const { getMedia, getOneMedia } = mediaStore;
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
const handleEdit = async () => {
try {
setIsLoading(true);
await editCity(id as string);
toast.success("Город успешно обновлен");
} catch (error) {
toast.error("Ошибка при обновлении города");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
(async () => {
if (id) {
await getCountries("ru");
// Fetch data for all languages
const ruData = await getCity(id as string, "ru");
const enData = await getCity(id as string, "en");
const zhData = await getCity(id as string, "zh");
// Set data for each language
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
setEditCityData(enData.name, enData.country_code, enData.arms, "en");
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
await getOneMedia(ruData.arms as string);
await getMedia();
}
})();
}, [id]);
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setEditCityData(
editCityData[language].name,
editCityData.country_code,
media.id,
language
);
};
const selectedMedia = editCityData.arms
? mediaStore.media.find((m) => m.id === editCityData.arms)
: null;
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"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start ">
<h1 className="text-3xl break-words">{editCityData.ru.name}</h1>
</div>
<TextField
fullWidth
label="Название"
value={editCityData[language].name}
required
onChange={(e) =>
setEditCityData(
e.target.value,
editCityData.country_code,
editCityData.arms,
language
)
}
/>
<FormControl fullWidth>
<InputLabel>Страна</InputLabel>
<Select
value={editCityData.country_code || ""}
label="Страна"
required
onChange={(e) => {
setEditCityData(
editCityData[language].name,
e.target.value,
editCityData.arms,
language
);
}}
>
{countryStore.countries.ru.data.map((country) => (
<MenuItem key={country.code} value={country.code}>
{country.name}
</MenuItem>
))}
</Select>
</FormControl>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard
title="Герб города"
imageKey="image"
imageUrl={selectedMedia?.id}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(selectedMedia?.id ?? "");
}}
onDeleteImageClick={() => {
setEditCityData(
editCityData[language].name,
editCityData.country_code,
"",
language
);
setActiveMenuType(null);
}}
onSelectFileClick={() => {
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("image");
}}
setHardcodeType={() => {
setActiveMenuType("image");
}}
/>
</div>
<Button
variant="contained"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleEdit}
disabled={
isLoading || !editCityData[language as keyof CashedCities]?.name
}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Сохранить"
)}
</Button>
</div>
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1} // Тип медиа для иконок
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={editCityData[language].name}
contextType="city"
afterUpload={handleMediaSelect}
hardcodeType={
activeMenuType as
| "thumbnail"
| "watermark_lu"
| "watermark_rd"
| "image"
| null
}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
</Paper>
);
});

View File

@ -0,0 +1,195 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { languageStore, cityStore, countryStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { toast } from "react-toastify";
import { Box, CircularProgress } from "@mui/material";
export const CityListPage = observer(() => {
const { cities, getCities, deleteCity } = cityStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [rows, setRows] = useState<any[]>([]);
const { language } = languageStore;
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
await countryStore.getCountries("ru");
await countryStore.getCountries("en");
await countryStore.getCountries("zh");
await getCities(language);
setIsLoading(false);
};
fetchData();
}, [language]);
useEffect(() => {
let newRows = cities[language]?.data?.map((city) => ({
id: city.id,
name: city.name,
country: city.country_code,
}));
let newRows2: any[] = [];
for (const city of newRows) {
const name = countryStore.countries[language]?.data?.find(
(country) => country.code === city.country
)?.name;
if (name) {
newRows2.push(city);
}
}
setRows(newRows2 || []);
console.log(newRows2);
}, [cities, countryStore.countries, language, isLoading]);
const columns: GridColDef[] = [
{
field: "country",
headerName: "Страна",
width: 150,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
countryStore.countries[language]?.data?.find(
(country) => country.code === params.value
)?.name
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "name",
headerName: "Название",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "actions",
headerName: "Действия",
align: "center",
headerAlign: "center",
width: 200,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/city/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
{/* <button onClick={() => navigate(`/city/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button> */}
<button
onClick={(e) => {
e.stopPropagation();
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
return (
<>
<LanguageSwitcher />
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Города</h1>
<CreateButton label="Создать город" path="/city/create" />
</div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooter
checkboxSelection
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет городов"}
</Box>
),
}}
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
await deleteCity(rowId.toString());
toast.success("Город успешно удален");
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteCity(id.toString())));
toast.success("Города успешно удалены");
getCities(language);
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</>
);
});

View File

@ -0,0 +1,69 @@
import { Paper } from "@mui/material";
import { cityStore, languageStore, mediaStore } from "@shared";
import { LanguageSwitcher, MediaViewer } from "@widgets";
import { observer } from "mobx-react-lite";
import { ArrowLeft } from "lucide-react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
export const CityPreviewPage = observer(() => {
const { id } = useParams();
const { getCity, city, setEditCityData } = cityStore;
const { oneMedia, getOneMedia } = mediaStore;
const navigate = useNavigate();
const { language } = languageStore;
useEffect(() => {
(async () => {
if (id) {
const ruData = await getCity(id as string, "ru");
const enData = await getCity(id as string, "en");
const zhData = await getCity(id as string, "zh");
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
setEditCityData(enData.name, enData.country_code, enData.arms, "en");
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
await getOneMedia(ruData.arms as string);
}
})();
}, [id]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Название</h1>
<p>{city[id!]?.[language]?.name}</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Страна</h1>
<p>{city[id!]?.[language]?.country}</p>
</div>
<div className="flex flex-col gap-2 pb-10">
<h1 className="text-lg font-bold">Герб</h1>
<div className="w-[300px] h-[200px]">
<MediaViewer
media={{
id: oneMedia?.id as string,
media_type: oneMedia?.media_type as number,
filename: oneMedia?.filename,
}}
/>
</div>
</div>
</div>
</Paper>
);
});

4
src/pages/City/index.ts Normal file
View File

@ -0,0 +1,4 @@
export * from "./CityListPage";
export * from "./CityPreviewPage";
export * from "./CityCreatePage";
export * from "./CityEditPage";

View File

@ -0,0 +1,115 @@
import {
Button,
Paper,
TextField,
Autocomplete,
FormControl,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import {
countryStore,
RU_COUNTRIES,
EN_COUNTRIES,
ZH_COUNTRIES,
} from "@shared";
import { useState } from "react";
export const CountryAddPage = observer(() => {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const { createCountryData, setCountryData, createCountry } = countryStore;
const handleCountryCodeChange = (code: string) => {
const ruCountry = RU_COUNTRIES.find((c) => c.code === code);
const enCountry = EN_COUNTRIES.find((c) => c.code === code);
const zhCountry = ZH_COUNTRIES.find((c) => c.code === code);
if (ruCountry && enCountry && zhCountry) {
setCountryData(code, ruCountry.name, "ru");
setCountryData(code, enCountry.name, "en");
setCountryData(code, zhCountry.name, "zh");
}
};
const handleCreate = async () => {
try {
setIsLoading(true);
await createCountry();
toast.success("Страна успешно создана");
navigate("/country");
} catch (error) {
toast.error("Ошибка при создании страны");
} finally {
setIsLoading(false);
}
};
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<FormControl fullWidth>
<Autocomplete
value={
RU_COUNTRIES.find((c) => c.code === createCountryData.code) ||
null
}
onChange={(_, newValue) => {
if (newValue) {
handleCountryCodeChange(newValue.code);
}
}}
options={RU_COUNTRIES}
getOptionLabel={(option) => `${option.code} - ${option.name}`}
renderInput={(params) => (
<TextField
{...params}
label="Страна"
required
inputProps={{
...params.inputProps,
maxLength: 2,
}}
/>
)}
filterOptions={(options, { inputValue }) => {
const searchValue = inputValue.toUpperCase();
return options.filter(
(option) =>
option.code.includes(searchValue) ||
option.name.toLowerCase().includes(inputValue.toLowerCase())
);
}}
/>
</FormControl>
<Button
variant="contained"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={isLoading || !createCountryData.code}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Создать"
)}
</Button>
</div>
</Paper>
);
});

View File

@ -0,0 +1,86 @@
import { Button, Paper, TextField } from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { countryStore, languageStore } from "@shared";
import { useState } from "react";
import { LanguageSwitcher } from "@widgets";
export const CountryCreatePage = observer(() => {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
const { createCountryData, setCountryData, createCountry } = countryStore;
const handleCreate = async () => {
try {
setIsLoading(true);
await createCountry();
toast.success("Страна успешно создана");
navigate("/country");
} catch (error) {
toast.error("Ошибка при создании страны");
} finally {
setIsLoading(false);
}
};
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"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
<h1 className="text-3xl break-words">{createCountryData.ru.name}</h1>
</div>
<TextField
fullWidth
label="Код страны"
value={createCountryData.code}
required
onChange={(e) =>
setCountryData(
e.target.value,
createCountryData[language].name,
language
)
}
/>
<TextField
fullWidth
label="Название"
value={createCountryData[language].name}
required
onChange={(e) =>
setCountryData(createCountryData.code, e.target.value, language)
}
/>
<Button
variant="contained"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={isLoading || !createCountryData[language].name}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Создать"
)}
</Button>
</div>
</Paper>
);
});

View File

@ -0,0 +1,102 @@
import { Button, Paper, TextField } from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import { countryStore, languageStore } from "@shared";
import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets";
export const CountryEditPage = observer(() => {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
const { id } = useParams();
const { editCountryData, editCountry, getCountry, setEditCountryData } =
countryStore;
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
const handleEdit = async () => {
try {
setIsLoading(true);
await editCountry(id as string);
toast.success("Страна успешно обновлена");
} catch (error) {
toast.error("Ошибка при обновлении страны");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
(async () => {
if (id) {
// Fetch data for all languages
const ruData = await getCountry(id as string, "ru");
const enData = await getCountry(id as string, "en");
const zhData = await getCountry(id as string, "zh");
// Set data for each language
setEditCountryData(ruData.name, "ru");
setEditCountryData(enData.name, "en");
setEditCountryData(zhData.name, "zh");
}
})();
}, [id]);
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"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
<h1 className="text-3xl break-words t">{editCountryData.ru.name}</h1>
</div>
<TextField
fullWidth
label="Код страны"
value={id as string}
required
disabled
/>
<TextField
fullWidth
label="Название"
value={editCountryData[language].name}
required
onChange={(e) =>
countryStore.setEditCountryData(e.target.value, language)
}
/>
<Button
variant="contained"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleEdit}
disabled={isLoading || !editCountryData[language].name}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Сохранить"
)}
</Button>
</div>
</Paper>
);
});

View File

@ -0,0 +1,156 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { countryStore, languageStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Trash2, Minus } from "lucide-react";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const CountryListPage = observer(() => {
const { countries, getCountries, deleteCountry } = countryStore;
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
useEffect(() => {
const fetchCountries = async () => {
setIsLoading(true);
await getCountries(language);
setIsLoading(false);
};
fetchCountries();
}, [language]);
const columns: GridColDef[] = [
{
field: "name",
headerName: "Название",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center ">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "actions",
headerName: "Действия",
align: "center",
headerAlign: "center",
width: 200,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
{/* <button
onClick={() => navigate(`/country/${params.row.code}/edit`)}
>
<Pencil size={20} className="text-blue-500" />
</button> */}
{/* <button onClick={() => navigate(`/country/${params.row.code}`)}>
<Eye size={20} className="text-green-500" />
</button> */}
<button
onClick={(e) => {
e.stopPropagation();
setIsDeleteModalOpen(true);
setRowId(params.row.code);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = countries[language]?.data.map((country) => ({
id: country.code,
code: country.code,
name: country.name,
}));
return (
<>
<LanguageSwitcher />
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Страны</h1>
<CreateButton label="Добавить страну" path="/country/add" />
</div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows || []}
columns={columns}
hideFooter
checkboxSelection
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет стран"}
</Box>
),
}}
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (!rowId) return;
await deleteCountry(rowId);
setRowId(null);
setIsDeleteModalOpen(false);
}}
onCancel={() => {
setRowId(null);
setIsDeleteModalOpen(false);
}}
/>
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteCountry(id.toString())));
getCountries(language);
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</>
);
});

View File

@ -0,0 +1,69 @@
import { Paper } from "@mui/material";
import { countryStore, languageStore } from "@shared";
import { observer } from "mobx-react-lite";
import { ArrowLeft } from "lucide-react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { LanguageSwitcher } from "@widgets";
export const CountryPreviewPage = observer(() => {
const { id } = useParams();
const { getCountry, country, setEditCountryData } = countryStore;
const navigate = useNavigate();
const { language } = languageStore;
useEffect(() => {
(async () => {
if (id) {
const ruData = await getCountry(id as string, "ru");
const enData = await getCountry(id as string, "en");
const zhData = await getCountry(id as string, "zh");
setEditCountryData(ruData.name, "ru");
setEditCountryData(enData.name, "en");
setEditCountryData(zhData.name, "zh");
}
})();
}, [id]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
{/* <div className="flex gap-2">
<Button
variant="contained"
color="primary"
onClick={() => navigate(`/user/${id}/edit`)}
startIcon={<Pencil size={20} />}
>
Редактировать
</Button>
<Button
variant="contained"
color="error"
onClick={() => navigate(`/user/${id}/delete`)}
startIcon={<Trash2 size={20} />}
>
Удалить
</Button>
</div> */}
</div>
{country[id!]?.[language] && (
<div className="flex flex-col gap-10 w-full">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Название</h1>
<p>{country[id!]?.ru?.name}</p>
</div>
</div>
)}
</Paper>
);
});

View File

@ -0,0 +1,5 @@
export * from "./CountryListPage";
export * from "./CountryPreviewPage";
export * from "./CountryCreatePage";
export * from "./CountryEditPage";
export * from "./CountryAddPage";

View File

@ -1,126 +0,0 @@
// import { Button, Paper, Typography, Box, Alert, Snackbar } from "@mui/material";
// import { useNavigate } from "react-router-dom";
// import { ArrowLeft, Upload } from "lucide-react";
// import { observer } from "mobx-react-lite";
// import { useState, DragEvent, useRef, useEffect } from "react";
// import { editSightStore, UploadMediaDialog } from "@shared";
// export const CreateMediaPage = observer(() => {
// const navigate = useNavigate();
// const [isDragging, setIsDragging] = useState(false);
// const [error, setError] = useState<string | null>(null);
// const [success, setSuccess] = useState(false);
// const fileInputRef = useRef<HTMLInputElement>(null);
// const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
// const handleDrop = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault();
// e.stopPropagation();
// setIsDragging(false);
// const files = Array.from(e.dataTransfer.files);
// if (files.length > 0) {
// editSightStore.fileToUpload = files[0];
// setUploadDialogOpen(true);
// }
// };
// const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault();
// setIsDragging(true);
// };
// const handleDragLeave = () => {
// setIsDragging(false);
// };
// const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
// const files = e.target.files;
// if (files && files.length > 0) {
// editSightStore.fileToUpload = files[0];
// setUploadDialogOpen(true);
// }
// };
// const handleUploadSuccess = () => {
// setSuccess(true);
// setUploadDialogOpen(false);
// };
// return (
// <div className="w-full h-full p-8">
// <div className="flex items-center gap-4 mb-8">
// <Button
// variant="outlined"
// startIcon={<ArrowLeft size={20} />}
// onClick={() => navigate("/media")}
// >
// Назад
// </Button>
// <Typography variant="h5">Загрузка медиафайла</Typography>
// </div>
// <Paper
// elevation={3}
// className={`w-full h-[60vh] flex flex-col items-center justify-center p-8 transition-colors ${
// isDragging ? "bg-blue-50 border-2 border-blue-500" : "bg-gray-50"
// }`}
// onDrop={handleDrop}
// onDragOver={handleDragOver}
// onDragLeave={handleDragLeave}
// >
// <input
// type="file"
// ref={fileInputRef}
// className="hidden"
// onChange={handleFileSelect}
// accept="image/*,video/*,.glb,.gltf"
// />
// <Box className="flex flex-col items-center gap-4 text-center">
// <Upload size={48} className="text-gray-400" />
// <Typography variant="h6" className="text-gray-600">
// Перетащите файл сюда или
// </Typography>
// <Button
// variant="contained"
// onClick={() => fileInputRef.current?.click()}
// startIcon={<Upload size={20} />}
// >
// Выберите файл
// </Button>
// <Typography variant="body2" className="text-gray-500 mt-4">
// Поддерживаемые форматы: JPG, PNG, GIF, MP4, WebM, GLB, GLTF
// </Typography>
// </Box>
// </Paper>
// <UploadMediaDialog
// open={uploadDialogOpen}
// onClose={() => setUploadDialogOpen(false)}
// afterUpload={handleUploadSuccess}
// />
// <Snackbar
// open={success}
// autoHideDuration={2000}
// onClose={() => setSuccess(false)}
// >
// <Alert severity="success" onClose={() => setSuccess(false)}>
// Медиафайл успешно загружен
// </Alert>
// </Snackbar>
// <Snackbar
// open={!!error}
// autoHideDuration={6000}
// onClose={() => setError(null)}
// >
// <Alert severity="error" onClose={() => setError(null)}>
// {error}
// </Alert>
// </Snackbar>
// </div>
// );
// });

View File

@ -1,6 +1,16 @@
import { Box, Tab, Tabs } from "@mui/material";
import { articlesStore, cityStore, languageStore } from "@shared";
import { CreateInformationTab, CreateLeftTab, CreateRightTab } from "@widgets";
import {
articlesStore,
cityStore,
createSightStore,
languageStore,
} from "@shared";
import {
CreateInformationTab,
CreateLeftTab,
CreateRightTab,
LeaveAgree,
} from "@widgets";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
@ -11,17 +21,26 @@ function a11yProps(index: number) {
};
}
import { useBlocker } from "react-router";
export const CreateSightPage = observer(() => {
const [value, setValue] = useState(0);
const { getCities } = cityStore;
const { getArticles } = articlesStore;
const { needLeaveAgree } = createSightStore;
const handleChange = (_: React.SyntheticEvent, newValue: number) => {
setValue(newValue);
};
let blocker = useBlocker(
({ currentLocation, nextLocation }) =>
needLeaveAgree && currentLocation.pathname !== nextLocation.pathname
);
useEffect(() => {
const fetchData = async () => {
await getCities();
await getCities("ru");
await getArticles(languageStore.language);
};
fetchData();
@ -34,6 +53,7 @@ export const CreateSightPage = observer(() => {
display: "flex",
flexDirection: "column",
minHeight: "100vh",
z: 10,
}}
>
<Box
@ -66,6 +86,8 @@ export const CreateSightPage = observer(() => {
<CreateLeftTab value={value} index={1} />
<CreateRightTab value={value} index={2} />
</div>
{blocker.state === "blocked" ? <LeaveAgree blocker={blocker} /> : null}
</Box>
);
});

View File

@ -1,15 +1,10 @@
import { Box, Tab, Tabs } from "@mui/material";
import { InformationTab, RightWidgetTab } from "@widgets";
import { InformationTab, LeaveAgree, RightWidgetTab } from "@widgets";
import { LeftWidgetTab } from "@widgets";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import {
articlesStore,
cityStore,
editSightStore,
languageStore,
} from "@shared";
import { useParams } from "react-router-dom";
import { articlesStore, cityStore, editSightStore } from "@shared";
import { useBlocker, useParams } from "react-router-dom";
function a11yProps(index: number) {
return {
@ -20,12 +15,17 @@ function a11yProps(index: number) {
export const EditSightPage = observer(() => {
const [value, setValue] = useState(0);
const { sight, getSightInfo } = editSightStore;
const { sight, getSightInfo, needLeaveAgree } = editSightStore;
const { getArticles } = articlesStore;
const { language } = languageStore;
const { id } = useParams();
const { getCities } = cityStore;
let blocker = useBlocker(
({ currentLocation, nextLocation }) =>
needLeaveAgree && currentLocation.pathname !== nextLocation.pathname
);
const handleChange = (_: React.SyntheticEvent, newValue: number) => {
setValue(newValue);
};
@ -33,13 +33,17 @@ export const EditSightPage = observer(() => {
useEffect(() => {
const fetchData = async () => {
if (id) {
await getSightInfo(+id, language);
await getArticles(language);
await getCities();
await getCities("ru");
await getSightInfo(+id, "ru");
await getSightInfo(+id, "en");
await getSightInfo(+id, "zh");
await getArticles("ru");
await getArticles("en");
await getArticles("zh");
}
};
fetchData();
}, [id, language]);
}, [id]);
return (
<Box
@ -82,6 +86,8 @@ export const EditSightPage = observer(() => {
<RightWidgetTab value={value} index={2} />
</div>
)}
{blocker.state === "blocked" ? <LeaveAgree blocker={blocker} /> : null}
</Box>
);
});

View File

@ -5,9 +5,12 @@ import {
Typography,
Alert,
CircularProgress,
FormControlLabel,
Checkbox,
Paper,
} from "@mui/material";
import { authStore } from "@shared";
import { useState } from "react";
import { authStore, userStore } from "@shared";
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
@ -15,9 +18,21 @@ export const LoginPage = () => {
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const { login } = authStore;
const { getUsers } = userStore;
useEffect(() => {
// Load saved credentials if they exist
const savedEmail = localStorage.getItem("rememberedEmail");
const savedPassword = localStorage.getItem("rememberedPassword");
if (savedEmail && savedPassword) {
setEmail(savedEmail);
setPassword(savedPassword);
setRememberMe(true);
}
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@ -26,7 +41,18 @@ export const LoginPage = () => {
try {
await login(email, password);
navigate("/sight");
// Save or clear credentials based on remember me checkbox
if (rememberMe) {
localStorage.setItem("rememberedEmail", email);
localStorage.setItem("rememberedPassword", password);
} else {
localStorage.removeItem("rememberedEmail");
localStorage.removeItem("rememberedPassword");
}
navigate("/map");
await getUsers();
toast.success("Вход в систему выполнен успешно");
} catch (err) {
setError(
@ -47,12 +73,31 @@ export const LoginPage = () => {
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
width: "100vw",
height: "100vh",
gap: 3,
p: 3,
backgroundImage: "url('/login-bg.png')",
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}}
>
<Typography variant="h4" component="h1" gutterBottom>
<Paper
elevation={3}
sx={{
p: 4,
borderRadius: 2,
backgroundColor: "white",
width: "100%",
maxWidth: "400px",
}}
>
<Typography
variant="h4"
component="h1"
className="text-center pb-[50px]"
>
Вход в систему
</Typography>
<Box
@ -63,7 +108,6 @@ export const LoginPage = () => {
flexDirection: "column",
gap: 2,
width: "100%",
maxWidth: "400px",
}}
>
{error && (
@ -95,6 +139,16 @@ export const LoginPage = () => {
disabled={isLoading}
error={!!error}
/>
<FormControlLabel
control={
<Checkbox
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
disabled={isLoading}
/>
}
label="Запомнить пароль"
/>
<Button
variant="contained"
color="primary"
@ -114,6 +168,7 @@ export const LoginPage = () => {
{isLoading ? <CircularProgress size={24} sx={{}} /> : "Войти"}
</Button>
</Box>
</Paper>
</Box>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,252 @@
import { languageInstance } from "@shared";
import { makeAutoObservable } from "mobx";
interface ApiRoute {
id: number;
route_number: string;
path: [number, number][];
}
interface ApiStation {
id: number;
name: string;
latitude: number;
longitude: number;
}
interface ApiSight {
id: number;
name: string;
description: string;
latitude: number;
longitude: number;
}
// Допуск для сравнения координат, чтобы избежать ошибок с точностью чисел.
const COORDINATE_PRECISION_TOLERANCE = 1e-9;
// Вспомогательная функция, обновленная для сравнения с допуском.
const arePathsEqual = (
path1: [number, number][],
path2: [number, number][]
): boolean => {
if (path1.length !== path2.length) {
return false;
}
for (let i = 0; i < path1.length; i++) {
if (
Math.abs(path1[i][0] - path2[i][0]) > COORDINATE_PRECISION_TOLERANCE ||
Math.abs(path1[i][1] - path2[i][1]) > COORDINATE_PRECISION_TOLERANCE
) {
return false;
}
}
return true;
};
class MapStore {
constructor() {
makeAutoObservable(this);
}
routes: ApiRoute[] = [];
stations: ApiStation[] = [];
sights: ApiSight[] = [];
getRoutes = async () => {
const response = await languageInstance("ru").get("/route");
const routesIds = response.data.map((route: any) => route.id);
for (const id of routesIds) {
const route = await languageInstance("ru").get(`/route/${id}`);
this.routes.push({
id: route.data.id,
route_number: route.data.route_number,
path: route.data.path,
});
}
const mappedRoutes: ApiRoute[] = response.data.map((route: any) => ({
id: route.id,
route_number: route.route_number,
path: route.path,
}));
this.routes = mappedRoutes.sort((a, b) =>
a.route_number.localeCompare(b.route_number)
);
};
getStations = async () => {
const stations = await languageInstance("ru").get("/station");
this.stations = stations.data.map((station: any) => ({
id: station.id,
name: station.name,
latitude: station.latitude,
longitude: station.longitude,
}));
};
getSights = async () => {
const sights = await languageInstance("ru").get("/sight");
this.sights = sights.data.map((sight: any) => ({
id: sight.id,
name: sight.name,
description: sight.description,
latitude: sight.latitude,
longitude: sight.longitude,
}));
};
deleteRecourse = async (recourse: string, id: number) => {
await languageInstance("ru").delete(`/${recourse}/${id}`);
if (recourse === "route") {
this.routes = this.routes.filter((route) => route.id !== id);
} else if (recourse === "station") {
this.stations = this.stations.filter((station) => station.id !== id);
} else if (recourse === "sight") {
this.sights = this.sights.filter((sight) => sight.id !== id);
}
};
handleSave = async (json: string) => {
const newSights: any[] = [];
const newRoutes: any[] = [];
const newStations: any[] = [];
const updatedSights: any[] = [];
const updatedRoutes: any[] = [];
const updatedStations: any[] = [];
const parsedJSON = JSON.parse(json);
for (const feature of parsedJSON.features) {
const { geometry, properties, id } = feature;
const idParts = String(id).split("-");
if (idParts.length > 1) {
const featureType = idParts[0];
const numericId = parseInt(idParts[1], 10);
if (isNaN(numericId)) continue;
if (featureType === "station") {
const originalStation = this.stations.find((s) => s.id === numericId);
if (!originalStation) continue;
const currentStation = {
name: properties.name || "",
latitude: geometry.coordinates[1],
longitude: geometry.coordinates[0],
};
// ИЗМЕНЕНИЕ: Сравнение координат с допуском
if (
originalStation.name !== currentStation.name ||
Math.abs(originalStation.latitude - currentStation.latitude) >
COORDINATE_PRECISION_TOLERANCE ||
Math.abs(originalStation.longitude - currentStation.longitude) >
COORDINATE_PRECISION_TOLERANCE
) {
updatedStations.push({ id: numericId, ...currentStation });
}
} else if (featureType === "route") {
const originalRoute = this.routes.find((r) => r.id === numericId);
if (!originalRoute) continue;
const currentRoute = {
route_number: properties.name || "",
path: geometry.coordinates,
};
// ИЗМЕНЕНИЕ: Используется новая функция arePathsEqual с допуском
if (
originalRoute.route_number !== currentRoute.route_number ||
!arePathsEqual(originalRoute.path, currentRoute.path)
) {
updatedRoutes.push({ id: numericId, ...currentRoute });
}
} else if (featureType === "sight") {
const originalSight = this.sights.find((s) => s.id === numericId);
if (!originalSight) continue;
const currentSight = {
name: properties.name || "",
description: properties.description || "",
latitude: geometry.coordinates[1],
longitude: geometry.coordinates[0],
};
// ИЗМЕНЕНИЕ: Сравнение координат с допуском
if (
originalSight.name !== currentSight.name ||
originalSight.description !== currentSight.description ||
Math.abs(originalSight.latitude - currentSight.latitude) >
COORDINATE_PRECISION_TOLERANCE ||
Math.abs(originalSight.longitude - currentSight.longitude) >
COORDINATE_PRECISION_TOLERANCE
) {
updatedSights.push({ id: numericId, ...currentSight });
}
}
} else {
if (properties.featureType === "station") {
newStations.push({
name: properties.name || "",
latitude: geometry.coordinates[1],
longitude: geometry.coordinates[0],
});
} else if (properties.featureType === "route") {
newRoutes.push({
route_number: properties.name || "",
path: geometry.coordinates,
});
} else if (properties.featureType === "sight") {
newSights.push({
name: properties.name || "",
description: properties.description || "",
latitude: geometry.coordinates[1],
longitude: geometry.coordinates[0],
});
}
}
}
const requests: Promise<any>[] = [];
newStations.forEach((data) =>
requests.push(languageInstance("ru").post("/station", data))
);
newRoutes.forEach((data) =>
requests.push(languageInstance("ru").post("/route", data))
);
newSights.forEach((data) =>
requests.push(languageInstance("ru").post("/sight", data))
);
updatedStations.forEach(({ id, ...data }) =>
requests.push(languageInstance("ru").patch(`/station/${id}`, data))
);
updatedRoutes.forEach(({ id, ...data }) =>
requests.push(languageInstance("ru").patch(`/route/${id}`, data))
);
updatedSights.forEach(({ id, ...data }) =>
requests.push(languageInstance("ru").patch(`/sight/${id}`, data))
);
if (requests.length === 0) {
return;
}
try {
await Promise.all(requests);
await Promise.all([
this.getRoutes(),
this.getStations(),
this.getSights(),
]);
} catch (error) {
console.error("Ошибка при сохранении данных:", error);
throw error;
}
};
}
export default new MapStore();

View File

@ -0,0 +1,89 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
} from "@mui/material";
import { mediaStore, MEDIA_TYPE_LABELS } from "@shared";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
export const MediaCreatePage = observer(() => {
const navigate = useNavigate();
const [name, setName] = useState("");
const [type, setType] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleCreate = async () => {
try {
setIsLoading(true);
await mediaStore.createMedia(name, type);
toast.success("Медиа успешно создано");
navigate("/media");
} catch (error) {
toast.error("Ошибка при создании медиа");
} finally {
setIsLoading(false);
}
};
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)}
/>
<FormControl fullWidth>
<InputLabel>Тип</InputLabel>
<Select
value={type}
label="Тип"
onChange={(e) => setType(e.target.value)}
required
>
{Object.entries(MEDIA_TYPE_LABELS).map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={isLoading || !name || !type}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Создать"
)}
</Button>
</div>
</Paper>
);
});

View File

@ -16,10 +16,15 @@ import {
Snackbar,
} from "@mui/material";
import { Save, ArrowLeft } from "lucide-react";
import { authInstance, mediaStore, MEDIA_TYPE_LABELS } from "@shared";
import {
authInstance,
mediaStore,
MEDIA_TYPE_LABELS,
languageStore,
} from "@shared";
import { MediaViewer } from "@widgets";
export const EditMediaPage = observer(() => {
export const MediaEditPage = observer(() => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
@ -34,6 +39,7 @@ export const EditMediaPage = observer(() => {
const [mediaName, setMediaName] = useState(media?.media_name ?? "");
const [mediaFilename, setMediaFilename] = useState(media?.filename ?? "");
const [mediaType, setMediaType] = useState(media?.media_type ?? 1);
const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>([]);
useEffect(() => {
if (id) {
@ -48,9 +54,26 @@ export const EditMediaPage = observer(() => {
setMediaName(media.media_name);
setMediaFilename(media.filename);
setMediaType(media.media_type);
// Set available media types based on current file extension
const extension = media.filename.split(".").pop()?.toLowerCase();
if (extension) {
if (["glb", "gltf"].includes(extension)) {
setAvailableMediaTypes([6]); // 3D model
} else if (["jpg", "jpeg", "png", "gif"].includes(extension)) {
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
} else if (["mp4", "webm", "mov"].includes(extension)) {
setAvailableMediaTypes([2]); // Video
}
}
}
}, [media]);
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
// const handleDrop = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault();
// e.stopPropagation();
@ -76,8 +99,25 @@ export const EditMediaPage = observer(() => {
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
setNewFile(files[0]);
setMediaFilename(files[0].name);
const file = files[0];
setNewFile(file);
setMediaFilename(file.name);
// Determine media type based on file extension
const extension = file.name.split(".").pop()?.toLowerCase();
if (extension) {
if (["glb", "gltf"].includes(extension)) {
setAvailableMediaTypes([6]); // 3D model
setMediaType(6);
} else if (["jpg", "jpeg", "png", "gif"].includes(extension)) {
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
setMediaType(1); // Default to Photo
} else if (["mp4", "webm", "mov"].includes(extension)) {
setAvailableMediaTypes([2]); // Video
setMediaType(2);
}
}
setUploadDialogOpen(true); // Open dialog on file selection
}
};
@ -134,7 +174,7 @@ export const EditMediaPage = observer(() => {
<Button
variant="outlined"
startIcon={<ArrowLeft size={20} />}
onClick={() => navigate("/media")}
onClick={() => navigate(-1)}
>
Назад
</Button>
@ -175,7 +215,17 @@ export const EditMediaPage = observer(() => {
onChange={(e) => setMediaType(Number(e.target.value))}
disabled={isLoading}
>
{Object.entries(MEDIA_TYPE_LABELS).map(([type, label]) => (
{availableMediaTypes.length > 0
? availableMediaTypes.map((type) => (
<MenuItem key={type} value={type}>
{
MEDIA_TYPE_LABELS[
type as keyof typeof MEDIA_TYPE_LABELS
]
}
</MenuItem>
))
: Object.entries(MEDIA_TYPE_LABELS).map(([type, label]) => (
<MenuItem key={type} value={Number(type)}>
{label}
</MenuItem>

View File

@ -0,0 +1,166 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { DeleteModal } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const MediaListPage = observer(() => {
const { media, getMedia, deleteMedia } = mediaStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null);
const [ids, setIds] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
useEffect(() => {
const fetchMedia = async () => {
setIsLoading(true);
await getMedia();
setIsLoading(false);
};
fetchMedia();
}, [language]);
const columns: GridColDef[] = [
{
field: "media_name",
headerName: "Название",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "media_type",
headerName: "Тип",
width: 200,
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
MEDIA_TYPE_LABELS[
params.row.media_type as keyof typeof MEDIA_TYPE_LABELS
]
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "actions",
headerName: "Действия",
width: 200,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/media/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = media.map((media) => ({
id: media.id,
media_name: media.media_name,
media_type: media.media_type,
}));
return (
<>
<div className="w-full">
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
checkboxSelection
loading={isLoading}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as string[]);
}}
hideFooter
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет медиафайлов"}
</Box>
),
}}
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
deleteMedia(rowId.toString());
getMedia();
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteMedia(id)));
getMedia();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</>
);
});

View File

@ -0,0 +1,46 @@
import { mediaStore } from "@shared";
import { useEffect } from "react";
import { useParams } from "react-router-dom";
import { MediaViewer } from "@widgets";
import { observer } from "mobx-react-lite";
import { Download } from "lucide-react";
import { Button } from "@mui/material";
export const MediaPreviewPage = observer(() => {
const { id } = useParams();
const { oneMedia, getOneMedia } = mediaStore;
useEffect(() => {
getOneMedia(id!);
}, []);
return (
<div className="w-full flex flex-col justify-center items-center gap-4">
<div className="w-full flex flex-col justify-center items-center gap-4">
<div className="flex justify-center items-center max-w-[60%]">
<MediaViewer media={oneMedia!} />
</div>
{oneMedia && (
<div className="flex-1 flex flex-col items-center gap-4 bg-[#998879] p-4 rounded-md">
<p className="text-white text-center">
Чтобы скачать файл, нажмите на кнопку ниже
</p>
<Button
variant="contained"
color="primary"
startIcon={<Download size={16} />}
component="a"
href={`${
import.meta.env.VITE_KRBL_MEDIA
}${id}/download?token=${localStorage.getItem("token")}`}
target="_blank"
>
Скачать
</Button>
</div>
)}
</div>
</div>
);
});

3
src/pages/Media/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from "./MediaEditPage";
export * from "./MediaListPage";
export * from "./MediaPreviewPage";

View File

@ -1,67 +0,0 @@
import { TableBody } from "@mui/material";
import { TableRow, TableCell } from "@mui/material";
import { Table, TableHead } from "@mui/material";
import { mediaStore, MEDIA_TYPE_LABELS } from "@shared";
import { useEffect } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Pencil, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
const rows = (media: any[]) => {
return media.map((row) => ({
id: row.id,
media_name: row.media_name,
media_type:
MEDIA_TYPE_LABELS[row.media_type as keyof typeof MEDIA_TYPE_LABELS],
}));
};
export const MediaListPage = observer(() => {
const { media, getMedia, deleteMedia } = mediaStore;
const navigate = useNavigate();
useEffect(() => {
getMedia();
}, []);
const currentRows = rows(media);
return (
<>
<Table sx={{ minWidth: 650 }} aria-label="simple table">
<TableHead>
<TableRow>
<TableCell align="center">Название</TableCell>
<TableCell align="center">Тип</TableCell>
<TableCell align="center">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{currentRows.map((row) => (
<TableRow
key={row.media_name}
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
>
<TableCell align="center">{row.media_name}</TableCell>
<TableCell align="center">{row.media_type}</TableCell>
<TableCell align="center">
<div className="flex gap-7 justify-center">
<button onClick={() => navigate(`/media/${row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button onClick={() => navigate(`/media/${row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
<button onClick={() => deleteMedia(row.id)}>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
);
});

View File

@ -1,44 +0,0 @@
import { mediaStore } from "@shared";
import { useEffect } from "react";
import { useParams } from "react-router-dom";
import { MediaViewer } from "../../widgets/MediaViewer/index";
import { observer } from "mobx-react-lite";
import { Download } from "lucide-react";
import { Button } from "@mui/material";
export const PreviewMediaPage = observer(() => {
const { id } = useParams();
const { oneMedia, getOneMedia } = mediaStore;
useEffect(() => {
getOneMedia(id!);
}, []);
return (
<div className="w-full h-[80vh] flex flex-col justify-center items-center gap-4">
<div className="w-full h-full flex justify-center items-center">
<MediaViewer className="w-full h-full" media={oneMedia!} />
</div>
{oneMedia && (
<div className="flex flex-col items-center gap-4 bg-[#998879] p-4 rounded-md">
<p className="text-white text-center">
Чтобы скачать файл, нажмите на кнопку ниже
</p>
<Button
variant="contained"
color="primary"
startIcon={<Download size={16} />}
component="a"
href={`${
import.meta.env.VITE_KRBL_MEDIA
}${id}/download?token=${localStorage.getItem("token")}`}
target="_blank"
>
Скачать
</Button>
</div>
)}
</div>
);
});

View File

@ -0,0 +1,601 @@
import { useState, useEffect } from "react";
import {
Stack,
Typography,
Button,
FormControl,
Accordion,
AccordionSummary,
AccordionDetails,
useTheme,
TextField,
Autocomplete,
TableCell,
TableContainer,
Table,
TableHead,
TableRow,
Paper,
TableBody,
IconButton,
Checkbox,
FormControlLabel,
Tabs,
Tab,
Box,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
} from "@hello-pangea/dnd";
import { authInstance, languageStore, routeStore } from "@shared";
import { EditStationModal } from "../../widgets/modals/EditStationModal";
// Helper function to insert an item at a specific position (1-based index)
function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] {
const index = pos - 1;
const result = [...arr];
if (index >= result.length) {
result.push(value);
} else {
result.splice(index, 0, value);
}
return result;
}
// Helper function to reorder items after drag and drop
const reorder = <T,>(list: T[], startIndex: number, endIndex: number): T[] => {
const result = Array.from(list);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
};
type Field<T> = {
label: string;
data: keyof T;
render?: (value: any) => React.ReactNode;
};
type LinkedItemsProps<T> = {
parentId: string | number;
fields: Field<T>[];
setItemsParent?: (items: T[]) => void;
type: "show" | "edit";
dragAllowed?: boolean;
onUpdate?: () => void;
dontRecurse?: boolean;
disableCreation?: boolean;
updatedLinkedItems?: T[];
refresh?: number;
cityId?: number;
routeDirection?: boolean;
};
export const LinkedItems = <
T extends { id: number; name: string; [key: string]: any }
>(
props: LinkedItemsProps<T>
) => {
const theme = useTheme();
return (
<>
<Accordion sx={{ width: "100%" }}>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
background: theme.palette.background.paper,
borderBottom: `1px solid ${theme.palette.divider}`,
width: "100%",
}}
>
<Typography variant="subtitle1" fontWeight="bold">
Привязанные станции
</Typography>
</AccordionSummary>
<AccordionDetails
sx={{ background: theme.palette.background.paper, width: "100%" }}
>
<Stack gap={2} width="100%">
<LinkedItemsContents {...props} />
</Stack>
</AccordionDetails>
</Accordion>
</>
);
};
export const LinkedItemsContents = <
T extends { id: number; name: string; [key: string]: any }
>({
parentId,
setItemsParent,
fields,
dragAllowed = false,
type,
onUpdate,
disableCreation = false,
updatedLinkedItems,
refresh,
cityId,
routeDirection,
}: LinkedItemsProps<T>) => {
const { language } = languageStore;
const [position, setPosition] = useState<number>(1);
const [allItems, setAllItems] = useState<T[]>([]);
const [linkedItems, setLinkedItems] = useState<T[]>([]);
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [activeTab, setActiveTab] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
console.log(error);
}, [error]);
const parentResource = "route";
const childResource = "station";
const availableItems = allItems
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
.filter((item) => {
// Если направление маршрута не указано, показываем все станции
if (routeDirection === undefined) return true;
// Фильтруем станции по направлению маршрута
return item.direction === routeDirection;
})
.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;
});
useEffect(() => {
if (updatedLinkedItems) {
setLinkedItems(updatedLinkedItems);
}
}, [updatedLinkedItems]);
useEffect(() => {
setItemsParent?.(linkedItems);
}, [linkedItems, setItemsParent]);
useEffect(() => {
setPosition(linkedItems.length + 1);
}, [linkedItems.length]);
const onDragEnd = (result: DropResult) => {
if (!result.destination) return;
const reorderedItems = reorder(
linkedItems,
result.source.index,
result.destination.index
);
setLinkedItems(reorderedItems);
authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, {
stations: reorderedItems.map((item) => ({ id: item.id })),
})
.catch((error) => {
console.error("Error updating station order:", error);
setError("Failed to update station order");
});
};
useEffect(() => {
if (parentId) {
setIsLoading(true);
setError(null);
authInstance
.get(`/${parentResource}/${parentId}/${childResource}`)
.then((response) => {
setLinkedItems(response?.data || []);
})
.catch((error) => {
console.error("Error fetching linked items:", error);
setError("Failed to load linked stations");
setLinkedItems([]);
})
.finally(() => {
setIsLoading(false);
});
}
}, [parentId, language, refresh]);
useEffect(() => {
if (type === "edit") {
setError(null);
authInstance
.get(`/${childResource}/`)
.then((response) => {
setAllItems(response?.data || []);
})
.catch((error) => {
console.error("Error fetching all items:", error);
setError("Failed to load available stations");
setAllItems([]);
});
}
}, [type]);
const linkItem = () => {
if (selectedItemId !== null) {
setError(null);
const requestData = {
stations: insertAtPosition(
linkedItems.map((item) => ({ id: item.id })),
position,
{ id: selectedItemId }
),
};
authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
.then(() => {
const newItem = allItems.find((item) => item.id === selectedItemId);
if (newItem) {
const updatedList = insertAtPosition(
[...linkedItems],
position,
newItem
);
setLinkedItems(updatedList);
}
setSelectedItemId(null);
onUpdate?.();
})
.catch((error) => {
console.error("Error linking item:", error);
setError("Failed to link station");
});
}
};
const deleteItem = (itemId: number) => {
setError(null);
authInstance
.delete(`/${parentResource}/${parentId}/${childResource}`, {
data: { [`${childResource}_id`]: itemId },
})
.then(() => {
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
onUpdate?.();
})
.catch((error) => {
console.error("Error deleting item:", error);
setError("Failed to delete station");
});
};
const handleStationClick = (item: T) => {
routeStore.setSelectedStationId(item.id);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
};
const handleCheckboxChange = (itemId: number) => {
const newSelected = new Set(selectedItems);
if (newSelected.has(itemId)) {
newSelected.delete(itemId);
} else {
newSelected.add(itemId);
}
setSelectedItems(newSelected);
};
const handleBulkLink = () => {
if (selectedItems.size === 0) return;
setError(null);
const selectedStations = Array.from(selectedItems).map((id) => ({ id }));
const requestData = {
stations: [
...linkedItems.map((item) => ({ id: item.id })),
...selectedStations,
],
};
authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
.then(() => {
const newItems = allItems.filter((item) => selectedItems.has(item.id));
setLinkedItems([...linkedItems, ...newItems]);
setSelectedItems(new Set());
onUpdate?.();
})
.catch((error) => {
console.error("Error linking items:", error);
setError("Failed to link stations");
});
};
return (
<>
{linkedItems?.length > 0 && (
<DragDropContext onDragEnd={onDragEnd}>
<TableContainer component={Paper} sx={{ width: "100%" }}>
<Table sx={{ width: "100%" }}>
<TableHead>
<TableRow>
{type === "edit" && dragAllowed && (
<TableCell width="40px"></TableCell>
)}
<TableCell key="id" width="60px">
</TableCell>
{fields.map((field) => (
<TableCell key={String(field.data)}>
{field.label}
</TableCell>
))}
{type === "edit" && (
<TableCell width="120px">Действие</TableCell>
)}
</TableRow>
</TableHead>
<Droppable
droppableId="droppable-stations"
isDropDisabled={type !== "edit" || !dragAllowed}
>
{(provided) => (
<TableBody
ref={provided.innerRef}
{...provided.droppableProps}
>
{linkedItems.map((item, index) => (
<Draggable
key={item.id}
draggableId={"station-" + String(item.id)}
index={index}
isDragDisabled={type !== "edit" || !dragAllowed}
>
{(provided) => (
<TableRow
sx={{ cursor: "pointer" }}
ref={provided.innerRef}
{...provided.draggableProps}
hover
onClick={() => handleStationClick(item)}
>
{type === "edit" && dragAllowed && (
<TableCell {...provided.dragHandleProps}>
<IconButton size="small">
<DragIndicatorIcon />
</IconButton>
</TableCell>
)}
<TableCell>{index + 1}</TableCell>
{fields.map((field, idx) => (
<TableCell key={String(field.data) + String(idx)}>
{field.render
? field.render(item[field.data])
: item[field.data]}
</TableCell>
))}
{type === "edit" && (
<TableCell>
<Button
variant="outlined"
color="error"
size="small"
onClick={(e) => {
e.stopPropagation();
deleteItem(item.id);
}}
>
Отвязать
</Button>
</TableCell>
)}
</TableRow>
)}
</Draggable>
))}
{provided.placeholder}
</TableBody>
)}
</Droppable>
</Table>
</TableContainer>
</DragDropContext>
)}
{linkedItems.length === 0 && !isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
Станции не найдены
</Typography>
)}
{type === "edit" && !disableCreation && (
<Stack gap={2} mt={2}>
<Typography variant="subtitle1">Добавить остановки</Typography>
{routeDirection !== undefined && (
<Typography variant="body2" color="textSecondary">
Показываются только остановки для{" "}
{routeDirection ? "прямого" : "обратного"} направления
</Typography>
)}
<Tabs
value={activeTab}
onChange={(_, newValue) => setActiveTab(newValue)}
>
<Tab label="По одной" />
<Tab label="Массово" />
</Tabs>
<Box sx={{ mt: 2 }}>
{activeTab === 0 && (
<Stack gap={2}>
<Autocomplete
fullWidth
value={
availableItems?.find(
(item) => item.id === selectedItemId
) || null
}
onChange={(_, newValue) =>
setSelectedItemId(newValue?.id || null)
}
options={availableItems.filter(
(item) => !cityId || item.city_id == cityId
)}
getOptionLabel={(item) => String(item.name)}
renderInput={(params) => (
<TextField
{...params}
label="Выберите остановку"
fullWidth
/>
)}
isOptionEqualToValue={(option, value) =>
option.id === value?.id
}
filterOptions={(options, { inputValue }) => {
const searchWords = inputValue
.toLowerCase()
.split(" ")
.filter(Boolean);
return options.filter((option) => {
const optionWords = String(option.name)
.toLowerCase()
.split(" ");
return searchWords.every((searchWord) =>
optionWords.some((word) => word.startsWith(searchWord))
);
});
}}
renderOption={(props, option) => (
<li {...props} key={option.id}>
<div className="flex justify-between items-center w-full">
<p>{String(option.name)}</p>
<p className="text-xs text-gray-500 max-w-[300px] mr-4 truncate">
{String(option.description)}
</p>
</div>
</li>
)}
/>
<FormControl fullWidth>
<TextField
type="number"
label="Позиция добавляемой остановки"
value={position}
onChange={(e) => {
const newValue = Math.max(1, Number(e.target.value));
setPosition(
newValue > linkedItems.length + 1
? linkedItems.length + 1
: newValue
);
}}
InputProps={{
inputProps: { min: 1, max: linkedItems.length + 1 },
}}
fullWidth
/>
</FormControl>
<Button
variant="contained"
onClick={linkItem}
disabled={!selectedItemId}
sx={{ alignSelf: "flex-start" }}
>
Добавить
</Button>
</Stack>
)}
{activeTab === 1 && (
<Stack gap={2}>
{/* Поле поиска */}
<TextField
fullWidth
label="Поиск остановок"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Введите название остановки..."
size="small"
sx={{ mb: 1 }}
/>
{/* Список доступных остановок с чекбоксами */}
<Paper sx={{ maxHeight: 300, overflow: "auto", p: 2 }}>
<Stack gap={1}>
{filteredAvailableItems.map((item) => (
<FormControlLabel
key={item.id}
control={
<Checkbox
checked={selectedItems.has(item.id)}
onChange={() => handleCheckboxChange(item.id)}
size="small"
/>
}
label={String(item.name)}
sx={{
margin: 0,
"& .MuiFormControlLabel-label": {
fontSize: "0.875rem",
},
}}
/>
))}
{filteredAvailableItems.length === 0 && (
<Typography
color="textSecondary"
textAlign="center"
py={1}
>
{searchQuery.trim()
? "Остановки не найдены"
: "Нет доступных остановок"}
</Typography>
)}
</Stack>
</Paper>
<Button
variant="contained"
onClick={handleBulkLink}
disabled={selectedItems.size === 0}
sx={{ alignSelf: "flex-start" }}
>
Добавить выбранные ({selectedItems.size})
</Button>
</Stack>
)}
</Box>
</Stack>
)}
<EditStationModal open={isModalOpen} onClose={handleCloseModal} />
</>
);
};

View File

@ -0,0 +1,474 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
Typography,
Box,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { MediaViewer } from "@widgets";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save, Plus } from "lucide-react";
import { useEffect, useState } 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";
export const RouteCreatePage = observer(() => {
const navigate = useNavigate();
const [carrier, setCarrier] = useState<string>("");
const [routeNumber, setRouteNumber] = useState("");
const [routeCoords, setRouteCoords] = useState("");
const [govRouteNumber, setGovRouteNumber] = useState("");
const [governorAppeal, setGovernorAppeal] = useState<string>("");
const [direction, setDirection] = useState("backward");
const [scaleMin, setScaleMin] = useState("");
const [scaleMax, setScaleMax] = useState("");
const [turn, setTurn] = useState("");
const [centerLat, setCenterLat] = useState("");
const [centerLng, setCenterLng] = useState("");
const [videoPreview, setVideoPreview] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
useState(false);
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const { language } = languageStore;
useEffect(() => {
carrierStore.getCarriers(language);
articlesStore.getArticleList();
}, [language]);
const validateCoordinates = (value: string) => {
try {
const lines = value.trim().split("\n");
const coordinates = lines.map((line) => {
const [lat, lon] = line
.trim()
.split(/[\s,]+/)
.map(Number);
return [lat, lon];
});
if (coordinates.length === 0) {
return "Введите хотя бы одну пару координат";
}
if (
!coordinates.every(
(point) => Array.isArray(point) && point.length === 2
)
) {
return "Каждая строка должна содержать две координаты";
}
if (
!coordinates.every((point) =>
point.every((coord) => !isNaN(coord) && typeof coord === "number")
)
) {
return "Координаты должны быть числами";
}
return true;
} catch {
return "Неверный формат координат";
}
};
const handleArticleSelect = (articleId: number) => {
setGovernorAppeal(articleId.toString());
setIsSelectArticleDialogOpen(false);
};
const handleVideoSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setVideoPreview(media.id);
setIsSelectVideoDialogOpen(false);
};
const handleVideoPreviewClick = () => {
setIsVideoPreviewOpen(true);
};
const handleCreateRoute = async () => {
try {
setIsLoading(true);
// Преобразуем значения в нужные типы
const carrier_id = Number(carrier);
const governor_appeal = Number(governorAppeal);
const scale_min = scaleMin ? Number(scaleMin) : undefined;
const scale_max = scaleMax ? Number(scaleMax) : undefined;
const rotate = turn ? Number(turn) : undefined;
const center_latitude = centerLat ? Number(centerLat) : undefined;
const center_longitude = centerLng ? Number(centerLng) : undefined;
const route_direction = direction === "forward";
const validationResult = validateCoordinates(routeCoords);
if (validationResult !== true) {
toast.error(validationResult);
return;
}
// Координаты маршрута как массив массивов чисел
const path = routeCoords
.trim()
.split("\n")
.map((line) => {
const [lat, lon] = line
.trim()
.split(/[\s,]+/)
.map(Number);
return [lat, lon];
});
// Собираем объект маршрута
const newRoute: Partial<Route> = {
carrier:
carrierStore.carriers[
language as keyof typeof carrierStore.carriers
].data?.find((c: any) => c.id === carrier_id)?.full_name || "",
carrier_id,
route_number: routeNumber,
route_sys_number: govRouteNumber,
governor_appeal,
route_direction,
scale_min,
scale_max,
rotate,
center_latitude,
center_longitude,
path,
video_preview:
videoPreview && videoPreview !== "" ? videoPreview : undefined,
};
await routeStore.createRoute(newRoute);
toast.success("Маршрут успешно создан");
navigate(-1);
} catch (error) {
console.error(error);
toast.error("Произошла ошибка при создании маршрута");
} finally {
setIsLoading(false);
}
};
// Получаем название выбранной статьи для отображения
const selectedArticle = articlesStore.articleList.ru.data.find(
(article) => article.id === Number(governorAppeal)
);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<Box className="flex flex-col gap-6 w-full">
<FormControl fullWidth required>
<InputLabel>Выберите перевозчика</InputLabel>
<Select
value={carrier}
label="Выберите перевозчика"
onChange={(e) => setCarrier(e.target.value as string)}
disabled={
carrierStore.carriers[
language as keyof typeof carrierStore.carriers
].data?.length === 0
}
>
<MenuItem value="">Не выбрано</MenuItem>
{carrierStore.carriers[
language as keyof typeof carrierStore.carriers
].data?.map((carrier) => (
<MenuItem key={carrier.id} value={carrier.id}>
{carrier.full_name}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
className="w-full"
label="Номер маршрута"
required
value={routeNumber}
onChange={(e) => setRouteNumber(e.target.value)}
/>
<TextField
className="w-full"
label="Координаты маршрута"
multiline
minRows={2}
maxRows={10}
value={routeCoords}
onChange={(e) => {
const newValue = e.target.value;
setRouteCoords(newValue);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
const lines = routeCoords.split("\n");
const lastLine = lines[lines.length - 1];
// Если мы на последней строке и она не пустая
if (lastLine && lastLine.trim()) {
e.preventDefault();
const newValue = routeCoords + "\n";
setRouteCoords(newValue);
}
}
}}
error={validateCoordinates(routeCoords) !== true}
helperText={
typeof validateCoordinates(routeCoords) === "string"
? validateCoordinates(routeCoords)
: "Формат: широта долгота"
}
placeholder="55.7558 37.6173&#10;55.7539 37.6208"
sx={{
"& .MuiInputBase-root": {
maxHeight: "500px",
overflow: "auto",
},
"& .MuiInputBase-input": {
fontFamily: "monospace",
fontSize: "0.8rem",
lineHeight: "1.2",
padding: "8px 12px",
},
"& .MuiFormHelperText-root": {
fontSize: "0.75rem",
marginTop: "2px",
},
}}
/>
<TextField
className="w-full"
label="Номер маршрута в Говорящем Городе"
required
value={govRouteNumber}
onChange={(e) => setGovRouteNumber(e.target.value)}
/>
{/* Заменяем Select на кнопку для выбора статьи */}
<Box className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">
Обращение к пассажирам
</label>
<Box className="flex gap-2">
<TextField
className="flex-1"
value={selectedArticle?.heading || "Статья не выбрана"}
placeholder="Выберите статью"
disabled
sx={{
"& .MuiInputBase-input": {
color: selectedArticle ? "inherit" : "#999",
},
}}
/>
<Button
variant="outlined"
onClick={() => setIsSelectArticleDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
</Box>
{/* Селектор видео превью */}
<Box className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">
Видео превью
</label>
<Box className="flex gap-2">
<Box
className="flex-1"
onClick={handleVideoPreviewClick}
sx={{
cursor:
videoPreview && videoPreview !== "" ? "pointer" : "default",
}}
>
<Box
className="w-full h-[50px] border border-gray-400 rounded-sm flex items-center justify-between px-4"
sx={{
"& .MuiInputBase-input": {
color:
videoPreview && videoPreview !== ""
? "inherit"
: "#999",
cursor:
videoPreview && videoPreview !== ""
? "pointer"
: "default",
},
}}
>
<Typography variant="body1" className="text-sm">
{videoPreview && videoPreview !== ""
? "Видео выбрано"
: "Видео не выбрано"}
</Typography>
{videoPreview && videoPreview !== "" && (
<Box
onClick={(e) => {
e.stopPropagation();
setVideoPreview("");
}}
sx={{
cursor: "pointer",
color: "#999",
"&:hover": {
color: "#666",
},
}}
>
<Typography variant="body1" className="text-lg font-bold">
×
</Typography>
</Box>
)}
</Box>
</Box>
<Button
variant="outlined"
onClick={() => setIsSelectVideoDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
</Box>
<FormControl fullWidth required>
<InputLabel>Прямой/обратный маршрут</InputLabel>
<Select
value={direction}
label="Прямой/обратный маршрут"
onChange={(e) => setDirection(e.target.value)}
>
<MenuItem value="forward">Прямой</MenuItem>
<MenuItem value="backward">Обратный</MenuItem>
</Select>
</FormControl>
<TextField
className="w-full"
label="Масштаб (мин)"
value={scaleMin}
onChange={(e) => setScaleMin(e.target.value)}
/>
<TextField
className="w-full"
label="Масштаб (макс)"
value={scaleMax}
onChange={(e) => setScaleMax(e.target.value)}
/>
<TextField
className="w-full"
label="Поворот"
value={turn}
onChange={(e) => setTurn(e.target.value)}
/>
<TextField
className="w-full"
label="Центр. широта"
value={centerLat}
onChange={(e) => setCenterLat(e.target.value)}
/>
<TextField
className="w-full"
label="Центр. долгота"
value={centerLng}
onChange={(e) => setCenterLng(e.target.value)}
/>
</Box>
<div className="flex w-full justify-end">
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreateRoute}
disabled={isLoading}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Сохранить"
)}
</Button>
</div>
</div>
{/* Модальное окно выбора статьи */}
<SelectArticleModal
open={isSelectArticleDialogOpen}
onClose={() => setIsSelectArticleDialogOpen(false)}
onSelectArticle={handleArticleSelect}
/>
{/* Модальное окно выбора видео */}
<SelectMediaDialog
open={isSelectVideoDialogOpen}
onClose={() => setIsSelectVideoDialogOpen(false)}
onSelectMedia={handleVideoSelect}
mediaType={2}
/>
{/* Модальное окно предпросмотра видео */}
{videoPreview && videoPreview !== "" && (
<Dialog
open={isVideoPreviewOpen}
onClose={() => setIsVideoPreviewOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>Предпросмотр видео</DialogTitle>
<DialogContent>
<Box className="flex justify-center items-center p-4">
<MediaViewer
media={{
id: videoPreview,
media_type: 2,
filename: "video_preview",
}}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsVideoPreviewOpen(false)}>
Закрыть
</Button>
</DialogActions>
</Dialog>
)}
</Paper>
);
});

View File

@ -0,0 +1,537 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
Typography,
Box,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { MediaViewer } from "@widgets";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Copy, Save, Plus } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { carrierStore } from "../../../shared/store/CarrierStore";
import { articlesStore } from "../../../shared/store/ArticlesStore";
import {
routeStore,
languageStore,
SelectArticleModal,
SelectMediaDialog,
} from "@shared";
import { toast } from "react-toastify";
import { stationsStore } from "@shared";
import { LinkedItems } from "../LinekedStations";
export const RouteEditPage = observer(() => {
const navigate = useNavigate();
const { id } = useParams();
const { editRouteData, copyRouteAction } = routeStore;
const [isLoading, setIsLoading] = useState(false);
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
useState(false);
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const { language } = languageStore;
const [coordinates, setCoordinates] = useState<string>("");
useEffect(() => {
const fetchData = async () => {
// Устанавливаем русский язык при загрузке страницы
const response = await routeStore.getRoute(Number(id));
routeStore.setEditRouteData(response);
languageStore.setLanguage("ru");
};
fetchData();
}, []);
useEffect(() => {
const fetchData = async () => {
carrierStore.getCarriers(language);
stationsStore.getStations();
articlesStore.getArticleList();
};
fetchData();
}, [id, language]);
useEffect(() => {
if (editRouteData.path && editRouteData.path.length > 0) {
const formattedPath = editRouteData.path
.map((coords) => coords.join(" "))
.join("\n");
setCoordinates(formattedPath);
}
}, [editRouteData.path]);
const handleSave = async () => {
setIsLoading(true);
await routeStore.editRoute(Number(id));
toast.success("Маршрут успешно сохранен");
setIsLoading(false);
};
const validateCoordinates = (value: string) => {
try {
const lines = value.trim().split("\n");
const coordinates = lines.map((line) => {
const [lat, lon] = line
.trim()
.split(/[\s,]+/)
.map(Number);
return [lat, lon];
});
if (coordinates.length === 0) {
return "Введите хотя бы одну пару координат";
}
if (
!coordinates.every(
(point) => Array.isArray(point) && point.length === 2
)
) {
return "Каждая строка должна содержать две координаты";
}
if (
!coordinates.every((point) =>
point.every((coord) => !isNaN(coord) && typeof coord === "number")
)
) {
return "Координаты должны быть числами";
}
return true;
} catch {
return "Неверный формат координат";
}
};
const handleCopy = async () => {
await copyRouteAction(Number(id));
toast.success("Маршрут успешно скопирован");
};
const handleArticleSelect = (articleId: number) => {
routeStore.setEditRouteData({
governor_appeal: articleId,
});
setIsSelectArticleDialogOpen(false);
};
const handleVideoSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
routeStore.setEditRouteData({
video_preview: media.id,
});
setIsSelectVideoDialogOpen(false);
};
const handleVideoPreviewClick = () => {
if (editRouteData.video_preview && editRouteData.video_preview !== "") {
setIsVideoPreviewOpen(true);
}
};
// Получаем название выбранной статьи для отображения
const selectedArticle = articlesStore.articleList.ru.data.find(
(article) => article.id === editRouteData.governor_appeal
);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<Box className="flex flex-col gap-6 w-full">
<FormControl fullWidth required>
<InputLabel>Выберите перевозчика</InputLabel>
<Select
value={editRouteData.carrier_id}
label="Выберите перевозчика"
onChange={(e) =>
routeStore.setEditRouteData({
carrier_id: Number(e.target.value),
carrier:
carrierStore.carriers[
language as keyof typeof carrierStore.carriers
].data?.find((c) => c.id === Number(e.target.value))
?.full_name || "",
})
}
disabled={
carrierStore.carriers[
language as keyof typeof carrierStore.carriers
].data?.length === 0
}
>
<MenuItem value="">Не выбрано</MenuItem>
{carrierStore.carriers[
language as keyof typeof carrierStore.carriers
].data?.map((carrier) => (
<MenuItem key={carrier.id} value={carrier.id}>
{carrier.full_name}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
className="w-full"
label="Номер маршрута"
required
value={editRouteData.route_number || ""}
onChange={(e) =>
routeStore.setEditRouteData({
route_number: e.target.value,
})
}
/>
<TextField
className="w-full"
label="Координаты маршрута"
multiline
minRows={2}
maxRows={10}
value={coordinates}
onChange={(e) => {
const newValue = e.target.value;
setCoordinates(newValue);
const validationResult = validateCoordinates(newValue);
if (validationResult === true) {
const lines = newValue.trim().split("\n");
const path = lines.map((line) => {
const [lat, lon] = line
.trim()
.split(/[\s,]+/)
.map(Number);
return [lat, lon];
});
routeStore.setEditRouteData({ path });
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
const lines = coordinates.split("\n");
const lastLine = lines[lines.length - 1];
// Если мы на последней строке и она не пустая
if (lastLine && lastLine.trim()) {
e.preventDefault();
const newValue = coordinates + "\n";
setCoordinates(newValue);
}
}
}}
error={validateCoordinates(coordinates) !== true}
helperText={
typeof validateCoordinates(coordinates) === "string"
? validateCoordinates(coordinates)
: "Формат: широта долгота"
}
placeholder="55.7558 37.6173&#10;55.7539 37.6208"
sx={{
"& .MuiInputBase-root": {
maxHeight: "500px",
overflow: "auto",
},
"& .MuiInputBase-input": {
fontFamily: "monospace",
fontSize: "0.8rem",
lineHeight: "1.2",
padding: "8px 12px",
},
"& .MuiFormHelperText-root": {
fontSize: "0.75rem",
marginTop: "2px",
},
}}
/>
<TextField
className="w-full"
label="Номер маршрута в Говорящем Городе"
required
value={editRouteData.route_sys_number || ""}
onChange={(e) =>
routeStore.setEditRouteData({
route_sys_number: e.target.value,
})
}
/>
{/* Заменяем Select на кнопку для выбора статьи */}
<Box className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">
Обращение к пассажирам
</label>
<Box className="flex gap-2">
<TextField
className="flex-1"
value={selectedArticle?.heading || "Статья не выбрана"}
placeholder="Выберите статью"
disabled
sx={{
"& .MuiInputBase-input": {
color: selectedArticle ? "inherit" : "#999",
},
}}
/>
<Button
variant="outlined"
onClick={() => setIsSelectArticleDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
</Box>
{/* Селектор видео превью */}
<Box className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">
Видео превью
</label>
<Box className="flex gap-2">
<Box
className="flex-1"
onClick={handleVideoPreviewClick}
sx={{
cursor:
editRouteData.video_preview &&
editRouteData.video_preview !== ""
? "pointer"
: "default",
}}
>
<Box
className="w-full h-[50px] border border-gray-400 rounded-sm flex items-center justify-between px-4"
sx={{
"& .MuiInputBase-input": {
color:
editRouteData.video_preview &&
editRouteData.video_preview !== ""
? "inherit"
: "#999",
cursor:
editRouteData.video_preview &&
editRouteData.video_preview !== ""
? "pointer"
: "default",
},
}}
>
<Typography variant="body1" className="text-sm">
{editRouteData.video_preview &&
editRouteData.video_preview !== ""
? "Видео выбрано"
: "Видео не выбрано"}
</Typography>
{editRouteData.video_preview &&
editRouteData.video_preview !== "" && (
<Box
onClick={(e) => {
e.stopPropagation();
routeStore.setEditRouteData({ video_preview: "" });
}}
sx={{
cursor: "pointer",
color: "#999",
"&:hover": {
color: "#666",
},
}}
>
<Typography
variant="body1"
className="text-lg font-bold"
>
×
</Typography>
</Box>
)}
</Box>
</Box>
<Button
variant="outlined"
onClick={() => setIsSelectVideoDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
</Box>
<FormControl fullWidth required>
<InputLabel>Прямой/обратный маршрут</InputLabel>
<Select
value={editRouteData.route_direction ? "forward" : "backward"}
label="Прямой/обратный маршрут"
onChange={(e) =>
routeStore.setEditRouteData({
route_direction: e.target.value === "forward",
})
}
>
<MenuItem value="forward">Прямой</MenuItem>
<MenuItem value="backward">Обратный</MenuItem>
</Select>
</FormControl>
<TextField
className="w-full"
label="Масштаб (мин)"
value={editRouteData.scale_min ?? ""}
onChange={(e) =>
routeStore.setEditRouteData({
scale_min:
e.target.value === "" ? null : parseFloat(e.target.value),
})
}
/>
<TextField
className="w-full"
label="Масштаб (макс)"
value={editRouteData.scale_max ?? ""}
onChange={(e) =>
routeStore.setEditRouteData({
scale_max:
e.target.value === "" ? null : parseFloat(e.target.value),
})
}
/>
<TextField
className="w-full"
label="Поворот"
value={editRouteData.rotate ?? ""}
onChange={(e) =>
routeStore.setEditRouteData({
rotate:
e.target.value === "" ? null : parseFloat(e.target.value),
})
}
/>
<TextField
className="w-full"
label="Центр. широта"
value={editRouteData.center_latitude ?? ""}
type="text"
onChange={(e) =>
routeStore.setEditRouteData({
center_latitude: e.target.value,
})
}
/>
<TextField
className="w-full"
label="Центр. долгота"
value={editRouteData.center_longitude ?? ""}
type="text"
onChange={(e) =>
routeStore.setEditRouteData({
center_longitude: e.target.value,
})
}
/>
</Box>
<LinkedItems
parentId={id || ""}
type="edit"
dragAllowed={true}
fields={[
{ label: "Название", data: "name" },
{ label: "Описание", data: "description" },
]}
onUpdate={() => {
routeStore.getRoute(Number(id));
}}
routeDirection={editRouteData.route_direction}
/>
<div className="flex w-full justify-between">
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Copy size={20} />}
onClick={handleCopy}
disabled={isLoading}
>
Скопировать
</Button>
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleSave}
disabled={isLoading}
>
Сохранить
</Button>
</div>
</div>
{/* Модальное окно выбора статьи */}
<SelectArticleModal
open={isSelectArticleDialogOpen}
onClose={() => setIsSelectArticleDialogOpen(false)}
onSelectArticle={handleArticleSelect}
/>
{/* Модальное окно выбора видео */}
<SelectMediaDialog
open={isSelectVideoDialogOpen}
onClose={() => setIsSelectVideoDialogOpen(false)}
onSelectMedia={handleVideoSelect}
mediaType={2}
/>
{/* Модальное окно предпросмотра видео */}
<Dialog
open={isVideoPreviewOpen}
onClose={() => setIsVideoPreviewOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>Предпросмотр видео</DialogTitle>
<DialogContent>
<Box className="flex justify-center items-center p-4">
<MediaViewer
media={{
id: editRouteData.video_preview,
media_type: 2,
filename: "video_preview",
}}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsVideoPreviewOpen(false)}>Закрыть</Button>
</DialogActions>
</Dialog>
</Paper>
);
});

View File

@ -0,0 +1,198 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { carrierStore, languageStore, routeStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Map, Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const RouteListPage = observer(() => {
const { routes, getRoutes, deleteRoute } = routeStore;
const { carriers, getCarriers } = carrierStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
await getCarriers("ru");
await getCarriers("en");
await getCarriers("zh");
await getRoutes();
setIsLoading(false);
};
fetchData();
}, [language]);
const columns: GridColDef[] = [
{
field: "carrier_id",
headerName: "Перевозчик",
width: 250,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
carriers[language].data.find(
(carrier) => carrier.id == params.value
)?.short_name
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "route_number",
headerName: "Номер маршрута",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "route_direction",
headerName: "Направление",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<p
className={
params.row.route_direction === "Прямой"
? "text-green-500"
: "text-red-500"
}
>
{params.row.route_direction}
</p>
);
},
},
{
field: "actions",
headerName: "Действия",
width: 250,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/route/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
<button onClick={() => navigate(`/route-preview/${params.row.id}`)}>
<Map size={20} className="text-purple-500" />
</button>
{/* <button onClick={() => navigate(`/route/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button> */}
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = routes.data.map((route) => ({
id: route.id,
carrier_id: route.carrier_id,
route_number: route.route_number,
route_direction: route.route_direction ? "Прямой" : "Обратный",
}));
return (
<>
<LanguageSwitcher />
<div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Маршруты</h1>
<CreateButton label="Создать маршрут" path="/route/create" />
</div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooter
checkboxSelection
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет маршрутов"}
</Box>
),
}}
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
deleteRoute(rowId);
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteRoute(id)));
getRoutes();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</>
);
});

4
src/pages/Route/index.ts Normal file
View File

@ -0,0 +1,4 @@
export * from "./RouteListPage";
export * from "./RouteCreatePage";
export { RoutePreview } from "./route-preview";
export * from "./RouteEditPage";

View File

@ -0,0 +1,9 @@
export const UP_SCALE = 30000;
export const PATH_WIDTH = 15;
export const STATION_RADIUS = 20;
export const STATION_OUTLINE_WIDTH = 10;
export const SIGHT_SIZE = 40;
export const SCALE_FACTOR = 50;
export const BACKGROUND_COLOR = 0x111111;
export const PATH_COLOR = 0xff4d4d;

View File

@ -0,0 +1,248 @@
import { FederatedMouseEvent, FederatedWheelEvent } from "pixi.js";
import { Component, ReactNode, useEffect, useState, useRef } from "react";
import { useTransform } from "./TransformContext";
import { useMapData } from "./MapDataContext";
import { SCALE_FACTOR } from "./Constants";
import { useApplication } from "@pixi/react";
class ErrorBoundary extends Component<
{ children: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("Error caught:", error, info);
}
render() {
return this.state.hasError ? <p>Whoopsie Daisy!</p> : this.props.children;
}
}
export function InfiniteCanvas({
children,
}: Readonly<{ children?: ReactNode }>) {
const {
position,
setPosition,
scale,
setScale,
rotation,
setRotation,
setScreenCenter,
screenCenter,
} = useTransform();
const { routeData, originalRouteData, setSelectedSight } = useMapData();
const applicationRef = useApplication();
const [isDragging, setIsDragging] = useState(false);
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
const [startRotation, setStartRotation] = useState(0);
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
const [isPointerDown, setIsPointerDown] = useState(false);
// Флаг для предотвращения конфликта между пользовательским вводом и данными маршрута
const [isUserInteracting, setIsUserInteracting] = useState(false);
// Реф для отслеживания последнего значения originalRouteData?.rotate
const lastOriginalRotation = useRef<number | undefined>(undefined);
useEffect(() => {
const canvas = applicationRef?.app?.canvas;
if (!canvas) return;
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]);
const handlePointerDown = (e: FederatedMouseEvent) => {
setIsPointerDown(true);
setIsDragging(false);
setIsUserInteracting(true); // Устанавливаем флаг взаимодействия пользователя
setStartPosition({
x: position.x,
y: position.y,
});
setStartMousePosition({
x: e.globalX,
y: e.globalY,
});
setStartRotation(rotation);
e.stopPropagation();
};
// Устанавливаем rotation только при изменении originalRouteData и отсутствии взаимодействия пользователя
useEffect(() => {
const newRotation = originalRouteData?.rotate ?? 0;
// Обновляем rotation только если:
// 1. Пользователь не взаимодействует с канвасом
// 2. Значение действительно изменилось
if (!isUserInteracting && lastOriginalRotation.current !== newRotation) {
setRotation((newRotation * Math.PI) / 180);
lastOriginalRotation.current = newRotation;
}
}, [originalRouteData?.rotate, isUserInteracting, setRotation]);
const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isPointerDown) return;
// Проверяем, началось ли перетаскивание
if (!isDragging) {
const dx = e.globalX - startMousePosition.x;
const dy = e.globalY - startMousePosition.y;
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
setIsDragging(true);
} else {
return;
}
}
if (e.shiftKey) {
const center = screenCenter ?? { x: 0, y: 0 };
const startAngle = Math.atan2(
startMousePosition.y - center.y,
startMousePosition.x - center.x
);
const currentAngle = Math.atan2(
e.globalY - center.y,
e.globalX - center.x
);
// Calculate rotation difference in radians
const rotationDiff = currentAngle - startAngle;
// Update rotation
setRotation(startRotation + rotationDiff);
const cosDelta = Math.cos(rotationDiff);
const sinDelta = Math.sin(rotationDiff);
setPosition({
x:
center.x * (1 - cosDelta) +
startPosition.x * cosDelta +
(center.y - startPosition.y) * sinDelta,
y:
center.y * (1 - cosDelta) +
startPosition.y * cosDelta +
(startPosition.x - center.x) * sinDelta,
});
} else {
setRotation(startRotation);
setPosition({
x: startPosition.x - startMousePosition.x + e.globalX,
y: startPosition.y - startMousePosition.y + e.globalY,
});
}
e.stopPropagation();
};
const handlePointerUp = (e: FederatedMouseEvent) => {
// Если не было перетаскивания, то это простой клик - закрываем виджет
if (!isDragging) {
setSelectedSight(undefined);
}
setIsPointerDown(false);
setIsDragging(false);
// Сбрасываем флаг взаимодействия через небольшую задержку
// чтобы избежать немедленного срабатывания useEffect
setTimeout(() => {
setIsUserInteracting(false);
}, 100);
e.stopPropagation();
};
const handleWheel = (e: FederatedWheelEvent) => {
e.stopPropagation();
setIsUserInteracting(true); // Устанавливаем флаг при зуме
// Get mouse position relative to canvas
const mouseX = e.globalX - position.x;
const mouseY = e.globalY - position.y;
// Calculate new scale
const scaleMin = (routeData?.scale_min ?? 10) / SCALE_FACTOR;
const scaleMax = (routeData?.scale_max ?? 20) / SCALE_FACTOR;
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; // Zoom out/in
const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor));
const actualZoomFactor = newScale / scale;
if (scale === newScale) {
// Сбрасываем флаг, если зум не изменился
setTimeout(() => {
setIsUserInteracting(false);
}, 100);
return;
}
// Update position to zoom towards mouse cursor
setPosition({
x: position.x + mouseX * (1 - actualZoomFactor),
y: position.y + mouseY * (1 - actualZoomFactor),
});
setScale(newScale);
// Сбрасываем флаг взаимодействия через задержку
setTimeout(() => {
setIsUserInteracting(false);
}, 100);
};
useEffect(() => {
applicationRef?.app.render();
}, [position, scale, rotation]);
return (
<ErrorBoundary>
{applicationRef?.app && (
<pixiGraphics
draw={(g) => {
const canvas = applicationRef.app.canvas;
g.clear();
g.rect(0, 0, canvas?.width ?? 0, canvas?.height ?? 0);
g.fill("#111");
}}
eventMode={"static"}
interactive
onPointerDown={handlePointerDown}
onGlobalPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerUpOutside={handlePointerUp}
onWheel={handleWheel}
/>
)}
<pixiContainer
x={position.x}
y={position.y}
scale={scale}
rotation={rotation}
>
{children}
</pixiContainer>
{/* Show center of the screen.
<pixiGraphics
eventMode="none"
draw={(g) => {
g.clear();
const center = screenCenter ?? {x: 0, y: 0};
g.circle(center.x, center.y, 1);
g.fill("#fff");
}}
/> */}
</ErrorBoundary>
);
}

View File

@ -0,0 +1,135 @@
import { Stack, Typography, Button } from "@mui/material";
import { useNavigate, useNavigationType } from "react-router";
import { MediaViewer } from "@widgets";
import { useMapData } from "./MapDataContext";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { authInstance } from "@shared";
export const LeftSidebar = observer(() => {
const navigate = useNavigate();
const navigationType = useNavigationType(); // PUSH, POP, REPLACE
const { routeData } = useMapData();
const [carrierThumbnail, setCarrierThumbnail] = useState<string | null>(null);
const [carrierLogo, setCarrierLogo] = useState<string | null>(null);
useEffect(() => {
async function fetchCarrierThumbnail() {
if (routeData?.carrier_id) {
const { city_id, logo } = (
await authInstance.get(`/carrier/${routeData.carrier_id}`)
).data;
const { arms } = (await authInstance.get(`/city/${city_id}`)).data;
setCarrierThumbnail(arms);
setCarrierLogo(logo);
}
}
fetchCarrierThumbnail();
}, [routeData?.carrier_id]);
const handleBack = () => {
if (navigationType === "PUSH") {
navigate(-1);
} else {
navigate("/route");
}
};
return (
<Stack direction="column" width="300px" p={2} bgcolor="primary.main">
<button
onClick={handleBack}
type="button"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: 10,
color: "#fff",
backgroundColor: "#222",
borderRadius: 10,
height: 40,
width: "100%",
border: "none",
cursor: "pointer",
}}
>
<p>Назад</p>
</button>
<Stack
direction="column"
alignItems="center"
justifyContent="center"
my={10}
>
<div
style={{
maxWidth: 200,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 10,
}}
>
{carrierThumbnail && (
<MediaViewer
media={{
id: carrierThumbnail,
media_type: 1, // Тип "Фото" для логотипа
filename: "route_thumbnail",
}}
fullWidth
fullHeight
/>
)}
<Typography sx={{ mb: 2, color: "#fff" }} textAlign="center">
При поддержке Правительства
</Typography>{" "}
</div>
</Stack>
<Stack
direction="column"
alignItems="center"
justifyContent="center"
my={10}
spacing={2}
>
<Button variant="outlined" color="warning" fullWidth>
Достопримечательности
</Button>
<Button variant="outlined" color="warning" fullWidth>
Остановки
</Button>
</Stack>
<Stack
direction="column"
alignItems="center"
maxHeight={150}
justifyContent="center"
my={10}
>
{carrierLogo && (
<MediaViewer
media={{
id: carrierLogo,
media_type: 1, // Тип "Фото" для логотипа
filename: "route_thumbnail_logo",
}}
fullHeight
/>
)}
</Stack>
<Typography
variant="h6"
textAlign="center"
mt="auto"
sx={{ color: "#fff" }}
>
#ВсемПоПути
</Typography>
</Stack>
);
});

View File

@ -0,0 +1,408 @@
import { useParams } from "react-router";
import { authInstance, languageInstance } from "@shared";
import {
createContext,
ReactNode,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import {
RouteData,
SightData,
SightPatchData,
StationData,
StationPatchData,
} from "./types";
import { observer } from "mobx-react-lite";
const MapDataContext = createContext<{
originalRouteData?: RouteData;
originalStationData?: StationData[];
originalSightData?: SightData[];
routeData?: RouteData;
stationData?: StationDataWithLanguage;
sightData?: SightData[];
isRouteLoading: boolean;
isStationLoading: boolean;
isSightLoading: boolean;
selectedSight?: SightData;
setSelectedSight: (sight?: SightData) => void;
setScaleRange: (min: number, max: number) => void;
setMapRotation: (rotation: number) => void;
setMapCenter: (x: number, y: number) => void;
setStationOffset: (stationId: number, x: number, y: number) => void;
setStationAlign: (stationId: number, align: number) => void;
setSightCoordinates: (
sightId: number,
latitude: number,
longitude: number
) => void;
saveChanges: () => void;
}>({
originalRouteData: undefined,
originalStationData: undefined,
originalSightData: undefined,
routeData: undefined,
stationData: undefined,
sightData: undefined,
isRouteLoading: true,
isStationLoading: true,
isSightLoading: true,
selectedSight: undefined,
setSelectedSight: () => {},
setScaleRange: () => {},
setMapRotation: () => {},
setMapCenter: () => {},
setStationOffset: () => {},
setStationAlign: () => {},
setSightCoordinates: () => {},
saveChanges: () => {},
});
type StationDataWithLanguage = {
[key: string]: StationData[];
};
export const MapDataProvider = observer(
({ children }: Readonly<{ children: ReactNode }>) => {
const { id: routeId } = useParams<{ id: string }>();
const [originalRouteData, setOriginalRouteData] = useState<RouteData>();
const [originalStationData, setOriginalStationData] =
useState<StationData[]>();
const [originalSightData, setOriginalSightData] = useState<SightData[]>();
const [routeData, setRouteData] = useState<RouteData>();
const [stationData, setStationData] = useState<StationDataWithLanguage>({
RU: [],
EN: [],
ZH: [],
});
const [sightData, setSightData] = useState<SightData[]>();
const [routeChanges, setRouteChanges] = useState<Partial<RouteData>>({});
const [stationChanges, setStationChanges] = useState<StationPatchData[]>(
[]
);
const [sightChanges, setSightChanges] = useState<SightPatchData[]>([]);
const [isRouteLoading, setIsRouteLoading] = useState(true);
const [isStationLoading, setIsStationLoading] = useState(true);
const [isSightLoading, setIsSightLoading] = useState(true);
const [selectedSight, setSelectedSight] = useState<SightData>();
useEffect(() => {
const fetchData = async () => {
try {
setIsRouteLoading(true);
setIsStationLoading(true);
setIsSightLoading(true);
const [
routeResponse,
ruStationResponse,
enStationResponse,
zhStationResponse,
sightResponse,
] = await Promise.all([
authInstance.get(`/route/${routeId}`),
languageInstance("ru").get(`/route/${routeId}/station`),
languageInstance("en").get(`/route/${routeId}/station`),
languageInstance("zh").get(`/route/${routeId}/station`),
languageInstance("ru").get(`/route/${routeId}/sight`),
]);
const routeData = routeResponse.data as RouteData;
setOriginalRouteData(routeData);
setOriginalStationData(ruStationResponse.data as StationData[]);
setStationData({
ru: ruStationResponse.data as StationData[],
en: enStationResponse.data as StationData[],
zh: zhStationResponse.data as StationData[],
});
setOriginalSightData(sightResponse.data as SightData[]);
setIsRouteLoading(false);
setIsStationLoading(false);
setIsSightLoading(false);
} catch (error) {
console.error("Error fetching data:", error);
setIsRouteLoading(false);
setIsStationLoading(false);
setIsSightLoading(false);
}
};
fetchData();
}, [routeId]);
useEffect(() => {
// combine changes with original data
if (originalRouteData)
setRouteData({ ...originalRouteData, ...routeChanges });
if (originalSightData) setSightData(originalSightData);
}, [
originalRouteData,
originalSightData,
routeChanges,
stationChanges,
sightChanges,
]);
function setScaleRange(min: number, max: number) {
setRouteChanges((prev) => {
return { ...prev, scale_min: min, scale_max: max };
});
}
function setMapRotation(rotation: number) {
setRouteChanges((prev) => {
return { ...prev, rotate: rotation };
});
}
function setMapCenter(x: number, y: number) {
setRouteChanges((prev) => {
return { ...prev, center_latitude: x, center_longitude: y };
});
}
async function saveChanges() {
await authInstance.patch(`/route/${routeId}`, routeData);
await saveStationChanges();
await saveSightChanges();
}
async function saveStationChanges() {
for (const station of stationChanges) {
await authInstance.patch(`/route/${routeId}/station`, station);
}
}
async function saveSightChanges() {
for (const sight of sightChanges) {
await authInstance.patch(`/route/${routeId}/sight`, sight);
}
}
function setStationOffset(stationId: number, x: number, y: number) {
const currentStation = stationData.ru?.find(
(station) => station.id === stationId
);
if (
currentStation &&
Math.abs(currentStation.offset_x - x) < 0.01 &&
Math.abs(currentStation.offset_y - y) < 0.01
) {
return;
}
setStationChanges((prev) => {
const existingIndex = prev.findIndex(
(station) => station.station_id === stationId
);
if (existingIndex !== -1) {
const newChanges = [...prev];
newChanges[existingIndex] = {
...newChanges[existingIndex],
offset_x: x,
offset_y: y,
};
return newChanges;
} else {
const originalStation = originalStationData?.find(
(s) => s.id === stationId
);
return [
...prev,
{
station_id: stationId,
offset_x: x,
offset_y: y,
align: originalStation?.align ?? 1,
transfers: originalStation?.transfers ?? {
bus: "",
metro_blue: "",
metro_green: "",
metro_orange: "",
metro_purple: "",
metro_red: "",
train: "",
tram: "",
trolleybus: "",
},
},
];
}
});
setStationData((prev) => {
const updated = { ...prev };
Object.keys(updated).forEach((lang) => {
updated[lang] = updated[lang].map((station) => {
if (station.id === stationId) {
return { ...station, offset_x: x, offset_y: y };
}
return station;
});
});
return updated;
});
}
function setStationAlign(stationId: number, align: number) {
const currentStation = stationData.ru?.find(
(station) => station.id === stationId
);
if (currentStation && currentStation.align === align) {
return;
}
setStationChanges((prev) => {
const existingIndex = prev.findIndex(
(station) => station.station_id === stationId
);
if (existingIndex !== -1) {
const newChanges = [...prev];
newChanges[existingIndex] = {
...newChanges[existingIndex],
align: align,
};
return newChanges;
} else {
const originalStation = originalStationData?.find(
(s) => s.id === stationId
);
return [
...prev,
{
station_id: stationId,
align: align,
offset_x: originalStation?.offset_x ?? 0,
offset_y: originalStation?.offset_y ?? 0,
transfers: originalStation?.transfers ?? {
bus: "",
metro_blue: "",
metro_green: "",
metro_orange: "",
metro_purple: "",
metro_red: "",
train: "",
tram: "",
trolleybus: "",
},
},
];
}
});
setStationData((prev) => {
const updated = { ...prev };
Object.keys(updated).forEach((lang) => {
updated[lang] = updated[lang].map((station) => {
if (station.id === stationId) {
return { ...station, align: align };
}
return station;
});
});
return updated;
});
}
function setSightCoordinates(
sightId: number,
latitude: number,
longitude: number
) {
setSightChanges((prev) => {
const existingIndex = prev.findIndex(
(sight) => sight.sight_id === sightId
);
if (existingIndex !== -1) {
return prev.map((sight, index) => {
if (index === existingIndex) {
return {
...sight,
latitude,
longitude,
};
}
return sight;
});
} else {
const foundSight = sightData?.find((sight) => sight.id === sightId);
if (foundSight) {
return [
...prev,
{
sight_id: sightId,
latitude,
longitude,
},
];
}
return prev;
}
});
}
useEffect(() => {}, [sightChanges]);
const value = useMemo(
() => ({
originalRouteData,
originalStationData,
originalSightData,
routeData,
stationData,
sightData,
isRouteLoading,
isStationLoading,
isSightLoading,
selectedSight,
setSelectedSight,
setScaleRange,
setMapRotation,
setMapCenter,
saveChanges,
setStationOffset,
setStationAlign,
setSightCoordinates,
}),
[
originalRouteData,
originalStationData,
originalSightData,
routeData,
stationData,
sightData,
isRouteLoading,
isStationLoading,
isSightLoading,
selectedSight,
]
);
return (
<MapDataContext.Provider value={value}>
{children}
</MapDataContext.Provider>
);
}
);
export const useMapData = () => {
const context = useContext(MapDataContext);
if (!context) {
throw new Error("useMapData must be used within a MapDataProvider");
}
return context;
};

View File

@ -0,0 +1,371 @@
import { Button, Stack, TextField, Typography, Slider } from "@mui/material";
import { useMapData } from "./MapDataContext";
import { useEffect, useState } from "react";
import { useTransform } from "./TransformContext";
import { coordinatesToLocal, localToCoordinates } from "./utils";
import { SCALE_FACTOR } from "./Constants";
export function RightSidebar() {
const {
routeData,
setScaleRange,
saveChanges,
originalRouteData,
setMapRotation,
setMapCenter,
} = useMapData();
const {
rotation,
position,
screenToLocal,
screenCenter,
rotateToAngle,
setTransform,
scale,
setScaleAtCenter,
} = useTransform();
const [minScale, setMinScale] = useState<number>(1);
const [maxScale, setMaxScale] = useState<number>(5);
const [localCenter, setLocalCenter] = useState<{ x: number; y: number }>({
x: 0,
y: 0,
});
const [rotationDegrees, setRotationDegrees] = useState<number>(0);
const [isUserEditing, setIsUserEditing] = useState<boolean>(false);
useEffect(() => {
if (originalRouteData) {
// Проверяем и сбрасываем минимальный масштаб если нужно
const originalMinScale = originalRouteData.scale_min ?? 1;
const resetMinScale = originalMinScale < 1 ? 1 : originalMinScale;
// Проверяем и сбрасываем максимальный масштаб если нужно
const originalMaxScale = originalRouteData.scale_max ?? 5;
const resetMaxScale = originalMaxScale < 3 ? 3 : originalMaxScale;
setMinScale(resetMinScale);
setMaxScale(resetMaxScale);
setRotationDegrees(originalRouteData.rotate ?? 0);
setLocalCenter({
x: originalRouteData.center_latitude ?? 0,
y: originalRouteData.center_longitude ?? 0,
});
}
}, [originalRouteData]);
useEffect(() => {
if (minScale && maxScale) {
setScaleRange(minScale, maxScale);
}
}, [minScale, maxScale]);
useEffect(() => {
setRotationDegrees(
((Math.round((rotation * 180) / Math.PI) % 360) + 360) % 360
);
}, [rotation]);
useEffect(() => {
setMapRotation(rotationDegrees);
}, [rotationDegrees]);
useEffect(() => {
if (!isUserEditing) {
const center = screenCenter ?? { x: 0, y: 0 };
const localCenter = screenToLocal(center.x, center.y);
const coordinates = localToCoordinates(localCenter.x, localCenter.y);
setLocalCenter({ x: coordinates.latitude, y: coordinates.longitude });
}
}, [
position,
screenCenter,
screenToLocal,
localToCoordinates,
setLocalCenter,
isUserEditing,
]);
useEffect(() => {
setMapCenter(localCenter.x, localCenter.y);
}, [localCenter]);
function setRotationFromDegrees(degrees: number) {
rotateToAngle((degrees * Math.PI) / 180);
}
function pan({ x, y }: { x: number; y: number }) {
const coordinates = coordinatesToLocal(x, y);
setTransform(coordinates.x, coordinates.y);
}
if (!routeData) {
console.error("routeData is null");
return null;
}
return (
<Stack
position="absolute"
right={8}
top={8}
bottom={8}
p={2}
gap={1}
minWidth="400px"
bgcolor="primary.main"
border="1px solid #e0e0e0"
borderRadius={2}
>
<Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center">
Детали о достопримечательностях
</Typography>
<Stack spacing={2} direction="row" alignItems="center">
<TextField
type="number"
label="Минимальный масштаб"
variant="filled"
value={minScale}
onChange={(e) => {
let newMinScale = Number(e.target.value);
// Сбрасываем к 1 если меньше
if (newMinScale < 1) {
newMinScale = 1;
}
setMinScale(newMinScale);
if (maxScale - newMinScale < 2) {
let newMaxScale = newMinScale + 2;
// Сбрасываем максимальный к 3 если меньше минимального
if (newMaxScale < 3) {
newMaxScale = 3;
setMinScale(1); // Сбрасываем минимальный к 1
}
setMaxScale(newMaxScale);
}
if (newMinScale > scale * SCALE_FACTOR) {
setScaleAtCenter(newMinScale / SCALE_FACTOR);
}
}}
style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{
"& .MuiInputLabel-root": {
color: "#fff",
},
"& .MuiInputBase-input": {
color: "#fff",
},
}}
slotProps={{
input: {
min: 1,
max: 10,
},
}}
/>
<TextField
type="number"
label="Максимальный масштаб"
variant="filled"
value={maxScale}
onChange={(e) => {
let newMaxScale = Number(e.target.value);
// Сбрасываем к 3 если меньше минимального
if (newMaxScale < 3) {
newMaxScale = 3;
}
setMaxScale(newMaxScale);
if (newMaxScale - minScale < 2) {
let newMinScale = newMaxScale - 2;
// Сбрасываем минимальный к 1 если меньше
if (newMinScale < 1) {
newMinScale = 1;
setMaxScale(3); // Сбрасываем максимальный к минимальному значению
}
setMinScale(newMinScale);
}
if (newMaxScale < scale * SCALE_FACTOR) {
setScaleAtCenter(newMaxScale / SCALE_FACTOR);
}
}}
style={{ backgroundColor: "#222", borderRadius: 4, color: "#fff" }}
sx={{
"& .MuiInputLabel-root": {
color: "#fff",
},
"& .MuiInputBase-input": {
color: "#fff",
},
}}
slotProps={{
input: {
min: 3,
max: 10,
},
}}
/>
</Stack>
<Typography variant="body2" sx={{ color: "#fff", textAlign: "center" }}>
Текущий масштаб: {Math.round(scale * SCALE_FACTOR * 100) / 100}
</Typography>
<Slider
value={scale * SCALE_FACTOR}
onChange={(_, newValue) => {
if (typeof newValue === "number") {
setScaleAtCenter(newValue / SCALE_FACTOR);
}
}}
min={minScale}
max={maxScale}
step={0.1}
sx={{
color: "#fff",
"& .MuiSlider-thumb": {
backgroundColor: "#fff",
},
"& .MuiSlider-track": {
backgroundColor: "#fff",
},
"& .MuiSlider-rail": {
backgroundColor: "#666",
},
}}
/>
<TextField
type="number"
label="Текущий масштаб"
variant="filled"
value={Math.round(scale * SCALE_FACTOR * 100) / 100}
onChange={(e) => {
const newScale = Number(e.target.value);
if (
!isNaN(newScale) &&
newScale >= minScale &&
newScale <= maxScale
) {
setScaleAtCenter(newScale / SCALE_FACTOR);
}
}}
style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{
"& .MuiInputLabel-root": {
color: "#fff",
},
"& .MuiInputBase-input": {
color: "#fff",
},
}}
inputProps={{
min: minScale,
max: maxScale,
}}
/>
<TextField
type="number"
label="Поворот (в градусах)"
variant="filled"
value={rotationDegrees}
onChange={(e) => {
const value = Number(e.target.value);
if (!isNaN(value)) {
setRotationFromDegrees(value);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.currentTarget.blur();
}
}}
style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{
"& .MuiInputLabel-root": {
color: "#fff",
},
"& .MuiInputBase-input": {
color: "#fff",
},
}}
slotProps={{
input: {
min: 0,
max: 360,
},
}}
/>
<Stack direction="row" spacing={2}>
<TextField
type="number"
label="Центр карты, широта"
variant="filled"
value={Math.round(localCenter.x * 1000) / 1000}
onChange={(e) => {
setIsUserEditing(true);
setLocalCenter((prev) => ({ ...prev, x: Number(e.target.value) }));
pan({ x: Number(e.target.value), y: localCenter.y });
}}
onBlur={() => setIsUserEditing(false)}
style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{
"& .MuiInputLabel-root": {
color: "#fff",
},
"& .MuiInputBase-input": {
color: "#fff",
},
}}
inputProps={{
step: 0.001,
}}
/>
<TextField
type="number"
label="Центр карты, высота"
variant="filled"
value={Math.round(localCenter.y * 1000) / 1000}
onChange={(e) => {
setIsUserEditing(true);
setLocalCenter((prev) => ({ ...prev, y: Number(e.target.value) }));
pan({ x: localCenter.x, y: Number(e.target.value) });
}}
onBlur={() => setIsUserEditing(false)}
style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{
"& .MuiInputLabel-root": {
color: "#fff",
},
"& .MuiInputBase-input": {
color: "#fff",
},
}}
inputProps={{
step: 0.001,
}}
/>
</Stack>
<Button
variant="contained"
color="secondary"
sx={{ mt: 2 }}
onClick={() => {
saveChanges();
}}
>
Сохранить изменения
</Button>
</Stack>
);
}

View File

@ -0,0 +1,139 @@
import { useEffect, useState } from "react";
import { useTransform } from "./TransformContext";
import { SightData } from "./types";
import { Assets, FederatedMouseEvent, Texture } from "pixi.js";
import { SIGHT_SIZE, UP_SCALE } from "./Constants";
import { coordinatesToLocal, localToCoordinates } from "./utils";
import { useMapData } from "./MapDataContext";
interface SightProps {
sight: SightData;
id: number;
}
export const Sight = ({ sight, id }: Readonly<SightProps>) => {
const { rotation, scale } = useTransform();
const { setSightCoordinates, setSelectedSight } = useMapData();
const [position, setPosition] = useState(
coordinatesToLocal(sight.latitude, sight.longitude)
);
const [isDragging, setIsDragging] = useState(false);
const [isPointerDown, setIsPointerDown] = useState(false);
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
const handlePointerDown = (e: FederatedMouseEvent) => {
setIsPointerDown(true);
setIsDragging(false);
setStartPosition({
x: position.x,
y: position.y,
});
setStartMousePosition({
x: e.globalX,
y: e.globalY,
});
e.stopPropagation();
};
const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isPointerDown) return;
if (!isDragging) {
const dx = e.globalX - startMousePosition.x;
const dy = e.globalY - startMousePosition.y;
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
setIsDragging(true);
} else {
return;
}
}
const dx = (e.globalX - startMousePosition.x) / scale / UP_SCALE;
const dy = (e.globalY - startMousePosition.y) / scale / UP_SCALE;
const cos = Math.cos(rotation);
const sin = Math.sin(rotation);
const newPosition = {
x: startPosition.x + dx * cos + dy * sin,
y: startPosition.y - dx * sin + dy * cos,
};
setPosition(newPosition);
const coordinates = localToCoordinates(newPosition.x, newPosition.y);
setSightCoordinates(sight.id, coordinates.latitude, coordinates.longitude);
e.stopPropagation();
};
const handlePointerUp = (e: FederatedMouseEvent) => {
setIsPointerDown(false);
// Если не было перетаскивания, то это клик
if (!isDragging) {
setSelectedSight(sight);
}
setIsDragging(false);
e.stopPropagation();
};
const [texture, setTexture] = useState(Texture.EMPTY);
useEffect(() => {
Assets.load("/SightIcon.png").then(setTexture);
}, []);
useEffect(() => {
console.log(
`Rendering Sight ${id + 1} at [${sight.latitude}, ${sight.longitude}]`
);
}, [id, sight.latitude, sight.longitude]);
if (!sight) {
console.error("sight is null");
return null;
}
// Компенсируем масштаб для сохранения постоянного размера
const compensatedSize = SIGHT_SIZE / scale;
const compensatedFontSize = 24 / scale;
return (
<pixiContainer
rotation={-rotation}
eventMode="static"
interactive
onPointerDown={handlePointerDown}
onGlobalPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerUpOutside={handlePointerUp}
x={position.x * UP_SCALE - SIGHT_SIZE / 2}
y={position.y * UP_SCALE - SIGHT_SIZE / 2}
>
<pixiSprite
texture={texture}
width={compensatedSize}
height={compensatedSize}
/>
<pixiGraphics
draw={(g) => {
g.clear();
g.circle(0, 0, 20 / scale);
g.fill({ color: "#000" });
}}
x={compensatedSize}
y={0}
/>
<pixiText
text={`${id + 1}`}
x={compensatedSize + 1 / scale}
y={0}
anchor={0.5}
style={{
fontSize: compensatedFontSize,
fontWeight: "bold",
fill: "#ffffff",
}}
/>
</pixiContainer>
);
};

View File

@ -0,0 +1,60 @@
import { Box, Typography, IconButton } from "@mui/material";
import { Close } from "@mui/icons-material";
import { useMapData } from "./MapDataContext";
export function SightInfoWidget() {
const { selectedSight, setSelectedSight } = useMapData();
if (!selectedSight) {
return null;
}
return (
<Box
sx={{
position: "absolute",
bottom: 16,
left: "50%",
transform: "translateX(-50%)",
backgroundColor: "rgba(0, 0, 0, 0.9)",
color: "white",
padding: "12px 16px",
borderRadius: "4px",
minWidth: 250,
maxWidth: 400,
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.2)",
zIndex: 1000,
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)",
}}
>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
mb: 1,
}}
>
<Typography variant="h6" sx={{ fontWeight: "bold", color: "#fff" }}>
{selectedSight.name}
</Typography>
<IconButton
size="small"
onClick={() => setSelectedSight(undefined)}
sx={{ color: "#fff", p: 0, minWidth: 24, width: 24, height: 24 }}
>
<Close fontSize="small" />
</IconButton>
</Box>
<Typography variant="body2" sx={{ color: "#ccc", mb: 1 }}>
{selectedSight.address}
</Typography>
<Typography variant="caption" sx={{ color: "#999" }}>
Город: {selectedSight.city}
</Typography>
</Box>
);
}

View File

@ -0,0 +1,575 @@
import { FederatedMouseEvent, Graphics } from "pixi.js";
import { useCallback, useState, useEffect, useRef, FC, useMemo } from "react";
import { observer } from "mobx-react-lite";
// --- Заглушки для зависимостей (замените на ваши реальные импорты) ---
import {
BACKGROUND_COLOR,
PATH_COLOR,
STATION_RADIUS,
STATION_OUTLINE_WIDTH,
UP_SCALE,
} from "./Constants";
import { useTransform } from "./TransformContext";
import { StationData } from "./types";
import { useMapData } from "./MapDataContext";
import { coordinatesToLocal } from "./utils";
import { languageStore } from "@shared";
// --- Конец заглушек ---
// --- Декларации для react-pixi ---
// (В реальном проекте типы должны быть предоставлены библиотекой @pixi/react-pixi)
declare const pixiContainer: any;
declare const pixiGraphics: any;
declare const pixiText: any;
// --- Типы ---
type HorizontalAlign = "left" | "center" | "right";
type VerticalAlign = "top" | "center" | "bottom";
type TextAlign = HorizontalAlign | `${HorizontalAlign} ${VerticalAlign}`;
type LabelAlign = "left" | "center" | "right";
// --- Утилиты ---
/**
* Преобразует текстовое позиционирование в anchor координаты.
*/
/**
* Получает координату anchor.x из типа выравнивания.
*/
// --- Интерфейсы пропсов ---
interface StationProps {
station: StationData;
ruLabel: string | null;
anchorPoint?: { x: number; y: number };
/** Anchor для всего блока с текстом. По умолчанию: `"right center"` */
labelBlockAnchor?: TextAlign | { x: number; y: number };
/** Внутреннее выравнивание текста в блоке. По умолчанию: `"left"` */
labelAlign?: LabelAlign;
/** Callback для изменения внутреннего выравнивания */
onLabelAlignChange?: (align: LabelAlign) => void;
/** Callback для отслеживания наведения на текст */
onTextHover?: (isHovered: boolean) => void;
}
interface LabelAlignmentControlProps {
scale: number;
currentAlign: LabelAlign;
onAlignChange: (align: LabelAlign) => void;
onPointerOver: () => void;
onPointerOut: () => void;
onControlPointerEnter: () => void;
onControlPointerLeave: () => void;
}
interface StationLabelProps
extends Omit<StationProps, "ruLabelAnchor" | "nameLabelAnchor"> {}
const getAnchorFromOffset = (
offsetX: number,
offsetY: number
): { x: number; y: number } => {
if (offsetX === 0 && offsetY === 0) {
return { x: 0.5, y: 0.5 };
}
const length = Math.hypot(offsetX, offsetY);
const nx = offsetX / length;
const ny = offsetY / length;
return { x: (1 - nx) / 2, y: (1 - ny) / 2 };
};
// =========================================================================
// Компонент: Панель управления выравниванием в стиле УрФУ
// =========================================================================
const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
scale,
currentAlign,
onAlignChange,
onControlPointerEnter,
onControlPointerLeave,
}) => {
const controlHeight = 50 / scale;
const controlWidth = 200 / scale;
const fontSize = 18 / scale;
const borderRadius = 8 / scale;
const compensatedRuFontSize = (26 * 0.75) / scale;
const buttonWidth = controlWidth / 3;
const strokeWidth = 2 / scale;
const drawBg = useCallback(
(g: Graphics) => {
g.clear();
// Основной фон с градиентом
g.roundRect(
-controlWidth / 2,
0,
controlWidth,
controlHeight,
borderRadius
);
g.fill({ color: "#1a1a1a" }); // Темный фон как у УрФУ
// Тонкая рамка
g.roundRect(
-controlWidth / 2,
0,
controlWidth,
controlHeight,
borderRadius
);
g.stroke({ color: "#333333", width: strokeWidth });
// Разделители между кнопками
for (let i = 1; i < 3; i++) {
const x = -controlWidth / 2 + buttonWidth * i;
g.moveTo(x, strokeWidth);
g.lineTo(x, controlHeight - strokeWidth);
g.stroke({ color: "#333333", width: strokeWidth });
}
},
[controlWidth, controlHeight, borderRadius, buttonWidth, strokeWidth]
);
const drawButtonHighlight = useCallback(
(g: Graphics, index: number, isActive: boolean) => {
g.clear();
if (isActive) {
const x = -controlWidth / 2 + buttonWidth * index;
g.roundRect(
x + strokeWidth,
strokeWidth,
buttonWidth - strokeWidth * 2,
controlHeight - strokeWidth * 2,
borderRadius / 2
);
g.fill({ color: "#0066cc", alpha: 0.8 }); // Синий акцент УрФУ
}
},
[controlWidth, controlHeight, buttonWidth, strokeWidth, borderRadius]
);
const getTextStyle = (isActive: boolean) => ({
fontSize,
fontWeight: isActive ? ("bold" as const) : ("normal" as const),
fill: isActive ? "#ffffff" : "#cccccc",
fontFamily: "Arial, sans-serif",
});
const alignOptions = [
{ key: "left" as const, label: "Left" },
{ key: "center" as const, label: "Center" },
{ key: "right" as const, label: "Right" },
];
return (
<pixiContainer
position={{ x: 0, y: compensatedRuFontSize * 1.1 + 15 / scale }}
zIndex={999999999999999999}
eventMode="static"
onPointerOver={(e: FederatedMouseEvent) => {
e.stopPropagation();
onControlPointerEnter();
}}
onPointerOut={(e: FederatedMouseEvent) => {
e.stopPropagation();
onControlPointerLeave();
}}
onPointerDown={(e: FederatedMouseEvent) => {
e.stopPropagation();
}}
>
{/* Основной фон */}
<pixiGraphics draw={drawBg} />
{/* Кнопки с подсветкой */}
{alignOptions.map((option, index) => (
<pixiContainer key={option.key}>
{/* Подсветка активной кнопки */}
<pixiGraphics
draw={(g: Graphics) =>
drawButtonHighlight(g, index, option.key === currentAlign)
}
/>
{/* Текст кнопки */}
<pixiText
text={option.label}
anchor={{ x: 0.5, y: 0.5 }}
position={{
x: -controlWidth / 2 + buttonWidth * (index + 0.5),
y: controlHeight / 2,
}}
style={getTextStyle(option.key === currentAlign)}
eventMode="static"
cursor="pointer"
onClick={(e: FederatedMouseEvent) => {
e.stopPropagation();
onAlignChange(option.key);
}}
onPointerDown={(e: FederatedMouseEvent) => {
e.stopPropagation();
onAlignChange(option.key);
}}
onPointerOver={(e: FederatedMouseEvent) => {
e.stopPropagation();
onControlPointerEnter();
}}
/>
</pixiContainer>
))}
</pixiContainer>
);
};
// =========================================================================
// Компонент: Метка Станции (с логикой)
// =========================================================================
const StationLabel = observer(
({
station,
ruLabel,
labelAlign: labelAlignProp = "center",
onLabelAlignChange,
onTextHover,
}: Readonly<StationLabelProps>) => {
const { language } = languageStore;
const { rotation, scale } = useTransform();
const { setStationOffset, setStationAlign } = useMapData();
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [isPointerDown, setIsPointerDown] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isControlHovered, setIsControlHovered] = useState(false);
const [currentLabelAlign, setCurrentLabelAlign] = useState(labelAlignProp);
const [ruLabelWidth, setRuLabelWidth] = useState(0);
const dragStartPos = useRef({ x: 0, y: 0 });
const mouseStartPos = useRef({ x: 0, y: 0 });
const hideTimer = useRef<NodeJS.Timeout | null>(null);
const ruLabelRef = useRef<any>(null);
useEffect(() => {
return () => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
}
};
}, []);
const handlePointerEnter = () => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
setIsHovered(true);
onTextHover?.(true); // Call the callback to indicate text is hovered
};
const handleControlPointerEnter = () => {
// Дополнительная обработка для панели управления
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
setIsControlHovered(true);
setIsHovered(true);
onTextHover?.(true); // Call the callback to indicate text/control is hovered
};
const handleControlPointerLeave = () => {
setIsControlHovered(false);
// Если курсор не над основным контейнером, скрываем панель через некоторое время
if (!isHovered) {
hideTimer.current = setTimeout(() => {
setIsHovered(false);
onTextHover?.(false); // Call the callback to indicate neither text nor control is hovered
}, 0);
}
};
const handlePointerLeave = () => {
// Увеличиваем время до скрытия панели и добавляем проверку
hideTimer.current = setTimeout(() => {
setIsHovered(false);
// Если курсор не над панелью управления, скрываем и её
if (!isControlHovered) {
setIsControlHovered(false);
}
onTextHover?.(false); // Call the callback to indicate text is no longer hovered
}, 100); // Увеличиваем время до скрытия панели
};
useEffect(() => {
setPosition({ x: station.offset_x ?? 0, y: station.offset_y ?? 0 });
}, [station.offset_x, station.offset_y, station.id]);
// Функция для конвертации числового align в строковый
const convertNumericAlign = (align: number): LabelAlign => {
switch (align) {
case 0:
return "left";
case 1:
return "center";
case 2:
return "right";
default:
return "center";
}
};
// Функция для конвертации строкового align в числовой
const convertStringAlign = (align: LabelAlign): number => {
switch (align) {
case "left":
return 0;
case "center":
return 1;
case "right":
return 2;
default:
return 1;
}
};
useEffect(() => {
setCurrentLabelAlign(convertNumericAlign(station.align ?? 1));
}, [station.align]);
if (!station) return null;
const coordinates = coordinatesToLocal(station.latitude, station.longitude);
const compensatedRuFontSize = (26 * 0.75) / scale;
const compensatedNameFontSize = (16 * 0.75) / scale;
// Измеряем ширину верхнего лейбла
useEffect(() => {
if (ruLabelRef.current && ruLabel) {
setRuLabelWidth(ruLabelRef.current.width);
}
}, [ruLabel, compensatedRuFontSize]);
const handlePointerDown = (e: FederatedMouseEvent) => {
setIsPointerDown(true);
setIsDragging(false);
dragStartPos.current = { ...position };
mouseStartPos.current = { x: e.global.x, y: e.global.y };
e.stopPropagation();
};
const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isPointerDown) return;
if (!isDragging) {
const dx = e.global.x - mouseStartPos.current.x;
const dy = e.global.y - mouseStartPos.current.y;
if (Math.hypot(dx, dy) > 3) setIsDragging(true);
else return;
}
const dx_screen = e.global.x - mouseStartPos.current.x;
const dy_screen = e.global.y - mouseStartPos.current.y;
const newPosition = {
x: dragStartPos.current.x + dx_screen,
y: dragStartPos.current.y + dy_screen,
};
// Проверяем, изменилась ли позиция
if (
Math.abs(newPosition.x - position.x) > 0.01 ||
Math.abs(newPosition.y - position.y) > 0.01
) {
setPosition(newPosition);
setStationOffset(station.id, newPosition.x, newPosition.y);
}
e.stopPropagation();
};
const handlePointerUp = (e: FederatedMouseEvent) => {
setIsPointerDown(false);
setTimeout(() => setIsDragging(false), 50);
e.stopPropagation();
};
const handleAlignChange = async (align: LabelAlign) => {
setCurrentLabelAlign(align);
onLabelAlignChange?.(align);
// Сохраняем в стор
const numericAlign = convertStringAlign(align);
setStationAlign(station.id, numericAlign);
};
const dynamicAnchor = useMemo(
() => getAnchorFromOffset(position.x, position.y),
[position.x, position.y]
);
// Функция для расчета позиции нижнего лейбла относительно ширины верхнего
const getSecondLabelPosition = (): number => {
if (!ruLabelWidth) return 0;
switch (currentLabelAlign) {
case "left":
// Позиционируем относительно левого края верхнего текста
return -ruLabelWidth / 2;
case "center":
// Центрируем относительно центра верхнего текста
return 0;
case "right":
// Позиционируем относительно правого края верхнего текста
return ruLabelWidth / 2;
default:
return 0;
}
};
// Функция для расчета anchor нижнего лейбла
const getSecondLabelAnchor = (): number => {
switch (currentLabelAlign) {
case "left":
return 0; // anchor.x = 0 (левый край)
case "center":
return 0.5; // anchor.x = 0.5 (центр)
case "right":
return 1; // anchor.x = 1 (правый край)
default:
return 0.5;
}
};
return (
<pixiContainer
x={coordinates.x * UP_SCALE}
y={coordinates.y * UP_SCALE}
rotation={-rotation}
zIndex={isHovered || isControlHovered ? 1000 : 0}
eventMode="static"
interactive
cursor={isDragging ? "grabbing" : "grab"}
onPointerOver={handlePointerEnter}
onPointerOut={handlePointerLeave}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerUpOutside={handlePointerUp}
onGlobalPointerMove={handlePointerMove}
>
<pixiContainer
position={{
x:
(position.x + Math.cos(Math.atan2(position.y, position.x))) /
scale,
y:
(position.y + Math.sin(Math.atan2(position.y, position.x))) /
scale,
}}
anchor={dynamicAnchor}
zIndex={isHovered || isControlHovered ? 1000 : 0}
>
{ruLabel && (
<pixiText
ref={ruLabelRef}
text={ruLabel}
position={{ x: 0, y: 0 }}
anchor={{ x: 0.5, y: 0.5 }}
style={{
fontSize: compensatedRuFontSize,
fontWeight: "bold",
fill: "#ffffff",
}}
/>
)}
{station.name && language !== "ru" && ruLabel && (
<pixiText
text={station.name}
position={{
x: getSecondLabelPosition(),
y: compensatedRuFontSize * 1.1,
}}
anchor={{ x: getSecondLabelAnchor(), y: 0.5 }}
style={{
fontSize: compensatedNameFontSize,
fontWeight: "bold",
fill: "#CCCCCC",
}}
/>
)}
{(isHovered || isControlHovered) && !isDragging && (
<LabelAlignmentControl
scale={scale}
currentAlign={currentLabelAlign}
onAlignChange={handleAlignChange}
onPointerOver={handlePointerEnter}
onPointerOut={handlePointerLeave}
onControlPointerEnter={handleControlPointerEnter}
onControlPointerLeave={handleControlPointerLeave}
/>
)}
</pixiContainer>
</pixiContainer>
);
}
);
// =========================================================================
// Главный экспортируемый компонент: Станция
// =========================================================================
export const Station = ({
station,
ruLabel,
labelAlign,
onLabelAlignChange,
}: Readonly<StationProps>) => {
const [isTextHovered, setIsTextHovered] = useState(false);
const draw = useCallback(
(g: Graphics) => {
g.clear();
const coordinates = coordinatesToLocal(
station.latitude,
station.longitude
);
const radius = STATION_RADIUS;
const strokeWidth = STATION_OUTLINE_WIDTH;
g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius);
// Change fill color when text is hovered
if (isTextHovered) {
g.fill({ color: 0x00aaff }); // Highlight color when hovered
g.stroke({ color: 0xffffff, width: strokeWidth + 1 }); // Brighter outline when hovered
} else {
g.fill({ color: PATH_COLOR });
g.stroke({ color: BACKGROUND_COLOR, width: strokeWidth });
}
},
[station.latitude, station.longitude, isTextHovered]
);
return (
<pixiContainer zIndex={isTextHovered ? 1000 : 0}>
<pixiGraphics draw={draw} />
<StationLabel
station={station}
ruLabel={ruLabel}
labelAlign={labelAlign}
onLabelAlignChange={onLabelAlignChange}
onTextHover={setIsTextHovered}
/>
</pixiContainer>
);
};

View File

@ -0,0 +1,247 @@
import {
createContext,
ReactNode,
useContext,
useMemo,
useState,
useCallback,
} from "react";
import { SCALE_FACTOR, UP_SCALE } from "./Constants";
const TransformContext = createContext<{
position: { x: number; y: number };
scale: number;
rotation: number;
screenCenter?: { x: number; y: number };
setPosition: React.Dispatch<React.SetStateAction<{ x: number; y: number }>>;
setScale: React.Dispatch<React.SetStateAction<number>>;
setRotation: React.Dispatch<React.SetStateAction<number>>;
screenToLocal: (x: number, y: number) => { x: number; y: number };
localToScreen: (x: number, y: number) => { x: number; y: number };
rotateToAngle: (to: number, fromPosition?: { x: number; y: number }) => void;
setTransform: (
latitude: number,
longitude: number,
rotationDegrees?: number,
scale?: number
) => void;
setScaleOnly: (newScale: number) => void;
setScaleWithoutMovingCenter: (newScale: number) => void;
setScreenCenter: React.Dispatch<
React.SetStateAction<{ x: number; y: number } | undefined>
>;
setScaleAtCenter: (newScale: number) => void;
}>({
position: { x: 0, y: 0 },
scale: 1,
rotation: 0,
screenCenter: undefined,
setPosition: () => {},
setScale: () => {},
setRotation: () => {},
screenToLocal: () => ({ x: 0, y: 0 }),
localToScreen: () => ({ x: 0, y: 0 }),
rotateToAngle: () => {},
setTransform: () => {},
setScaleOnly: () => {},
setScaleWithoutMovingCenter: () => {},
setScreenCenter: () => {},
setScaleAtCenter: () => {},
});
// Provider component
export const TransformProvider = ({ children }: { children: ReactNode }) => {
const [position, setPosition] = useState({ x: 0, y: 0 });
const [scale, setScale] = useState(1);
const [rotation, setRotation] = useState(0);
const [screenCenter, setScreenCenter] = useState<{ x: number; y: number }>();
const screenToLocal = useCallback(
(screenX: number, screenY: number) => {
// Translate point relative to current pan position
const translatedX = (screenX - position.x) / scale;
const translatedY = (screenY - position.y) / scale;
// Rotate point around center
const cosRotation = Math.cos(-rotation); // Negative rotation to reverse transform
const sinRotation = Math.sin(-rotation);
const rotatedX = translatedX * cosRotation - translatedY * sinRotation;
const rotatedY = translatedX * sinRotation + translatedY * cosRotation;
return {
x: rotatedX / UP_SCALE,
y: rotatedY / UP_SCALE,
};
},
[position.x, position.y, scale, rotation]
);
// Inverse of screenToLocal
const localToScreen = useCallback(
(localX: number, localY: number) => {
const upscaledX = localX * UP_SCALE;
const upscaledY = localY * UP_SCALE;
const cosRotation = Math.cos(rotation);
const sinRotation = Math.sin(rotation);
const rotatedX = upscaledX * cosRotation - upscaledY * sinRotation;
const rotatedY = upscaledX * sinRotation + upscaledY * cosRotation;
const translatedX = rotatedX * scale + position.x;
const translatedY = rotatedY * scale + position.y;
return {
x: translatedX,
y: translatedY,
};
},
[position.x, position.y, scale, rotation]
);
const rotateToAngle = useCallback(
(to: number, fromPosition?: { x: number; y: number }) => {
const rotationDiff = to - rotation;
const center = screenCenter ?? { x: 0, y: 0 };
const cosDelta = Math.cos(rotationDiff);
const sinDelta = Math.sin(rotationDiff);
const currentFromPosition = fromPosition ?? position;
const newPosition = {
x:
center.x * (1 - cosDelta) +
currentFromPosition.x * cosDelta +
(center.y - currentFromPosition.y) * sinDelta,
y:
center.y * (1 - cosDelta) +
currentFromPosition.y * cosDelta +
(currentFromPosition.x - center.x) * sinDelta,
};
// Update both rotation and position in a single batch to avoid stale closure
setRotation(to);
setPosition(newPosition);
},
[rotation, position, screenCenter]
);
const setTransform = useCallback(
(
latitude: number,
longitude: number,
rotationDegrees?: number,
useScale?: number
) => {
const selectedRotation =
rotationDegrees !== undefined
? (rotationDegrees * Math.PI) / 180
: rotation;
const selectedScale =
useScale !== undefined ? useScale / SCALE_FACTOR : scale;
const center = screenCenter ?? { x: 0, y: 0 };
const newPosition = {
x: -latitude * UP_SCALE * selectedScale,
y: -longitude * UP_SCALE * selectedScale,
};
const cosRot = Math.cos(selectedRotation);
const sinRot = Math.sin(selectedRotation);
// Translate point relative to center, rotate, then translate back
const dx = newPosition.x;
const dy = newPosition.y;
newPosition.x = dx * cosRot - dy * sinRot + center.x;
newPosition.y = dx * sinRot + dy * cosRot + center.y;
// Batch state updates to avoid intermediate renders
setPosition(newPosition);
setRotation(selectedRotation);
setScale(selectedScale);
},
[rotation, scale, screenCenter]
);
const setScaleAtCenter = useCallback(
(newScale: number) => {
if (scale === newScale) return;
const center = screenCenter ?? { x: 0, y: 0 };
const actualZoomFactor = newScale / scale;
const newPosition = {
x: position.x + (center.x - position.x) * (1 - actualZoomFactor),
y: position.y + (center.y - position.y) * (1 - actualZoomFactor),
};
setPosition(newPosition);
setScale(newScale);
},
[position, scale, screenCenter]
);
const setScaleOnly = useCallback((newScale: number) => {
// Изменяем только масштаб, не трогая позицию и поворот
setScale(newScale);
}, []);
const setScaleWithoutMovingCenter = useCallback(
(newScale: number) => {
setScale(newScale);
},
[setScale]
);
const value = useMemo(
() => ({
position,
scale,
rotation,
screenCenter,
setPosition,
setScale,
setRotation,
rotateToAngle,
screenToLocal,
localToScreen,
setTransform,
setScaleOnly,
setScaleWithoutMovingCenter,
setScreenCenter,
setScaleAtCenter,
}),
[
position,
scale,
rotation,
screenCenter,
setScale,
rotateToAngle,
screenToLocal,
localToScreen,
setTransform,
setScaleOnly,
setScaleWithoutMovingCenter,
setScreenCenter,
setScaleAtCenter,
]
);
return (
<TransformContext.Provider value={value}>
{children}
</TransformContext.Provider>
);
};
// Custom hook for easy access to transform values
export const useTransform = () => {
const context = useContext(TransformContext);
if (!context) {
throw new Error("useTransform must be used within a TransformProvider");
}
return context;
};

View File

@ -0,0 +1,34 @@
import { Graphics } from "pixi.js";
import { useCallback } from "react";
import { PATH_COLOR, PATH_WIDTH } from "./Constants";
import { coordinatesToLocal } from "./utils";
interface TravelPathProps {
points: { x: number; y: number }[];
}
export function TravelPath({ points }: Readonly<TravelPathProps>) {
const draw = useCallback(
(g: Graphics) => {
g.clear();
const coordStart = coordinatesToLocal(points[0].x, points[0].y);
g.moveTo(coordStart.x, coordStart.y);
for (let i = 1; i <= points.length - 1; i++) {
const coordinates = coordinatesToLocal(points[i].x, points[i].y);
g.lineTo(coordinates.x, coordinates.y);
}
g.stroke({
color: PATH_COLOR,
width: PATH_WIDTH,
});
},
[points]
);
if (points.length === 0) {
console.error("points is empty");
return null;
}
return <pixiGraphics draw={draw} />;
}

View File

@ -0,0 +1,140 @@
import { Stack, Typography, Box, IconButton } from "@mui/material";
import { Close } from "@mui/icons-material";
import { Landmark } from "lucide-react";
import { useMapData } from "./MapDataContext";
export function Widgets() {
const { selectedSight, setSelectedSight } = useMapData();
return (
<Stack
direction="column"
spacing={2}
position="absolute"
top={32}
left={32}
sx={{ pointerEvents: "none" }}
>
<Stack
bgcolor="primary.main"
width={361}
height={96}
p={2}
m={2}
borderRadius={2}
alignItems="center"
justifyContent="center"
>
<Typography variant="h6" sx={{ color: "#fff" }}>
Станция
</Typography>
</Stack>
{/* Виджет выбранной достопримечательности (заменяет виджет погоды) */}
<Stack
bgcolor="primary.main"
width={223}
height={262}
p={2}
m={2}
borderRadius={2}
sx={{
pointerEvents: "auto",
position: "relative",
overflow: "hidden",
}}
>
{selectedSight ? (
<Box
sx={{ height: "100%", display: "flex", flexDirection: "column" }}
>
{/* Заголовок с кнопкой закрытия */}
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
mb: 1,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<Landmark size={16} />
<Typography
variant="subtitle2"
sx={{ color: "#fff", fontWeight: "bold" }}
>
{selectedSight.name}
</Typography>
</Box>
<IconButton
size="small"
onClick={() => setSelectedSight(undefined)}
sx={{
color: "#fff",
p: 0,
minWidth: 20,
width: 20,
height: 20,
"&:hover": { backgroundColor: "rgba(255, 255, 255, 0.1)" },
}}
>
<Close fontSize="small" />
</IconButton>
</Box>
{/* Описание достопримечательности */}
{selectedSight.address && (
<Typography
variant="caption"
sx={{
color: "#fff",
mb: 1,
opacity: 0.9,
lineHeight: 1.3,
overflow: "hidden",
textOverflow: "ellipsis",
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
}}
>
{selectedSight.address}
</Typography>
)}
{/* Город */}
{selectedSight.city && (
<Typography
variant="caption"
sx={{
color: "#fff",
opacity: 0.7,
mt: "auto",
}}
>
Город: {selectedSight.city}
</Typography>
)}
</Box>
) : (
<Box
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 5,
justifyContent: "center",
textAlign: "center",
}}
>
<Landmark size={32} />
<Typography variant="body2" sx={{ color: "#fff", opacity: 0.8 }}>
Выберите достопримечательность
</Typography>
</Box>
)}
</Stack>
</Stack>
);
}

View File

@ -0,0 +1,175 @@
import { useRef, useEffect, useState } from "react";
import { Widgets } from "./Widgets";
import { Application, extend } from "@pixi/react";
import {
Container,
Graphics,
Sprite,
Texture,
TilingSprite,
Text,
} from "pixi.js";
import { Stack } from "@mui/material";
import { MapDataProvider, useMapData } from "./MapDataContext";
import { TransformProvider, useTransform } from "./TransformContext";
import { InfiniteCanvas } from "./InfiniteCanvas";
import { TravelPath } from "./TravelPath";
import { LeftSidebar } from "./LeftSidebar";
import { RightSidebar } from "./RightSidebar";
import { coordinatesToLocal } from "./utils";
import { LanguageSwitcher } from "@widgets";
import { languageStore } from "@shared";
import { observer } from "mobx-react-lite";
import { Sight } from "./Sight";
import { SightData } from "./types";
import { Station } from "./Station";
import { UP_SCALE } from "./Constants";
extend({
Container,
Graphics,
Sprite,
Texture,
TilingSprite,
Text,
});
export const RoutePreview = () => {
return (
<MapDataProvider>
<TransformProvider>
<Stack direction="row" height="100vh" width="100vw" overflow="hidden">
<LanguageSwitcher />
<LeftSidebar />
<Stack direction="row" flex={1} position="relative" height="100%">
<RouteMap />
<Widgets />
<RightSidebar />
</Stack>
</Stack>
</TransformProvider>
</MapDataProvider>
);
};
export const RouteMap = observer(() => {
const { language } = languageStore;
const { setPosition, setTransform, screenCenter } = useTransform();
const {
routeData,
stationData,
sightData,
originalRouteData,
originalSightData,
} = useMapData();
const [points, setPoints] = useState<{ x: number; y: number }[]>([]);
const [isSetup, setIsSetup] = useState(false);
const parentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "auto";
};
}, []);
useEffect(() => {
if (originalRouteData) {
const path = originalRouteData?.path;
const points =
path?.map(([x, y]: [number, number]) => ({
x: x * UP_SCALE,
y: y * UP_SCALE,
})) ?? [];
setPoints(points);
}
}, [originalRouteData]);
useEffect(() => {
if (isSetup || !screenCenter) {
return;
}
if (
originalRouteData?.center_latitude ===
originalRouteData?.center_longitude &&
originalRouteData?.center_latitude === 0
) {
if (points.length > 0) {
let boundingBox = {
from: { x: Infinity, y: Infinity },
to: { x: -Infinity, y: -Infinity },
};
for (const point of points) {
boundingBox.from.x = Math.min(boundingBox.from.x, point.x);
boundingBox.from.y = Math.min(boundingBox.from.y, point.y);
boundingBox.to.x = Math.max(boundingBox.to.x, point.x);
boundingBox.to.y = Math.max(boundingBox.to.y, point.y);
}
const newCenter = {
x: -(boundingBox.from.x + boundingBox.to.x) / 2,
y: -(boundingBox.from.y + boundingBox.to.y) / 2,
};
setPosition(newCenter);
setIsSetup(true);
}
} else if (
originalRouteData?.center_latitude &&
originalRouteData?.center_longitude
) {
const coordinates = coordinatesToLocal(
originalRouteData?.center_latitude,
originalRouteData?.center_longitude
);
setTransform(
coordinates.x,
coordinates.y,
originalRouteData?.rotate,
originalRouteData?.scale_min
);
setIsSetup(true);
}
}, [
points,
originalRouteData?.center_latitude,
originalRouteData?.center_longitude,
originalRouteData?.rotate,
isSetup,
screenCenter,
]);
if (!routeData || !stationData || !sightData) {
console.error("routeData, stationData or sightData is null");
return <div>Loading...</div>;
}
return (
<div style={{ width: "100%", height: "100%" }} ref={parentRef}>
<Application resizeTo={parentRef} background="#fff">
<InfiniteCanvas>
<TravelPath points={points} />
{stationData[language].map((obj, index) => (
<Station
station={obj}
key={obj.id}
ruLabel={
language === "ru"
? stationData.ru[index].name
: stationData.ru[index].name
}
/>
))}
{originalSightData?.map((sight: SightData, index: number) => {
return <Sight sight={sight} id={index} key={sight.id} />;
})}
</InfiniteCanvas>
</Application>
</div>
);
});

View File

@ -0,0 +1,72 @@
export interface RouteData {
carrier: string;
carrier_id: number;
center_latitude: number;
center_longitude: number;
governor_appeal: number;
id: number;
path: [number, number][];
rotate: number;
route_direction: boolean;
route_number: string;
route_sys_number: string;
scale_max: number;
scale_min: number;
thumbnail?: string; // uuid логотипа маршрута
}
export interface StationTransferData {
bus: string;
metro_blue: string;
metro_green: string;
metro_orange: string;
metro_purple: string;
metro_red: string;
train: string;
tram: string;
trolleybus: string;
}
export interface StationData {
address: string;
city_id?: number;
description: string;
id: number;
latitude: number;
longitude: number;
name: string;
offset_x: number;
offset_y: number;
system_name: string;
transfers: StationTransferData;
align: number;
}
export interface StationPatchData {
station_id: number;
offset_x: number;
offset_y: number;
align: number;
transfers: StationTransferData;
}
export interface SightPatchData {
sight_id: number;
latitude: number;
longitude: number;
}
export interface SightData {
address: string;
city: string;
city_id: number;
id: number;
latitude: number;
left_article: number;
longitude: number;
name: string;
preview_media: number;
thumbnail: string; // uuid
watermark_lu: string; // uuid
watermark_rd: string; // uuid
}

View File

@ -0,0 +1,14 @@
// approximation
export function coordinatesToLocal(latitude: number, longitude: number) {
return {
x: longitude,
y: -latitude*2,
}
}
export function localToCoordinates(x: number, y: number) {
return {
longitude: x,
latitude: -y/2,
}
}

View File

@ -0,0 +1,178 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { languageStore, sightsStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const SightListPage = observer(() => {
const { sights, getSights, deleteListSight } = sightsStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
useEffect(() => {
const fetchSights = async () => {
setIsLoading(true);
await getSights();
setIsLoading(false);
};
fetchSights();
}, [language]);
const columns: GridColDef[] = [
{
field: "name",
headerName: "Имя",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "city",
headerName: "Город",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "actions",
headerName: "Действия",
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/sight/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
{/* <button onClick={() => navigate(`/sight/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button> */}
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = sights.map((sight) => ({
id: sight.id,
name: sight.name,
city: sight.city,
}));
return (
<>
<LanguageSwitcher />
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Достопримечательности</h1>
<CreateButton
label="Создать достопримечательность"
path="/sight/create"
/>
</div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooter
checkboxSelection
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => {
console.log(newSelection);
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? (
<CircularProgress size={20} />
) : (
"Нет достопримечательностей"
)}
</Box>
),
}}
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
await deleteListSight(Number(rowId));
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteListSight(id)));
getSights();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</>
);
});

1
src/pages/Sight/index.ts Normal file
View File

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

View File

@ -0,0 +1,69 @@
import { Button, Paper, 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 { toast } from "react-toastify";
export const SnapshotCreatePage = observer(() => {
const { id } = useParams();
const { getSnapshot, 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)}
/>
<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>
</div>
</Paper>
);
});

View File

@ -0,0 +1,134 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { languageStore, snapshotStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { DatabaseBackup, Trash2 } from "lucide-react";
import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const SnapshotListPage = observer(() => {
const { snapshots, getSnapshots, deleteSnapshot, restoreSnapshot } =
snapshotStore;
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
const { language } = languageStore;
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const fetchSnapshots = async () => {
setIsLoading(true);
await getSnapshots();
setIsLoading(false);
};
fetchSnapshots();
}, [language]);
const columns: GridColDef[] = [
{
field: "name",
headerName: "Название",
flex: 1,
},
{
field: "parent",
headerName: "Родитель",
flex: 1,
},
{
field: "actions",
headerName: "Действия",
width: 300,
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button
onClick={() => {
setIsRestoreModalOpen(true);
setRowId(params.row.id);
}}
>
<DatabaseBackup size={20} className="text-blue-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = snapshots.map((snapshot) => ({
id: snapshot.ID,
name: snapshot.Name,
parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name,
}));
return (
<>
<div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl ">Снапшоты</h1>
<CreateButton label="Создать снапшот" path="/snapshot/create" />
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет снапшотов"}
</Box>
),
}}
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
await deleteSnapshot(rowId);
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
<SnapshotRestore
open={isRestoreModalOpen}
onDelete={async () => {
if (rowId) {
await restoreSnapshot(rowId);
}
setIsRestoreModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsRestoreModalOpen(false);
setRowId(null);
}}
/>
</>
);
});

View File

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

View File

@ -0,0 +1,317 @@
import { useState, useEffect } from "react";
import {
Stack,
Typography,
Button,
Accordion,
AccordionSummary,
AccordionDetails,
useTheme,
TextField,
Autocomplete,
TableCell,
TableContainer,
Table,
TableHead,
TableRow,
Paper,
TableBody,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { authInstance, languageStore } from "@shared";
type Field<T> = {
label: string;
data: keyof T;
render?: (value: any) => React.ReactNode;
};
type LinkedSightsProps<T> = {
parentId: string | number;
fields: Field<T>[];
setItemsParent?: (items: T[]) => void;
type: "show" | "edit";
onUpdate?: () => void;
disableCreation?: boolean;
updatedLinkedItems?: T[];
refresh?: number;
};
export const LinkedSights = <
T extends { id: number; name: string; [key: string]: any }
>(
props: LinkedSightsProps<T>
) => {
const theme = useTheme();
return (
<>
<Accordion sx={{ width: "100%" }}>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
background: theme.palette.background.paper,
borderBottom: `1px solid ${theme.palette.divider}`,
width: "100%",
}}
>
<Typography variant="subtitle1" fontWeight="bold">
Привязанные достопримечательности
</Typography>
</AccordionSummary>
<AccordionDetails
sx={{ background: theme.palette.background.paper, width: "100%" }}
>
<Stack gap={2} width="100%">
<LinkedSightsContents {...props} />
</Stack>
</AccordionDetails>
</Accordion>
</>
);
};
export const LinkedSightsContents = <
T extends { id: number; name: string; [key: string]: any }
>({
parentId,
setItemsParent,
fields,
type,
onUpdate,
disableCreation = false,
updatedLinkedItems,
refresh,
}: LinkedSightsProps<T>) => {
const { language } = languageStore;
const [allItems, setAllItems] = useState<T[]>([]);
const [linkedItems, setLinkedItems] = useState<T[]>([]);
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
console.log(error);
}, [error]);
const parentResource = "station";
const childResource = "sight";
const availableItems = allItems
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
.sort((a, b) => a.name.localeCompare(b.name));
useEffect(() => {
if (updatedLinkedItems) {
setLinkedItems(updatedLinkedItems);
}
}, [updatedLinkedItems]);
useEffect(() => {
setItemsParent?.(linkedItems);
}, [linkedItems, setItemsParent]);
const linkItem = () => {
if (selectedItemId !== null) {
setError(null);
const requestData = {
sight_id: selectedItemId,
};
authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
.then(() => {
const newItem = allItems.find((item) => item.id === selectedItemId);
if (newItem) {
setLinkedItems([...linkedItems, newItem]);
}
setSelectedItemId(null);
onUpdate?.();
})
.catch((error) => {
console.error("Error linking sight:", error);
setError("Failed to link sight");
});
}
};
const deleteItem = (itemId: number) => {
setError(null);
authInstance
.delete(`/${parentResource}/${parentId}/${childResource}`, {
data: { [`${childResource}_id`]: itemId },
})
.then(() => {
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
onUpdate?.();
})
.catch((error) => {
console.error("Error deleting sight:", error);
setError("Failed to delete sight");
});
};
useEffect(() => {
if (parentId) {
setIsLoading(true);
setError(null);
authInstance
.get(`/${parentResource}/${parentId}/${childResource}`)
.then((response) => {
setLinkedItems(response?.data || []);
})
.catch((error) => {
console.error("Error fetching linked sights:", error);
setError("Failed to load linked sights");
setLinkedItems([]);
})
.finally(() => {
setIsLoading(false);
});
}
}, [parentId, language, refresh]);
useEffect(() => {
if (type === "edit") {
setError(null);
authInstance
.get(`/${childResource}/`)
.then((response) => {
setAllItems(response?.data || []);
})
.catch((error) => {
console.error("Error fetching all sights:", error);
setError("Failed to load available sights");
setAllItems([]);
});
}
}, [type]);
return (
<>
{linkedItems?.length > 0 && (
<TableContainer component={Paper} sx={{ width: "100%" }}>
<Table sx={{ width: "100%" }}>
<TableHead>
<TableRow>
<TableCell key="id" width="60px">
</TableCell>
{fields.map((field) => (
<TableCell key={String(field.data)}>{field.label}</TableCell>
))}
{type === "edit" && (
<TableCell width="120px">Действие</TableCell>
)}
</TableRow>
</TableHead>
<TableBody>
{linkedItems.map((item, index) => (
<TableRow key={item.id} hover>
<TableCell>{index + 1}</TableCell>
{fields.map((field, idx) => (
<TableCell key={String(field.data) + String(idx)}>
{field.render
? field.render(item[field.data])
: item[field.data]}
</TableCell>
))}
{type === "edit" && (
<TableCell>
<Button
variant="outlined"
color="error"
size="small"
onClick={(e) => {
e.stopPropagation();
deleteItem(item.id);
}}
>
Отвязать
</Button>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
{linkedItems.length === 0 && !isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
Достопримечательности не найдены
</Typography>
)}
{type === "edit" && !disableCreation && (
<Stack gap={2} mt={2}>
<Typography variant="subtitle1">
Добавить достопримечательность
</Typography>
<Autocomplete
fullWidth
value={
availableItems?.find((item) => item.id === selectedItemId) || null
}
onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)}
options={availableItems}
getOptionLabel={(item) => String(item.name)}
renderInput={(params) => (
<TextField
{...params}
label="Выберите достопримечательность"
fullWidth
/>
)}
isOptionEqualToValue={(option, value) => option.id === value?.id}
filterOptions={(options, { inputValue }) => {
const searchWords = inputValue
.toLowerCase()
.split(" ")
.filter(Boolean);
return options.filter((option) => {
const optionWords = String(option.name)
.toLowerCase()
.split(" ");
return searchWords.every((searchWord) =>
optionWords.some((word) => word.startsWith(searchWord))
);
});
}}
renderOption={(props, option) => (
<li {...props} key={option.id}>
{String(option.name)}
</li>
)}
/>
<Button
variant="contained"
onClick={linkItem}
disabled={!selectedItemId}
sx={{ alignSelf: "flex-start" }}
>
Добавить
</Button>
</Stack>
)}
{isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
Загрузка...
</Typography>
)}
{error && (
<Typography color="error" textAlign="center" py={2}>
{error}
</Typography>
)}
</>
);
};

View File

@ -0,0 +1,206 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
} from "@mui/material";
import { observer } from "mobx-react-lite";
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 { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets";
export const StationCreatePage = observer(() => {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
const {
createStationData,
setCreateCommonData,
createStation,
setLanguageCreateStationData,
} = stationsStore;
const { cities, getCities } = cityStore;
const [coordinates, setCoordinates] = useState<string>("");
useEffect(() => {
if (
createStationData.common.latitude !== 0 ||
createStationData.common.longitude !== 0
) {
setCoordinates(
`${createStationData.common.latitude}, ${createStationData.common.longitude}`
);
}
}, [createStationData.common.latitude, createStationData.common.longitude]);
const handleCreate = async () => {
try {
setIsLoading(true);
await createStation();
toast.success("Остановка успешно создана");
navigate("/station");
} catch (error) {
console.error("Error creating station:", error);
toast.error("Ошибка при создании станции");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
const fetchCities = async () => {
await getCities("ru");
await getCities("en");
await getCities("zh");
};
fetchCities();
}, []);
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"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
<h1 className="text-3xl break-words">Создание остановки</h1>
</div>
<TextField
fullWidth
label="Название"
value={createStationData[language].name || ""}
required
onChange={(e) =>
setLanguageCreateStationData(language, {
name: e.target.value,
})
}
/>
<FormControl fullWidth>
<InputLabel id="direction-label">Прямой/обратный маршрут</InputLabel>
<Select
labelId="direction-label"
value={createStationData.common.direction ? "Прямой" : "Обратный"}
label="Прямой/обратный маршрут"
onChange={(e) =>
setCreateCommonData({
direction: e.target.value === "Прямой",
})
}
>
<MenuItem value="Прямой">Прямой</MenuItem>
<MenuItem value="Обратный">Обратный</MenuItem>
</Select>
</FormControl>
<TextField
fullWidth
label="Описание"
value={createStationData.common.description || ""}
onChange={(e) =>
setCreateCommonData({
description: e.target.value,
})
}
/>
{/* <TextField
fullWidth
label="Адрес"
value={createStationData[language].address || ""}
onChange={(e) =>
setLanguageCreateStationData(language, {
address: e.target.value,
})
}
/> */}
<TextField
fullWidth
label="Координаты"
value={coordinates}
onChange={(e) => {
const newValue = e.target.value;
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);
if (isValidLat && isValidLon) {
setCreateCommonData({
latitude: lat,
longitude: lon,
});
} else {
setCreateCommonData({
latitude: 0,
longitude: 0,
});
}
}}
placeholder="Введите координаты в формате: широта долгота (можно использовать запятые или пробелы)"
/>
<FormControl fullWidth>
<InputLabel>Город</InputLabel>
<Select
value={createStationData.common.city_id || ""}
label="Город"
onChange={(e) => {
const selectedCity = cities["ru"].data.find(
(city) => city.id === e.target.value
);
setCreateCommonData({
city_id: e.target.value as number,
city: selectedCity?.name || "",
});
}}
>
{cities["ru"].data.map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="contained"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={isLoading || !createStationData[language]?.name}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Создать"
)}
</Button>
</div>
</Paper>
);
});

View File

@ -0,0 +1,225 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import { stationsStore, languageStore, cityStore } from "@shared";
import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets";
import { LinkedSights } from "../LinkedSights";
export const StationEditPage = observer(() => {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
const { id } = useParams();
const {
editStationData,
getEditStation,
setEditCommonData,
editStation,
setLanguageEditStationData,
} = stationsStore;
const { cities, getCities } = cityStore;
const [coordinates, setCoordinates] = useState<string>("");
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
useEffect(() => {
if (
editStationData.common.latitude !== 0 ||
editStationData.common.longitude !== 0
) {
setCoordinates(
`${editStationData.common.latitude}, ${editStationData.common.longitude}`
);
}
}, [editStationData.common.latitude, editStationData.common.longitude]);
const handleEdit = async () => {
try {
setIsLoading(true);
await editStation(Number(id));
toast.success("Остановка успешно обновлена");
} catch (error) {
console.error("Error updating station:", error);
toast.error("Ошибка при обновлении станции");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
const fetchAndSetStationData = async () => {
if (!id) return;
const stationId = Number(id);
await getEditStation(stationId);
await getCities("ru");
await getCities("en");
await getCities("zh");
};
fetchAndSetStationData();
}, [id]);
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"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
<h1 className="text-3xl break-words">{editStationData.ru.name}</h1>
</div>
<TextField
fullWidth
label="Название"
value={editStationData[language].name || ""}
required
onChange={(e) =>
setLanguageEditStationData(language, {
name: e.target.value,
})
}
/>
<FormControl fullWidth>
<InputLabel id="direction-label">Прямой/обратный маршрут</InputLabel>
<Select
labelId="direction-label"
value={editStationData.common.direction ? "Прямой" : "Обратный"}
label="Прямой/обратный маршрут"
onChange={(e) =>
setEditCommonData({
direction: e.target.value === "Прямой",
})
}
>
<MenuItem value="Прямой">Прямой</MenuItem>
<MenuItem value="Обратный">Обратный</MenuItem>
</Select>
</FormControl>
<TextField
fullWidth
label="Описание"
value={editStationData.common.description || ""}
onChange={(e) =>
setEditCommonData({
description: e.target.value,
})
}
/>
{/* <TextField
fullWidth
label="Адрес"
value={editStationData[language].address || ""}
onChange={(e) =>
setLanguageEditStationData(language, {
address: e.target.value,
})
}
/> */}
<TextField
fullWidth
label="Координаты"
value={coordinates}
onChange={(e) => {
const newValue = e.target.value;
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);
if (isValidLat && isValidLon) {
setEditCommonData({
latitude: lat,
longitude: lon,
});
} else {
setEditCommonData({
latitude: 0,
longitude: 0,
});
}
}}
placeholder="Введите координаты в формате: широта долгота (можно использовать запятые или пробелы)"
/>
<FormControl fullWidth>
<InputLabel>Город</InputLabel>
<Select
value={editStationData.common.city_id || ""}
label="Город"
onChange={(e) => {
const selectedCity = cities["ru"].data.find(
(city) => city.id === e.target.value
);
setEditCommonData({
city_id: e.target.value as number,
city: selectedCity?.name || "",
});
}}
>
{cities["ru"].data.map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>
))}
</Select>
</FormControl>
{id && (
<LinkedSights
parentId={Number(id)}
fields={[{ label: "Название", data: "name" }]}
type="edit"
/>
)}
<Button
variant="contained"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleEdit}
disabled={isLoading || !editStationData[language]?.name}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Сохранить"
)}
</Button>
</div>
</Paper>
);
});

View File

@ -0,0 +1,192 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { languageStore, stationsStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const StationListPage = observer(() => {
const { stationLists, getStationList, deleteStation } = stationsStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
useEffect(() => {
const fetchStations = async () => {
setIsLoading(true);
await getStationList();
setIsLoading(false);
};
fetchStations();
}, [language]);
const columns: GridColDef[] = [
{
field: "name",
headerName: "Название",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "system_name",
headerName: "Системное название",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "direction",
headerName: "Направление",
width: 200,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<p
className={
params.row.direction === true ? "text-green-500" : "text-red-500"
}
>
{params.row.direction ? "Прямой" : "Обратный"}
</p>
);
},
},
{
field: "actions",
headerName: "Действия",
width: 140,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/station/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
<button onClick={() => navigate(`/station/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = stationLists[language].data.map((station: any) => ({
id: station.id,
name: station.name,
system_name: station.system_name,
direction: station.direction,
}));
return (
<>
<LanguageSwitcher />
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Станции</h1>
<CreateButton label="Создать остановки" path="/station/create" />
</div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
checkboxSelection
loading={isLoading}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет станций"}
</Box>
),
}}
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
deleteStation(rowId);
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteStation(id)));
getStationList();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</>
);
});

View File

@ -0,0 +1,89 @@
import { Paper } from "@mui/material";
import { languageStore, stationsStore } from "@shared";
import { LanguageSwitcher } from "@widgets";
import { observer } from "mobx-react-lite";
import { ArrowLeft } from "lucide-react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { LinkedSights } from "../LinkedSights";
export const StationPreviewPage = observer(() => {
const { id } = useParams();
const { stationPreview, getStationPreview } = stationsStore;
const navigate = useNavigate();
const { language } = languageStore;
useEffect(() => {
(async () => {
if (id) {
await getStationPreview(Number(id));
}
})();
}, [id, language]);
return (
<Paper className="w-full p-3 py-5 flex flex-col gap-10">
<LanguageSwitcher />
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Название</h1>
<p>{stationPreview[id!]?.[language]?.data.name}</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Системное название</h1>
<p>{stationPreview[id!]?.[language]?.data.system_name}</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Направление</h1>
<p
className={`${
stationPreview[id!]?.[language]?.data.direction
? "text-green-500"
: "text-red-500"
}`}
>
{stationPreview[id!]?.[language]?.data.direction
? "Прямой"
: "Обратный"}
</p>
</div>
{stationPreview[id!]?.[language]?.data.address && (
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Адрес</h1>
<p>{stationPreview[id!]?.[language]?.data.address}</p>
</div>
)}
{stationPreview[id!]?.[language]?.data.description && (
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Описание</h1>
<p>{stationPreview[id!]?.[language]?.data.description}</p>
</div>
)}
{id && (
<LinkedSights
parentId={Number(id)}
fields={[
{ label: "Название", data: "name" },
{ label: "Описание", data: "description" },
]}
type="show"
/>
)}
</div>
</Paper>
);
});

View File

@ -0,0 +1,5 @@
export * from "./StationListPage";
export * from "./StationCreatePage";
export * from "./StationPreviewPage";
export * from "./StationEditPage";
export * from "./LinkedSights";

View File

@ -0,0 +1,129 @@
import {
Button,
Paper,
TextField,
Checkbox,
FormControlLabel,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { userStore } from "@shared";
import { useState } from "react";
export const UserCreatePage = observer(() => {
const navigate = useNavigate();
const { createUserData, setCreateUserData, createUser } = userStore;
const [isLoading, setIsLoading] = useState(false);
const handleCreate = async () => {
try {
setIsLoading(true);
await createUser();
toast.success("Пользователь успешно создан");
navigate("/user");
} catch (error) {
toast.error("Ошибка при создании пользователя");
} finally {
setIsLoading(false);
}
};
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
fullWidth
label="Имя"
value={createUserData.name || ""}
required
onChange={(e) =>
setCreateUserData(
e.target.value,
createUserData.email || "",
createUserData.password || "",
createUserData.is_admin || false
)
}
/>
<TextField
fullWidth
label="Email"
value={createUserData.email || ""}
required
onChange={(e) =>
setCreateUserData(
createUserData.name || "",
e.target.value,
createUserData.password || "",
createUserData.is_admin || false
)
}
/>
<TextField
fullWidth
label="Пароль"
value={createUserData.password || ""}
required
onChange={(e) =>
setCreateUserData(
createUserData.name || "",
createUserData.email || "",
e.target.value,
createUserData.is_admin || false
)
}
/>
<div className="w-full flex flex-col items-start">
<FormControlLabel
control={
<Checkbox
checked={createUserData.is_admin || false}
onChange={(e) => {
setCreateUserData(
createUserData.name || "",
createUserData.email || "",
createUserData.password || "",
e.target.checked
);
}}
/>
}
label="Администратор"
/>
</div>
<Button
variant="contained"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={
isLoading || !createUserData.name || !createUserData.password
}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Создать"
)}
</Button>
</div>
</Paper>
);
});

View File

@ -0,0 +1,145 @@
import {
Button,
FormControlLabel,
Checkbox,
Paper,
TextField,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import { userStore, languageStore } from "@shared";
import { useEffect, useState } from "react";
export const UserEditPage = observer(() => {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const { id } = useParams();
const { editUserData, editUser, getUser, setEditUserData } = userStore;
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
const handleEdit = async () => {
try {
setIsLoading(true);
await editUser(Number(id));
toast.success("Пользователь успешно обновлен");
navigate("/user");
} catch (error) {
toast.error("Ошибка при обновлении пользователя");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
(async () => {
if (id) {
const data = await getUser(Number(id));
setEditUserData(
data?.name || "",
data?.email || "",
data?.password || "",
data?.is_admin || false
);
}
})();
}, [id]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-start">
<TextField
fullWidth
label="Имя"
value={editUserData.name || ""}
required
onChange={(e) =>
setEditUserData(
e.target.value,
editUserData.email || "",
editUserData.password || "",
editUserData.is_admin || false
)
}
/>
<TextField
fullWidth
label="Email"
value={editUserData.email || ""}
required
onChange={(e) =>
setEditUserData(
editUserData.name || "",
e.target.value,
editUserData.password || "",
editUserData.is_admin || false
)
}
/>
<TextField
fullWidth
label="Пароль"
value={editUserData.password || ""}
required
onChange={(e) =>
setEditUserData(
editUserData.name || "",
editUserData.email || "",
e.target.value,
editUserData.is_admin || false
)
}
/>
<FormControlLabel
control={
<Checkbox
checked={editUserData.is_admin || false}
onChange={(e) =>
setEditUserData(
editUserData.name || "",
editUserData.email || "",
editUserData.password || "",
e.target.checked
)
}
/>
}
label="Администратор"
/>
<Button
variant="contained"
className="w-min flex gap-2 items-center self-end"
startIcon={<Save size={20} />}
onClick={handleEdit}
disabled={isLoading || !editUserData.name || !editUserData.email}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Сохранить"
)}
</Button>
</div>
</Paper>
);
});

View File

@ -0,0 +1,195 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { userStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const UserListPage = observer(() => {
const { users, getUsers, deleteUser } = userStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const fetchUsers = async () => {
setIsLoading(true);
await getUsers();
setIsLoading(false);
};
fetchUsers();
}, []);
const columns: GridColDef[] = [
{
field: "name",
headerName: "Имя",
width: 400,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "email",
headerName: "Email",
width: 400,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "is_admin",
headerName: "Роль",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<p
className={
params.row.is_admin === true ? "text-green-500" : "text-red-500"
}
>
{params.row.is_admin ? "Администратор" : "Пользователь"}
</p>
);
},
},
{
field: "actions",
headerName: "Действия",
flex: 1,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button>
<Pencil
size={20}
className="text-blue-500"
onClick={() => {
navigate(`/user/${params.row.id}/edit`);
}}
/>
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = users.data?.map((user) => ({
id: user.id,
email: user.email,
is_admin: user.is_admin,
name: user.name,
}));
return (
<>
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Пользователи</h1>
<CreateButton label="Создать пользователя" path="/user/create" />
</div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
checkboxSelection
loading={isLoading}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? (
<CircularProgress size={20} />
) : (
"Нет пользователей"
)}
</Box>
),
}}
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
await deleteUser(rowId);
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteUser(id)));
getUsers();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</>
);
});

3
src/pages/User/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from "./UserListPage";
export * from "./UserCreatePage";
export * from "./UserEditPage";

View File

@ -0,0 +1,122 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
} from "@mui/material";
import {
vehicleStore,
VEHICLE_TYPES,
carrierStore,
languageStore,
} from "@shared";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
export const VehicleCreatePage = observer(() => {
const navigate = useNavigate();
const [tailNumber, setTailNumber] = useState("");
const [type, setType] = useState("");
const [carrierId, setCarrierId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
useEffect(() => {
carrierStore.getCarriers(language);
}, [language]);
const handleCreate = async () => {
try {
setIsLoading(true);
await vehicleStore.createVehicle(
Number(tailNumber),
Number(type),
carrierStore.carriers[language].data?.find((c) => c.id === carrierId)
?.full_name as string,
carrierId!
);
toast.success("Транспорт успешно создан");
} catch (error) {
toast.error("Ошибка при создании транспорта");
} finally {
setIsLoading(false);
}
};
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
fullWidth
label="Бортовой номер"
value={tailNumber}
required
onChange={(e) => setTailNumber(e.target.value)}
/>
<FormControl fullWidth>
<InputLabel>Тип</InputLabel>
<Select
value={type}
label="Тип"
required
onChange={(e) => setType(e.target.value)}
>
{VEHICLE_TYPES.map((type) => (
<MenuItem key={type.value} value={type.value}>
{type.label}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Перевозчик</InputLabel>
<Select
value={carrierId || ""}
label="Перевозчик"
required
onChange={(e) => setCarrierId(e.target.value as number)}
>
{carrierStore.carriers[language].data?.map((carrier) => (
<MenuItem key={carrier.id} value={carrier.id}>
{carrier.full_name}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="contained"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={isLoading || !tailNumber || !type || !carrierId}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Создать"
)}
</Button>
</div>
</Paper>
);
});

View File

@ -0,0 +1,152 @@
import {
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
Button,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { useEffect, useState } from "react";
import {
carrierStore,
languageStore,
VEHICLE_TYPES,
vehicleStore,
} from "@shared";
import { toast } from "react-toastify";
export const VehicleEditPage = observer(() => {
const navigate = useNavigate();
const { id } = useParams();
const {
getVehicle,
vehicle,
editVehicleData,
setEditVehicleData,
editVehicle,
} = vehicleStore;
const { getCarriers } = carrierStore;
const { language } = languageStore;
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
useEffect(() => {
(async () => {
await getVehicle(Number(id));
await getCarriers(language);
setEditVehicleData({
tail_number: vehicle[Number(id)]?.vehicle.tail_number,
type: vehicle[Number(id)]?.vehicle.type,
carrier: vehicle[Number(id)]?.vehicle.carrier,
carrier_id: vehicle[Number(id)]?.vehicle.carrier_id,
});
})();
}, [id, language]);
const [isLoading, setIsLoading] = useState(false);
const handleEdit = async () => {
try {
setIsLoading(true);
await editVehicle(Number(id), editVehicleData);
toast.success("Транспортное средство успешно обновлено");
navigate("/vehicle");
} catch (error) {
toast.error("Ошибка при обновлении транспортного средства");
} finally {
setIsLoading(false);
}
};
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
fullWidth
label="Бортовой номер"
value={editVehicleData.tail_number}
required
onChange={(e) =>
setEditVehicleData({
...editVehicleData,
tail_number: Number(e.target.value),
})
}
/>
<FormControl fullWidth>
<InputLabel>Тип</InputLabel>
<Select
value={editVehicleData.type}
label="Тип"
required
onChange={(e) =>
setEditVehicleData({ ...editVehicleData, type: e.target.value })
}
>
{VEHICLE_TYPES.map((type) => (
<MenuItem key={type.value} value={type.value}>
{type.label}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Перевозчик</InputLabel>
<Select
value={editVehicleData.carrier_id}
label="Перевозчик"
required
onChange={(e) =>
setEditVehicleData({
...editVehicleData,
carrier_id: e.target.value as number,
})
}
>
{carrierStore.carriers[language].data?.map((carrier) => (
<MenuItem key={carrier.id} value={carrier.id}>
{carrier.full_name}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="contained"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleEdit}
disabled={
isLoading ||
!editVehicleData.tail_number ||
!editVehicleData.type ||
!editVehicleData.carrier_id
}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Сохранить"
)}
</Button>
</div>
</Paper>
);
});

View File

@ -0,0 +1,217 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { carrierStore, languageStore, vehicleStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal } from "@widgets";
import { VEHICLE_TYPES } from "@shared";
import { Box, CircularProgress } from "@mui/material";
export const VehicleListPage = observer(() => {
const { vehicles, getVehicles, deleteVehicle } = vehicleStore;
const { carriers, getCarriers } = carrierStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore;
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
await getVehicles();
await getCarriers(language);
setIsLoading(false);
};
fetchData();
}, [language]);
const columns: GridColDef[] = [
{
field: "tail_number",
headerName: "Бортовой номер",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "type",
headerName: "Тип",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
VEHICLE_TYPES.find((type) => type.value === params.row.type)
?.label || params.row.type
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "carrier",
headerName: "Перевозчик",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "city",
headerName: "Город",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "actions",
headerName: "Действия",
width: 200,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/vehicle/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" />
</button>
<button onClick={() => navigate(`/vehicle/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = vehicles.data?.map((vehicle) => ({
id: vehicle.vehicle.id,
tail_number: vehicle.vehicle.tail_number,
type: vehicle.vehicle.type,
carrier: vehicle.vehicle.carrier,
city: carriers[language].data?.find(
(carrier) => carrier.id === vehicle.vehicle.carrier_id
)?.city,
}));
return (
<>
<div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Транспортные средства</h1>
<CreateButton
label="Создать транспортное средство"
path="/vehicle/create"
/>
</div>
<div
className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }}
>
<button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)}
>
<Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length})
</button>
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
checkboxSelection
loading={isLoading}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? (
<CircularProgress size={20} />
) : (
"Нет транспортных средств"
)}
</Box>
),
}}
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
await deleteVehicle(rowId);
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
<DeleteModal
open={isBulkDeleteModalOpen}
onDelete={async () => {
await Promise.all(ids.map((id) => deleteVehicle(id)));
getVehicles();
setIsBulkDeleteModalOpen(false);
setIds([]);
}}
onCancel={() => {
setIsBulkDeleteModalOpen(false);
}}
/>
</>
);
});

View File

@ -0,0 +1,70 @@
import { Paper } from "@mui/material";
import { vehicleStore, VEHICLE_TYPES } from "@shared";
import { observer } from "mobx-react-lite";
import { ArrowLeft } from "lucide-react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
export const VehiclePreviewPage = observer(() => {
const { id } = useParams();
const { getVehicle, vehicle } = vehicleStore;
const navigate = useNavigate();
useEffect(() => {
(async () => {
await getVehicle(Number(id));
})();
}, [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 className="flex gap-2">
<Button
variant="contained"
color="primary"
onClick={() => navigate(`/vehicle/${id}/edit`)}
startIcon={<Pencil size={20} />}
>
Редактировать
</Button>
<Button
variant="contained"
color="error"
onClick={() => navigate(`/vehicle/${id}/delete`)}
startIcon={<Trash2 size={20} />}
>
Удалить
</Button>
</div> */}
</div>
{vehicle[id!] && (
<div className="flex flex-col gap-10 w-full">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Системный номер</h1>
<p>{vehicle[id!]?.vehicle.tail_number}</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Тип транспортного средства</h1>
<p>
{VEHICLE_TYPES.find(
(type) => type.value === vehicle[id!]?.vehicle.type
)?.label || vehicle[id!]?.vehicle.type}
</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Перевозчик</h1>
<p>{vehicle[id!]?.vehicle.carrier}</p>
</div>
</div>
)}
</Paper>
);
});

View File

@ -0,0 +1,4 @@
export * from "./VehicleListPage";
export * from "./VehiclePreviewPage";
export * from "./VehicleCreatePage";
export * from "./VehicleEditPage";

View File

@ -5,7 +5,15 @@ export * from "./DevicesPage";
export * from "./SightPage";
export * from "./CreateSightPage";
export * from "./MapPage";
export * from "./MediaListPage";
export * from "./PreviewMediaPage";
export * from "./EditMediaPage";
export * from "./Media";
// export * from "./CreateMediaPage";
export * from "./Country";
export * from "./City";
export * from "./Route";
export * from "./User";
export * from "./Snapshot";
export * from "./Carrier";
export * from "./Station";
export * from "./Vehicle";
export * from "./Article";
export * from "./Sight";

View File

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

View File

@ -3,18 +3,31 @@ import {
Power,
LucideIcon,
Building2,
MonitorSmartphone,
Map,
BookImage,
Users,
Earth,
Landmark,
GitBranch,
// Car,
Table,
Split,
// Newspaper,
PersonStanding,
Cpu,
// BookImage,
} from "lucide-react";
import { CarrierSvg } from "./CarrierSvg";
export const DRAWER_WIDTH = 300;
interface NavigationItem {
id: string;
label: string;
icon: LucideIcon;
icon?: LucideIcon | React.ReactNode;
path?: string;
onClick?: () => void;
nestedItems?: NavigationItem[];
isActive?: boolean;
}
export const NAVIGATION_ITEMS: {
@ -23,10 +36,10 @@ export const NAVIGATION_ITEMS: {
} = {
primary: [
{
id: "attractions",
label: "Достопримечательности",
icon: Building2,
path: "/sight",
id: "snapshots",
label: "Снапшоты",
icon: GitBranch,
path: "/snapshot",
},
{
id: "map",
@ -37,21 +50,78 @@ export const NAVIGATION_ITEMS: {
{
id: "devices",
label: "Устройства",
icon: MonitorSmartphone,
icon: Cpu,
path: "/devices",
},
// {
// id: "vehicles",
// label: "Транспорт",
// icon: Car,
// path: "/vehicle",
// },
{
id: "media",
label: "Медиа",
icon: BookImage,
path: "/media",
id: "users",
label: "Пользователи",
icon: Users,
path: "/user",
},
{
id: "all",
label: "Справочник",
icon: Table,
nestedItems: [
// {
// id: "media",
// label: "Медиа",
// icon: BookImage,
// path: "/media",
// },
// {
// id: "articles",
// label: "Статьи",
// icon: Newspaper,
// path: "/articles",
// path: "/article",
// },
{
id: "attractions",
label: "Достопримечательности",
icon: Landmark,
path: "/sight",
},
{
id: "stations",
label: "Остановки",
icon: PersonStanding,
path: "/station",
},
{
id: "routes",
label: "Маршруты",
icon: Split,
path: "/route",
},
{
id: "countries",
label: "Страны",
icon: Earth,
path: "/country",
},
{
id: "cities",
label: "Города",
icon: Building2,
path: "/city",
},
{
id: "carriers",
label: "Перевозчики",
// @ts-ignore
icon: CarrierSvg,
path: "/carrier",
},
],
},
],
secondary: [
{
@ -65,3 +135,8 @@ export const NAVIGATION_ITEMS: {
},
],
};
export const VEHICLE_TYPES = [
{ label: "Трамвай", value: 1 },
{ label: "Троллейбус", value: 2 },
];

View File

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

View File

@ -1,2 +1,54 @@
export * from "./mui/theme";
export * from "./DecodeJWT";
/**
* Генерирует название медиа по умолчанию в разных форматах
*
* Примеры использования:
* - Для достопримечательности с названием: "Михайловский замок_mikhail-zamok_Фото"
* - Для достопримечательности без названия: "Название_mikhail-zamok_Фото"
* - Для статьи: "Михайловский замок_mikhail-zamok_Факты" (где "Факты" - название статьи)
*
* @param objectName - Название объекта (достопримечательности, города и т.д.)
* @param fileName - Название файла
* @param mediaType - Тип медиа (число) или название статьи
* @param isArticle - Флаг, указывающий что медиа добавляется к статье
* @returns Строка в нужном формате
*/
export const generateDefaultMediaName = (
objectName: string,
fileName: string,
mediaType: number | string,
isArticle: boolean = false
): string => {
// Убираем расширение из названия файла
const fileNameWithoutExtension = fileName.split(".").slice(0, -1).join(".");
if (isArticle && typeof mediaType === "string") {
// Для статей: "Название достопримечательности_название файлаазвание статьи"
return `${objectName}_${fileNameWithoutExtension}_${mediaType}`;
} else if (typeof mediaType === "number") {
// Получаем название типа медиа
const mediaTypeLabels: Record<number, string> = {
1: "Фото",
2: "Видео",
3: "Иконка",
4: "Водяной знак",
5: "Панорама",
6: "3Д-модель",
};
const mediaTypeLabel = mediaTypeLabels[mediaType] || "Медиа";
if (objectName && objectName.trim() !== "") {
// Если есть название объекта: "Название объектаазвание файла_тип медиа"
return `${objectName}_${fileNameWithoutExtension}_${mediaTypeLabel}`;
} else {
// Если нет названия объекта: "Названиеазвание файла_тип медиа"
return `Название_${fileNameWithoutExtension}_${mediaTypeLabel}`;
}
}
// Fallback
return `${objectName || "Название"}_${fileNameWithoutExtension}_Медиа`;
};

View File

@ -75,6 +75,7 @@ export const PreviewMediaDialog = observer(
setError(err instanceof Error ? err.message : "Failed to save media");
} finally {
setIsLoading(false);
onClose();
}
};
@ -96,7 +97,6 @@ export const PreviewMediaDialog = observer(
className="flex gap-4"
dividers
sx={{
height: "600px",
display: "flex",
flexDirection: "column",
gap: 2,
@ -120,7 +120,6 @@ export const PreviewMediaDialog = observer(
disabled={isLoading}
/>
</Box>
<TextField
fullWidth
label="Тип медиа"
@ -133,7 +132,7 @@ export const PreviewMediaDialog = observer(
sx={{ width: "50%" }}
/>
<Box className="flex gap-4 h-full">
<Box className="flex gap-4 h-[40vh]">
<Paper
elevation={2}
sx={{
@ -142,7 +141,6 @@ export const PreviewMediaDialog = observer(
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 400,
}}
>
<MediaViewer
@ -151,6 +149,8 @@ export const PreviewMediaDialog = observer(
media_type: media.media_type,
filename: media.filename,
}}
className="h-full w-full object-contain"
fullHeight
/>
</Paper>

View File

@ -17,7 +17,7 @@ import {
InputAdornment,
} from "@mui/material";
import { ImagePlus, Search } from "lucide-react";
import { ReactMarkdownComponent } from "@widgets";
import { MediaViewer, ReactMarkdownComponent } from "@widgets";
interface SelectArticleModalProps {
open: boolean;
@ -38,7 +38,9 @@ export const SelectArticleModal = observer(
onSelectArticle,
linkedArticleIds = [],
}: SelectArticleModalProps) => {
const { articles, getArticle, getArticleMedia } = articlesStore;
const { language } = languageStore;
const { articles, getArticle, getArticleMedia, getArticles } =
articlesStore;
const [searchQuery, setSearchQuery] = useState("");
const [selectedArticleId, setSelectedArticleId] = useState<number | null>(
null
@ -54,6 +56,21 @@ export const SelectArticleModal = observer(
}
}, [open]);
useEffect(() => {
const fetchData = async () => {
await getArticles("ru");
await getArticles("en");
await getArticles("zh");
};
fetchData();
}, []);
useEffect(() => {
if (selectedArticleId) {
handleArticleClick(selectedArticleId);
}
}, [language]);
useEffect(() => {
const handleKeyPress = async (event: KeyboardEvent) => {
if (event.key.toLowerCase() === "enter") {
@ -106,7 +123,6 @@ export const SelectArticleModal = observer(
// article.service_name.toLowerCase().includes(searchQuery.toLowerCase())
// );
const token = localStorage.getItem("token");
return (
<Dialog
open={open}
@ -128,10 +144,12 @@ export const SelectArticleModal = observer(
height: "600px",
display: "flex",
flexDirection: "row",
alignItems: "center",
p: 2,
}}
>
<Paper className="w-[66%] flex flex-col" elevation={2}>
<Paper className="w-[66%] flex flex-col h-full" elevation={2}>
<TextField
fullWidth
placeholder="Поиск статей..."
@ -201,109 +219,119 @@ export const SelectArticleModal = observer(
)}
</List>
</Paper>
<Paper className="flex-1 flex flex-col" elevation={2}>
<Box
className="rounded-2xl overflow-hidden"
<Box className="max-h-[80%] overflow-y-scroll mx-auto rounded-[10px]">
<Paper
elevation={3}
sx={{
width: "100%",
height: "100%",
background: "#877361",
borderColor: "grey.300",
minWidth: 320,
maxWidth: 310,
background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
padding: 0,
margin: "0px auto",
display: "flex",
flexDirection: "column",
}}
>
{isLoading ? (
<Box
sx={{
overflow: "hidden",
width: "100%",
height: "100%",
minHeight: 100,
padding: "3px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Typography color="white">Загрузка...</Typography>
</Box>
) : (
<>
{articlesStore.articleMedia && (
<Box sx={{ p: 2, backgroundColor: "rgba(0,0,0,0.1)" }}>
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
articlesStore.articleMedia.id
}/download?token=${token}`}
alt={articlesStore.articleMedia.filename}
style={{
maxWidth: "100%",
"& img": {
borderTopLeftRadius: "10px",
borderTopRightRadius: "10px",
width: "100%",
height: "auto",
maxHeight: "300px",
objectFit: "contain",
borderRadius: 8,
}}
/>
</Box>
)}
{!articlesStore.articleMedia && (
<Box
sx={{
width: "100%",
height: 200,
flexShrink: 0,
backgroundColor: "rgba(0,0,0,0.1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
},
}}
>
<ImagePlus size={48} color="white" />
</Box>
)}
<Box
sx={{
width: "100%",
minHeight: "70px",
background: "#877361",
display: "flex",
flexShrink: 0,
alignItems: "center",
borderBottom: "1px solid rgba(255,255,255,0.1)",
px: 2,
{articlesStore.articleMedia ? (
<MediaViewer
media={{
id: articlesStore.articleMedia.id,
media_type: articlesStore.articleMedia.media_type,
filename: articlesStore.articleMedia.filename,
}}
>
<Typography variant="h6" color="white">
{articlesStore.articleData?.heading || "Выберите статью"}
</Typography>
</Box>
<Box
sx={{
px: 2,
flexGrow: 1,
overflowY: "auto",
backgroundColor: "#877361",
color: "white",
py: 1,
}}
>
{articlesStore.articleData?.body ? (
<ReactMarkdownComponent
value={articlesStore.articleData.body}
fullWidth={true}
/>
) : (
<Typography
color="rgba(255,255,255,0.7)"
sx={{ textAlign: "center", mt: 4 }}
<ImagePlus size={48} color="white" />
)}
</Box>
<Box
sx={{
background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
color: "white",
margin: "5px 0px 5px 0px",
display: "flex",
flexDirection: "column",
gap: 1,
padding: 1,
}}
>
Предпросмотр статьи появится здесь
<Typography
variant="h5"
component="h2"
sx={{
wordBreak: "break-word",
fontSize: "24px",
fontWeight: 700,
lineHeight: "120%",
cursor: "pointer",
"&:hover": {
textDecoration: "underline",
},
}}
onDoubleClick={async () => {
if (selectedArticleId) {
const media = await authInstance.get(
`/article/${selectedArticleId}/media`
);
onSelectArticle(
selectedArticleId,
articlesStore.articleData?.heading || "",
articlesStore.articleData?.body || "",
media.data || []
);
onClose();
setSelectedArticleId(null);
}
}}
>
{articlesStore.articleData?.heading || "Название cтатьи"}
</Typography>
)}
</Box>
</>
)}
{articlesStore.articleData?.body && (
<Box
sx={{
padding: 1,
maxHeight: "200px",
overflowY: "scroll",
background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
}}
>
<ReactMarkdownComponent
value={articlesStore.articleData?.body || "Описание"}
/>
</Box>
)}
</Paper>
</Box>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={onClose}>Отмена</Button>

View File

@ -1,4 +1,4 @@
import { mediaStore } from "@shared";
import { Media, mediaStore } from "@shared";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import {
@ -29,6 +29,7 @@ interface SelectMediaDialogProps {
}) => void; // Renamed from onSelectArticle
onSelectForSightMedia?: (mediaId: string) => void;
linkedMediaIds?: string[]; // Renamed from linkedArticleIds, assuming it refers to media already in use
mediaType?: number;
}
export const SelectMediaDialog = observer(
@ -38,12 +39,29 @@ export const SelectMediaDialog = observer(
onSelectMedia, // Renamed prop
onSelectForSightMedia,
linkedMediaIds = [], // Default to empty array if not provided, renamed
mediaType,
}: SelectMediaDialogProps) => {
const { media, getMedia } = mediaStore;
const [searchQuery, setSearchQuery] = useState("");
const [hoveredMediaId, setHoveredMediaId] = useState<string | null>(null);
const [currentHoveredMedia, setCurrentHoveredMedia] =
useState<Media | null>(null);
useEffect(() => {
if (hoveredMediaId) {
setCurrentHoveredMedia(
media.find((m) => m.id === hoveredMediaId) ?? null
);
}
}, [hoveredMediaId]);
const handleClose = () => {
setHoveredMediaId(null);
setCurrentHoveredMedia(null);
onClose();
setSearchQuery("");
};
// Fetch media on component mount
useEffect(() => {
getMedia();
}, [getMedia]);
@ -63,7 +81,7 @@ export const SelectMediaDialog = observer(
onSelectMedia(mediaItem);
}
}
onClose();
handleClose();
}
}
};
@ -74,19 +92,20 @@ export const SelectMediaDialog = observer(
};
}, [hoveredMediaId, onSelectMedia, onClose]); // Dependencies for keyboard listener
const filteredMedia = media
let filteredMedia = media
.filter((mediaItem) => !linkedMediaIds.includes(mediaItem.id)) // Use mediaItem to avoid name collision
.filter((mediaItem) =>
mediaItem.media_name.toLowerCase().includes(searchQuery.toLowerCase())
);
// Find the currently hovered media object for MediaViewer
const currentHoveredMedia = hoveredMediaId
? media.find((m) => m.id === hoveredMediaId)
: null;
if (mediaType) {
filteredMedia = filteredMedia.filter(
(mediaItem) => mediaItem.media_type === mediaType
);
}
return (
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
<Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth>
<DialogTitle>Выберите существующее медиа</DialogTitle>
<DialogContent
className="flex gap-4"
@ -125,17 +144,31 @@ export const SelectMediaDialog = observer(
} else if (onSelectMedia) {
onSelectMedia(mediaItem);
}
onClose();
handleClose();
}}
selected={hoveredMediaId === mediaItem.id}
sx={{
borderRadius: 1,
mb: 0.5,
"&:hover": {
backgroundColor: "action.hover",
},
"&.Mui-selected": {
backgroundColor: "primary.main",
color: "primary.contrastText",
"&:hover": {
backgroundColor: "primary.dark",
},
},
}}
>
<ListItemText primary={mediaItem.media_name} />
<ListItemText
primary={
mediaItem.media_name
? mediaItem.media_name
: mediaItem.filename
}
/>
</ListItemButton>
)
)
@ -149,7 +182,7 @@ export const SelectMediaDialog = observer(
)}
</List>
</Paper>
{currentHoveredMedia ? ( // Only render MediaViewer if currentHoveredMedia is found
{currentHoveredMedia !== null && hoveredMediaId !== null ? ( // Only render MediaViewer if currentHoveredMedia is found
<Paper className="w-[33%] h-[100%] flex justify-center items-center">
<MediaViewer
media={{
@ -167,8 +200,28 @@ export const SelectMediaDialog = observer(
</Paper>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Отмена</Button>
<DialogActions sx={{ p: 2 }}>
<Button onClick={handleClose}>Отмена</Button>
<Button
variant="contained"
onClick={() => {
if (hoveredMediaId) {
const mediaItem = media.find((m) => m.id === hoveredMediaId);
if (mediaItem) {
if (onSelectForSightMedia) {
onSelectForSightMedia(mediaItem.id);
} else if (onSelectMedia) {
onSelectMedia(mediaItem);
}
}
handleClose();
}
}}
disabled={hoveredMediaId === null}
>
Выбрать
</Button>
</DialogActions>
</Dialog>
);

View File

@ -1,4 +1,9 @@
import { MEDIA_TYPE_LABELS, editSightStore } from "@shared";
import {
MEDIA_TYPE_LABELS,
MEDIA_TYPE_VALUES,
editSightStore,
generateDefaultMediaName,
} from "@shared";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import {
@ -31,6 +36,17 @@ interface UploadMediaDialogProps {
media_type: number;
}) => void;
afterUploadSight?: (id: string) => void;
hardcodeType?: "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null;
contextObjectName?: string;
contextType?:
| "sight"
| "city"
| "carrier"
| "country"
| "vehicle"
| "station";
isArticle?: boolean;
articleName?: string;
}
export const UploadMediaDialog = observer(
@ -39,6 +55,11 @@ export const UploadMediaDialog = observer(
onClose,
afterUpload,
afterUploadSight,
hardcodeType,
contextObjectName,
isArticle,
articleName,
}: UploadMediaDialogProps) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -64,7 +85,7 @@ export const UploadMediaDialog = observer(
setAvailableMediaTypes([6]);
setMediaType(6);
}
if (["jpg", "jpeg", "png", "gif"].includes(extension)) {
if (["jpg", "jpeg", "png", "gif", "svg"].includes(extension)) {
// Для изображений доступны все типы кроме видео
setAvailableMediaTypes([1, 3, 4, 5]); // Фото, Иконка, Водяной знак, Панорама, 3Д-модель
setMediaType(1); // По умолчанию Фото
@ -74,8 +95,95 @@ export const UploadMediaDialog = observer(
setMediaType(2);
}
}
// Генерируем название по умолчанию если есть контекст
if (fileToUpload.name) {
let defaultName = "";
if (isArticle && articleName && contextObjectName) {
// Для статей: "Название достопримечательности_название файлаазвание статьи"
defaultName = generateDefaultMediaName(
contextObjectName,
fileToUpload.name,
articleName,
true
);
} else if (contextObjectName && contextObjectName.trim() !== "") {
// Для обычных медиа с названием объекта
const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType]
: 1; // По умолчанию фото
defaultName = generateDefaultMediaName(
contextObjectName,
fileToUpload.name,
currentMediaType,
false
);
} else {
// Для медиа без названия объекта
const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType]
: 1; // По умолчанию фото
defaultName = generateDefaultMediaName(
"",
fileToUpload.name,
currentMediaType,
false
);
}
}, [fileToUpload]);
setMediaName(defaultName);
}
}
}, [fileToUpload, contextObjectName, hardcodeType, isArticle, articleName]);
// Обновляем название при изменении типа медиа
useEffect(() => {
if (mediaFilename && mediaType > 0) {
let defaultName = "";
if (isArticle && articleName && contextObjectName) {
// Для статей: "Название достопримечательности_название файлаазвание статьи"
defaultName = generateDefaultMediaName(
contextObjectName,
mediaFilename,
articleName,
true
);
} else if (contextObjectName && contextObjectName.trim() !== "") {
// Для обычных медиа с названием объекта
const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType]
: mediaType;
defaultName = generateDefaultMediaName(
contextObjectName,
mediaFilename,
currentMediaType,
false
);
} else {
// Для медиа без названия объекта
const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType]
: mediaType;
defaultName = generateDefaultMediaName(
"",
mediaFilename,
currentMediaType,
false
);
}
setMediaName(defaultName);
}
}, [
mediaType,
contextObjectName,
mediaFilename,
hardcodeType,
isArticle,
articleName,
]);
useEffect(() => {
if (mediaFile) {
@ -104,7 +212,9 @@ export const UploadMediaDialog = observer(
try {
const media = await uploadMedia(
mediaFilename,
mediaType,
hardcodeType
? (MEDIA_TYPE_VALUES[hardcodeType] as number)
: mediaType,
mediaFile,
mediaName
);
@ -137,7 +247,6 @@ export const UploadMediaDialog = observer(
className="flex gap-4"
dividers
sx={{
height: "600px",
display: "flex",
flexDirection: "column",
gap: 2,
@ -165,10 +274,12 @@ export const UploadMediaDialog = observer(
<FormControl fullWidth sx={{ width: "50%" }}>
<InputLabel>Тип медиа</InputLabel>
<Select
value={mediaType}
value={
hardcodeType ? MEDIA_TYPE_VALUES[hardcodeType] : mediaType
}
disabled={!!hardcodeType}
label="Тип медиа"
onChange={(e) => setMediaType(Number(e.target.value))}
disabled={isLoading}
>
{availableMediaTypes.map((type) => (
<MenuItem key={type} value={type}>
@ -182,7 +293,7 @@ export const UploadMediaDialog = observer(
</Select>
</FormControl>
<Box className="flex gap-4 h-full">
<Box className="flex gap-4 h-[40vh]">
<Paper
elevation={2}
sx={{
@ -191,16 +302,19 @@ export const UploadMediaDialog = observer(
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 400,
height: "100%",
}}
>
{/* <MediaViewer
media={{
id: "",
media_type: mediaType,
filename: mediaFilename,
}}
/> */}
{mediaType == 2 && mediaUrl && (
<video
src={mediaUrl}
autoPlay
muted
loop
controls
style={{ maxWidth: "100%", maxHeight: "100%" }}
/>
)}
{mediaType === 6 && mediaUrl && (
<ModelViewer3D fileUrl={mediaUrl} height="100%" />
)}
@ -209,8 +323,7 @@ export const UploadMediaDialog = observer(
src={mediaUrl ?? ""}
alt="Uploaded media"
style={{
maxWidth: "100%",
maxHeight: "100%",
height: "100%",
objectFit: "contain",
}}
/>

View File

@ -1,4 +1,10 @@
import { authInstance, editSightStore, Language, languageStore } from "@shared";
import {
authInstance,
editSightStore,
Language,
languageStore,
languageInstance,
} from "@shared";
import { computed, makeAutoObservable, runInAction } from "mobx";
export type Article = {
@ -6,6 +12,18 @@ export type Article = {
heading: string;
body: string;
service_name: string;
ru?: {
heading: string;
body: string;
};
en?: {
heading: string;
body: string;
};
zh?: {
heading: string;
body: string;
};
};
type Media = {
@ -15,6 +33,29 @@ type Media = {
media_type: number;
};
type ArticleListCashed = {
ru: {
data: Article[];
loaded: boolean;
};
en: {
data: Article[];
loaded: boolean;
};
zh: {
data: Article[];
loaded: boolean;
};
};
type PreviewCashed = {
ru: Article;
en: Article;
zh: Article;
};
type ArticlePreviewCashed = Record<string, PreviewCashed>;
class ArticlesStore {
constructor() {
makeAutoObservable(this);
@ -25,13 +66,51 @@ class ArticlesStore {
en: [],
zh: [],
};
articleList: ArticleListCashed = {
ru: {
data: [],
loaded: false,
},
en: {
data: [],
loaded: false,
},
zh: {
data: [],
loaded: false,
},
};
articlePreview: ArticlePreviewCashed = {};
articleData: Article | null = null;
articleMedia: Media | null = null;
articleLoading: boolean = false;
getArticleList = async () => {
const { language } = languageStore;
if (this.articleList[language].loaded) {
return;
}
const response = await authInstance.get("/article");
runInAction(() => {
this.articleList[language].data = response.data;
this.articleList[language].loaded = true;
});
};
getArticlePreview = async (id: number) => {
const { language } = languageStore;
if (this.articlePreview[id][language]) {
return;
}
const response = await authInstance.get(`/article/${id}/preview`);
this.articlePreview[id][language] = response.data;
};
getArticles = async (language: Language) => {
this.articleLoading = true;
const response = await authInstance.get("/article");
const response = await languageInstance(language).get("/article");
runInAction(() => {
this.articles[language] = response.data;
@ -39,13 +118,27 @@ class ArticlesStore {
this.articleLoading = false;
};
getArticle = async (id: number) => {
getArticle = async (id: number, language?: Language) => {
this.articleLoading = true;
const response = await authInstance.get(`/article/${id}`);
let response: any;
if (language) {
response = await languageInstance(language).get(`/article/${id}`);
runInAction(() => {
if (!this.articleData) {
this.articleData = { id, heading: "", body: "", service_name: "" };
}
this.articleData[language] = {
heading: response.data.heading,
body: response.data.body,
};
});
} else {
response = await authInstance.get(`/article/${id}`);
runInAction(() => {
this.articleData = response.data;
});
}
return response;
this.articleLoading = false;
};
@ -77,6 +170,20 @@ class ArticlesStore {
}
return null;
});
deleteArticles = async (ids: number[]) => {
for (const id of ids) {
await authInstance.delete(`/article/${id}`);
}
for (const id of ["ru", "en", "zh"] as Language[]) {
runInAction(() => {
this.articleList[id].data = this.articleList[id].data.filter(
(article) => !ids.includes(article.id)
);
});
}
};
}
export const articlesStore = new ArticlesStore();

View File

@ -55,7 +55,11 @@ class AuthStore {
runInAction(() => {
this.setAuthToken(data.token);
this.payload = response.data;
this.payload = {
...response.data.user,
// @ts-ignore
user_id: response.data.user.id,
};
this.error = null;
});
} catch (error) {

View File

@ -0,0 +1,307 @@
import {
authInstance,
cityStore,
languageStore,
languageInstance,
Language,
} from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
export type Carrier = {
id: number;
short_name: string;
full_name: string;
slogan: string;
city: string;
city_id: number;
logo: string;
// main_color: string;
// left_color: string;
// right_color: string;
};
type CarrierData = {
data: Carrier[];
loaded: boolean;
};
type Carriers = {
ru: CarrierData;
en: CarrierData;
zh: CarrierData;
};
type CashedCarrier = Record<
number,
{
ru: Carrier | null;
en: Carrier | null;
zh: Carrier | null;
}
>;
class CarrierStore {
carriers: Carriers = {
ru: {
data: [],
loaded: false,
},
en: {
data: [],
loaded: false,
},
zh: {
data: [],
loaded: false,
},
};
carrier: CashedCarrier = {};
constructor() {
makeAutoObservable(this);
}
getCarriers = async (language: Language) => {
if (this.carriers[language as keyof Carriers].loaded) return;
const response = await languageInstance(language).get("/carrier");
runInAction(() => {
this.carriers[language as keyof Carriers].data = response.data;
this.carriers[language as keyof Carriers].loaded = true;
});
};
deleteCarrier = async (id: number) => {
await authInstance.delete(`/carrier/${id}`);
runInAction(() => {
for (const language of ["ru", "en", "zh"] as const) {
this.carriers[language].data = this.carriers[language].data.filter(
(carrier: Carrier) => carrier.id !== id
);
}
delete this.carrier[id];
});
};
getCarrier = async (id: number) => {
if (this.carrier[id]?.ru && this.carrier[id]?.en && this.carrier[id]?.zh)
return;
const ruResponse = await languageInstance("ru").get(`/carrier/${id}`);
const enResponse = await languageInstance("en").get(`/carrier/${id}`);
const zhResponse = await languageInstance("zh").get(`/carrier/${id}`);
runInAction(() => {
if (!this.carrier[id]) {
this.carrier[id] = {
ru: null,
en: null,
zh: null,
};
}
this.carrier[id].ru = ruResponse.data;
this.carrier[id].en = enResponse.data;
this.carrier[id].zh = zhResponse.data;
});
return this.carrier[id];
};
createCarrierData = {
city_id: 0,
logo: "",
ru: {
full_name: "",
short_name: "",
slogan: "",
},
en: {
full_name: "",
short_name: "",
slogan: "",
},
zh: {
full_name: "",
short_name: "",
slogan: "",
},
};
setCreateCarrierData = (
fullName: string,
shortName: string,
cityId: number,
slogan: string,
logoId: string,
language: Language
) => {
this.createCarrierData.city_id = cityId;
this.createCarrierData.logo = logoId;
this.createCarrierData[language] = {
full_name: fullName,
short_name: shortName,
slogan: slogan,
};
};
createCarrier = async () => {
const { language } = languageStore;
const cityName =
cityStore.cities[language].data.find(
(city) => city.id === this.createCarrierData.city_id
)?.name || "";
const payload = {
full_name: this.createCarrierData[language].full_name,
short_name: this.createCarrierData[language].short_name,
city: cityName,
city_id: this.createCarrierData.city_id,
slogan: this.createCarrierData[language].slogan,
...(this.createCarrierData.logo
? { logo: this.createCarrierData.logo }
: {}),
};
const response = await languageInstance(language).post("/carrier", payload);
const carrierId = response.data.id;
runInAction(() => {
this.carriers[language].data.push(response.data);
});
// Create translations for other languages
for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) {
const patchPayload = {
// @ts-ignore
full_name: this.createCarrierData[lang as any].full_name as string,
// @ts-ignore
short_name: this.createCarrierData[lang as any].short_name as string,
city: cityName,
city_id: this.createCarrierData.city_id,
// @ts-ignore
slogan: this.createCarrierData[lang as any].slogan as string,
...(this.createCarrierData.logo
? { logo: this.createCarrierData.logo }
: {}),
};
const response = await languageInstance(lang as Language).patch(
`/carrier/${carrierId}`,
patchPayload
);
runInAction(() => {
this.carriers[lang as keyof Carriers].data.push(response.data);
});
}
this.createCarrierData = {
city_id: 0,
logo: "",
ru: {
full_name: "",
short_name: "",
slogan: "",
},
en: {
full_name: "",
short_name: "",
slogan: "",
},
zh: {
full_name: "",
short_name: "",
slogan: "",
},
};
};
editCarrierData = {
ru: {
full_name: "",
short_name: "",
// main_color: "",
// left_color: "",
// right_color: "",
slogan: "",
},
en: {
full_name: "",
short_name: "",
// main_color: "",
// left_color: "",
// right_color: "",
slogan: "",
},
city_id: 0,
logo: "",
zh: {
full_name: "",
short_name: "",
// main_color: "",
// left_color: "",
// right_color: "",
slogan: "",
},
};
setEditCarrierData = (
fullName: string,
shortName: string,
cityId: number,
// main_color: string,
// left_color: string,
// right_color: string,
slogan: string,
logoId: string,
language: Language
) => {
this.editCarrierData.city_id = cityId;
this.editCarrierData.logo = logoId;
this.editCarrierData[language] = {
full_name: fullName,
short_name: shortName,
// main_color: main_color,
// left_color: left_color,
// right_color: right_color,
slogan: slogan,
};
};
editCarrier = async (id: number) => {
const { language } = languageStore;
const cityName =
cityStore.cities[language].data.find(
(city) => city.id === this.editCarrierData.city_id
)?.name || "";
for (const lang of ["ru", "en", "zh"] as const) {
const response = await languageInstance(lang).patch(`/carrier/${id}`, {
...this.editCarrierData[lang],
city: cityName,
city_id: this.editCarrierData.city_id,
...(this.editCarrierData.logo
? { logo: this.editCarrierData.logo }
: {}),
});
runInAction(() => {
if (this.carrier[id]) {
this.carrier[id][lang] = response.data;
}
this.carriers[lang].data = this.carriers[lang].data.map(
(carrier: Carrier) =>
carrier.id === id ? { ...carrier, ...response.data } : carrier
);
});
}
};
}
export const carrierStore = new CarrierStore();

View File

@ -0,0 +1,324 @@
import {
authInstance,
languageInstance,
Language,
languageStore,
countryStore,
CashedCountries,
} from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
export type City = {
id?: number;
name: string;
country: string;
country_code: string;
arms: string;
};
export type CashedCities = {
ru: {
data: City[];
loaded: boolean;
};
en: {
data: City[];
loaded: boolean;
};
zh: {
data: City[];
loaded: boolean;
};
};
export type CashedCity = {
ru: City | null;
en: City | null;
zh: City | null;
};
class CityStore {
cities: CashedCities = {
ru: {
data: [],
loaded: false,
},
en: {
data: [],
loaded: false,
},
zh: {
data: [],
loaded: false,
},
};
city: Record<string, CashedCity> = {};
constructor() {
makeAutoObservable(this);
}
ruCities: {
data: City[];
loaded: boolean;
} = {
data: [],
loaded: false,
};
getRuCities = async () => {
if (this.ruCities.loaded) {
return;
}
const response = await languageInstance("ru").get(`/city`);
runInAction(() => {
this.ruCities.data = response.data;
this.ruCities.loaded = true;
});
};
getCities = async (language: keyof CashedCities) => {
if (this.cities[language].loaded) {
return;
}
const response = await languageInstance(language).get(`/city`);
runInAction(() => {
this.cities[language].data = response.data;
this.cities[language].loaded = true;
});
};
getCity = async (code: string, language: keyof CashedCities) => {
if (this.city[code]?.[language] && this.city[code][language] !== null) {
return;
}
const response = await languageInstance(language).get(`/city/${code}`);
runInAction(() => {
if (!this.city[code]) {
this.city[code] = {
ru: null,
en: null,
zh: null,
};
}
this.city[code][language] = response.data;
});
return response.data;
};
deleteCity = async (code: string) => {
await authInstance.delete(`/city/${code}`);
runInAction(() => {
for (const secondaryLanguage of ["ru", "en", "zh"] as Language[]) {
this.cities[secondaryLanguage].data = this.cities[
secondaryLanguage
].data.filter((city) => city.id !== Number(code));
if (this.city[code]) {
this.city[code][secondaryLanguage] = null;
}
}
});
};
createCityData = {
country_code: "",
arms: "",
ru: {
name: "",
},
en: {
name: "",
},
zh: {
name: "",
},
};
setCreateCityData = (
name: string,
country_code: string,
arms: string,
language: keyof CashedCities
) => {
this.createCityData = {
...this.createCityData,
country_code: country_code,
arms: arms,
[language]: {
name: name,
},
};
};
async createCity() {
const language = languageStore.language as Language;
const { country_code, arms } = this.createCityData;
const { name } = this.createCityData[language];
if (!name || !country_code) {
return;
}
try {
// Create city in primary language
const cityPayload = {
name,
country:
countryStore.countries[language as keyof CashedCountries]?.data.find(
(c) => c.code === country_code
)?.name || "",
country_code,
...(arms ? { arms } : {}),
};
const cityResponse = await languageInstance(language).post(
"/city",
cityPayload
);
const cityId = cityResponse.data.id;
// Create/update other language versions
for (const secondaryLanguage of (["ru", "en", "zh"] as Language[]).filter(
(l) => l !== language
)) {
const { name: secondaryName } = this.createCityData[secondaryLanguage];
// Get country name in secondary language
const countryName =
countryStore.countries[secondaryLanguage]?.data.find(
(c) => c.code === country_code
)?.name || "";
const patchPayload = {
name: secondaryName || "",
country: countryName,
country_code: country_code || "",
...(arms ? { arms } : {}),
};
const patchResponse = await languageInstance(secondaryLanguage).patch(
`/city/${cityId}`,
patchPayload
);
runInAction(() => {
this.cities[secondaryLanguage].data = [
...this.cities[secondaryLanguage].data,
patchResponse.data,
];
});
}
// Update primary language data
runInAction(() => {
this.cities[language].data = [
...this.cities[language].data,
cityResponse.data,
];
});
// Reset form data
runInAction(() => {
this.createCityData = {
country_code: "",
arms: "",
ru: { name: "" },
en: { name: "" },
zh: { name: "" },
};
});
} catch (error) {
console.error("Error creating city:", error);
throw error;
}
}
editCityData = {
country_code: "",
arms: "",
ru: {
name: "",
},
en: {
name: "",
},
zh: {
name: "",
},
};
setEditCityData = (
name: string,
country_code: string,
arms: string,
language: keyof CashedCities
) => {
this.editCityData = {
...this.editCityData,
country_code: country_code,
arms: arms,
[language]: {
name: name,
},
};
};
editCity = async (code: string) => {
for (const language of ["ru", "en", "zh"]) {
const { country_code, arms } = this.editCityData;
const { name } = this.editCityData[language as keyof CashedCities];
const { countries } = countryStore;
const country = countries[language as keyof CashedCities].data.find(
(country) => country.code === country_code
);
await languageInstance(language as Language).patch(`/city/${code}`, {
name,
country: country?.name || "",
country_code: country_code,
arms,
});
runInAction(() => {
if (this.city[code]) {
this.city[code][language as keyof CashedCities] = {
name,
country: country?.name || "",
country_code: country_code,
arms,
};
}
if (this.cities[language as keyof CashedCities]) {
this.cities[language as keyof CashedCities].data = this.cities[
language as keyof CashedCities
].data.map((city) =>
city.id === Number(code)
? {
id: city.id,
name,
country: country?.name || "",
country_code: country_code,
arms,
}
: city
);
}
});
}
};
}
export const cityStore = new CityStore();

Some files were not shown because too many files have changed in this diff Show More