3 Commits

114 changed files with 3365 additions and 12849 deletions

7
.env
View File

@@ -1,8 +1,3 @@
# # VITE_API_URL='https://wn.st.unprism.ru'
# # VITE_REACT_APP ='https://wn.st.unprism.ru/'
# # VITE_KRBL_MEDIA='https://wn.st.unprism.ru/media/'
# VITE_NEED_AUTH='true'
VITE_API_URL='https://wn.krbl.ru' VITE_API_URL='https://wn.krbl.ru'
VITE_REACT_APP ='https://wn.krbl.ru/' VITE_REACT_APP ='https://wn.krbl.ru/'
VITE_KRBL_MEDIA='https://wn.krbl.ru/media/' VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
VITE_NEED_AUTH='true'

View File

@@ -1,7 +1,7 @@
{ {
"name": "white-nights", "name": "white-nights",
"private": true, "private": true,
"version": "1.0.5", "version": "0.0.0",
"type": "module", "type": "module",
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {
@@ -41,8 +41,7 @@
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"tailwindcss": "^4.1.8", "tailwindcss": "^4.1.8",
"three": "^0.177.0", "three": "^0.177.0"
"uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",

View File

@@ -16,17 +16,22 @@ import {
SnapshotListPage, SnapshotListPage,
CarrierListPage, CarrierListPage,
StationListPage, StationListPage,
// VehicleListPage,
ArticleListPage, ArticleListPage,
// CountryPreviewPage,
// VehiclePreviewPage,
// CarrierPreviewPage,
SnapshotCreatePage, SnapshotCreatePage,
CountryCreatePage, CountryCreatePage,
CityCreatePage, CityCreatePage,
CarrierCreatePage, CarrierCreatePage,
VehicleCreatePage, VehicleCreatePage,
VehicleEditPage,
CountryEditPage, CountryEditPage,
CityEditPage, CityEditPage,
UserCreatePage, UserCreatePage,
UserEditPage, UserEditPage,
// VehicleEditPage,
CarrierEditPage, CarrierEditPage,
StationCreatePage, StationCreatePage,
StationPreviewPage, StationPreviewPage,
@@ -37,7 +42,7 @@ import {
ArticlePreviewPage, ArticlePreviewPage,
CountryAddPage, CountryAddPage,
} from "@pages"; } from "@pages";
import { authStore, createSightStore, editSightStore, ROUTE_REQUIRED_RESOURCES } from "@shared"; import { authStore, createSightStore, editSightStore } from "@shared";
import { Layout } from "@widgets"; import { Layout } from "@widgets";
import { runInAction } from "mobx"; import { runInAction } from "mobx";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
@@ -48,14 +53,11 @@ import {
Navigate, Navigate,
Outlet, Outlet,
useLocation, useLocation,
useMatches,
} from "react-router-dom"; } from "react-router-dom";
const PublicRoute = ({ children }: { children: React.ReactNode }) => { const PublicRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated } = authStore; const { isAuthenticated } = authStore;
const need_auth = import.meta.env.VITE_NEED_AUTH == "true"; if (isAuthenticated) {
if (isAuthenticated || !need_auth) {
return <Navigate to="/map" replace />; return <Navigate to="/map" replace />;
} }
return <>{children}</>; return <>{children}</>;
@@ -63,34 +65,17 @@ const PublicRoute = ({ children }: { children: React.ReactNode }) => {
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated } = authStore; const { isAuthenticated } = authStore;
const need_auth = import.meta.env.VITE_NEED_AUTH == "true";
const location = useLocation(); const location = useLocation();
const matches = useMatches(); if (!isAuthenticated) {
if (!isAuthenticated && need_auth) {
return <Navigate to="/login" replace />; return <Navigate to="/login" replace />;
} }
if (location.pathname === "/") {
if (location.pathname === "/" && authStore.canRead("map")) {
return <Navigate to="/map" replace />; return <Navigate to="/map" replace />;
} }
const lastMatch = matches[matches.length - 1] as
| { handle?: { permissions?: string[] } }
| undefined;
const requiredPermissions = lastMatch?.handle?.permissions ?? [];
if (
requiredPermissions.length > 0 &&
!requiredPermissions.every((permission) => authStore.canAccess(permission))
) {
return <Navigate to="/" replace />;
}
return <>{children}</>; return <>{children}</>;
}; };
// Чтобы очистка сторов происходила при смене локации
const ClearStoresWrapper: React.FC<{ children: React.ReactNode }> = ({ const ClearStoresWrapper: React.FC<{ children: React.ReactNode }> = ({
children, children,
}) => { }) => {
@@ -116,10 +101,7 @@ const router = createBrowserRouter([
</PublicRoute> </PublicRoute>
), ),
}, },
{ { path: "route-preview/:id", element: <RoutePreview /> },
path: "route-preview/:id",
element: <RoutePreview />,
},
{ {
path: "/", path: "/",
element: ( element: (
@@ -132,258 +114,67 @@ const router = createBrowserRouter([
</ProtectedRoute> </ProtectedRoute>
), ),
children: [ children: [
{ { index: true, element: <MainPage /> },
index: true,
element: <MainPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/"],
},
},
{ // Sight
path: "sight", { path: "sight", element: <SightListPage /> },
element: <SightListPage />, { path: "sight/create", element: <CreateSightPage /> },
handle: { { path: "sight/:id/edit", element: <EditSightPage /> },
permissions: ROUTE_REQUIRED_RESOURCES["/sight"],
},
},
{
path: "sight/create",
element: <CreateSightPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/sight/create"],
},
},
{
path: "sight/:id/edit",
element: <EditSightPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/sight/:id/edit"],
},
},
{ // Device
path: "devices", { path: "devices", element: <DevicesPage /> },
element: <DevicesPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/devices"],
},
},
{ // Map
path: "map", { path: "map", element: <MapPage /> },
element: <MapPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/map"],
},
},
{ // Media
path: "media", { path: "media", element: <MediaListPage /> },
element: <MediaListPage />, { path: "media/:id", element: <MediaPreviewPage /> },
handle: { { path: "media/:id/edit", element: <MediaEditPage /> },
permissions: ROUTE_REQUIRED_RESOURCES["/media"],
},
},
{
path: "media/:id",
element: <MediaPreviewPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/media/:id"],
},
},
{
path: "media/:id/edit",
element: <MediaEditPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/media/:id/edit"],
},
},
{ // Country
path: "country", { path: "country", element: <CountryListPage /> },
element: <CountryListPage />, { path: "country/create", element: <CountryCreatePage /> },
handle: { { path: "country/add", element: <CountryAddPage /> },
permissions: ROUTE_REQUIRED_RESOURCES["/country"], // { path: "country/:id", element: <CountryPreviewPage /> },
}, { path: "country/:id/edit", element: <CountryEditPage /> },
}, // City
{ { path: "city", element: <CityListPage /> },
path: "country/create", { path: "city/create", element: <CityCreatePage /> },
element: <CountryCreatePage />, // { path: "city/:id", element: <CityPreviewPage /> },
handle: { { path: "city/:id/edit", element: <CityEditPage /> },
permissions: ROUTE_REQUIRED_RESOURCES["/country/create"], // Route
}, { path: "route", element: <RouteListPage /> },
}, { path: "route/create", element: <RouteCreatePage /> },
{ { path: "route/:id/edit", element: <RouteEditPage /> },
path: "country/add",
element: <CountryAddPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/country/add"],
},
},
{
path: "country/:id/edit",
element: <CountryEditPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/country/:id/edit"],
},
},
{
path: "city",
element: <CityListPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/city"],
},
},
{
path: "city/create",
element: <CityCreatePage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/city/create"],
},
},
{
path: "city/:id/edit",
element: <CityEditPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/city/:id/edit"],
},
},
{
path: "route",
element: <RouteListPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/route"],
},
},
{
path: "route/create",
element: <RouteCreatePage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/route/create"],
},
},
{
path: "route/:id/edit",
element: <RouteEditPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/route/:id/edit"],
},
},
{ // User
path: "user", { path: "user", element: <UserListPage /> },
element: <UserListPage />, { path: "user/create", element: <UserCreatePage /> },
handle: { { path: "user/:id/edit", element: <UserEditPage /> },
permissions: ROUTE_REQUIRED_RESOURCES["/user"], // Snapshot
}, { path: "snapshot", element: <SnapshotListPage /> },
}, { path: "snapshot/create", element: <SnapshotCreatePage /> },
{
path: "user/create",
element: <UserCreatePage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/user/create"],
},
},
{
path: "user/:id/edit",
element: <UserEditPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/user/:id/edit"],
},
},
{
path: "snapshot",
element: <SnapshotListPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/snapshot"],
},
},
{
path: "snapshot/create",
element: <SnapshotCreatePage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/snapshot/create"],
},
},
{ // Carrier
path: "carrier", { path: "carrier", element: <CarrierListPage /> },
element: <CarrierListPage />, { path: "carrier/create", element: <CarrierCreatePage /> },
handle: { // { path: "carrier/:id", element: <CarrierPreviewPage /> },
permissions: ROUTE_REQUIRED_RESOURCES["/carrier"], { path: "carrier/:id/edit", element: <CarrierEditPage /> },
}, // Station
}, { path: "station", element: <StationListPage /> },
{ { path: "station/create", element: <StationCreatePage /> },
path: "carrier/create", { path: "station/:id", element: <StationPreviewPage /> },
element: <CarrierCreatePage />, { path: "station/:id/edit", element: <StationEditPage /> },
handle: { // Vehicle
permissions: ROUTE_REQUIRED_RESOURCES["/carrier/create"], // { path: "vehicle", element: <VehicleListPage /> },
}, { path: "vehicle/create", element: <VehicleCreatePage /> },
}, // { path: "vehicle/:id", element: <VehiclePreviewPage /> },
{ // { path: "vehicle/:id/edit", element: <VehicleEditPage /> },
path: "carrier/:id/edit", // Article
element: <CarrierEditPage />, { path: "article", element: <ArticleListPage /> },
handle: { { path: "article/:id", element: <ArticlePreviewPage /> },
permissions: ROUTE_REQUIRED_RESOURCES["/carrier/:id/edit"], // { path: "media/create", element: <CreateMediaPage /> },
},
},
{
path: "station",
element: <StationListPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/station"],
},
},
{
path: "station/create",
element: <StationCreatePage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/station/create"],
},
},
{
path: "station/:id",
element: <StationPreviewPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/station/:id"],
},
},
{
path: "station/:id/edit",
element: <StationEditPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/station/:id/edit"],
},
},
{
path: "vehicle/create",
element: <VehicleCreatePage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/vehicle/create"],
},
},
{
path: "vehicle/:id/edit",
element: <VehicleEditPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/vehicle/:id/edit"],
},
},
{
path: "article",
element: <ArticleListPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/article"],
},
},
{
path: "article/:id",
element: <ArticlePreviewPage />,
handle: {
permissions: ROUTE_REQUIRED_RESOURCES["/article/:id"],
},
},
], ],
}, },
]); ]);

View File

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

View File

@@ -10,6 +10,7 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import type { NavigationItem } from "../model"; import type { NavigationItem } from "../model";
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { authStore } from "@shared";
interface NavigationItemProps { interface NavigationItemProps {
item: NavigationItem; item: NavigationItem;
@@ -30,10 +31,20 @@ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [isExpanded, setIsExpanded] = React.useState(false); const [isExpanded, setIsExpanded] = React.useState(false);
const { payload } = authStore;
// @ts-ignore
const isAdmin = payload?.is_admin || false;
const isActive = item.path ? location.pathname.startsWith(item.path) : false; const isActive = item.path ? location.pathname.startsWith(item.path) : false;
const filteredNestedItems = item.nestedItems; const filteredNestedItems = item.nestedItems?.filter((nestedItem) => {
if (nestedItem.for_admin) {
return isAdmin;
}
return true;
});
const handleClick = () => { const handleClick = () => {
if (item.id === "all" && !open) { if (item.id === "all" && !open) {

View File

@@ -1,6 +1,6 @@
import List from "@mui/material/List"; import List from "@mui/material/List";
import Divider from "@mui/material/Divider"; import Divider from "@mui/material/Divider";
import { authStore, NAVIGATION_ITEMS, ROUTE_REQUIRED_RESOURCES } from "@shared"; import { authStore, NAVIGATION_ITEMS } from "@shared";
import { NavigationItem, NavigationItemComponent } from "@entities"; import { NavigationItem, NavigationItemComponent } from "@entities";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
@@ -9,48 +9,28 @@ interface NavigationListProps {
onDrawerOpen?: () => void; onDrawerOpen?: () => void;
} }
const isItemVisible = (item: (typeof NAVIGATION_ITEMS.primary)[number]): boolean => {
// Для карты в навигации требуем наличие ВСЕХ трёх rw-ролей: routes/stations/sights
if (item.id === "map") {
return ["routes", "stations", "sights"].every((resource) =>
authStore.canWrite(resource),
);
}
const routePermissions = item.path ? ROUTE_REQUIRED_RESOURCES[item.path] ?? [] : [];
const canAccessRoute = routePermissions.every((permission) =>
authStore.canAccess(permission),
);
if (!canAccessRoute) {
return false;
}
if (!item.requiredRoles || item.requiredRoles.length === 0) {
return true;
}
return item.requiredRoles.some((role) => {
const match = role.match(/^(.+)_r[ow]$/);
if (match) {
return authStore.canRead(match[1]);
}
return authStore.hasRole(role);
});
};
export const NavigationList = observer( export const NavigationList = observer(
({ open, onDrawerOpen }: NavigationListProps) => { ({ open, onDrawerOpen }: NavigationListProps) => {
const primaryItems = NAVIGATION_ITEMS.primary const { payload } = authStore;
.filter(isItemVisible) // @ts-ignore
.map((item) => { const isAdmin = Boolean(payload?.is_admin) || false;
if (!item.nestedItems) return item;
return { const primaryItems = NAVIGATION_ITEMS.primary.filter((item) => {
...item, if (item.for_admin) {
nestedItems: item.nestedItems.filter(isItemVisible), return isAdmin;
}; }
})
.filter((item) => !item.nestedItems || item.nestedItems.length > 0); if (item.nestedItems && item.nestedItems.length > 0) {
return item.nestedItems.some((nestedItem) => {
if (nestedItem.for_admin) {
return isAdmin;
}
return true;
});
}
return true;
});
return ( return (
<> <>
@@ -71,7 +51,7 @@ export const NavigationList = observer(
key={item.id} key={item.id}
item={item as NavigationItem} item={item as NavigationItem}
open={open} open={open}
onClick={item.onClick ?? undefined} onClick={item.onClick ? item.onClick : undefined}
onDrawerOpen={onDrawerOpen} onDrawerOpen={onDrawerOpen}
/> />
))} ))}

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales"; import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, articlesStore, languageStore, SearchInput } from "@shared"; import { articlesStore, languageStore } from "@shared";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Trash2, Eye, Minus } from "lucide-react"; import { Trash2, Eye, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -17,12 +17,6 @@ export const ArticleListPage = observer(() => {
const { language } = languageStore; const { language } = languageStore;
const [ids, setIds] = useState<number[]>([]); const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const canWriteArticles = authStore.canWrite("sights");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => { useEffect(() => {
const fetchArticles = async () => { const fetchArticles = async () => {
@@ -53,12 +47,13 @@ export const ArticleListPage = observer(() => {
field: "actions", field: "actions",
headerName: "Действия", headerName: "Действия",
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> renderCell: (params: GridRenderCellParams) => {
<button onClick={() => navigate(`/article/${params.row.id}`)}> return (
<Eye size={20} className="text-green-500" /> <div className="flex h-full gap-7 justify-center items-center">
</button> <button onClick={() => navigate(`/article/${params.row.id}`)}>
{canWriteArticles && ( <Eye size={20} className="text-green-500" />
</button>
<button <button
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
@@ -67,22 +62,17 @@ export const ArticleListPage = observer(() => {
> >
<Trash2 size={20} className="text-red-500" /> <Trash2 size={20} className="text-red-500" />
</button> </button>
)} </div>
</div> );
), },
}, },
]; ];
const rows = useMemo(() => { const rows = articleList[language].data.map((article) => ({
const query = searchQuery.trim().toLowerCase(); id: article.id,
return articleList[language].data heading: article.heading,
.filter((article) => !query || (article.heading ?? "").toLowerCase().includes(query)) body: article.body,
.map((article) => ({ }));
id: article.id,
heading: article.heading,
body: article.body,
}));
}, [articleList[language].data, searchQuery]);
return ( return (
<> <>
@@ -93,55 +83,31 @@ export const ArticleListPage = observer(() => {
<h1 className="text-2xl">Статьи</h1> <h1 className="text-2xl">Статьи</h1>
</div> </div>
{canWriteArticles && ids.length > 0 && ( <div
<div className="flex justify-end mb-5 duration-300"> className="flex justify-end mb-5 duration-300"
<button style={{ opacity: ids.length > 0 ? 1 : 0 }}
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" >
onClick={() => setIsBulkDeleteModalOpen(true)} <button
> className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
<Trash2 size={20} className="text-white" /> Удалить выбранные ( onClick={() => setIsBulkDeleteModalOpen(true)}
{ids.length}) >
</button> <Trash2 size={20} className="text-white" /> Удалить выбранные (
</div> {ids.length})
)} </button>
</div>
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<div className="w-full"> <div className="w-full">
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
checkboxSelection={canWriteArticles} hideFooterPagination
disableRowSelectionExcludeModel checkboxSelection
loading={isLoading} loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={ onRowSelectionModelChange={(newSelection) => {
canWriteArticles setIds(Array.from(newSelection.ids) as number[]);
? (newSelection: any) => { }}
if (Array.isArray(newSelection)) { hideFooter
const selectedIds = newSelection.map(
(id: string | number) => Number(id)
);
setIds(selectedIds);
} else if (
newSelection &&
typeof newSelection === "object" &&
"ids" in newSelection
) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map(
(id: string | number) => Number(id)
);
setIds(selectedIds);
} else {
setIds([]);
}
}
: undefined
}
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (
<Box <Box

View File

@@ -1,9 +1,9 @@
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useEffect, useState } from "react"; import { useEffect } from "react";
import { Box } from "@mui/material"; import { Box } from "@mui/material";
import { PreviewLeftWidget } from "./PreviewLeftWidget"; import { PreviewLeftWidget } from "./PreviewLeftWidget";
import { PreviewRightWidget } from "./PreviewRightWidget"; import { PreviewRightWidget } from "./PreviewRightWidget";
import { articlesStore, languageStore, LoadingSpinner } from "@shared"; import { articlesStore, languageStore } from "@shared";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
export const ArticlePreviewPage = () => { export const ArticlePreviewPage = () => {
@@ -11,41 +11,18 @@ export const ArticlePreviewPage = () => {
const { id } = useParams(); const { id } = useParams();
const { getArticle, getArticleMedia, getArticlePreview } = articlesStore; const { getArticle, getArticleMedia, getArticlePreview } = articlesStore;
const { language } = languageStore; const { language } = languageStore;
const [isLoadingData, setIsLoadingData] = useState(true);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
if (id) { if (id) {
setIsLoadingData(true); await getArticle(Number(id), language);
try { await getArticleMedia(Number(id));
await getArticle(Number(id), language); await getArticlePreview(Number(id));
await getArticleMedia(Number(id));
await getArticlePreview(Number(id));
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
} }
}; };
fetchData(); fetchData();
}, [id, language]); }, [id, language]);
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных статьи..." />
</Box>
);
}
return ( return (
<> <>
<div className="flex items-center gap-4 mb-10"> <div className="flex items-center gap-4 mb-10">

View File

@@ -8,31 +8,30 @@ import {
InputLabel, InputLabel,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { import {
carrierStore, carrierStore,
cityStore, cityStore,
authStore,
mediaStore, mediaStore,
languageStore, languageStore,
isMediaIdEmpty,
useSelectedCity, useSelectedCity,
} from "@shared";
import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
import {
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
} from "@shared"; } from "@shared";
import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
export const CarrierCreatePage = observer(() => { export const CarrierCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const { createCarrierData, setCreateCarrierData } = carrierStore; const { createCarrierData, setCreateCarrierData } = carrierStore;
const { language } = languageStore; const { language } = languageStore;
const canReadCities = authStore.canRead("cities"); const { selectedCityId } = useSelectedCity();
const { selectedCityId, selectedCity } = useSelectedCity();
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null); const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
@@ -44,37 +43,12 @@ export const CarrierCreatePage = observer(() => {
>(null); >(null);
useEffect(() => { useEffect(() => {
const fetchCities = async () => { cityStore.getCities("ru");
if (!authStore.me) {
await authStore.getMeAction().catch(() => undefined);
}
if (authStore.canRead("cities")) {
await cityStore.getCities("ru");
return;
}
await authStore.fetchMeCities().catch(() => undefined);
};
fetchCities();
mediaStore.getMedia(); mediaStore.getMedia();
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}, []); }, []);
const baseCities = canReadCities // Автоматически устанавливаем выбранный город при загрузке страницы
? cityStore.cities["ru"].data
: authStore.meCities["ru"].map((city) => ({
id: city.city_id,
name: city.name,
country: "",
country_code: "",
arms: "",
}));
const availableCities =
selectedCity?.id && !baseCities.some((city) => city.id === selectedCity.id)
? [selectedCity, ...baseCities]
: baseCities;
useEffect(() => { useEffect(() => {
if (selectedCityId && !createCarrierData.city_id) { if (selectedCityId && !createCarrierData.city_id) {
setCreateCarrierData( setCreateCarrierData(
@@ -83,7 +57,7 @@ export const CarrierCreatePage = observer(() => {
selectedCityId, selectedCityId,
createCarrierData[language].slogan, createCarrierData[language].slogan,
selectedMediaId || "", selectedMediaId || "",
language, language
); );
} }
}, [selectedCityId, createCarrierData.city_id]); }, [selectedCityId, createCarrierData.city_id]);
@@ -115,17 +89,13 @@ export const CarrierCreatePage = observer(() => {
createCarrierData.city_id, createCarrierData.city_id,
createCarrierData[language].slogan, createCarrierData[language].slogan,
media.id, media.id,
language, language
); );
}; };
const selectedMedia = const selectedMedia = selectedMediaId
selectedMediaId && !isMediaIdEmpty(selectedMediaId) ? mediaStore.media.find((m) => m.id === selectedMediaId)
? mediaStore.media.find((m) => m.id === selectedMediaId) : null;
: null;
const effectiveLogoUrl = isMediaIdEmpty(selectedMediaId)
? null
: selectedMedia?.id ?? selectedMediaId ?? null;
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
@@ -158,11 +128,11 @@ export const CarrierCreatePage = observer(() => {
e.target.value as number, e.target.value as number,
createCarrierData[language].slogan, createCarrierData[language].slogan,
selectedMediaId || "", selectedMediaId || "",
language, language
) )
} }
> >
{availableCities.map((city) => ( {cityStore.cities["ru"].data.map((city) => (
<MenuItem key={city.id} value={city.id}> <MenuItem key={city.id} value={city.id}>
{city.name} {city.name}
</MenuItem> </MenuItem>
@@ -182,7 +152,7 @@ export const CarrierCreatePage = observer(() => {
createCarrierData.city_id, createCarrierData.city_id,
createCarrierData[language].slogan, createCarrierData[language].slogan,
selectedMediaId || "", selectedMediaId || "",
language, language
) )
} }
/> />
@@ -199,7 +169,7 @@ export const CarrierCreatePage = observer(() => {
createCarrierData.city_id, createCarrierData.city_id,
createCarrierData[language].slogan, createCarrierData[language].slogan,
selectedMediaId || "", selectedMediaId || "",
language, language
) )
} }
/> />
@@ -215,7 +185,7 @@ export const CarrierCreatePage = observer(() => {
createCarrierData.city_id, createCarrierData.city_id,
e.target.value, e.target.value,
selectedMediaId || "", selectedMediaId || "",
language, language
) )
} }
/> />
@@ -224,10 +194,10 @@ export const CarrierCreatePage = observer(() => {
<ImageUploadCard <ImageUploadCard
title="Логотип перевозчика" title="Логотип перевозчика"
imageKey="thumbnail" imageKey="thumbnail"
imageUrl={effectiveLogoUrl} imageUrl={selectedMedia?.id}
onImageClick={() => { onImageClick={() => {
setIsPreviewMediaOpen(true); setIsPreviewMediaOpen(true);
setMediaId(effectiveLogoUrl ?? ""); setMediaId(selectedMedia?.id ?? "");
}} }}
onDeleteImageClick={() => { onDeleteImageClick={() => {
setSelectedMediaId(null); setSelectedMediaId(null);
@@ -238,7 +208,7 @@ export const CarrierCreatePage = observer(() => {
createCarrierData.city_id, createCarrierData.city_id,
createCarrierData[language].slogan, createCarrierData[language].slogan,
"", "",
language, language
); );
}} }}
onSelectFileClick={() => { onSelectFileClick={() => {

View File

@@ -6,22 +6,13 @@ import {
MenuItem, MenuItem,
FormControl, FormControl,
InputLabel, InputLabel,
Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { import { carrierStore, cityStore, mediaStore, languageStore } from "@shared";
carrierStore,
cityStore,
authStore,
mediaStore,
languageStore,
isMediaIdEmpty,
LoadingSpinner,
} from "@shared";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets"; import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets";
import { import {
@@ -35,72 +26,55 @@ export const CarrierEditPage = observer(() => {
const { id } = useParams(); const { id } = useParams();
const { getCarrier, setEditCarrierData, editCarrierData } = carrierStore; const { getCarrier, setEditCarrierData, editCarrierData } = carrierStore;
const { language } = languageStore; const { language } = languageStore;
const canReadCities = authStore.canRead("cities");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState(""); const [mediaId, setMediaId] = useState("");
const [isDeleteLogoModalOpen, setIsDeleteLogoModalOpen] = useState(false); const [isDeleteLogoModalOpen, setIsDeleteLogoModalOpen] = useState(false);
const [initialCityName, setInitialCityName] = useState("");
const [activeMenuType, setActiveMenuType] = useState< const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null); >(null);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (!id) { await cityStore.getCities("ru");
setIsLoadingData(false); await cityStore.getCities("en");
return; await cityStore.getCities("zh");
} const carrierData = await getCarrier(Number(id));
setIsLoadingData(true);
try {
if (!authStore.me) {
await authStore.getMeAction().catch(() => undefined);
}
if (authStore.canRead("cities")) {
await cityStore.getCities("ru");
} else {
await authStore.fetchMeCities().catch(() => undefined);
}
const carrierData = await getCarrier(Number(id));
if (carrierData) { if (carrierData) {
setEditCarrierData( setEditCarrierData(
carrierData.ru?.full_name || "", carrierData.ru?.full_name || "",
carrierData.ru?.short_name || "", carrierData.ru?.short_name || "",
carrierData.ru?.city_id || 0, carrierData.ru?.city_id || 0,
carrierData.ru?.slogan || "", carrierData.ru?.slogan || "",
carrierData.ru?.logo || "", carrierData.ru?.logo || "",
"ru" "ru"
); );
setEditCarrierData( setEditCarrierData(
carrierData.en?.full_name || "", carrierData.en?.full_name || "",
carrierData.en?.short_name || "", carrierData.en?.short_name || "",
carrierData.en?.city_id || 0, carrierData.en?.city_id || 0,
carrierData.en?.slogan || "", carrierData.en?.slogan || "",
carrierData.en?.logo || "", carrierData.en?.logo || "",
"en" "en"
); );
setEditCarrierData( setEditCarrierData(
carrierData.zh?.full_name || "", carrierData.zh?.full_name || "",
carrierData.zh?.short_name || "", carrierData.zh?.short_name || "",
carrierData.zh?.city_id || 0, carrierData.zh?.city_id || 0,
carrierData.zh?.slogan || "", carrierData.zh?.slogan || "",
carrierData.zh?.logo || "", carrierData.zh?.logo || "",
"zh" "zh"
); );
setInitialCityName(carrierData.ru?.city || "");
}
await mediaStore.getMedia();
} finally {
setIsLoadingData(false);
} }
mediaStore.getMedia();
})(); })();
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}, [id]); }, [id]);
@@ -133,53 +107,9 @@ export const CarrierEditPage = observer(() => {
); );
}; };
const selectedMedia = const selectedMedia = editCarrierData.logo
editCarrierData.logo && !isMediaIdEmpty(editCarrierData.logo) ? mediaStore.media.find((m) => m.id === editCarrierData.logo)
? mediaStore.media.find((m) => m.id === editCarrierData.logo) : null;
: null;
const effectiveLogoUrl = isMediaIdEmpty(editCarrierData.logo)
? null
: (selectedMedia?.id ?? editCarrierData.logo);
const baseCities = canReadCities
? cityStore.cities["ru"].data
: authStore.meCities["ru"].map((city) => ({
id: city.city_id,
name: city.name,
country: "",
country_code: "",
arms: "",
}));
const availableCities =
editCarrierData.city_id &&
!baseCities.some((city) => city.id === editCarrierData.city_id)
? [
{
id: editCarrierData.city_id,
name: initialCityName || `Город ${editCarrierData.city_id}`,
country: "",
country_code: "",
arms: "",
},
...baseCities,
]
: baseCities;
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных перевозчика..." />
</Box>
);
}
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
@@ -215,7 +145,7 @@ export const CarrierEditPage = observer(() => {
) )
} }
> >
{availableCities.map((city) => ( {cityStore.cities["ru"].data?.map((city) => (
<MenuItem key={city.id} value={city.id}> <MenuItem key={city.id} value={city.id}>
{city.name} {city.name}
</MenuItem> </MenuItem>
@@ -277,10 +207,10 @@ export const CarrierEditPage = observer(() => {
<ImageUploadCard <ImageUploadCard
title="Логотип перевозчика" title="Логотип перевозчика"
imageKey="thumbnail" imageKey="thumbnail"
imageUrl={effectiveLogoUrl} imageUrl={selectedMedia?.id}
onImageClick={() => { onImageClick={() => {
setIsPreviewMediaOpen(true); setIsPreviewMediaOpen(true);
setMediaId(effectiveLogoUrl ?? ""); setMediaId(selectedMedia?.id ?? "");
}} }}
onDeleteImageClick={() => { onDeleteImageClick={() => {
setIsDeleteLogoModalOpen(true); setIsDeleteLogoModalOpen(true);

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales"; import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, carrierStore, cityStore, languageStore, SearchInput } from "@shared"; import { carrierStore, cityStore, languageStore } from "@shared";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react"; import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -10,31 +10,21 @@ import { Box, CircularProgress } from "@mui/material";
export const CarrierListPage = observer(() => { export const CarrierListPage = observer(() => {
const { carriers, getCarriers, deleteCarrier } = carrierStore; const { carriers, getCarriers, deleteCarrier } = carrierStore;
const { getCities, cities } = cityStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null); const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]); const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const { language } = languageStore; const { language } = languageStore;
const canReadCities = authStore.canRead("cities");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
setIsLoading(true); setIsLoading(true);
if (!authStore.me) { await getCities("ru");
await authStore.getMeAction().catch(() => undefined); await getCities("en");
} await getCities("zh");
if (authStore.canRead("cities")) {
await cityStore.getCities(language);
} else {
await authStore.fetchMeCities().catch(() => undefined);
}
await getCarriers(language); await getCarriers(language);
setIsLoading(false); setIsLoading(false);
}; };
@@ -79,64 +69,56 @@ export const CarrierListPage = observer(() => {
headerName: "Город", headerName: "Город",
flex: 1, flex: 1,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
const lang = language as "ru" | "en" | "zh"; const city = cities[language]?.data.find(
const cityName = canReadCities (city) => city.id == params.value
? cityStore.cities[lang]?.data.find((c) => c.id === params.value)?.name );
: authStore.meCities[lang]?.find((c) => c.city_id === params.value)?.name;
return ( return (
<div className="w-full h-full flex items-center"> <div className="w-full h-full flex items-center">
{cityName ?? <Minus size={20} className="text-red-500" />} {city && city.name ? (
city.name
) : (
<Minus size={20} className="text-red-500" />
)}
</div> </div>
); );
}, },
}, },
...(authStore.canWrite("carriers") ? [{ {
field: "actions", field: "actions",
headerName: "Действия", headerName: "Действия",
headerAlign: "center" as const, headerAlign: "center",
width: 200, width: 200,
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> renderCell: (params: GridRenderCellParams) => {
<button onClick={() => navigate(`/carrier/${params.row.id}/edit`)}> return (
<Pencil size={20} className="text-blue-500" /> <div className="flex h-full gap-7 justify-center items-center">
</button> <button onClick={() => navigate(`/carrier/${params.row.id}/edit`)}>
<button <Pencil size={20} className="text-blue-500" />
onClick={() => { </button>
setIsDeleteModalOpen(true); {/* <button onClick={() => navigate(`/carrier/${params.row.id}`)}>
setRowId(params.row.id); <Eye size={20} className="text-green-500" />
}} </button> */}
> <button
<Trash2 size={20} className="text-red-500" /> onClick={() => {
</button> setIsDeleteModalOpen(true);
</div> setRowId(params.row.id);
), }}
}] : []), >
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
]; ];
const allowedCityIds = canReadCities const rows = carriers[language].data?.map((carrier) => ({
? null id: carrier.id,
: authStore.meCities["ru"].map((c) => c.city_id); full_name: carrier.full_name,
short_name: carrier.short_name,
const canWriteCarriers = authStore.canWrite("carriers"); city_id: carrier.city_id,
}));
const rows = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
return (carriers[language].data ?? [])
.filter((carrier) => !allowedCityIds || allowedCityIds.includes(carrier.city_id))
.filter(
(carrier) =>
!query ||
(carrier.full_name ?? "").toLowerCase().includes(query) ||
(carrier.short_name ?? "").toLowerCase().includes(query)
)
.map((carrier) => ({
id: carrier.id,
full_name: carrier.full_name,
short_name: carrier.short_name,
city_id: carrier.city_id,
}));
}, [carriers[language].data, searchQuery, allowedCityIds]);
return ( return (
<> <>
@@ -144,55 +126,32 @@ export const CarrierListPage = observer(() => {
<div className="w-full"> <div className="w-full">
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Перевозчики</h1> <h1 className="text-2xl">Перевозчики</h1>
{canWriteCarriers && ( <CreateButton label="Создать перевозчика" path="/carrier/create" />
<CreateButton label="Создать перевозчика" path="/carrier/create" />
)}
</div> </div>
{canWriteCarriers && ids.length > 0 && ( <div
<div className="flex justify-end mb-5 duration-300"> className="flex justify-end mb-5 duration-300"
<button style={{ opacity: ids.length > 0 ? 1 : 0 }}
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" >
onClick={() => setIsBulkDeleteModalOpen(true)} <button
> className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
<Trash2 size={20} className="text-white" /> Удалить выбранные ( onClick={() => setIsBulkDeleteModalOpen(true)}
{ids.length}) >
</button> <Trash2 size={20} className="text-white" /> Удалить выбранные (
</div> {ids.length})
)} </button>
</div>
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
checkboxSelection={canWriteCarriers} hideFooter
disableRowSelectionExcludeModel checkboxSelection
loading={isLoading} loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={ onRowSelectionModelChange={(newSelection) => {
canWriteCarriers setIds(Array.from(newSelection.ids as unknown as number[]));
? (newSelection: any) => { }}
if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map(Number);
setIds(selectedIds);
} else if (
newSelection &&
typeof newSelection === "object" &&
"ids" in newSelection
) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map(Number);
setIds(selectedIds);
} else {
setIds([]);
}
}
: undefined
}
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>

View File

@@ -12,18 +12,14 @@ import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { cityStore, countryStore, languageStore, mediaStore } from "@shared";
import { useState, useEffect } from "react";
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
import { import {
cityStore,
countryStore,
languageStore,
mediaStore,
isMediaIdEmpty,
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
} from "@shared"; } from "@shared";
import { useState, useEffect } from "react";
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
export const CityCreatePage = observer(() => { export const CityCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -76,13 +72,9 @@ export const CityCreatePage = observer(() => {
); );
}; };
const selectedMedia = const selectedMedia = createCityData.arms
createCityData.arms && !isMediaIdEmpty(createCityData.arms) ? mediaStore.media.find((m) => m.id === createCityData.arms)
? mediaStore.media.find((m) => m.id === createCityData.arms) : null;
: null;
const effectiveArmsUrl = isMediaIdEmpty(createCityData.arms)
? null
: (selectedMedia?.id ?? createCityData.arms);
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
@@ -143,10 +135,10 @@ export const CityCreatePage = observer(() => {
<ImageUploadCard <ImageUploadCard
title="Герб города" title="Герб города"
imageKey="image" imageKey="image"
imageUrl={effectiveArmsUrl} imageUrl={selectedMedia?.id}
onImageClick={() => { onImageClick={() => {
setIsPreviewMediaOpen(true); setIsPreviewMediaOpen(true);
setMediaId(effectiveArmsUrl ?? ""); setMediaId(selectedMedia?.id ?? "");
}} }}
onDeleteImageClick={() => { onDeleteImageClick={() => {
setCreateCityData( setCreateCityData(

View File

@@ -6,10 +6,10 @@ import {
MenuItem, MenuItem,
FormControl, FormControl,
InputLabel, InputLabel,
Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { import {
@@ -17,20 +17,19 @@ import {
countryStore, countryStore,
languageStore, languageStore,
mediaStore, mediaStore,
isMediaIdEmpty,
CashedCities, CashedCities,
LoadingSpinner, } from "@shared";
import { useEffect, useState } from "react";
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
import {
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
} from "@shared"; } from "@shared";
import { useEffect, useState } from "react";
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
export const CityEditPage = observer(() => { export const CityEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
@@ -45,6 +44,7 @@ export const CityEditPage = observer(() => {
const { getMedia, getOneMedia } = mediaStore; const { getMedia, getOneMedia } = mediaStore;
useEffect(() => { useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}, []); }, []);
@@ -63,26 +63,20 @@ export const CityEditPage = observer(() => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
setIsLoadingData(true); await getCountries("ru");
try { // Fetch data for all languages
await getCountries("ru"); const ruData = await getCity(id as string, "ru");
const enData = await getCity(id as string, "en");
const zhData = await getCity(id as string, "zh");
const ruData = await getCity(id as string, "ru"); // Set data for each language
const enData = await getCity(id as string, "en"); setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
const zhData = await getCity(id as string, "zh"); setEditCityData(enData.name, enData.country_code, enData.arms, "en");
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru"); await getOneMedia(ruData.arms as string);
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();
await getMedia();
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
} }
})(); })();
}, [id]); }, [id]);
@@ -97,32 +91,13 @@ export const CityEditPage = observer(() => {
editCityData[language].name, editCityData[language].name,
editCityData.country_code, editCityData.country_code,
media.id, media.id,
language, language
); );
}; };
const selectedMedia = const selectedMedia = editCityData.arms
editCityData.arms && !isMediaIdEmpty(editCityData.arms) ? mediaStore.media.find((m) => m.id === editCityData.arms)
? mediaStore.media.find((m) => m.id === editCityData.arms) : null;
: null;
const effectiveArmsUrl = isMediaIdEmpty(editCityData.arms)
? null
: selectedMedia?.id ?? editCityData.arms;
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных города..." />
</Box>
);
}
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
@@ -151,7 +126,7 @@ export const CityEditPage = observer(() => {
e.target.value, e.target.value,
editCityData.country_code, editCityData.country_code,
editCityData.arms, editCityData.arms,
language, language
) )
} }
/> />
@@ -167,7 +142,7 @@ export const CityEditPage = observer(() => {
editCityData[language].name, editCityData[language].name,
e.target.value, e.target.value,
editCityData.arms, editCityData.arms,
language, language
); );
}} }}
> >
@@ -183,17 +158,17 @@ export const CityEditPage = observer(() => {
<ImageUploadCard <ImageUploadCard
title="Герб города" title="Герб города"
imageKey="image" imageKey="image"
imageUrl={effectiveArmsUrl} imageUrl={selectedMedia?.id}
onImageClick={() => { onImageClick={() => {
setIsPreviewMediaOpen(true); setIsPreviewMediaOpen(true);
setMediaId(effectiveArmsUrl ?? ""); setMediaId(selectedMedia?.id ?? "");
}} }}
onDeleteImageClick={() => { onDeleteImageClick={() => {
setEditCityData( setEditCityData(
editCityData[language].name, editCityData[language].name,
editCityData.country_code, editCityData.country_code,
"", "",
language, language
); );
setActiveMenuType(null); setActiveMenuType(null);
}} }}
@@ -232,7 +207,7 @@ export const CityEditPage = observer(() => {
open={isSelectMediaOpen} open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)} onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect} onSelectMedia={handleMediaSelect}
mediaType={1} mediaType={1} // Тип медиа для иконок
/> />
<UploadMediaDialog <UploadMediaDialog

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales"; import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, languageStore, cityStore, countryStore, SearchInput } from "@shared"; import { languageStore, cityStore, countryStore } from "@shared";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react"; import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -18,13 +18,7 @@ export const CityListPage = observer(() => {
const [ids, setIds] = useState<number[]>([]); const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [rows, setRows] = useState<any[]>([]); const [rows, setRows] = useState<any[]>([]);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const { language } = languageStore; const { language } = languageStore;
const canWriteCities = authStore.canWrite("cities");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
@@ -58,18 +52,6 @@ export const CityListPage = observer(() => {
setRows(newRows2 || []); setRows(newRows2 || []);
}, [cities, countryStore.countries, language, isLoading]); }, [cities, countryStore.countries, language, isLoading]);
const filteredRows = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
if (!query) return rows;
return rows.filter((row) => {
const cityName = (row.name ?? "").toLowerCase();
const countryName = (
countryStore.countries[language]?.data?.find((c) => c.code === row.country)?.name ?? ""
).toLowerCase();
return cityName.includes(query) || countryName.includes(query);
});
}, [rows, searchQuery, countryStore.countries, language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
field: "country", field: "country",
@@ -105,30 +87,35 @@ export const CityListPage = observer(() => {
); );
}, },
}, },
...(authStore.canWrite("cities") ? [{ {
field: "actions", field: "actions",
headerName: "Действия", headerName: "Действия",
align: "center" as const, align: "center",
headerAlign: "center" as const, headerAlign: "center",
width: 200, width: 200,
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => {
<div className="flex h-full gap-7 justify-center items-center"> return (
<button onClick={() => navigate(`/city/${params.row.id}/edit`)}> <div className="flex h-full gap-7 justify-center items-center">
<Pencil size={20} className="text-blue-500" /> <button onClick={() => navigate(`/city/${params.row.id}/edit`)}>
</button> <Pencil size={20} className="text-blue-500" />
<button </button>
onClick={(e) => { {/* <button onClick={() => navigate(`/city/${params.row.id}`)}>
e.stopPropagation(); <Eye size={20} className="text-green-500" />
setIsDeleteModalOpen(true); </button> */}
setRowId(params.row.id); <button
}} onClick={(e) => {
> e.stopPropagation();
<Trash2 size={20} className="text-red-500" /> setIsDeleteModalOpen(true);
</button> setRowId(params.row.id);
</div> }}
), >
}] : []), <Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
]; ];
return ( return (
@@ -138,59 +125,32 @@ export const CityListPage = observer(() => {
<div className="w-full"> <div className="w-full">
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Города</h1> <h1 className="text-2xl">Города</h1>
{canWriteCities && ( <CreateButton label="Создать город" path="/city/create" />
<CreateButton label="Создать город" path="/city/create" />
)}
</div> </div>
{canWriteCities && ids.length > 0 && ( <div
<div className="flex justify-end mb-5 duration-300"> className="flex justify-end mb-5 duration-300"
<button style={{ opacity: ids.length > 0 ? 1 : 0 }}
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" >
onClick={() => setIsBulkDeleteModalOpen(true)} <button
> className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
<Trash2 size={20} className="text-white" /> Удалить выбранные ( onClick={() => setIsBulkDeleteModalOpen(true)}
{ids.length}) >
</button> <Trash2 size={20} className="text-white" /> Удалить выбранные (
</div> {ids.length})
)} </button>
</div>
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<DataGrid <DataGrid
rows={filteredRows} rows={rows}
columns={columns} columns={columns}
checkboxSelection={canWriteCities} hideFooter
disableRowSelectionExcludeModel checkboxSelection
loading={isLoading} loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={ onRowSelectionModelChange={(newSelection) => {
canWriteCities setIds(Array.from(newSelection.ids as unknown as number[]));
? (newSelection: any) => { }}
if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map(
(id: string | number) => Number(id)
);
setIds(selectedIds);
} else if (
newSelection &&
typeof newSelection === "object" &&
"ids" in newSelection
) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map(
(id: string | number) => Number(id)
);
setIds(selectedIds);
} else {
setIds([]);
}
}
: undefined
}
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>

View File

@@ -1,23 +1,23 @@
import { Button, Paper, TextField, Box } from "@mui/material"; import { Button, Paper, TextField } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { countryStore, languageStore, LoadingSpinner } from "@shared"; import { countryStore, languageStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets"; import { LanguageSwitcher } from "@widgets";
export const CountryEditPage = observer(() => { export const CountryEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const { language } = languageStore; const { language } = languageStore;
const { id } = useParams(); const { id } = useParams();
const { editCountryData, editCountry, getCountry, setEditCountryData } = const { editCountryData, editCountry, getCountry, setEditCountryData } =
countryStore; countryStore;
useEffect(() => { useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}, []); }, []);
@@ -36,39 +36,19 @@ export const CountryEditPage = observer(() => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
setIsLoadingData(true); // Fetch data for all languages
try { const ruData = await getCountry(id as string, "ru");
const ruData = await getCountry(id as string, "ru"); const enData = await getCountry(id as string, "en");
const enData = await getCountry(id as string, "en"); const zhData = await getCountry(id as string, "zh");
const zhData = await getCountry(id as string, "zh");
setEditCountryData(ruData.name, "ru"); // Set data for each language
setEditCountryData(enData.name, "en"); setEditCountryData(ruData.name, "ru");
setEditCountryData(zhData.name, "zh"); setEditCountryData(enData.name, "en");
} finally { setEditCountryData(zhData.name, "zh");
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
} }
})(); })();
}, [id]); }, [id]);
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных страны..." />
</Box>
);
}
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher /> <LanguageSwitcher />

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales"; import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, countryStore, languageStore, SearchInput } from "@shared"; import { countryStore, languageStore } from "@shared";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Trash2, Minus } from "lucide-react"; import { Trash2, Minus } from "lucide-react";
@@ -16,13 +16,7 @@ export const CountryListPage = observer(() => {
const [rowId, setRowId] = useState<string | null>(null); const [rowId, setRowId] = useState<string | null>(null);
const [ids, setIds] = useState<number[]>([]); const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const { language } = languageStore; const { language } = languageStore;
const canWriteCountries = authStore.canWrite("countries");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => { useEffect(() => {
const fetchCountries = async () => { const fetchCountries = async () => {
@@ -50,39 +44,44 @@ export const CountryListPage = observer(() => {
); );
}, },
}, },
...(authStore.canWrite("countries") ? [{ {
field: "actions", field: "actions",
headerName: "Действия", headerName: "Действия",
align: "center" as const, align: "center",
headerAlign: "center" as const, headerAlign: "center",
width: 200, width: 200,
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => {
<div className="flex h-full gap-7 justify-center items-center"> return (
<button <div className="flex h-full gap-7 justify-center items-center">
onClick={(e) => { {/* <button
e.stopPropagation(); onClick={() => navigate(`/country/${params.row.code}/edit`)}
setIsDeleteModalOpen(true); >
setRowId(params.row.code); <Pencil size={20} className="text-blue-500" />
}} </button> */}
> {/* <button onClick={() => navigate(`/country/${params.row.code}`)}>
<Trash2 size={20} className="text-red-500" /> <Eye size={20} className="text-green-500" />
</button> </button> */}
</div> <button
), onClick={(e) => {
}] : []), e.stopPropagation();
setIsDeleteModalOpen(true);
setRowId(params.row.code);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
]; ];
const rows = useMemo(() => { const rows = countries[language]?.data.map((country) => ({
const query = searchQuery.trim().toLowerCase(); id: country.code,
return (countries[language]?.data ?? []) code: country.code,
.filter((country) => !query || (country.name ?? "").toLowerCase().includes(query)) name: country.name,
.map((country) => ({ }));
id: country.code,
code: country.code,
name: country.name,
}));
}, [countries[language]?.data, searchQuery]);
return ( return (
<> <>
@@ -91,59 +90,32 @@ export const CountryListPage = observer(() => {
<div className="w-full"> <div className="w-full">
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Страны</h1> <h1 className="text-2xl">Страны</h1>
{canWriteCountries && ( <CreateButton label="Добавить страну" path="/country/add" />
<CreateButton label="Добавить страну" path="/country/add" />
)}
</div> </div>
{canWriteCountries && ids.length > 0 && ( <div
<div className="flex justify-end mb-5 duration-300"> className="flex justify-end mb-5 duration-300"
<button style={{ opacity: ids.length > 0 ? 1 : 0 }}
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" >
onClick={() => setIsBulkDeleteModalOpen(true)} <button
> className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
<Trash2 size={20} className="text-white" /> Удалить выбранные ( onClick={() => setIsBulkDeleteModalOpen(true)}
{ids.length}) >
</button> <Trash2 size={20} className="text-white" /> Удалить выбранные (
</div> {ids.length})
)} </button>
</div>
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<DataGrid <DataGrid
rows={rows} rows={rows || []}
columns={columns} columns={columns}
checkboxSelection={canWriteCountries} hideFooter
disableRowSelectionExcludeModel checkboxSelection
loading={isLoading} loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={ onRowSelectionModelChange={(newSelection) => {
canWriteCountries setIds(Array.from(newSelection.ids as unknown as number[]));
? (newSelection: any) => { }}
if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map(
(id: string | number) => Number(id)
);
setIds(selectedIds);
} else if (
newSelection &&
typeof newSelection === "object" &&
"ids" in newSelection
) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map(
(id: string | number) => Number(id)
);
setIds(selectedIds);
} else {
setIds([]);
}
}
: undefined
}
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>

View File

@@ -1,7 +1,6 @@
import { Box, Tab, Tabs } from "@mui/material"; import { Box, Tab, Tabs } from "@mui/material";
import { import {
articlesStore, articlesStore,
authStore,
cityStore, cityStore,
createSightStore, createSightStore,
languageStore, languageStore,
@@ -41,14 +40,7 @@ export const CreateSightPage = observer(() => {
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
if (!authStore.me) { await getCities("ru");
await authStore.getMeAction().catch(() => undefined);
}
if (authStore.canRead("cities")) {
await getCities("ru");
} else {
await authStore.fetchMeCities().catch(() => undefined);
}
await getArticles(languageStore.language); await getArticles(languageStore.language);
}; };
fetchData(); fetchData();

View File

@@ -3,13 +3,7 @@ import { InformationTab, LeaveAgree, RightWidgetTab } from "@widgets";
import { LeftWidgetTab } from "@widgets"; import { LeftWidgetTab } from "@widgets";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { import { articlesStore, cityStore, editSightStore } from "@shared";
articlesStore,
authStore,
cityStore,
editSightStore,
LoadingSpinner,
} from "@shared";
import { useBlocker, useParams } from "react-router-dom"; import { useBlocker, useParams } from "react-router-dom";
function a11yProps(index: number) { function a11yProps(index: number) {
@@ -21,8 +15,7 @@ function a11yProps(index: number) {
export const EditSightPage = observer(() => { export const EditSightPage = observer(() => {
const [value, setValue] = useState(0); const [value, setValue] = useState(0);
const [isLoadingData, setIsLoadingData] = useState(true); const { sight, getSightInfo, needLeaveAgree } = editSightStore;
const { sight, getSightInfo, needLeaveAgree, getRightArticles } = editSightStore;
const { getArticles } = articlesStore; const { getArticles } = articlesStore;
const { id } = useParams(); const { id } = useParams();
@@ -40,29 +33,13 @@ export const EditSightPage = observer(() => {
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
if (id) { if (id) {
setIsLoadingData(true); await getCities("ru");
try { await getSightInfo(+id, "ru");
if (!authStore.me) { await getSightInfo(+id, "en");
await authStore.getMeAction().catch(() => undefined); await getSightInfo(+id, "zh");
} await getArticles("ru");
if (authStore.canRead("cities")) { await getArticles("en");
await getCities("ru"); await getArticles("zh");
} else {
await authStore.fetchMeCities().catch(() => undefined);
}
await getSightInfo(+id, "ru");
await getSightInfo(+id, "en");
await getSightInfo(+id, "zh");
await getArticles("ru");
await getArticles("en");
await getArticles("zh");
// Загружаем данные правого виджета перед завершением загрузки
await getRightArticles(+id);
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
} }
}; };
fetchData(); fetchData();
@@ -102,25 +79,12 @@ export const EditSightPage = observer(() => {
</Tabs> </Tabs>
</Box> </Box>
{isLoadingData ? ( {sight.common.id !== 0 && (
<Box <div className="flex-1">
sx={{ <InformationTab value={value} index={0} />
display: "flex", <LeftWidgetTab value={value} index={1} />
justifyContent: "center", <RightWidgetTab value={value} index={2} />
alignItems: "center", </div>
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных достопримечательности..." />
</Box>
) : (
sight.common.id !== 0 && (
<div className="flex-1">
<InformationTab value={value} index={0} />
<LeftWidgetTab value={value} index={1} />
<RightWidgetTab value={value} index={2} />
</div>
)
)} )}
{blocker.state === "blocked" ? <LeaveAgree blocker={blocker} /> : null} {blocker.state === "blocked" ? <LeaveAgree blocker={blocker} /> : null}

View File

@@ -24,6 +24,7 @@ export const LoginPage = () => {
const { login } = authStore; const { login } = authStore;
const { getUsers } = userStore; const { getUsers } = userStore;
useEffect(() => { useEffect(() => {
// Load saved credentials if they exist
const savedEmail = localStorage.getItem("rememberedEmail"); const savedEmail = localStorage.getItem("rememberedEmail");
const savedPassword = localStorage.getItem("rememberedPassword"); const savedPassword = localStorage.getItem("rememberedPassword");
if (savedEmail && savedPassword) { if (savedEmail && savedPassword) {
@@ -41,6 +42,7 @@ export const LoginPage = () => {
try { try {
await login(email, password); await login(email, password);
// Save or clear credentials based on remember me checkbox
if (rememberMe) { if (rememberMe) {
localStorage.setItem("rememberedEmail", email); localStorage.setItem("rememberedEmail", email);
localStorage.setItem("rememberedPassword", password); localStorage.setItem("rememberedPassword", password);

View File

@@ -1,5 +1,37 @@
import * as React from "react"; import * as React from "react";
import Typography from "@mui/material/Typography";
export const MainPage: React.FC = () => { export const MainPage: React.FC = () => {
return null; return (
<>
<Typography sx={{ marginBottom: 2 }}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Rhoncus dolor purus
non enim praesent elementum facilisis leo vel. Risus at ultrices mi
tempus imperdiet. Semper risus in hendrerit gravida rutrum quisque non
tellus. Convallis convallis tellus id interdum velit laoreet id donec
ultrices. Odio morbi quis commodo odio aenean sed adipiscing. Amet nisl
suscipit adipiscing bibendum est ultricies integer quis. Cursus euismod
quis viverra nibh cras. Metus vulputate eu scelerisque felis imperdiet
proin fermentum leo. Mauris commodo quis imperdiet massa tincidunt. Cras
tincidunt lobortis feugiat vivamus at augue. At augue eget arcu dictum
varius duis at consectetur lorem. Velit sed ullamcorper morbi tincidunt.
Lorem donec massa sapien faucibus et molestie ac.
</Typography>
<Typography sx={{ marginBottom: 2 }}>
Consequat mauris nunc congue nisi vitae suscipit. Fringilla est
ullamcorper eget nulla facilisi etiam dignissim diam. Pulvinar elementum
integer enim neque volutpat ac tincidunt. Ornare suspendisse sed nisi
lacus sed viverra tellus. Purus sit amet volutpat consequat mauris.
Elementum eu facilisis sed odio morbi. Euismod lacinia at quis risus sed
vulputate odio. Morbi tincidunt ornare massa eget egestas purus viverra
accumsan in. In hendrerit gravida rutrum quisque non tellus orci ac.
Pellentesque nec nam aliquam sem et tortor. Habitant morbi tristique
senectus et. Adipiscing elit duis tristique sollicitudin nibh sit.
Ornare aenean euismod elementum nisi quis eleifend. Commodo viverra
maecenas accumsan lacus vel facilisis. Nulla posuere sollicitudin
aliquam ultrices sagittis orci a.
</Typography>
</>
);
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -22,8 +22,10 @@ interface ApiSight {
longitude: number; longitude: number;
} }
// Допуск для сравнения координат, чтобы избежать ошибок с точностью чисел.
const COORDINATE_PRECISION_TOLERANCE = 1e-9; const COORDINATE_PRECISION_TOLERANCE = 1e-9;
// Вспомогательная функция, обновленная для сравнения с допуском.
const arePathsEqual = ( const arePathsEqual = (
path1: [number, number][], path1: [number, number][],
path2: [number, number][] path2: [number, number][]
@@ -69,7 +71,7 @@ class MapStore {
path: route.path, path: route.path,
})); }));
this.routes = mappedRoutes.sort((a, b) => this.routes = mappedRoutes.sort((a, b) =>
a.route_number.trim().localeCompare(b.route_number.trim()) a.route_number.localeCompare(b.route_number)
); );
}; };
@@ -134,6 +136,7 @@ class MapStore {
longitude: geometry.coordinates[0], longitude: geometry.coordinates[0],
}; };
// ИЗМЕНЕНИЕ: Сравнение координат с допуском
if ( if (
originalStation.name !== currentStation.name || originalStation.name !== currentStation.name ||
Math.abs(originalStation.latitude - currentStation.latitude) > Math.abs(originalStation.latitude - currentStation.latitude) >
@@ -152,6 +155,7 @@ class MapStore {
path: geometry.coordinates, path: geometry.coordinates,
}; };
// ИЗМЕНЕНИЕ: Используется новая функция arePathsEqual с допуском
if ( if (
originalRoute.route_number !== currentRoute.route_number || originalRoute.route_number !== currentRoute.route_number ||
!arePathsEqual(originalRoute.path, currentRoute.path) !arePathsEqual(originalRoute.path, currentRoute.path)
@@ -169,6 +173,7 @@ class MapStore {
longitude: geometry.coordinates[0], longitude: geometry.coordinates[0],
}; };
// ИЗМЕНЕНИЕ: Сравнение координат с допуском
if ( if (
originalSight.name !== currentSight.name || originalSight.name !== currentSight.name ||
originalSight.description !== currentSight.description || originalSight.description !== currentSight.description ||

View File

@@ -21,7 +21,6 @@ import {
mediaStore, mediaStore,
MEDIA_TYPE_LABELS, MEDIA_TYPE_LABELS,
languageStore, languageStore,
LoadingSpinner,
} from "@shared"; } from "@shared";
import { MediaViewer } from "@widgets"; import { MediaViewer } from "@widgets";
@@ -34,7 +33,7 @@ export const MediaEditPage = observer(() => {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [newFile, setNewFile] = useState<File | null>(null); const [newFile, setNewFile] = useState<File | null>(null);
const [uploadDialogOpen, setUploadDialogOpen] = useState(false); const [uploadDialogOpen, setUploadDialogOpen] = useState(false); // State for the upload dialog
const media = id ? mediaStore.media.find((m) => m.id === id) : null; const media = id ? mediaStore.media.find((m) => m.id === id) : null;
const [mediaName, setMediaName] = useState(media?.media_name ?? ""); const [mediaName, setMediaName] = useState(media?.media_name ?? "");
@@ -56,27 +55,51 @@ export const MediaEditPage = observer(() => {
setMediaFilename(media.filename); setMediaFilename(media.filename);
setMediaType(media.media_type); setMediaType(media.media_type);
// Set available media types based on current file extension
const extension = media.filename.split(".").pop()?.toLowerCase(); const extension = media.filename.split(".").pop()?.toLowerCase();
if (extension) { if (extension) {
if (["glb", "gltf"].includes(extension)) { if (["glb", "gltf"].includes(extension)) {
setAvailableMediaTypes([6]); setAvailableMediaTypes([6]); // 3D model
} else if ( } else if (
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes( ["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
extension extension
) )
) { ) {
setAvailableMediaTypes([1, 3, 4, 5]); setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
} else if (["mp4", "webm", "mov"].includes(extension)) { } else if (["mp4", "webm", "mov"].includes(extension)) {
setAvailableMediaTypes([2]); setAvailableMediaTypes([2]); // Video
} }
} }
} }
}, [media]); }, [media]);
useEffect(() => { useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}, []); }, []);
// const handleDrop = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault();
// e.stopPropagation();
// setIsDragging(false);
// const files = Array.from(e.dataTransfer.files);
// if (files.length > 0) {
// setNewFile(files[0]);
// setMediaFilename(files[0].name);
// setUploadDialogOpen(true); // Open dialog on file drop
// }
// };
// const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault();
// setIsDragging(true);
// };
// const handleDragLeave = () => {
// setIsDragging(false);
// };
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files; const files = e.target.files;
if (files && files.length > 0) { if (files && files.length > 0) {
@@ -84,25 +107,26 @@ export const MediaEditPage = observer(() => {
setNewFile(file); setNewFile(file);
setMediaFilename(file.name); setMediaFilename(file.name);
// Determine media type based on file extension
const extension = file.name.split(".").pop()?.toLowerCase(); const extension = file.name.split(".").pop()?.toLowerCase();
if (extension) { if (extension) {
if (["glb", "gltf"].includes(extension)) { if (["glb", "gltf"].includes(extension)) {
setAvailableMediaTypes([6]); setAvailableMediaTypes([6]); // 3D model
setMediaType(6); setMediaType(6);
} else if ( } else if (
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes( ["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
extension extension
) )
) { ) {
setAvailableMediaTypes([1, 3, 4, 5]); setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
setMediaType(1); setMediaType(1); // Default to Photo
} else if (["mp4", "webm", "mov"].includes(extension)) { } else if (["mp4", "webm", "mov"].includes(extension)) {
setAvailableMediaTypes([2]); setAvailableMediaTypes([2]); // Video
setMediaType(2); setMediaType(2);
} }
} }
setUploadDialogOpen(true); setUploadDialogOpen(true); // Open dialog on file selection
} }
}; };
@@ -119,6 +143,11 @@ export const MediaEditPage = observer(() => {
type: mediaType, type: mediaType,
}); });
// If a new file was selected, the actual file upload will happen
// via the UploadMediaDialog. We just need to make sure the metadata
// is updated correctly before or after.
// Since the dialog handles the actual upload, we don't call updateMediaFile here.
setSuccess(true); setSuccess(true);
handleUploadSuccess(); handleUploadSuccess();
} catch (err) { } catch (err) {
@@ -129,25 +158,20 @@ export const MediaEditPage = observer(() => {
}; };
const handleUploadSuccess = () => { const handleUploadSuccess = () => {
// After successful upload in the dialog, refresh media data if needed
if (id) { if (id) {
mediaStore.getOneMedia(id); mediaStore.getOneMedia(id);
} }
setNewFile(null); setNewFile(null); // Clear the new file state after successful upload
setUploadDialogOpen(false); setUploadDialogOpen(false);
setSuccess(true); setSuccess(true);
}; };
if (!media && id) { if (!media && id) {
// Only show loading if an ID is present and media is not yet loaded
return ( return (
<Box <Box className="flex justify-center items-center h-screen">
sx={{ <CircularProgress />
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных медиа..." />
</Box> </Box>
); );
} }

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales"; import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, languageStore, MEDIA_TYPE_LABELS, mediaStore, SearchInput } from "@shared"; import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Eye, Trash2, Minus } from "lucide-react"; import { Eye, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -16,13 +16,7 @@ export const MediaListPage = observer(() => {
const [rowId, setRowId] = useState<string | null>(null); const [rowId, setRowId] = useState<string | null>(null);
const [ids, setIds] = useState<string[]>([]); const [ids, setIds] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const { language } = languageStore; const { language } = languageStore;
const canWriteMedia = authStore.canWrite("sights");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => { useEffect(() => {
const fetchMedia = async () => { const fetchMedia = async () => {
@@ -73,15 +67,16 @@ export const MediaListPage = observer(() => {
field: "actions", field: "actions",
headerName: "Действия", headerName: "Действия",
width: 200, width: 200,
align: "center" as const, align: "center",
headerAlign: "center" as const, headerAlign: "center",
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> renderCell: (params: GridRenderCellParams) => {
<button onClick={() => navigate(`/media/${params.row.id}`)}> return (
<Eye size={20} className="text-green-500" /> <div className="flex h-full gap-7 justify-center items-center">
</button> <button onClick={() => navigate(`/media/${params.row.id}`)}>
{canWriteMedia && ( <Eye size={20} className="text-green-500" />
</button>
<button <button
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
@@ -90,73 +85,44 @@ export const MediaListPage = observer(() => {
> >
<Trash2 size={20} className="text-red-500" /> <Trash2 size={20} className="text-red-500" />
</button> </button>
)} </div>
</div> );
), },
}, },
]; ];
const rows = useMemo(() => { const rows = media.map((media) => ({
const query = searchQuery.trim().toLowerCase(); id: media.id,
return media media_name: media.media_name,
.filter((item) => !query || (item.media_name ?? "").toLowerCase().includes(query)) media_type: media.media_type,
.map((item) => ({ }));
id: item.id,
media_name: item.media_name,
media_type: item.media_type,
}));
}, [media, searchQuery]);
return ( return (
<> <>
<div className="w-full"> <div className="w-full">
<SearchInput value={searchQuery} onChange={setSearchQuery} /> <div
className="flex justify-end mb-5 duration-300"
{canWriteMedia && ids.length > 0 && ( style={{ opacity: ids.length > 0 ? 1 : 0 }}
<div className="flex justify-end mb-5 duration-300"> >
<button <button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)} onClick={() => setIsBulkDeleteModalOpen(true)}
> >
<Trash2 size={20} className="text-white" /> Удалить выбранные ( <Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length}) {ids.length})
</button> </button>
</div> </div>
)}
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
checkboxSelection={canWriteMedia} hideFooterPagination
disableRowSelectionExcludeModel checkboxSelection
loading={isLoading} loading={isLoading}
paginationModel={paginationModel} onRowSelectionModelChange={(newSelection) => {
onPaginationModelChange={setPaginationModel} setIds(Array.from(newSelection.ids) as string[]);
pageSizeOptions={[50]} }}
onRowSelectionModelChange={ hideFooter
canWriteMedia
? (newSelection: any) => {
if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map(
(id: string | number) => String(id)
);
setIds(selectedIds);
} else if (
newSelection &&
typeof newSelection === "object" &&
"ids" in newSelection
) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map(
(id: string | number) => String(id)
);
setIds(selectedIds);
} else {
setIds([]);
}
}
: undefined
}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (

View File

@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import { import {
Stack, Stack,
Typography, Typography,
Button,
FormControl, FormControl,
Accordion, Accordion,
AccordionSummary, AccordionSummary,
@@ -34,15 +35,14 @@ import {
} from "@hello-pangea/dnd"; } from "@hello-pangea/dnd";
import { import {
AnimatedCircleButton,
authInstance, authInstance,
languageStore, languageStore,
routeStore, routeStore,
selectedCityStore, selectedCityStore,
stationsStore,
} from "@shared"; } from "@shared";
import { EditStationModal } from "../../widgets/modals/EditStationModal"; import { EditStationModal } from "../../widgets/modals/EditStationModal";
// Helper function to insert an item at a specific position (1-based index)
function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] { function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] {
const index = pos - 1; const index = pos - 1;
const result = [...arr]; const result = [...arr];
@@ -54,6 +54,7 @@ function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] {
return result; return result;
} }
// Helper function to reorder items after drag and drop
const reorder = <T,>(list: T[], startIndex: number, endIndex: number): T[] => { const reorder = <T,>(list: T[], startIndex: number, endIndex: number): T[] => {
const result = Array.from(list); const result = Array.from(list);
const [removed] = result.splice(startIndex, 1); const [removed] = result.splice(startIndex, 1);
@@ -78,6 +79,7 @@ type LinkedItemsProps<T> = {
disableCreation?: boolean; disableCreation?: boolean;
updatedLinkedItems?: T[]; updatedLinkedItems?: T[];
refresh?: number; refresh?: number;
routeDirection?: boolean;
}; };
export const LinkedItems = < export const LinkedItems = <
@@ -127,6 +129,7 @@ const LinkedItemsContentsInner = <
disableCreation = false, disableCreation = false,
updatedLinkedItems, updatedLinkedItems,
refresh, refresh,
routeDirection,
}: LinkedItemsProps<T>) => { }: LinkedItemsProps<T>) => {
const { language } = languageStore; const { language } = languageStore;
@@ -140,9 +143,6 @@ const LinkedItemsContentsInner = <
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set()); const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [activeTab, setActiveTab] = useState(0); const [activeTab, setActiveTab] = useState(0);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [isLinkingSingle, setIsLinkingSingle] = useState(false);
const [isLinkingBulk, setIsLinkingBulk] = useState(false);
const [detachingIds, setDetachingIds] = useState<Set<number>>(new Set());
useEffect(() => {}, [error]); useEffect(() => {}, [error]);
@@ -152,6 +152,13 @@ const LinkedItemsContentsInner = <
const availableItems = allItems const availableItems = allItems
.filter((item) => !linkedItems.some((linked) => linked.id === item.id)) .filter((item) => !linkedItems.some((linked) => linked.id === item.id))
.filter((item) => { .filter((item) => {
// Если направление маршрута не указано, показываем все станции
if (routeDirection === undefined) return true;
// Фильтруем станции по направлению маршрута
return item.direction === routeDirection;
})
.filter((item) => {
// Фильтруем по городу из навбара
const selectedCityId = selectedCityStore.selectedCityId; const selectedCityId = selectedCityStore.selectedCityId;
if (selectedCityId && "city_id" in item) { if (selectedCityId && "city_id" in item) {
return item.city_id === selectedCityId; return item.city_id === selectedCityId;
@@ -160,12 +167,10 @@ const LinkedItemsContentsInner = <
}) })
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
// Фильтрация по поиску для массового режима
const filteredAvailableItems = availableItems.filter((item) => { const filteredAvailableItems = availableItems.filter((item) => {
if (!searchQuery.trim()) return true; if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase(); return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());
const name = String(item.name || "").toLowerCase();
const description = String(item.description || "").toLowerCase();
return name.includes(query) || description.includes(query);
}); });
useEffect(() => { useEffect(() => {
@@ -182,19 +187,6 @@ const LinkedItemsContentsInner = <
setPosition(linkedItems.length + 1); setPosition(linkedItems.length + 1);
}, [linkedItems.length]); }, [linkedItems.length]);
const getStationTransfers = (stationId: number, fallbackTransfers?: any) => {
const { stationLists } = stationsStore;
for (const lang of ["ru", "en", "zh"] as const) {
const station = stationLists[lang].data.find(
(s: any) => s.id === stationId
);
if (station?.transfers) {
return station.transfers;
}
}
return fallbackTransfers;
};
const onDragEnd = (result: DropResult) => { const onDragEnd = (result: DropResult) => {
if (!result.destination) return; if (!result.destination) return;
@@ -208,13 +200,7 @@ const LinkedItemsContentsInner = <
authInstance authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, { .post(`/${parentResource}/${parentId}/${childResource}`, {
stations: reorderedItems.map((item) => { stations: reorderedItems.map((item) => ({ id: item.id })),
const transfers = getStationTransfers(item.id, item.transfers);
return {
...item,
transfers: transfers || item.transfers,
};
}),
}) })
.catch((error) => { .catch((error) => {
console.error("Error updating station order:", error); console.error("Error updating station order:", error);
@@ -252,7 +238,7 @@ const LinkedItemsContentsInner = <
}) })
.catch((error) => { .catch((error) => {
console.error("Error fetching all items:", error); console.error("Error fetching all items:", error);
setError(null); setError("Failed to load available stations");
setAllItems([]); setAllItems([]);
}); });
} }
@@ -261,32 +247,14 @@ const LinkedItemsContentsInner = <
const linkItem = () => { const linkItem = () => {
if (selectedItemId !== null) { if (selectedItemId !== null) {
setError(null); setError(null);
const selectedItem = allItems.find((item) => item.id === selectedItemId);
const requestData = { const requestData = {
stations: insertAtPosition( stations: insertAtPosition(
linkedItems.map((item) => { linkedItems.map((item) => ({ id: item.id })),
const transfers = getStationTransfers(item.id, item.transfers);
return {
...item,
transfers: transfers || item.transfers,
};
}),
position, position,
(() => { { id: selectedItemId }
if (!selectedItem) return { id: selectedItemId };
const transfers = getStationTransfers(
selectedItemId,
selectedItem.transfers
);
return {
...selectedItem,
transfers: transfers || selectedItem.transfers,
};
})()
), ),
}; };
setIsLinkingSingle(true);
authInstance authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, requestData) .post(`/${parentResource}/${parentId}/${childResource}`, requestData)
.then(() => { .then(() => {
@@ -305,20 +273,12 @@ const LinkedItemsContentsInner = <
.catch((error) => { .catch((error) => {
console.error("Error linking item:", error); console.error("Error linking item:", error);
setError("Failed to link station"); setError("Failed to link station");
})
.finally(() => {
setIsLinkingSingle(false);
}); });
} }
}; };
const deleteItem = (itemId: number) => { const deleteItem = (itemId: number) => {
setError(null); setError(null);
setDetachingIds((prev) => {
const next = new Set(prev);
next.add(itemId);
return next;
});
authInstance authInstance
.delete(`/${parentResource}/${parentId}/${childResource}`, { .delete(`/${parentResource}/${parentId}/${childResource}`, {
data: { [`${childResource}_id`]: itemId }, data: { [`${childResource}_id`]: itemId },
@@ -330,13 +290,6 @@ const LinkedItemsContentsInner = <
.catch((error) => { .catch((error) => {
console.error("Error deleting item:", error); console.error("Error deleting item:", error);
setError("Failed to delete station"); setError("Failed to delete station");
})
.finally(() => {
setDetachingIds((prev) => {
const next = new Set(prev);
next.delete(itemId);
return next;
});
}); });
}; };
@@ -363,25 +316,10 @@ const LinkedItemsContentsInner = <
if (selectedItems.size === 0) return; if (selectedItems.size === 0) return;
setError(null); setError(null);
setIsLinkingBulk(true); const selectedStations = Array.from(selectedItems).map((id) => ({ id }));
const selectedStations = Array.from(selectedItems).map((id) => {
const item = allItems.find((item) => item.id === id);
if (!item) return { id };
const transfers = getStationTransfers(id, item.transfers);
return {
...item,
transfers: transfers || item.transfers,
};
});
const requestData = { const requestData = {
stations: [ stations: [
...linkedItems.map((item) => { ...linkedItems.map((item) => ({ id: item.id })),
const transfers = getStationTransfers(item.id, item.transfers);
return {
...item,
transfers: transfers || item.transfers,
};
}),
...selectedStations, ...selectedStations,
], ],
}; };
@@ -397,9 +335,6 @@ const LinkedItemsContentsInner = <
.catch((error) => { .catch((error) => {
console.error("Error linking items:", error); console.error("Error linking items:", error);
setError("Failed to link stations"); setError("Failed to link stations");
})
.finally(() => {
setIsLinkingBulk(false);
}); });
}; };
@@ -469,7 +404,7 @@ const LinkedItemsContentsInner = <
))} ))}
{type === "edit" && ( {type === "edit" && (
<TableCell> <TableCell>
<AnimatedCircleButton <Button
variant="outlined" variant="outlined"
color="error" color="error"
size="small" size="small"
@@ -477,11 +412,9 @@ const LinkedItemsContentsInner = <
e.stopPropagation(); e.stopPropagation();
deleteItem(item.id); deleteItem(item.id);
}} }}
disabled={detachingIds.has(item.id)}
loading={detachingIds.has(item.id)}
> >
Отвязать Отвязать
</AnimatedCircleButton> </Button>
</TableCell> </TableCell>
)} )}
</TableRow> </TableRow>
@@ -506,6 +439,12 @@ const LinkedItemsContentsInner = <
{type === "edit" && !disableCreation && ( {type === "edit" && !disableCreation && (
<Stack gap={2} mt={2}> <Stack gap={2} mt={2}>
<Typography variant="subtitle1">Добавить остановки</Typography> <Typography variant="subtitle1">Добавить остановки</Typography>
{routeDirection !== undefined && (
<Typography variant="body2" color="textSecondary">
Показываются только остановки для{" "}
{routeDirection ? "прямого" : "обратного"} направления
</Typography>
)}
<Tabs <Tabs
value={activeTab} value={activeTab}
@@ -534,7 +473,6 @@ const LinkedItemsContentsInner = <
<TextField <TextField
{...params} {...params}
label="Выберите остановку" label="Выберите остановку"
placeholder="Введите название или описание остановки..."
fullWidth fullWidth
/> />
)} )}
@@ -542,15 +480,16 @@ const LinkedItemsContentsInner = <
option.id === value?.id option.id === value?.id
} }
filterOptions={(options, { inputValue }) => { filterOptions={(options, { inputValue }) => {
if (!inputValue.trim()) return options; const searchWords = inputValue
const query = inputValue.toLowerCase(); .toLowerCase()
.split(" ")
.filter(Boolean);
return options.filter((option) => { return options.filter((option) => {
const name = String(option.name || "").toLowerCase(); const optionWords = String(option.name)
const description = String( .toLowerCase()
option.description || "" .split(" ");
).toLowerCase(); return searchWords.every((searchWord) =>
return ( optionWords.some((word) => word.startsWith(searchWord))
name.includes(query) || description.includes(query)
); );
}); });
}} }}
@@ -586,15 +525,14 @@ const LinkedItemsContentsInner = <
/> />
</FormControl> </FormControl>
<AnimatedCircleButton <Button
variant="contained" variant="contained"
onClick={linkItem} onClick={linkItem}
disabled={!selectedItemId || isLinkingSingle} disabled={!selectedItemId}
loading={isLinkingSingle}
sx={{ alignSelf: "flex-start" }} sx={{ alignSelf: "flex-start" }}
> >
Добавить Добавить
</AnimatedCircleButton> </Button>
</Stack> </Stack>
)} )}
@@ -624,14 +562,7 @@ const LinkedItemsContentsInner = <
size="small" size="small"
/> />
} }
label={ label={String(item.name)}
<div className="flex justify-between items-center w-full gap-10">
<p>{String(item.name)}</p>
<p className="text-xs text-gray-500 max-w-[300px] truncate text-ellipsis">
{String(item.description)}
</p>
</div>
}
sx={{ sx={{
margin: 0, margin: 0,
"& .MuiFormControlLabel-label": { "& .MuiFormControlLabel-label": {
@@ -654,15 +585,14 @@ const LinkedItemsContentsInner = <
</Stack> </Stack>
</Paper> </Paper>
<AnimatedCircleButton <Button
variant="contained" variant="contained"
onClick={handleBulkLink} onClick={handleBulkLink}
disabled={selectedItems.size === 0 || isLinkingBulk} disabled={selectedItems.size === 0}
loading={isLinkingBulk}
sx={{ alignSelf: "flex-start" }} sx={{ alignSelf: "flex-start" }}
> >
Добавить выбранные ({selectedItems.size}) Добавить выбранные ({selectedItems.size})
</AnimatedCircleButton> </Button>
</Stack> </Stack>
)} )}
</Box> </Box>

View File

@@ -13,30 +13,22 @@ import {
DialogContent, DialogContent,
DialogActions, DialogActions,
} from "@mui/material"; } from "@mui/material";
import { import { MediaViewer, VideoPreviewCard } from "@widgets";
MediaViewer,
VideoPreviewCard,
ImageUploadCard,
} from "@widgets";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save, Plus, X } from "lucide-react"; import { ArrowLeft, Loader2, Save, Plus } from "lucide-react";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { carrierStore } from "../../../shared/store/CarrierStore";
import { articlesStore } from "../../../shared/store/ArticlesStore";
import { Route, routeStore } from "../../../shared/store/RouteStore";
import { import {
carrierStore,
articlesStore,
routeStore,
languageStore, languageStore,
mediaStore,
isMediaIdEmpty,
ArticleSelectOrCreateDialog, ArticleSelectOrCreateDialog,
SelectMediaDialog, SelectMediaDialog,
selectedCityStore, selectedCityStore,
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog,
} from "@shared"; } from "@shared";
import type { Route } from "@shared";
export const RouteCreatePage = observer(() => { export const RouteCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -46,34 +38,25 @@ export const RouteCreatePage = observer(() => {
const [govRouteNumber, setGovRouteNumber] = useState(""); const [govRouteNumber, setGovRouteNumber] = useState("");
const [governorAppeal, setGovernorAppeal] = useState<string>(""); const [governorAppeal, setGovernorAppeal] = useState<string>("");
const [direction, setDirection] = useState("backward"); const [direction, setDirection] = useState("backward");
const [scaleMin, setScaleMin] = useState("10"); const [scaleMin, setScaleMin] = useState("");
const [scaleMax, setScaleMax] = useState("100"); const [scaleMax, setScaleMax] = useState("");
const [routeName, setRouteName] = useState(""); const [routeName, setRouteName] = useState("");
const [turn, setTurn] = useState(""); const [turn, setTurn] = useState("");
const [centerLat, setCenterLat] = useState(""); const [centerLat, setCenterLat] = useState("");
const [centerLng, setCenterLng] = useState(""); const [centerLng, setCenterLng] = useState("");
const [videoPreview, setVideoPreview] = useState<string>(""); const [videoPreview, setVideoPreview] = useState<string>("");
const [icon, setIcon] = useState<string>("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] = const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
useState(false); useState(false);
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false); const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false); const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false); const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
const [isSelectIconDialogOpen, setIsSelectIconDialogOpen] = useState(false);
const [isUploadIconDialogOpen, setIsUploadIconDialogOpen] = useState(false);
const [isPreviewIconOpen, setIsPreviewIconOpen] = useState(false);
const [previewIconId, setPreviewIconId] = useState("");
const [activeIconMenuType, setActiveIconMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
const [fileToUpload, setFileToUpload] = useState<File | null>(null); const [fileToUpload, setFileToUpload] = useState<File | null>(null);
const { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
carrierStore.getCarriers(language); carrierStore.getCarriers(language);
articlesStore.getArticleList(); articlesStore.getArticleList();
mediaStore.getMedia();
}, [language]); }, [language]);
const filteredCarriers = useMemo(() => { const filteredCarriers = useMemo(() => {
@@ -130,6 +113,7 @@ export const RouteCreatePage = observer(() => {
const handleArticleSelect = (articleId: number) => { const handleArticleSelect = (articleId: number) => {
setGovernorAppeal(articleId.toString()); setGovernorAppeal(articleId.toString());
setIsSelectArticleDialogOpen(false); setIsSelectArticleDialogOpen(false);
// Обновляем список статей после создания новой
articlesStore.getArticleList(); articlesStore.getArticleList();
}; };
@@ -167,89 +151,26 @@ export const RouteCreatePage = observer(() => {
setIsVideoPreviewOpen(true); setIsVideoPreviewOpen(true);
}; };
const handleIconSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setIcon(media.id);
setIsSelectIconDialogOpen(false);
};
const selectedIconMedia =
icon && !isMediaIdEmpty(icon)
? mediaStore.media.find((m) => m.id === icon)
: null;
const effectiveIconUrl = isMediaIdEmpty(icon) ? null : selectedIconMedia?.id ?? icon;
const effectiveVideoId = isMediaIdEmpty(videoPreview) ? null : videoPreview;
const handleCreateRoute = async () => { const handleCreateRoute = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
// Преобразуем значения в нужные типы
if (!routeName.trim()) {
toast.error("Заполните название маршрута");
setIsLoading(false);
return;
}
if (!carrier) {
toast.error("Выберите перевозчика");
setIsLoading(false);
return;
}
if (!routeNumber.trim()) {
toast.error("Заполните номер маршрута");
setIsLoading(false);
return;
}
if (!govRouteNumber.trim()) {
toast.error("Заполните номер маршрута в Говорящем Городе");
setIsLoading(false);
return;
}
const validationResult = validateCoordinates(routeCoords);
if (validationResult !== true) {
toast.error(validationResult);
setIsLoading(false);
return;
}
const scale_min = scaleMin ? Number(scaleMin) : null;
const scale_max = scaleMax ? Number(scaleMax) : null;
if (
scale_min === 0 ||
scale_max === 0 ||
scale_min === null ||
scale_max === null
) {
toast.error("Масштабы не могут быть равны 0");
setIsLoading(false);
return;
}
if (
scale_min !== null &&
scale_max !== null &&
scale_max !== undefined &&
scale_min > scale_max
) {
toast.error("Максимальный масштаб не может быть меньше минимального");
setIsLoading(false);
return;
}
const carrier_id = Number(carrier); const carrier_id = Number(carrier);
const governor_appeal = governorAppeal const governor_appeal = Number(governorAppeal);
? Number(governorAppeal) const scale_min = scaleMin ? Number(scaleMin) : undefined;
: undefined; const scale_max = scaleMax ? Number(scaleMax) : undefined;
const rotate = turn ? Number(turn) : undefined; const rotate = turn ? Number(turn) : undefined;
const center_latitude = centerLat ? Number(centerLat) : undefined; const center_latitude = centerLat ? Number(centerLat) : undefined;
const center_longitude = centerLng ? Number(centerLng) : undefined; const center_longitude = centerLng ? Number(centerLng) : undefined;
const route_direction = direction === "forward"; const route_direction = direction === "forward";
const validationResult = validateCoordinates(routeCoords);
if (validationResult !== true) {
toast.error(validationResult);
return;
}
// Координаты маршрута как массив массивов чисел
const path = routeCoords const path = routeCoords
.trim() .trim()
.split("\n") .split("\n")
@@ -261,30 +182,28 @@ export const RouteCreatePage = observer(() => {
return [lat, lon]; return [lat, lon];
}); });
// Собираем объект маршрута
const newRoute: Partial<Route> = { const newRoute: Partial<Route> = {
carrier: carrier:
carrierStore.carriers[ carrierStore.carriers[
language as keyof typeof carrierStore.carriers language as keyof typeof carrierStore.carriers
].data?.find((c: any) => c.id === carrier_id)?.full_name || "", ].data?.find((c: any) => c.id === carrier_id)?.full_name || "",
carrier_id, carrier_id,
route_number: routeNumber.trim(), route_number: routeNumber,
route_sys_number: govRouteNumber.trim(), route_sys_number: govRouteNumber,
route_name: routeName.trim(), governor_appeal,
route_name: routeName,
route_direction, route_direction,
scale_min: scale_min !== null ? scale_min : 0, scale_min,
scale_max: scale_max !== null ? scale_max : 0, scale_max,
rotate, rotate,
center_latitude, center_latitude,
center_longitude, center_longitude,
path, path,
video_preview: !isMediaIdEmpty(videoPreview) ? videoPreview : undefined, video_preview:
icon: !isMediaIdEmpty(icon) ? icon : undefined, videoPreview && videoPreview !== "" ? videoPreview : undefined,
}; };
if (governor_appeal !== undefined) {
newRoute.governor_appeal = governor_appeal;
}
await routeStore.createRoute(newRoute); await routeStore.createRoute(newRoute);
toast.success("Маршрут успешно создан"); toast.success("Маршрут успешно создан");
navigate(-1); navigate(-1);
@@ -296,6 +215,7 @@ export const RouteCreatePage = observer(() => {
} }
}; };
// Получаем название выбранной статьи для отображения
const selectedArticle = articlesStore.articleList.ru.data.find( const selectedArticle = articlesStore.articleList.ru.data.find(
(article) => article.id === Number(governorAppeal) (article) => article.id === Number(governorAppeal)
); );
@@ -416,17 +336,6 @@ export const RouteCreatePage = observer(() => {
}, },
}} }}
/> />
{selectedArticle && (
<Button
variant="outlined"
color="error"
onClick={() => setGovernorAppeal("")}
startIcon={<X size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Сбросить
</Button>
)}
<Button <Button
variant="outlined" variant="outlined"
onClick={() => setIsSelectArticleDialogOpen(true)} onClick={() => setIsSelectArticleDialogOpen(true)}
@@ -437,41 +346,16 @@ export const RouteCreatePage = observer(() => {
</Button> </Button>
</Box> </Box>
<Box className="w-full flex justify-center gap-4 flex-wrap"> <VideoPreviewCard
<div className="flex flex-col gap-4 max-w-[300px]"> title="Видеозаставка"
<ImageUploadCard videoId={videoPreview}
title="Иконка маршрута" onVideoClick={handleVideoPreviewClick}
imageKey="thumbnail" onDeleteVideoClick={() => {
imageUrl={effectiveIconUrl} setVideoPreview("");
onImageClick={() => { }}
setIsPreviewIconOpen(true); onSelectVideoClick={handleVideoFileSelect}
setPreviewIconId(selectedIconMedia?.id ?? icon ?? ""); className="w-full"
}} />
onDeleteImageClick={() => {
setIcon("");
setActiveIconMenuType(null);
}}
onSelectFileClick={() => {
setActiveIconMenuType("image");
setIsSelectIconDialogOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadIconDialogOpen(true);
setActiveIconMenuType("image");
}}
/>
</div>
<div className="flex flex-col gap-4 max-w-[300px]">
<VideoPreviewCard
title="Видеозаставка"
videoId={effectiveVideoId}
onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => setVideoPreview("")}
onSelectVideoClick={handleVideoFileSelect}
className="w-full"
/>
</div>
</Box>
<FormControl fullWidth required> <FormControl fullWidth required>
<InputLabel>Прямой/обратный маршрут</InputLabel> <InputLabel>Прямой/обратный маршрут</InputLabel>
@@ -487,51 +371,14 @@ export const RouteCreatePage = observer(() => {
<TextField <TextField
className="w-full" className="w-full"
label="Масштаб (мин)" label="Масштаб (мин)"
type="number"
value={scaleMin} value={scaleMin}
onChange={(e) => { onChange={(e) => setScaleMin(e.target.value)}
let value = e.target.value;
if (Number(value) > 297) {
value = "297";
}
if (Number(value) < 10) {
value = "10";
}
setScaleMin(value);
if (value && scaleMax && Number(value) > Number(scaleMax)) {
setScaleMax(value);
}
}}
error={
scaleMin !== "" &&
scaleMax !== "" &&
Number(scaleMin) > Number(scaleMax)
}
required
helperText={
scaleMin !== "" &&
scaleMax !== "" &&
Number(scaleMin) > Number(scaleMax)
? "Минимальный масштаб не может быть больше максимального"
: ""
}
/> />
<TextField <TextField
className="w-full" className="w-full"
label="Масштаб (макс)" label="Масштаб (макс)"
type="number"
value={scaleMax} value={scaleMax}
required onChange={(e) => setScaleMax(e.target.value)}
onChange={(e) => {
if (Number(e.target.value) > 300) {
e.target.value = "300";
}
const value = e.target.value;
setScaleMax(value);
}}
/> />
<TextField <TextField
@@ -581,7 +428,7 @@ export const RouteCreatePage = observer(() => {
onSelectMedia={handleVideoSelect} onSelectMedia={handleVideoSelect}
mediaType={2} mediaType={2}
/> />
{effectiveVideoId && ( {videoPreview && videoPreview !== "" && (
<Dialog <Dialog
open={isVideoPreviewOpen} open={isVideoPreviewOpen}
onClose={() => setIsVideoPreviewOpen(false)} onClose={() => setIsVideoPreviewOpen(false)}
@@ -593,7 +440,7 @@ export const RouteCreatePage = observer(() => {
<Box className="flex justify-center items-center p-4"> <Box className="flex justify-center items-center p-4">
<MediaViewer <MediaViewer
media={{ media={{
id: effectiveVideoId, id: videoPreview,
media_type: 2, media_type: 2,
filename: "video_preview", filename: "video_preview",
}} }}
@@ -619,28 +466,6 @@ export const RouteCreatePage = observer(() => {
initialFile={fileToUpload || undefined} initialFile={fileToUpload || undefined}
afterUpload={handleVideoUpload} afterUpload={handleVideoUpload}
/> />
<SelectMediaDialog
open={isSelectIconDialogOpen}
onClose={() => setIsSelectIconDialogOpen(false)}
onSelectMedia={handleIconSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadIconDialogOpen}
onClose={() => setIsUploadIconDialogOpen(false)}
contextObjectName={routeName || "Маршрут"}
contextType="route"
afterUpload={handleIconSelect}
hardcodeType={activeIconMenuType}
/>
<PreviewMediaDialog
open={isPreviewIconOpen}
onClose={() => setIsPreviewIconOpen(false)}
mediaId={previewIconId}
/>
</Paper> </Paper>
); );
}); });

View File

@@ -13,31 +13,23 @@ import {
DialogContent, DialogContent,
DialogActions, DialogActions,
} from "@mui/material"; } from "@mui/material";
import { import { MediaViewer, VideoPreviewCard } from "@widgets";
MediaViewer,
VideoPreviewCard,
ImageUploadCard,
DeleteModal,
} from "@widgets";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Copy, Save, Plus, X } from "lucide-react"; import { ArrowLeft, Copy, Save, Plus } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import { carrierStore } from "../../../shared/store/CarrierStore";
import { articlesStore } from "../../../shared/store/ArticlesStore";
import { import {
carrierStore,
articlesStore,
routeStore, routeStore,
languageStore, languageStore,
mediaStore,
isMediaIdEmpty,
stationsStore,
ArticleSelectOrCreateDialog, ArticleSelectOrCreateDialog,
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog,
LoadingSpinner,
} from "@shared"; } from "@shared";
import { toast } from "react-toastify";
import { stationsStore } from "@shared";
import { LinkedItems } from "../LinekedStations"; import { LinkedItems } from "../LinekedStations";
export const RouteEditPage = observer(() => { export const RouteEditPage = observer(() => {
@@ -45,73 +37,33 @@ export const RouteEditPage = observer(() => {
const { id } = useParams(); const { id } = useParams();
const { editRouteData, copyRouteAction } = routeStore; const { editRouteData, copyRouteAction } = routeStore;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] = const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
useState(false); useState(false);
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false); const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false); const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false); const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
const [isSelectIconDialogOpen, setIsSelectIconDialogOpen] = useState(false);
const [isUploadIconDialogOpen, setIsUploadIconDialogOpen] = useState(false);
const [isPreviewIconOpen, setIsPreviewIconOpen] = useState(false);
const [isDeleteIconModalOpen, setIsDeleteIconModalOpen] = useState(false);
const [previewIconId, setPreviewIconId] = useState("");
const [activeIconMenuType, setActiveIconMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
const [fileToUpload, setFileToUpload] = useState<File | null>(null); const [fileToUpload, setFileToUpload] = useState<File | null>(null);
const { language } = languageStore; const { language } = languageStore;
const [coordinates, setCoordinates] = useState<string>(""); const [coordinates, setCoordinates] = useState<string>("");
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
if (!id) { const response = await routeStore.getRoute(Number(id));
setIsLoadingData(false); routeStore.setEditRouteData(response);
return; languageStore.setLanguage("ru");
}
setIsLoadingData(true);
try {
const response = await routeStore.getRoute(Number(id));
routeStore.setEditRouteData(response);
languageStore.setLanguage("ru");
} finally {
setIsLoadingData(false);
}
}; };
fetchData(); fetchData();
}, [id]); }, []);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
await carrierStore.getCarriers(language); carrierStore.getCarriers(language);
await stationsStore.getStations(); stationsStore.getStations();
await articlesStore.getArticleList(); articlesStore.getArticleList();
await mediaStore.getMedia();
}; };
fetchData(); fetchData();
}, [id, language]); }, [id, language]);
const handleIconSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
routeStore.setEditRouteData({ icon: media.id });
setIsSelectIconDialogOpen(false);
};
const selectedIconMedia =
editRouteData.icon && !isMediaIdEmpty(editRouteData.icon)
? mediaStore.media.find((m) => m.id === editRouteData.icon)
: null;
const effectiveIconUrl = isMediaIdEmpty(editRouteData.icon)
? null
: (selectedIconMedia?.id ?? editRouteData.icon);
const effectiveVideoId = isMediaIdEmpty(editRouteData.video_preview)
? null
: editRouteData.video_preview;
useEffect(() => { useEffect(() => {
if (editRouteData.path && editRouteData.path.length > 0) { if (editRouteData.path && editRouteData.path.length > 0) {
const formattedPath = editRouteData.path const formattedPath = editRouteData.path
@@ -122,63 +74,10 @@ export const RouteEditPage = observer(() => {
}, [editRouteData.path]); }, [editRouteData.path]);
const handleSave = async () => { const handleSave = async () => {
// Валидация обязательных полей
if (!editRouteData.route_name?.trim()) {
toast.error("Заполните название маршрута");
return;
}
if (!editRouteData.carrier_id) {
toast.error("Выберите перевозчика");
return;
}
if (!editRouteData.route_number?.trim()) {
toast.error("Заполните номер маршрута");
return;
}
if (!editRouteData.route_sys_number?.trim()) {
toast.error("Заполните номер маршрута в Говорящем Городе");
return;
}
const validationResult = validateCoordinates(coordinates);
if (validationResult !== true) {
toast.error(validationResult);
return;
}
// Валидация масштабов
if (
editRouteData.scale_min !== null &&
editRouteData.scale_min !== undefined &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
editRouteData.scale_min > editRouteData.scale_max
) {
toast.error("Максимальный масштаб не может быть меньше минимального");
return;
}
if (
editRouteData.scale_min === 0 ||
editRouteData.scale_max === 0 ||
editRouteData.scale_min === null ||
editRouteData.scale_max === null
) {
toast.error("Масштабы не могут быть равны 0");
setIsLoading(false);
return;
}
setIsLoading(true); setIsLoading(true);
try { await routeStore.editRoute(Number(id));
await routeStore.editRoute(Number(id)); toast.success("Маршрут успешно сохранен");
toast.success("Маршрут успешно сохранен"); setIsLoading(false);
} catch (error) {
console.error(error);
toast.error("Произошла ошибка при сохранении маршрута");
} finally {
setIsLoading(false);
}
}; };
const validateCoordinates = (value: string) => { const validateCoordinates = (value: string) => {
@@ -277,21 +176,6 @@ export const RouteEditPage = observer(() => {
(article) => article.id === editRouteData.governor_appeal (article) => article.id === editRouteData.governor_appeal
); );
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных маршрута..." />
</Box>
);
}
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -449,69 +333,23 @@ export const RouteEditPage = observer(() => {
<TextField <TextField
className="w-full" className="w-full"
label="Масштаб (мин)" label="Масштаб (мин)"
type="number"
value={editRouteData.scale_min ?? ""} value={editRouteData.scale_min ?? ""}
onChange={(e) => { onChange={(e) =>
let value = e.target.value === "" ? null : e.target.value;
if (value && Number(value) > 297) {
value = "297";
}
if (value && Number(value) < 10) {
value = "10";
}
routeStore.setEditRouteData({ routeStore.setEditRouteData({
scale_min: value ? Number(value) : null, scale_min:
}); e.target.value === "" ? null : parseFloat(e.target.value),
// Если максимальный масштаб стал меньше минимального, обновляем его })
if ( }
value !== null &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
value &&
Number(value) > (editRouteData.scale_max ?? 0)
) {
routeStore.setEditRouteData({
scale_max: value ? Number(value) : null,
});
}
}}
required
/> />
<TextField <TextField
className="w-full" className="w-full"
required
label="Масштаб (макс)" label="Масштаб (макс)"
type="number"
value={editRouteData.scale_max ?? ""} value={editRouteData.scale_max ?? ""}
onChange={(e) => { onChange={(e) =>
let value = e.target.value;
if (Number(value) > 300) {
value = "300";
}
routeStore.setEditRouteData({ routeStore.setEditRouteData({
scale_max: value === "" ? null : parseFloat(value), scale_max:
}); e.target.value === "" ? null : parseFloat(e.target.value),
}} })
error={
editRouteData.scale_min !== null &&
editRouteData.scale_min !== undefined &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
editRouteData.scale_max < editRouteData.scale_min
}
helperText={
editRouteData.scale_min !== null &&
editRouteData.scale_min !== undefined &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
editRouteData.scale_max < editRouteData.scale_min
? "Максимальный масштаб не может быть меньше минимального"
: ""
} }
/> />
<TextField <TextField
@@ -564,21 +402,6 @@ export const RouteEditPage = observer(() => {
}, },
}} }}
/> />
{selectedArticle && (
<Button
variant="outlined"
color="error"
onClick={() =>
routeStore.setEditRouteData({
governor_appeal: 0,
})
}
startIcon={<X size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Сбросить
</Button>
)}
<Button <Button
variant="outlined" variant="outlined"
onClick={() => setIsSelectArticleDialogOpen(true)} onClick={() => setIsSelectArticleDialogOpen(true)}
@@ -589,42 +412,16 @@ export const RouteEditPage = observer(() => {
</Button> </Button>
</Box> </Box>
<Box className="w-full flex justify-center gap-4 flex-wrap"> <VideoPreviewCard
<div className="flex flex-col gap-4 max-w-[300px]"> title="Видеозаставка"
<ImageUploadCard videoId={editRouteData.video_preview}
title="Иконка маршрута" onVideoClick={handleVideoPreviewClick}
imageKey="thumbnail" onDeleteVideoClick={() => {
imageUrl={effectiveIconUrl} routeStore.setEditRouteData({ video_preview: "" });
onImageClick={() => { }}
setIsPreviewIconOpen(true); onSelectVideoClick={handleVideoFileSelect}
setPreviewIconId( className="w-full"
selectedIconMedia?.id ?? editRouteData.icon ?? "" />
);
}}
onDeleteImageClick={() => setIsDeleteIconModalOpen(true)}
onSelectFileClick={() => {
setActiveIconMenuType("image");
setIsSelectIconDialogOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadIconDialogOpen(true);
setActiveIconMenuType("image");
}}
/>
</div>
<div className="flex flex-col gap-4 max-w-[300px]">
<VideoPreviewCard
title="Видеозаставка"
videoId={effectiveVideoId}
onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => {
routeStore.setEditRouteData({ video_preview: "" });
}}
onSelectVideoClick={handleVideoFileSelect}
className="w-full"
/>
</div>
</Box>
</Box> </Box>
<LinkedItems <LinkedItems
@@ -638,6 +435,7 @@ export const RouteEditPage = observer(() => {
onUpdate={() => { onUpdate={() => {
routeStore.getRoute(Number(id)); routeStore.getRoute(Number(id));
}} }}
routeDirection={editRouteData.route_direction}
/> />
<div className="flex w-full justify-between"> <div className="flex w-full justify-between">
@@ -684,10 +482,10 @@ export const RouteEditPage = observer(() => {
<DialogTitle>Предпросмотр видео</DialogTitle> <DialogTitle>Предпросмотр видео</DialogTitle>
<DialogContent> <DialogContent>
<Box className="flex justify-center items-center p-4"> <Box className="flex justify-center items-center p-4">
{effectiveVideoId && ( {editRouteData.video_preview && (
<MediaViewer <MediaViewer
media={{ media={{
id: effectiveVideoId, id: editRouteData.video_preview,
media_type: 2, media_type: 2,
filename: "video_preview", filename: "video_preview",
}} }}
@@ -711,38 +509,6 @@ export const RouteEditPage = observer(() => {
initialFile={fileToUpload || undefined} initialFile={fileToUpload || undefined}
afterUpload={handleVideoUpload} afterUpload={handleVideoUpload}
/> />
<SelectMediaDialog
open={isSelectIconDialogOpen}
onClose={() => setIsSelectIconDialogOpen(false)}
onSelectMedia={handleIconSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadIconDialogOpen}
onClose={() => setIsUploadIconDialogOpen(false)}
contextObjectName={editRouteData.route_name || "Маршрут"}
contextType="route"
afterUpload={handleIconSelect}
hardcodeType={activeIconMenuType}
/>
<PreviewMediaDialog
open={isPreviewIconOpen}
onClose={() => setIsPreviewIconOpen(false)}
mediaId={previewIconId}
/>
<DeleteModal
open={isDeleteIconModalOpen}
onDelete={() => {
routeStore.setEditRouteData({ icon: "" });
setIsDeleteIconModalOpen(false);
}}
onCancel={() => setIsDeleteIconModalOpen(false)}
edit
/>
</Paper> </Paper>
); );
}); });

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales"; import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, carrierStore, languageStore, routeStore, selectedCityStore, SearchInput } from "@shared"; import { carrierStore, languageStore, routeStore } from "@shared";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Map, Pencil, Trash2, Minus } from "lucide-react"; import { Map, Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -17,18 +17,7 @@ export const RouteListPage = observer(() => {
const [rowId, setRowId] = useState<number | null>(null); const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]); const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const { language } = languageStore; const { language } = languageStore;
const canWriteRoutes = authStore.canWrite("routes");
const canShowRoutePreview =
authStore.canWrite("stations") &&
authStore.canWrite("sights") &&
authStore.canWrite("routes");
const canShowActionsColumn = canWriteRoutes || canShowRoutePreview;
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
@@ -111,69 +100,44 @@ export const RouteListPage = observer(() => {
); );
}, },
}, },
...(canShowActionsColumn ? [{ {
field: "actions", field: "actions",
headerName: "Действия", headerName: "Действия",
width: 250, width: 250,
align: "center" as const, align: "center",
headerAlign: "center" as const, headerAlign: "center",
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
return ( return (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
{canWriteRoutes && ( <button onClick={() => navigate(`/route/${params.row.id}/edit`)}>
<button onClick={() => navigate(`/route/${params.row.id}/edit`)}> <Pencil size={20} className="text-blue-500" />
<Pencil size={20} className="text-blue-500" /> </button>
</button> <button onClick={() => navigate(`/route-preview/${params.row.id}`)}>
)} <Map size={20} className="text-purple-500" />
{canShowRoutePreview && ( </button>
<button onClick={() => navigate(`/route-preview/${params.row.id}`)}>
<Map size={20} className="text-purple-500" /> <button
</button> onClick={() => {
)} setIsDeleteModalOpen(true);
{canWriteRoutes && ( setRowId(params.row.id);
<button }}
onClick={() => { >
setIsDeleteModalOpen(true); <Trash2 size={20} className="text-red-500" />
setRowId(params.row.id); </button>
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
)}
</div> </div>
); );
}, },
}] : []), },
]; ];
const rows = useMemo(() => { const rows = routes.data.map((route) => ({
const { selectedCityId } = selectedCityStore; id: route.id,
const query = searchQuery.trim().toLowerCase(); carrier_id: route.carrier_id,
let filtered = routes.data; route_number: route.route_number,
if (selectedCityId) { route_direction: route.route_direction ? "Прямой" : "Обратный",
const cityCarrierIds = new Set( route_name: route.route_name,
carriers["ru"].data }));
.filter((c) => c.city_id === selectedCityId)
.map((c) => c.id)
);
filtered = filtered.filter((route) => cityCarrierIds.has(route.carrier_id));
}
return filtered
.filter(
(route) =>
!query ||
(route.route_name ?? "").toLowerCase().includes(query) ||
String(route.route_number ?? "").toLowerCase().includes(query)
)
.map((route) => ({
id: route.id,
carrier_id: route.carrier_id,
route_number: route.route_number,
route_direction: route.route_direction ? "Прямой" : "Обратный",
route_name: route.route_name,
}));
}, [routes.data, carriers["ru"].data, selectedCityStore.selectedCityId, searchQuery]);
return ( return (
<> <>
@@ -182,59 +146,32 @@ export const RouteListPage = observer(() => {
<div style={{ width: "100%" }}> <div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Маршруты</h1> <h1 className="text-2xl">Маршруты</h1>
{canWriteRoutes && ( <CreateButton label="Создать маршрут" path="/route/create" />
<CreateButton label="Создать маршрут" path="/route/create" />
)}
</div> </div>
{canWriteRoutes && ids.length > 0 && ( <div
<div className="flex justify-end mb-5 duration-300"> className="flex justify-end mb-5 duration-300"
<button style={{ opacity: ids.length > 0 ? 1 : 0 }}
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" >
onClick={() => setIsBulkDeleteModalOpen(true)} <button
> className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
<Trash2 size={20} className="text-white" /> Удалить выбранные ( onClick={() => setIsBulkDeleteModalOpen(true)}
{ids.length}) >
</button> <Trash2 size={20} className="text-white" /> Удалить выбранные (
</div> {ids.length})
)} </button>
</div>
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
checkboxSelection={canWriteRoutes} hideFooter
disableRowSelectionExcludeModel checkboxSelection
loading={isLoading} loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={ onRowSelectionModelChange={(newSelection) => {
canWriteRoutes setIds(Array.from(newSelection.ids as unknown as number[]));
? (newSelection: any) => { }}
if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map((id: string | number) =>
Number(id)
);
setIds(selectedIds);
} else if (
newSelection &&
typeof newSelection === "object" &&
"ids" in newSelection
) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map(
(id: string | number) => Number(id)
);
setIds(selectedIds);
} else {
setIds([]);
}
}
: undefined
}
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>

View File

@@ -5,5 +5,5 @@ export const STATION_OUTLINE_WIDTH = 4;
export const SIGHT_SIZE = 40; export const SIGHT_SIZE = 40;
export const SCALE_FACTOR = 50; export const SCALE_FACTOR = 50;
export const BACKGROUND_COLOR = 0x000; export const BACKGROUND_COLOR = 0x111111;
export const PATH_COLOR = 0xff4d4d; export const PATH_COLOR = 0xff4d4d;

View File

@@ -47,8 +47,10 @@ export function InfiniteCanvas({
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 }); const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
const [isPointerDown, setIsPointerDown] = useState(false); const [isPointerDown, setIsPointerDown] = useState(false);
// Флаг для предотвращения конфликта между пользовательским вводом и данными маршрута
const [isUserInteracting, setIsUserInteracting] = useState(false); const [isUserInteracting, setIsUserInteracting] = useState(false);
// Реф для отслеживания последнего значения originalRouteData?.rotate
const lastOriginalRotation = useRef<number | undefined>(undefined); const lastOriginalRotation = useRef<number | undefined>(undefined);
useEffect(() => { useEffect(() => {
@@ -66,7 +68,7 @@ export function InfiniteCanvas({
const handlePointerDown = (e: FederatedMouseEvent) => { const handlePointerDown = (e: FederatedMouseEvent) => {
setIsPointerDown(true); setIsPointerDown(true);
setIsDragging(false); setIsDragging(false);
setIsUserInteracting(true); setIsUserInteracting(true); // Устанавливаем флаг взаимодействия пользователя
setStartPosition({ setStartPosition({
x: position.x, x: position.x,
y: position.y, y: position.y,
@@ -79,9 +81,13 @@ export function InfiniteCanvas({
e.stopPropagation(); e.stopPropagation();
}; };
// Устанавливаем rotation только при изменении originalRouteData и отсутствии взаимодействия пользователя
useEffect(() => { useEffect(() => {
const newRotation = originalRouteData?.rotate ?? 0; const newRotation = originalRouteData?.rotate ?? 0;
// Обновляем rotation только если:
// 1. Пользователь не взаимодействует с канвасом
// 2. Значение действительно изменилось
if (!isUserInteracting && lastOriginalRotation.current !== newRotation) { if (!isUserInteracting && lastOriginalRotation.current !== newRotation) {
setRotation((newRotation * Math.PI) / 180); setRotation((newRotation * Math.PI) / 180);
lastOriginalRotation.current = newRotation; lastOriginalRotation.current = newRotation;
@@ -91,6 +97,7 @@ export function InfiniteCanvas({
const handlePointerMove = (e: FederatedMouseEvent) => { const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isPointerDown) return; if (!isPointerDown) return;
// Проверяем, началось ли перетаскивание
if (!isDragging) { if (!isDragging) {
const dx = e.globalX - startMousePosition.x; const dx = e.globalX - startMousePosition.x;
const dy = e.globalY - startMousePosition.y; const dy = e.globalY - startMousePosition.y;
@@ -112,8 +119,10 @@ export function InfiniteCanvas({
e.globalX - center.x e.globalX - center.x
); );
// Calculate rotation difference in radians
const rotationDiff = currentAngle - startAngle; const rotationDiff = currentAngle - startAngle;
// Update rotation
setRotation(startRotation + rotationDiff); setRotation(startRotation + rotationDiff);
const cosDelta = Math.cos(rotationDiff); const cosDelta = Math.cos(rotationDiff);
@@ -140,13 +149,15 @@ export function InfiniteCanvas({
}; };
const handlePointerUp = (e: FederatedMouseEvent) => { const handlePointerUp = (e: FederatedMouseEvent) => {
// Если не было перетаскивания, то это простой клик - закрываем виджет
if (!isDragging) { if (!isDragging) {
setSelectedSight(undefined); setSelectedSight(undefined);
} }
setIsPointerDown(false); setIsPointerDown(false);
setIsDragging(false); setIsDragging(false);
// Сбрасываем флаг взаимодействия через небольшую задержку
// чтобы избежать немедленного срабатывания useEffect
setTimeout(() => { setTimeout(() => {
setIsUserInteracting(false); setIsUserInteracting(false);
}, 100); }, 100);
@@ -155,25 +166,29 @@ export function InfiniteCanvas({
const handleWheel = (e: FederatedWheelEvent) => { const handleWheel = (e: FederatedWheelEvent) => {
e.stopPropagation(); e.stopPropagation();
setIsUserInteracting(true); setIsUserInteracting(true); // Устанавливаем флаг при зуме
// Get mouse position relative to canvas
const mouseX = e.globalX - position.x; const mouseX = e.globalX - position.x;
const mouseY = e.globalY - position.y; const mouseY = e.globalY - position.y;
// Calculate new scale
const scaleMin = (routeData?.scale_min ?? 10) / SCALE_FACTOR; const scaleMin = (routeData?.scale_min ?? 10) / SCALE_FACTOR;
const scaleMax = (routeData?.scale_max ?? 20) / SCALE_FACTOR; const scaleMax = (routeData?.scale_max ?? 20) / SCALE_FACTOR;
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; // Zoom out/in
const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor)); const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor));
const actualZoomFactor = newScale / scale; const actualZoomFactor = newScale / scale;
if (scale === newScale) { if (scale === newScale) {
// Сбрасываем флаг, если зум не изменился
setTimeout(() => { setTimeout(() => {
setIsUserInteracting(false); setIsUserInteracting(false);
}, 100); }, 100);
return; return;
} }
// Update position to zoom towards mouse cursor
setPosition({ setPosition({
x: position.x + mouseX * (1 - actualZoomFactor), x: position.x + mouseX * (1 - actualZoomFactor),
y: position.y + mouseY * (1 - actualZoomFactor), y: position.y + mouseY * (1 - actualZoomFactor),
@@ -181,6 +196,7 @@ export function InfiniteCanvas({
setScale(newScale); setScale(newScale);
// Сбрасываем флаг взаимодействия через задержку
setTimeout(() => { setTimeout(() => {
setIsUserInteracting(false); setIsUserInteracting(false);
}, 100); }, 100);

View File

@@ -1,19 +1,12 @@
import { Box, Stack, Typography, Button } from "@mui/material"; import { Stack, Typography, Button } from "@mui/material";
import { useNavigate, useNavigationType } from "react-router"; import { useNavigate, useNavigationType } from "react-router";
import { MediaViewer } from "@widgets"; import { MediaViewer } from "@widgets";
import { useMapData } from "./MapDataContext"; import { useMapData } from "./MapDataContext";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { authInstance, isMediaIdEmpty } from "@shared"; import { authInstance } from "@shared";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import LanguageSelector from "./web-gl/LanguageSelector";
type LeftSidebarProps = { export const LeftSidebar = observer(() => {
open: boolean;
onToggle: () => void;
};
export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const navigationType = useNavigationType(); // PUSH, POP, REPLACE const navigationType = useNavigationType(); // PUSH, POP, REPLACE
const { routeData } = useMapData(); const { routeData } = useMapData();
@@ -42,131 +35,101 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
}; };
return ( return (
<Box <Stack direction="column" width="300px" p={2} bgcolor="primary.main">
sx={{ <button
position: "relative", onClick={handleBack}
height: "100%", type="button"
color: "#fff", style={{
transition: "padding 0.3s ease", display: "flex",
p: open ? 2 : 0, justifyContent: "center",
display: "flex", alignItems: "center",
flexDirection: "column", gap: 10,
alignItems: "stretch", color: "#fff",
justifyContent: "flex-start", backgroundColor: "#222",
}} borderRadius: 10,
> height: 40,
<Stack width: "100%",
direction="column" border: "none",
height="100%" cursor: "pointer",
width="100%"
spacing={4}
alignItems="stretch"
justifyContent="space-between"
sx={{
opacity: open ? 1 : 0,
transition: "opacity 0.25s ease",
pointerEvents: open ? "auto" : "none",
display: open ? "flex" : "none",
}} }}
> >
<div> <p>Назад</p>
<Button </button>
onClick={handleBack}
variant="contained"
color="primary"
sx={{
backgroundColor: "#222",
color: "#fff",
borderRadius: 1.5,
px: 2,
py: 1,
marginBottom: 10,
"&:hover": {
backgroundColor: "#2d2d2d",
},
}}
fullWidth
startIcon={<ArrowBackIcon />}
>
Назад
</Button>
<Stack <Stack
direction="column" direction="column"
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
spacing={3} my={10}
> >
<div <div
style={{ style={{
maxWidth: 150, maxWidth: 200,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
gap: 10, gap: 10,
}} }}
>
{carrierThumbnail && !isMediaIdEmpty(carrierThumbnail) && (
<MediaViewer
media={{
id: carrierThumbnail,
media_type: 1, // Тип "Фото" для логотипа
filename: "route_thumbnail",
}}
fullWidth
fullHeight
/>
)}
<Typography sx={{ color: "#fff" }} textAlign="center">
При поддержке Правительства
</Typography>
</div>
</Stack>
<div className="flex flex-col items-center justify-center gap-2 mt-10">
<button className="bg-[#fcd500] text-black px-4 py-2 rounded-md w-full font-medium my-10">
Обращение губернатора
</button>
<button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
Достопримечательности
</button>
<button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
Остановки
</button>
</div>
</div>
<Stack
direction="column"
alignItems="center"
maxHeight={150}
justifyContent="center"
flexGrow={1}
> >
{carrierLogo && !isMediaIdEmpty(carrierLogo) && ( {carrierThumbnail && (
<MediaViewer <MediaViewer
media={{ media={{
id: carrierLogo, id: carrierThumbnail,
media_type: 1, // Тип "Фото" для логотипа media_type: 1, // Тип "Фото" для логотипа
filename: "route_thumbnail_logo", filename: "route_thumbnail",
}} }}
fullWidth
fullHeight fullHeight
/> />
)} )}
</Stack> <Typography sx={{ mb: 2, color: "#fff" }} textAlign="center">
При поддержке Правительства
<Typography </Typography>{" "}
variant="h6" </div>
textAlign="center"
sx={{ color: "#fff", marginTop: "auto" }}
>
#ВсемПоПути
</Typography>
</Stack> </Stack>
<div className="absolute bottom-[20px] -right-[520px] z-10"> <Stack
<LanguageSelector onBack={onToggle} isSidebarOpen={open} /> direction="column"
</div> alignItems="center"
</Box> 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

@@ -36,15 +36,11 @@ const MapDataContext = createContext<{
setMapCenter: (x: number, y: number) => void; setMapCenter: (x: number, y: number) => void;
setStationOffset: (stationId: number, x: number, y: number) => void; setStationOffset: (stationId: number, x: number, y: number) => void;
setStationAlign: (stationId: number, align: number) => void; setStationAlign: (stationId: number, align: number) => void;
setStationIconSize: (stationId: number, size: number) => void;
setSightCoordinates: ( setSightCoordinates: (
sightId: number, sightId: number,
latitude: number, latitude: number,
longitude: number longitude: number
) => void; ) => void;
setSightIconSize: (sightId: number, size: number) => void;
setFontSize: (size: number) => void;
setRouteIconSize: (size: number) => void;
saveChanges: () => void; saveChanges: () => void;
}>({ }>({
originalRouteData: undefined, originalRouteData: undefined,
@@ -64,11 +60,7 @@ const MapDataContext = createContext<{
setMapCenter: () => {}, setMapCenter: () => {},
setStationOffset: () => {}, setStationOffset: () => {},
setStationAlign: () => {}, setStationAlign: () => {},
setStationIconSize: () => {},
setSightCoordinates: () => {}, setSightCoordinates: () => {},
setSightIconSize: () => {},
setFontSize: () => {},
setRouteIconSize: () => {},
saveChanges: () => {}, saveChanges: () => {},
}); });
@@ -149,16 +141,17 @@ export const MapDataProvider = observer(
}, [routeId]); }, [routeId]);
useEffect(() => { useEffect(() => {
if (originalRouteData) { // combine changes with original data
if (originalRouteData)
setRouteData({ ...originalRouteData, ...routeChanges }); setRouteData({ ...originalRouteData, ...routeChanges });
} if (originalSightData) setSightData(originalSightData);
}, [originalRouteData, routeChanges]); }, [
originalRouteData,
useEffect(() => { originalSightData,
if (originalSightData) { routeChanges,
setSightData(originalSightData); stationChanges,
} sightChanges,
}, [originalSightData]); ]);
function setScaleRange(min: number, max: number) { function setScaleRange(min: number, max: number) {
setRouteChanges((prev) => { setRouteChanges((prev) => {
@@ -172,57 +165,9 @@ export const MapDataProvider = observer(
}); });
} }
function setFontSize(size: number) { function setMapCenter(x: number, y: number) {
const clamped = Math.max(1, Math.min(300, size));
setRouteChanges((prev) => { setRouteChanges((prev) => {
if (prev.font_size === clamped) { return { ...prev, center_latitude: x, center_longitude: y };
return prev;
}
return { ...prev, font_size: clamped };
});
}
function setRouteIconSize(size: number) {
const clamped = Math.max(1, Math.min(300, size));
setRouteChanges((prev) => {
if (prev.icon_size === clamped) {
return prev;
}
return { ...prev, icon_size: clamped };
});
}
function setMapCenter(latitude: number, longitude: number) {
const epsilon = 1e-6;
setRouteChanges((prev) => {
const prevLat = prev.center_latitude;
const prevLon = prev.center_longitude;
if (
prevLat !== undefined &&
prevLon !== undefined &&
Math.abs(prevLat - latitude) < epsilon &&
Math.abs(prevLon - longitude) < epsilon
) {
return prev;
}
return {
...prev,
center_latitude: latitude,
center_longitude: longitude,
};
});
setRouteData((routePrev) => {
if (!routePrev) return routePrev;
return {
...routePrev,
center_latitude: latitude,
center_longitude: longitude,
};
}); });
} }
@@ -235,51 +180,12 @@ export const MapDataProvider = observer(
async function saveStationChanges() { async function saveStationChanges() {
for (const station of stationChanges) { for (const station of stationChanges) {
await authInstance.patch(`/route/${routeId}/station`, station); await authInstance.patch(`/route/${routeId}/station`, station);
setStationData((prev) => {
const updated = { ...prev };
Object.keys(updated).forEach((lang) => {
updated[lang] = updated[lang].map((s) =>
s.id === station.station_id
? {
...s,
offset_x: station.offset_x,
offset_y: station.offset_y,
align: station.align,
icon_size:
typeof station.icon_size === "number"
? station.icon_size
: s.icon_size,
}
: s
);
});
return updated;
});
} }
} }
async function saveSightChanges() { async function saveSightChanges() {
for (const sight of sightChanges) { for (const sight of sightChanges) {
await authInstance.patch(`/route/${routeId}/sight`, sight); await authInstance.patch(`/route/${routeId}/sight`, sight);
setSightData((prev) =>
prev
? prev.map((s) =>
s.id === sight.sight_id
? {
...s,
latitude: sight.latitude,
longitude: sight.longitude,
icon_size:
typeof sight.icon_size === "number"
? sight.icon_size
: s.icon_size,
}
: s
)
: prev
);
} }
} }
@@ -319,7 +225,6 @@ export const MapDataProvider = observer(
offset_x: x, offset_x: x,
offset_y: y, offset_y: y,
align: originalStation?.align ?? 1, align: originalStation?.align ?? 1,
icon_size: originalStation?.icon_size,
transfers: originalStation?.transfers ?? { transfers: originalStation?.transfers ?? {
bus: "", bus: "",
metro_blue: "", metro_blue: "",
@@ -381,7 +286,6 @@ export const MapDataProvider = observer(
align: align, align: align,
offset_x: originalStation?.offset_x ?? 0, offset_x: originalStation?.offset_x ?? 0,
offset_y: originalStation?.offset_y ?? 0, offset_y: originalStation?.offset_y ?? 0,
icon_size: originalStation?.icon_size,
transfers: originalStation?.transfers ?? { transfers: originalStation?.transfers ?? {
bus: "", bus: "",
metro_blue: "", metro_blue: "",
@@ -412,83 +316,11 @@ export const MapDataProvider = observer(
}); });
} }
function setStationIconSize(stationId: number, size: number) {
const clamped = Math.max(1, Math.min(300, Math.round(size)));
const currentStation = stationData.ru?.find(
(station) => station.id === stationId
);
if (currentStation?.icon_size === clamped) {
return;
}
setStationChanges((prev) => {
const existingIndex = prev.findIndex(
(station) => station.station_id === stationId
);
if (existingIndex !== -1) {
const next = [...prev];
next[existingIndex] = {
...next[existingIndex],
icon_size: clamped,
};
return next;
}
const originalStation = originalStationData?.find(
(s) => s.id === stationId
);
return [
...prev,
{
station_id: stationId,
offset_x: currentStation?.offset_x ?? originalStation?.offset_x ?? 0,
offset_y: currentStation?.offset_y ?? originalStation?.offset_y ?? 0,
align: currentStation?.align ?? originalStation?.align ?? 1,
icon_size: clamped,
transfers: currentStation?.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) =>
station.id === stationId
? { ...station, icon_size: clamped }
: station
);
});
return updated;
});
}
function setSightCoordinates( function setSightCoordinates(
sightId: number, sightId: number,
latitude: number, latitude: number,
longitude: number longitude: number
) { ) {
setSightData((prev) =>
prev
? prev.map((sight) =>
sight.id === sightId ? { ...sight, latitude, longitude } : sight
)
: prev
);
setSightChanges((prev) => { setSightChanges((prev) => {
const existingIndex = prev.findIndex( const existingIndex = prev.findIndex(
(sight) => sight.sight_id === sightId (sight) => sight.sight_id === sightId
@@ -514,7 +346,6 @@ export const MapDataProvider = observer(
sight_id: sightId, sight_id: sightId,
latitude, latitude,
longitude, longitude,
icon_size: foundSight.icon_size,
}, },
]; ];
} }
@@ -523,49 +354,6 @@ export const MapDataProvider = observer(
}); });
} }
function setSightIconSize(sightId: number, size: number) {
const clamped = Math.max(1, Math.min(300, Math.round(size)));
setSightData((prev) =>
prev
? prev.map((sight) =>
sight.id === sightId ? { ...sight, icon_size: clamped } : sight
)
: prev
);
setSightChanges((prev) => {
const existingIndex = prev.findIndex(
(sight) => sight.sight_id === sightId
);
if (existingIndex !== -1) {
const next = [...prev];
next[existingIndex] = {
...next[existingIndex],
icon_size: clamped,
};
return next;
}
const foundSight =
sightData?.find((sight) => sight.id === sightId) ??
originalSightData?.find((sight) => sight.id === sightId);
if (!foundSight) {
return prev;
}
return [
...prev,
{
sight_id: sightId,
latitude: foundSight.latitude,
longitude: foundSight.longitude,
icon_size: clamped,
},
];
});
}
useEffect(() => {}, [sightChanges]); useEffect(() => {}, [sightChanges]);
const value = useMemo( const value = useMemo(
@@ -587,11 +375,7 @@ export const MapDataProvider = observer(
saveChanges, saveChanges,
setStationOffset, setStationOffset,
setStationAlign, setStationAlign,
setStationIconSize,
setSightCoordinates, setSightCoordinates,
setSightIconSize,
setFontSize,
setRouteIconSize,
}), }),
[ [
originalRouteData, originalRouteData,
@@ -604,10 +388,6 @@ export const MapDataProvider = observer(
isStationLoading, isStationLoading,
isSightLoading, isSightLoading,
selectedSight, selectedSight,
setStationIconSize,
setSightIconSize,
setFontSize,
setRouteIconSize,
] ]
); );

View File

@@ -1,15 +1,8 @@
import { import { Button, Stack, TextField, Typography, Slider } from "@mui/material";
Button,
Stack,
TextField,
Typography,
Slider,
CircularProgress,
Box,
} from "@mui/material";
import { useMapData } from "./MapDataContext"; import { useMapData } from "./MapDataContext";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTransform } from "./TransformContext"; import { useTransform } from "./TransformContext";
import { coordinatesToLocal, localToCoordinates } from "./utils";
import { SCALE_FACTOR } from "./Constants"; import { SCALE_FACTOR } from "./Constants";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@@ -21,10 +14,17 @@ export function RightSidebar() {
originalRouteData, originalRouteData,
setMapRotation, setMapRotation,
setMapCenter, setMapCenter,
setFontSize: updateFontSize,
setRouteIconSize: updateRouteIconSize,
} = useMapData(); } = useMapData();
const { rotation, rotateToAngle, scale, setScaleAtCenter } = useTransform(); const {
rotation,
position,
screenToLocal,
screenCenter,
rotateToAngle,
setTransform,
scale,
setScaleAtCenter,
} = useTransform();
const [minScale, setMinScale] = useState<number>(1); const [minScale, setMinScale] = useState<number>(1);
const [maxScale, setMaxScale] = useState<number>(5); const [maxScale, setMaxScale] = useState<number>(5);
@@ -34,15 +34,14 @@ export function RightSidebar() {
}); });
const [rotationDegrees, setRotationDegrees] = useState<number>(0); const [rotationDegrees, setRotationDegrees] = useState<number>(0);
const [isUserEditing, setIsUserEditing] = useState<boolean>(false); const [isUserEditing, setIsUserEditing] = useState<boolean>(false);
const [fontSize, setFontSize] = useState<number>(100);
const [defaultIconSize, setDefaultIconSize] = useState<number>(100);
const [isSaving, setIsSaving] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
if (originalRouteData) { if (originalRouteData) {
// Проверяем и сбрасываем минимальный масштаб если нужно
const originalMinScale = originalRouteData.scale_min ?? 1; const originalMinScale = originalRouteData.scale_min ?? 1;
const resetMinScale = originalMinScale < 1 ? 1 : originalMinScale; const resetMinScale = originalMinScale < 1 ? 1 : originalMinScale;
// Проверяем и сбрасываем максимальный масштаб если нужно
const originalMaxScale = originalRouteData.scale_max ?? 5; const originalMaxScale = originalRouteData.scale_max ?? 5;
const resetMaxScale = originalMaxScale < 3 ? 3 : originalMaxScale; const resetMaxScale = originalMaxScale < 3 ? 3 : originalMaxScale;
@@ -53,8 +52,6 @@ export function RightSidebar() {
x: originalRouteData.center_latitude ?? 0, x: originalRouteData.center_latitude ?? 0,
y: originalRouteData.center_longitude ?? 0, y: originalRouteData.center_longitude ?? 0,
}); });
setFontSize(originalRouteData.font_size ?? 100);
setDefaultIconSize(originalRouteData.icon_size ?? 100);
} }
}, [originalRouteData]); }, [originalRouteData]);
@@ -66,7 +63,7 @@ export function RightSidebar() {
useEffect(() => { useEffect(() => {
setRotationDegrees( setRotationDegrees(
((Math.round((rotation * 180) / Math.PI) % 360) + 360) % 360, ((Math.round((rotation * 180) / Math.PI) % 360) + 360) % 360
); );
}, [rotation]); }, [rotation]);
@@ -75,55 +72,33 @@ export function RightSidebar() {
}, [rotationDegrees]); }, [rotationDegrees]);
useEffect(() => { useEffect(() => {
if (isUserEditing) { if (!isUserEditing) {
return; 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,
]);
const latitude = routeData?.center_latitude ?? 0; useEffect(() => {
const longitude = routeData?.center_longitude ?? 0; setMapCenter(localCenter.x, localCenter.y);
}, [localCenter]);
setLocalCenter((prev) => {
if (
Math.abs(prev.x - latitude) < 1e-6 &&
Math.abs(prev.y - longitude) < 1e-6
) {
return prev;
}
return { x: latitude, y: longitude };
});
}, [isUserEditing, routeData?.center_latitude, routeData?.center_longitude]);
function setRotationFromDegrees(degrees: number) { function setRotationFromDegrees(degrees: number) {
rotateToAngle((degrees * Math.PI) / 180); rotateToAngle((degrees * Math.PI) / 180);
} }
const handleFontSizeChange = (value: number) => { function pan({ x, y }: { x: number; y: number }) {
if (!Number.isFinite(value)) { const coordinates = coordinatesToLocal(x, y);
return; setTransform(coordinates.x, coordinates.y);
} }
const clamped = Math.max(1, Math.min(300, Math.round(value)));
setFontSize(clamped);
updateFontSize(clamped);
};
useEffect(() => {
const next = routeData?.font_size ?? originalRouteData?.font_size ?? 100;
setFontSize((prev) => (Math.abs(prev - next) > 0.5 ? next : prev));
}, [routeData?.font_size, originalRouteData?.font_size]);
const handleDefaultIconSizeChange = (value: number) => {
if (!Number.isFinite(value)) {
return;
}
const clamped = Math.max(1, Math.min(300, Math.round(value)));
setDefaultIconSize(clamped);
updateRouteIconSize(clamped);
};
useEffect(() => {
const next = routeData?.icon_size ?? originalRouteData?.icon_size ?? 100;
setDefaultIconSize((prev) => (Math.abs(prev - next) > 0.5 ? next : prev));
}, [routeData?.icon_size, originalRouteData?.icon_size]);
if (!routeData) { if (!routeData) {
return null; return null;
@@ -143,7 +118,7 @@ export function RightSidebar() {
borderRadius={2} borderRadius={2}
> >
<Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center"> <Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center">
Настройка маршрута Детали о достопримечательностях
</Typography> </Typography>
<Stack spacing={2} direction="row" alignItems="center"> <Stack spacing={2} direction="row" alignItems="center">
@@ -155,26 +130,19 @@ export function RightSidebar() {
onChange={(e) => { onChange={(e) => {
let newMinScale = Number(e.target.value); let newMinScale = Number(e.target.value);
if (newMinScale < 10) { // Сбрасываем к 1 если меньше
newMinScale = 10; if (newMinScale < 1) {
} newMinScale = 1;
if (newMinScale > 300) {
newMinScale = 297;
} }
setMinScale(newMinScale); setMinScale(newMinScale);
if (maxScale - newMinScale < 2) { if (maxScale - newMinScale < 2) {
let newMaxScale = newMinScale + 2; let newMaxScale = newMinScale + 2;
// Сбрасываем максимальный к 3 если меньше минимального
if (newMaxScale > 300) {
newMaxScale = 300;
}
if (newMaxScale < 3) { if (newMaxScale < 3) {
newMaxScale = 3; newMaxScale = 3;
setMinScale(1); setMinScale(1); // Сбрасываем минимальный к 1
} }
setMaxScale(newMaxScale); setMaxScale(newMaxScale);
} }
@@ -207,22 +175,19 @@ export function RightSidebar() {
onChange={(e) => { onChange={(e) => {
let newMaxScale = Number(e.target.value); let newMaxScale = Number(e.target.value);
if (newMaxScale < 13) { // Сбрасываем к 3 если меньше минимального
newMaxScale = 13; if (newMaxScale < 3) {
} newMaxScale = 3;
if (newMaxScale > 300) {
newMaxScale = 300;
} }
setMaxScale(newMaxScale); setMaxScale(newMaxScale);
if (newMaxScale - minScale < 2) { if (newMaxScale - minScale < 2) {
let newMinScale = newMaxScale - 2; let newMinScale = newMaxScale - 2;
// Сбрасываем минимальный к 1 если меньше
if (newMinScale < 1) { if (newMinScale < 1) {
newMinScale = 1; newMinScale = 1;
setMaxScale(3); setMaxScale(3); // Сбрасываем максимальный к минимальному значению
} }
setMinScale(newMinScale); setMinScale(newMinScale);
} }
@@ -243,7 +208,7 @@ export function RightSidebar() {
slotProps={{ slotProps={{
input: { input: {
min: 3, min: 3,
max: 300, max: 10,
}, },
}} }}
/> />
@@ -307,60 +272,6 @@ export function RightSidebar() {
}} }}
/> />
<TextField
type="number"
label="Размер шрифта (%)"
variant="filled"
value={fontSize}
onChange={(e) => {
const value = Number(e.target.value);
if (!isNaN(value)) {
handleFontSizeChange(value);
}
}}
style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{
"& .MuiInputLabel-root": {
color: "#fff",
},
"& .MuiInputBase-input": {
color: "#fff",
},
}}
inputProps={{
min: 1,
max: 300,
step: 1,
}}
/>
<TextField
type="number"
label="Размер иконок по умолчанию (%)"
variant="filled"
value={defaultIconSize}
onChange={(e) => {
const value = Number(e.target.value);
if (!isNaN(value)) {
handleDefaultIconSizeChange(value);
}
}}
style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{
"& .MuiInputLabel-root": {
color: "#fff",
},
"& .MuiInputBase-input": {
color: "#fff",
},
}}
inputProps={{
min: 1,
max: 300,
step: 1,
}}
/>
<TextField <TextField
type="number" type="number"
label="Поворот (в градусах)" label="Поворот (в градусах)"
@@ -402,15 +313,10 @@ export function RightSidebar() {
value={Math.round(localCenter.x * 1000) / 1000} value={Math.round(localCenter.x * 1000) / 1000}
onChange={(e) => { onChange={(e) => {
setIsUserEditing(true); setIsUserEditing(true);
const newValue = Number(e.target.value); setLocalCenter((prev) => ({ ...prev, x: Number(e.target.value) }));
setLocalCenter((prev) => ({ ...prev, x: newValue })); pan({ x: Number(e.target.value), y: localCenter.y });
if (!isNaN(newValue) && localCenter.y !== undefined) {
setMapCenter(newValue, localCenter.y);
}
}}
onBlur={() => {
setIsUserEditing(false);
}} }}
onBlur={() => setIsUserEditing(false)}
style={{ backgroundColor: "#222", borderRadius: 4 }} style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{ sx={{
"& .MuiInputLabel-root": { "& .MuiInputLabel-root": {
@@ -426,20 +332,15 @@ export function RightSidebar() {
/> />
<TextField <TextField
type="number" type="number"
label="Центр карты, долгота" label="Центр карты, высота"
variant="filled" variant="filled"
value={Math.round(localCenter.y * 1000) / 1000} value={Math.round(localCenter.y * 1000) / 1000}
onChange={(e) => { onChange={(e) => {
setIsUserEditing(true); setIsUserEditing(true);
const newValue = Number(e.target.value); setLocalCenter((prev) => ({ ...prev, y: Number(e.target.value) }));
setLocalCenter((prev) => ({ ...prev, y: newValue })); pan({ x: localCenter.x, y: Number(e.target.value) });
if (!isNaN(newValue) && localCenter.x !== undefined) {
setMapCenter(localCenter.x, newValue);
}
}}
onBlur={() => {
setIsUserEditing(false);
}} }}
onBlur={() => setIsUserEditing(false)}
style={{ backgroundColor: "#222", borderRadius: 4 }} style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{ sx={{
"& .MuiInputLabel-root": { "& .MuiInputLabel-root": {
@@ -458,51 +359,19 @@ export function RightSidebar() {
<Button <Button
variant="contained" variant="contained"
color="secondary" color="secondary"
sx={{ mt: 2, position: "relative" }} sx={{ mt: 2 }}
disabled={isSaving}
onClick={async () => { onClick={async () => {
setIsSaving(true);
try { try {
await saveChanges(); await saveChanges();
toast.success("Изменения сохранены"); toast.success("Изменения сохранены");
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.error("Ошибка при сохранении изменений"); toast.error("Ошибка при сохранении изменений");
} finally {
setIsSaving(false);
} }
}} }}
> >
{isSaving ? ( Сохранить изменения
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
}}
>
<CircularProgress size={20} sx={{ color: "inherit" }} />
<span>Сохранение...</span>
</Box>
) : (
"Сохранить изменения"
)}
</Button> </Button>
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 48 48"
fill="none"
style={{ cursor: "pointer" }}
className="absolute bottom-5 left-[-68px] z-100"
>
<path
d="M24.0013 0C23.3513 0 22.7013 0.03 22.0413 0.08C10.4213 1 1.01127 10.41 0.0812683 22.03C-0.428732 28.39 1.55127 34.28 5.14127 38.83C5.60127 39.42 5.76127 40.21 5.46127 40.89C4.76127 42.43 3.63127 43.64 3.05127 44.23C2.50127 44.78 1.97127 45.27 1.38127 45.7C0.791268 46.13 0.841268 47.03 1.45127 47.42C2.08127 47.82 3.01127 47.99 4.12127 47.99C6.84127 47.99 10.5813 46.99 13.3013 46.06C13.5013 45.99 13.7013 45.96 13.9113 45.96C14.1813 45.96 14.4613 46.02 14.7113 46.13C17.5713 47.33 20.7113 48 24.0013 48C24.6513 48 25.3213 47.97 25.9813 47.92C37.6313 46.98 47.0613 37.51 47.9313 25.85C48.9913 11.76 37.8713 0 24.0013 0ZM29.5113 37.71C29.4813 37.82 29.3413 37.94 29.2313 37.98C27.7413 38.48 26.2713 39.12 24.7313 39.42C22.9513 39.77 21.1413 39.68 19.5513 38.58C18.2213 37.66 17.7313 36.36 17.8113 34.8C17.9013 32.91 18.5113 31.13 19.0013 29.33C19.5213 27.42 20.1113 25.53 20.4613 23.59C20.9413 20.94 19.7813 20.48 17.3913 20.74C16.8013 20.8 16.2313 21.04 15.5813 21.22C15.7213 20.62 15.8313 20.08 15.9913 19.55C16.0213 19.45 19.6313 17.94 21.4413 17.78C23.3513 17.61 25.2013 17.8 26.6013 19.32C27.3913 20.17 27.6113 21.21 27.5913 22.33C27.5413 24.8 26.5813 27.07 25.9813 29.42C25.6113 30.86 25.2513 32.3 24.9313 33.75C24.8413 34.15 24.8413 34.59 24.8813 35C24.9613 35.97 25.4413 36.39 26.4313 36.57C27.6213 36.78 28.7213 36.45 29.9313 36.04C29.7813 36.66 29.6613 37.19 29.5213 37.7L29.5113 37.71ZM26.8513 15.21C26.6513 15.23 26.4613 15.23 26.2013 15.25C24.4013 15.27 22.7313 14.15 22.2013 12.52C21.5913 10.65 22.5613 8.71 24.5313 7.86C26.7913 6.88 29.5813 8.07 30.2713 10.33C31.0513 12.87 29.0613 14.95 26.8613 15.21H26.8513Z"
fill="white"
/>
</svg>
</Stack> </Stack>
); );
} }

View File

@@ -2,6 +2,7 @@ import { FederatedMouseEvent, Graphics } from "pixi.js";
import { useCallback, useState, useEffect, useRef, FC, useMemo } from "react"; import { useCallback, useState, useEffect, useRef, FC, useMemo } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// --- Заглушки для зависимостей (замените на ваши реальные импорты) ---
import { import {
BACKGROUND_COLOR, BACKGROUND_COLOR,
PATH_COLOR, PATH_COLOR,
@@ -14,16 +15,22 @@ import { StationData } from "./types";
import { useMapData } from "./MapDataContext"; import { useMapData } from "./MapDataContext";
import { coordinatesToLocal } from "./utils"; import { coordinatesToLocal } from "./utils";
import { languageStore } from "@shared"; import { languageStore } from "@shared";
// --- Конец заглушек ---
// --- Декларации для react-pixi ---
// (В реальном проекте типы должны быть предоставлены библиотекой @pixi/react-pixi)
declare const pixiContainer: any; declare const pixiContainer: any;
declare const pixiGraphics: any; declare const pixiGraphics: any;
declare const pixiText: any; declare const pixiText: any;
// --- Типы ---
type HorizontalAlign = "left" | "center" | "right"; type HorizontalAlign = "left" | "center" | "right";
type VerticalAlign = "top" | "center" | "bottom"; type VerticalAlign = "top" | "center" | "bottom";
type TextAlign = HorizontalAlign | `${HorizontalAlign} ${VerticalAlign}`; type TextAlign = HorizontalAlign | `${HorizontalAlign} ${VerticalAlign}`;
type LabelAlign = "left" | "center" | "right"; type LabelAlign = "left" | "center" | "right";
// --- Утилиты ---
/** /**
* Преобразует текстовое позиционирование в anchor координаты. * Преобразует текстовое позиционирование в anchor координаты.
*/ */
@@ -32,6 +39,8 @@ type LabelAlign = "left" | "center" | "right";
* Получает координату anchor.x из типа выравнивания. * Получает координату anchor.x из типа выравнивания.
*/ */
// --- Интерфейсы пропсов ---
interface StationProps { interface StationProps {
station: StationData; station: StationData;
ruLabel: string | null; ruLabel: string | null;
@@ -74,6 +83,10 @@ const getAnchorFromOffset = (
return { x: (1 - nx) / 2, y: (1 - ny) / 2 }; return { x: (1 - nx) / 2, y: (1 - ny) / 2 };
}; };
// =========================================================================
// Компонент: Панель управления выравниванием в стиле УрФУ
// =========================================================================
const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
scale, scale,
currentAlign, currentAlign,
@@ -94,6 +107,7 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
(g: Graphics) => { (g: Graphics) => {
g.clear(); g.clear();
// Основной фон с градиентом
g.roundRect( g.roundRect(
-controlWidth / 2, -controlWidth / 2,
0, 0,
@@ -101,8 +115,9 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
controlHeight, controlHeight,
borderRadius borderRadius
); );
g.fill({ color: "#1a1a1a" }); g.fill({ color: "#1a1a1a" }); // Темный фон как у УрФУ
// Тонкая рамка
g.roundRect( g.roundRect(
-controlWidth / 2, -controlWidth / 2,
0, 0,
@@ -112,6 +127,7 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
); );
g.stroke({ color: "#333333", width: strokeWidth }); g.stroke({ color: "#333333", width: strokeWidth });
// Разделители между кнопками
for (let i = 1; i < 3; i++) { for (let i = 1; i < 3; i++) {
const x = -controlWidth / 2 + buttonWidth * i; const x = -controlWidth / 2 + buttonWidth * i;
g.moveTo(x, strokeWidth); g.moveTo(x, strokeWidth);
@@ -135,7 +151,7 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
controlHeight - strokeWidth * 2, controlHeight - strokeWidth * 2,
borderRadius / 2 borderRadius / 2
); );
g.fill({ color: "#0066cc", alpha: 0.8 }); g.fill({ color: "#0066cc", alpha: 0.8 }); // Синий акцент УрФУ
} }
}, },
[controlWidth, controlHeight, buttonWidth, strokeWidth, borderRadius] [controlWidth, controlHeight, buttonWidth, strokeWidth, borderRadius]
@@ -214,6 +230,10 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
); );
}; };
// =========================================================================
// Компонент: Метка Станции (с логикой)
// =========================================================================
const StationLabel = observer( const StationLabel = observer(
({ ({
station, station,
@@ -254,45 +274,48 @@ const StationLabel = observer(
hideTimer.current = null; hideTimer.current = null;
} }
setIsHovered(true); setIsHovered(true);
onTextHover?.(true); onTextHover?.(true); // Call the callback to indicate text is hovered
}; };
const handleControlPointerEnter = () => { const handleControlPointerEnter = () => {
// Дополнительная обработка для панели управления
if (hideTimer.current) { if (hideTimer.current) {
clearTimeout(hideTimer.current); clearTimeout(hideTimer.current);
hideTimer.current = null; hideTimer.current = null;
} }
setIsControlHovered(true); setIsControlHovered(true);
setIsHovered(true); setIsHovered(true);
onTextHover?.(true); onTextHover?.(true); // Call the callback to indicate text/control is hovered
}; };
const handleControlPointerLeave = () => { const handleControlPointerLeave = () => {
setIsControlHovered(false); setIsControlHovered(false);
// Если курсор не над основным контейнером, скрываем панель через некоторое время
if (!isHovered) { if (!isHovered) {
hideTimer.current = setTimeout(() => { hideTimer.current = setTimeout(() => {
setIsHovered(false); setIsHovered(false);
onTextHover?.(false); onTextHover?.(false); // Call the callback to indicate neither text nor control is hovered
}, 0); }, 0);
} }
}; };
const handlePointerLeave = () => { const handlePointerLeave = () => {
// Увеличиваем время до скрытия панели и добавляем проверку
hideTimer.current = setTimeout(() => { hideTimer.current = setTimeout(() => {
setIsHovered(false); setIsHovered(false);
// Если курсор не над панелью управления, скрываем и её
if (!isControlHovered) { if (!isControlHovered) {
setIsControlHovered(false); setIsControlHovered(false);
} }
onTextHover?.(false); onTextHover?.(false); // Call the callback to indicate text is no longer hovered
}, 100); }, 100); // Увеличиваем время до скрытия панели
}; };
useEffect(() => { useEffect(() => {
setPosition({ x: station.offset_x ?? 0, y: station.offset_y ?? 0 }); setPosition({ x: station.offset_x ?? 0, y: station.offset_y ?? 0 });
}, [station.offset_x, station.offset_y, station.id]); }, [station.offset_x, station.offset_y, station.id]);
// Функция для конвертации числового align в строковый
const convertNumericAlign = (align: number): LabelAlign => { const convertNumericAlign = (align: number): LabelAlign => {
switch (align) { switch (align) {
case 0: case 0:
@@ -306,6 +329,7 @@ const StationLabel = observer(
} }
}; };
// Функция для конвертации строкового align в числовой
const convertStringAlign = (align: LabelAlign): number => { const convertStringAlign = (align: LabelAlign): number => {
switch (align) { switch (align) {
case "left": case "left":
@@ -329,6 +353,7 @@ const StationLabel = observer(
const compensatedRuFontSize = (26 * 0.75) / scale; const compensatedRuFontSize = (26 * 0.75) / scale;
const compensatedNameFontSize = (16 * 0.75) / scale; const compensatedNameFontSize = (16 * 0.75) / scale;
// Измеряем ширину верхнего лейбла
useEffect(() => { useEffect(() => {
if (ruLabelRef.current && ruLabel) { if (ruLabelRef.current && ruLabel) {
setRuLabelWidth(ruLabelRef.current.width); setRuLabelWidth(ruLabelRef.current.width);
@@ -361,6 +386,7 @@ const StationLabel = observer(
y: dragStartPos.current.y + dy_screen, y: dragStartPos.current.y + dy_screen,
}; };
// Проверяем, изменилась ли позиция
if ( if (
Math.abs(newPosition.x - position.x) > 0.01 || Math.abs(newPosition.x - position.x) > 0.01 ||
Math.abs(newPosition.y - position.y) > 0.01 Math.abs(newPosition.y - position.y) > 0.01
@@ -380,7 +406,7 @@ const StationLabel = observer(
const handleAlignChange = async (align: LabelAlign) => { const handleAlignChange = async (align: LabelAlign) => {
setCurrentLabelAlign(align); setCurrentLabelAlign(align);
onLabelAlignChange?.(align); onLabelAlignChange?.(align);
// Сохраняем в стор
const numericAlign = convertStringAlign(align); const numericAlign = convertStringAlign(align);
setStationAlign(station.id, numericAlign); setStationAlign(station.id, numericAlign);
}; };
@@ -390,29 +416,34 @@ const StationLabel = observer(
[position.x, position.y] [position.x, position.y]
); );
// Функция для расчета позиции нижнего лейбла относительно ширины верхнего
const getSecondLabelPosition = (): number => { const getSecondLabelPosition = (): number => {
if (!ruLabelWidth) return 0; if (!ruLabelWidth) return 0;
switch (currentLabelAlign) { switch (currentLabelAlign) {
case "left": case "left":
// Позиционируем относительно левого края верхнего текста
return -ruLabelWidth / 2; return -ruLabelWidth / 2;
case "center": case "center":
// Центрируем относительно центра верхнего текста
return 0; return 0;
case "right": case "right":
// Позиционируем относительно правого края верхнего текста
return ruLabelWidth / 2; return ruLabelWidth / 2;
default: default:
return 0; return 0;
} }
}; };
// Функция для расчета anchor нижнего лейбла
const getSecondLabelAnchor = (): number => { const getSecondLabelAnchor = (): number => {
switch (currentLabelAlign) { switch (currentLabelAlign) {
case "left": case "left":
return 0; return 0; // anchor.x = 0 (левый край)
case "center": case "center":
return 0.5; return 0.5; // anchor.x = 0.5 (центр)
case "right": case "right":
return 1; return 1; // anchor.x = 1 (правый край)
default: default:
return 0.5; return 0.5;
} }
@@ -491,6 +522,10 @@ const StationLabel = observer(
} }
); );
// =========================================================================
// Главный экспортируемый компонент: Станция
// =========================================================================
export const Station = ({ export const Station = ({
station, station,
ruLabel, ruLabel,
@@ -513,9 +548,10 @@ export const Station = ({
g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius); g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius);
// Change fill color when text is hovered
if (isTextHovered) { if (isTextHovered) {
g.fill({ color: 0x00aaff }); g.fill({ color: 0x00aaff }); // Highlight color when hovered
g.stroke({ color: 0xffffff, width: strokeWidth + 1 }); g.stroke({ color: 0xffffff, width: strokeWidth + 1 }); // Brighter outline when hovered
} else { } else {
g.fill({ color: PATH_COLOR }); g.fill({ color: PATH_COLOR });
g.stroke({ color: BACKGROUND_COLOR, width: strokeWidth }); g.stroke({ color: BACKGROUND_COLOR, width: strokeWidth });

View File

@@ -50,6 +50,7 @@ const TransformContext = createContext<{
setScaleAtCenter: () => {}, setScaleAtCenter: () => {},
}); });
// Provider component
export const TransformProvider = ({ children }: { children: ReactNode }) => { export const TransformProvider = ({ children }: { children: ReactNode }) => {
const [position, setPosition] = useState({ x: 0, y: 0 }); const [position, setPosition] = useState({ x: 0, y: 0 });
const [scale, setScale] = useState(1); const [scale, setScale] = useState(1);
@@ -58,10 +59,12 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
const screenToLocal = useCallback( const screenToLocal = useCallback(
(screenX: number, screenY: number) => { (screenX: number, screenY: number) => {
// Translate point relative to current pan position
const translatedX = (screenX - position.x) / scale; const translatedX = (screenX - position.x) / scale;
const translatedY = (screenY - position.y) / scale; const translatedY = (screenY - position.y) / scale;
const cosRotation = Math.cos(-rotation); // Rotate point around center
const cosRotation = Math.cos(-rotation); // Negative rotation to reverse transform
const sinRotation = Math.sin(-rotation); const sinRotation = Math.sin(-rotation);
const rotatedX = translatedX * cosRotation - translatedY * sinRotation; const rotatedX = translatedX * cosRotation - translatedY * sinRotation;
const rotatedY = translatedX * sinRotation + translatedY * cosRotation; const rotatedY = translatedX * sinRotation + translatedY * cosRotation;
@@ -74,6 +77,7 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
[position.x, position.y, scale, rotation] [position.x, position.y, scale, rotation]
); );
// Inverse of screenToLocal
const localToScreen = useCallback( const localToScreen = useCallback(
(localX: number, localY: number) => { (localX: number, localY: number) => {
const upscaledX = localX * UP_SCALE; const upscaledX = localX * UP_SCALE;
@@ -116,6 +120,7 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
(currentFromPosition.x - center.x) * sinDelta, (currentFromPosition.x - center.x) * sinDelta,
}; };
// Update both rotation and position in a single batch to avoid stale closure
setRotation(to); setRotation(to);
setPosition(newPosition); setPosition(newPosition);
}, },
@@ -145,11 +150,13 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
const cosRot = Math.cos(selectedRotation); const cosRot = Math.cos(selectedRotation);
const sinRot = Math.sin(selectedRotation); const sinRot = Math.sin(selectedRotation);
// Translate point relative to center, rotate, then translate back
const dx = newPosition.x; const dx = newPosition.x;
const dy = newPosition.y; const dy = newPosition.y;
newPosition.x = dx * cosRot - dy * sinRot + center.x; newPosition.x = dx * cosRot - dy * sinRot + center.x;
newPosition.y = dx * sinRot + dy * cosRot + center.y; newPosition.y = dx * sinRot + dy * cosRot + center.y;
// Batch state updates to avoid intermediate renders
setPosition(newPosition); setPosition(newPosition);
setRotation(selectedRotation); setRotation(selectedRotation);
setScale(selectedScale); setScale(selectedScale);
@@ -177,6 +184,7 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
); );
const setScaleOnly = useCallback((newScale: number) => { const setScaleOnly = useCallback((newScale: number) => {
// Изменяем только масштаб, не трогая позицию и поворот
setScale(newScale); setScale(newScale);
}, []); }, []);
@@ -229,6 +237,7 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
); );
}; };
// Custom hook for easy access to transform values
export const useTransform = () => { export const useTransform = () => {
const context = useContext(TransformContext); const context = useContext(TransformContext);
if (!context) { if (!context) {

View File

@@ -1,6 +1,6 @@
import { useRef, useEffect, useState } from "react"; import { useRef, useEffect, useState } from "react";
import { Widgets } from "./Widgets"; import { Widgets } from "./Widgets";
import { extend } from "@pixi/react"; import { Application, extend } from "@pixi/react";
import { import {
Container, Container,
Graphics, Graphics,
@@ -9,18 +9,24 @@ import {
TilingSprite, TilingSprite,
Text, Text,
} from "pixi.js"; } from "pixi.js";
import { Box, Stack } from "@mui/material"; import { Stack } from "@mui/material";
import { MapDataProvider, useMapData } from "./MapDataContext"; import { MapDataProvider, useMapData } from "./MapDataContext";
import { TransformProvider, useTransform } from "./TransformContext"; import { TransformProvider, useTransform } from "./TransformContext";
import { InfiniteCanvas } from "./InfiniteCanvas";
import { TravelPath } from "./TravelPath";
import { LeftSidebar } from "./LeftSidebar"; import { LeftSidebar } from "./LeftSidebar";
import { RightSidebar } from "./RightSidebar"; import { RightSidebar } from "./RightSidebar";
import { coordinatesToLocal } from "./utils"; import { coordinatesToLocal } from "./utils";
import { LanguageSwitcher } from "@widgets";
import { languageStore } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Sight } from "./Sight";
import { SightData } from "./types";
import { Station } from "./Station";
import { UP_SCALE } from "./Constants"; import { UP_SCALE } from "./Constants";
import { WebGLRouteMapPrototype } from "./webgl-prototype/WebGLRouteMapPrototype"; import CircularProgress from "@mui/material/CircularProgress";
import { CircularProgress } from "@mui/material";
extend({ extend({
Container, Container,
@@ -36,7 +42,7 @@ const Loading = () => {
if (isRouteLoading || isStationLoading || isSightLoading) { if (isRouteLoading || isStationLoading || isSightLoading) {
return ( return (
<div className="fixed flex z-1000000000 items-center justify-center h-screen w-screen bg-[#111]"> <div className="fixed flex z-1 items-center justify-center h-screen w-screen bg-[#111]">
<CircularProgress /> <CircularProgress />
</div> </div>
); );
@@ -45,33 +51,14 @@ const Loading = () => {
return null; return null;
}; };
export const RoutePreview = () => { export const RoutePreview = () => {
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(true); const { routeData, stationData, sightData } = useMapData();
return ( return (
<MapDataProvider> <MapDataProvider>
<TransformProvider> <TransformProvider>
<Stack direction="row" height="100vh" width="100vw" overflow="hidden"> <Stack direction="row" height="100vh" width="100vw" overflow="hidden">
{routeData && stationData && sightData ? <LanguageSwitcher /> : null}
<Loading /> <Loading />
<Box <LeftSidebar />
sx={{
position: "relative",
width: isLeftSidebarOpen ? 300 : 0,
transition: "width 0.3s ease",
overflow: "visible",
height: "100%",
bgcolor: "primary.main",
borderRight: isLeftSidebarOpen
? "1px solid rgba(255,255,255,0.08)"
: "none",
display: "flex",
justifyContent: "flex-start",
flexShrink: 0,
}}
>
<LeftSidebar
open={isLeftSidebarOpen}
onToggle={() => setIsLeftSidebarOpen((prev) => !prev)}
/>
</Box>
<Stack direction="row" flex={1} position="relative" height="100%"> <Stack direction="row" flex={1} position="relative" height="100%">
<RouteMap /> <RouteMap />
<Widgets /> <Widgets />
@@ -84,8 +71,15 @@ export const RoutePreview = () => {
}; };
export const RouteMap = observer(() => { export const RouteMap = observer(() => {
const { language } = languageStore;
const { setPosition, setTransform, screenCenter } = useTransform(); const { setPosition, setTransform, screenCenter } = useTransform();
const { routeData, stationData, sightData, originalRouteData } = useMapData(); const {
routeData,
stationData,
sightData,
originalRouteData,
originalSightData,
} = useMapData();
const [points, setPoints] = useState<{ x: number; y: number }[]>([]); const [points, setPoints] = useState<{ x: number; y: number }[]>([]);
const [isSetup, setIsSetup] = useState(false); const [isSetup, setIsSetup] = useState(false);
@@ -171,7 +165,8 @@ export const RouteMap = observer(() => {
return ( return (
<div style={{ width: "100%", height: "100%" }} ref={parentRef}> <div style={{ width: "100%", height: "100%" }} ref={parentRef}>
{/* <Application resizeTo={parentRef} background="#000" preference="webgl"> <LanguageSwitcher />
<Application resizeTo={parentRef} background="#fff" preference="webgl">
<InfiniteCanvas> <InfiniteCanvas>
<TravelPath points={points} /> <TravelPath points={points} />
{stationData[language].map((obj, index) => ( {stationData[language].map((obj, index) => (
@@ -189,8 +184,7 @@ export const RouteMap = observer(() => {
return <Sight sight={sight} id={index} key={sight.id} />; return <Sight sight={sight} id={index} key={sight.id} />;
})} })}
</InfiniteCanvas> </InfiniteCanvas>
</Application> */} </Application>
<WebGLRouteMapPrototype />
</div> </div>
); );
}); });

View File

@@ -3,8 +3,6 @@ export interface RouteData {
carrier_id: number; carrier_id: number;
center_latitude: number; center_latitude: number;
center_longitude: number; center_longitude: number;
icon_size?: number;
font_size: number;
governor_appeal: number; governor_appeal: number;
id: number; id: number;
path: [number, number][]; path: [number, number][];
@@ -33,8 +31,6 @@ export interface StationData {
address: string; address: string;
city_id?: number; city_id?: number;
description: string; description: string;
icon?: string;
icon_size?: number;
id: number; id: number;
latitude: number; latitude: number;
longitude: number; longitude: number;
@@ -52,33 +48,25 @@ export interface StationPatchData {
offset_y: number; offset_y: number;
align: number; align: number;
transfers: StationTransferData; transfers: StationTransferData;
icon_size?: number;
} }
export interface SightPatchData { export interface SightPatchData {
sight_id: number; sight_id: number;
latitude: number; latitude: number;
longitude: number; longitude: number;
icon_size?: number;
} }
export interface SightData { export interface SightData {
address: string; address: string;
alt_icon?: string | null; // uuid
city: string; city: string;
city_id: number; city_id: number;
id: number; id: number;
is_default_icon?: boolean;
icon?: string | null; // uuid
icon_size?: number;
latitude: number; latitude: number;
left_article: number; left_article: number;
longitude: number; longitude: number;
name: string; name: string;
offset_x?: number;
offset_y?: number;
preview_media: number; preview_media: number;
thumbnail?: string | null; // uuid thumbnail: string; // uuid
watermark_lu: string; // uuid watermark_lu: string; // uuid
watermark_rd: string; // uuid watermark_rd: string; // uuid
} }

View File

@@ -1,203 +0,0 @@
import { useEffect, useRef, useState, type ReactElement } from "react";
import { observer } from "mobx-react-lite";
import { languageStore } from "@shared";
const LANGUAGES = ["ru", "zh", "en"] as const;
type Language = (typeof LANGUAGES)[number];
type LanguageSelectorProps = {
onBack?: () => void;
isSidebarOpen?: boolean;
};
const renderLanguageIcon = (lang: Language): ReactElement => {
switch (lang) {
case "ru":
return (
<svg
className="h-12 w-12 cursor-pointer"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="3" y="3" width="42" height="42" rx="21" fill="black" />
<path
d="M24 0C10.75 0 0 10.75 0 24C0 37.25 10.75 48 24 48C37.25 48 48 37.25 48 24C48 10.75 37.25 0 24 0ZM24.2 33.55H19.92L16.29 26.46H13.11V33.55H9.12V14.18H16.32C18.61 14.18 20.37 14.69 21.62 15.71C22.87 16.73 23.48 18.17 23.48 20.03C23.48 21.35 23.19 22.45 22.62 23.34C22.05 24.22 21.18 24.93 20.02 25.45L24.21 33.37V33.56L24.2 33.55ZM40.3 26.94C40.3 29.06 39.64 30.74 38.31 31.97C36.98 33.2 35.17 33.82 32.87 33.82C30.57 33.82 28.81 33.22 27.48 32.02C26.15 30.82 25.47 29.18 25.44 27.08V14.18H29.43V26.97C29.43 28.24 29.73 29.16 30.34 29.74C30.95 30.32 31.79 30.61 32.86 30.61C35.1 30.61 36.24 29.43 36.28 27.07V14.18H40.28V26.94H40.3Z"
fill="white"
/>
<path
d="M16.3086 17.4099H13.0986V23.2199H16.3186C17.3186 23.2199 18.0986 22.9599 18.6486 22.4499C19.1986 21.9399 19.4686 21.2399 19.4686 20.3399C19.4686 19.4399 19.2086 18.7099 18.6886 18.1799C18.1686 17.6499 17.3686 17.3899 16.2986 17.3899L16.3086 17.4099Z"
fill="white"
/>
</svg>
);
case "zh":
return (
<svg
className="h-12 w-12 cursor-pointer"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="3" y="3" width="42" height="42" rx="21" fill="black" />
<path d="M10.287 20.382H6.291V24.147H10.287V20.382Z" fill={"white"} />
<path
d="M13.704 24.147H17.721V20.382H13.704V24.147Z"
fill={"white"}
/>
<path
d="M36.1254 20.046H29.8575C30.6606 21.9406 31.7187 23.6442 33.0513 25.1217C34.3105 23.6927 35.3315 22.0126 36.1254 20.046Z"
fill={"white"}
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M24 48C37.2548 48 48 37.2548 48 24C48 10.7452 37.2548 0 24 0C10.7452 0 0 10.7452 0 24C0 37.2548 10.7452 48 24 48ZM10.287 13.5H13.704V17.1541H21.117V28.446H17.721V27.375H13.704V33.969H10.287V27.375H6.291V28.5511H3V17.1541H10.287V13.5ZM31.35 13.5H34.704V16.8181H43.083V20.046H39.8887C38.804 22.9506 37.3746 25.3834 35.581 27.4065C37.6488 28.9237 40.1651 30.0542 43.1682 30.7291L43.8469 30.8817L43.3465 31.3649C42.7753 31.9162 41.9777 33.0771 41.5886 33.7939L41.4484 34.0521L41.1642 33.9778C37.8385 33.1088 35.1249 31.7521 32.8974 29.9253C30.6296 31.6954 27.9389 33.0335 24.802 34.015L24.4889 34.1129L24.3502 33.8156C24.0724 33.2203 23.2933 32.029 22.8051 31.439L22.4307 30.9868L22.9986 30.8373C25.936 30.0648 28.4025 28.9702 30.4373 27.4935C28.6775 25.4061 27.319 22.9142 26.2412 20.046H23.097V16.8181H31.35V13.5Z"
fill={"white"}
/>
</svg>
);
case "en":
default:
return (
<svg
className="h-12 w-12 cursor-pointer"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="3" y="3" width="42" height="42" rx="21" fill="black" />
<path
d="M24 0C10.75 0 0 10.75 0 24C0 37.25 10.75 48 24 48C37.25 48 48 37.25 48 24C48 10.75 37.25 0 24 0ZM21.57 33.79H8.41V14.15H21.55V17.43H12.45V22.11H20.22V25.28H12.45V30.54H21.57V33.79ZM39.54 33.79H35.49L27.61 20.87V33.79H23.56V14.15H27.61L35.5 27.1V14.15H39.53V33.79H39.54Z"
fill="white"
/>
</svg>
);
}
};
const CollapsedIcon = () => (
<svg
className="h-12 w-12 cursor-pointer"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="4" y="3" width="39" height="42" rx="19.5" fill="black" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 24C0 10.75 10.75 0 24 0C37.25 0 48 10.75 48 24C48 37.25 37.25 48 24 48C10.75 48 0 37.25 0 24ZM36.05 19.41L38.27 25.47L40.47 19.39L39.17 19.77C39.16 19.75 39.16 19.35 39.16 19.32C38.58 12.41 32.99 7.15 25.94 7.15C25.42 7.15 24.99 7.58 24.99 8.1C24.99 8.62 25.42 9.05 25.94 9.05C31.92 9.05 36.68 13.47 37.26 19.31C37.26 19.325 37.2625 19.435 37.265 19.545C37.2675 19.655 37.27 19.765 37.27 19.78L36.05 19.41ZM8.90375 27.5369C11.3535 26.9568 13.6332 25.8438 15.5701 24.2838L15.5709 24.2846C17.2124 25.8526 19.2497 26.9784 21.4812 27.5521C21.5875 27.5649 21.6945 27.5649 21.8007 27.5521C22.3194 27.6074 22.8298 27.3911 23.1385 26.9848C23.448 26.5786 23.5086 26.0441 23.2986 25.5826C23.0879 25.1211 22.6389 24.803 22.1202 24.7477C20.4804 24.3182 18.9808 23.4937 17.7634 22.3495C20.2489 20.0131 22.1036 17.1245 23.1659 13.9363C23.2729 13.5077 23.165 13.0566 22.8754 12.716C22.6016 12.3627 22.1709 12.1552 21.7136 12.1552H16.6307V10.402C16.6307 9.90121 16.3535 9.43889 15.9045 9.1881C15.4556 8.9373 14.9012 8.9373 14.4522 9.1881C14.0033 9.43809 13.7261 9.90121 13.7261 10.402V12.1824H8.64317C8.12367 12.1824 7.64484 12.45 7.38509 12.8835C7.12534 13.317 7.12534 13.8522 7.38509 14.2857C7.64484 14.7192 8.1245 14.9868 8.64317 14.9868H19.6655C18.6804 16.9971 17.3443 18.828 15.7153 20.3993C14.8373 19.3977 14.0688 18.3128 13.4207 17.1598C13.2747 16.7896 12.9734 16.4972 12.5909 16.3529C12.2083 16.2087 11.7809 16.2279 11.4141 16.405C11.0473 16.5821 10.7751 16.901 10.6647 17.2824C10.5544 17.6638 10.6166 18.0724 10.8357 18.4074C11.6058 19.7423 12.5153 20.997 13.551 22.1516C12.0207 23.3728 10.2307 24.2533 8.30873 24.7317C7.92284 24.7709 7.56932 24.956 7.32617 25.2469C7.08219 25.5369 6.96767 25.9095 7.00833 26.2813C7.04899 26.6539 7.24069 26.9952 7.54193 27.23C7.84235 27.4656 8.22824 27.5761 8.61329 27.5369C8.70956 27.5513 8.80748 27.5513 8.90375 27.5369ZM34.9002 38.8803C35.2512 39.0301 35.6487 39.0397 36.0072 38.9067C36.3815 38.7865 36.6886 38.5237 36.8587 38.18C37.0288 37.8362 37.0462 37.4404 36.9077 37.0839L30.3434 20.7767C30.238 20.5131 30.0529 20.2863 29.8115 20.1261C29.57 19.9658 29.2845 19.8793 28.9924 19.8785C28.7019 19.8785 28.4173 19.9626 28.1766 20.1196C27.9359 20.2775 27.7501 20.501 27.6422 20.7623L21.136 36.523C20.9443 36.9885 21.024 37.5181 21.346 37.9116C21.668 38.305 22.1825 38.5029 22.6962 38.4308C23.2107 38.3579 23.6455 38.0269 23.8372 37.5606L25.4057 33.6489H32.3185L34.1342 38.1079C34.2737 38.4524 34.5492 38.7304 34.9002 38.8803ZM28.9052 25.1652L31.1857 30.8445H31.1849H26.5667L28.9052 25.1652Z"
fill="#FFFFFF"
/>
</svg>
);
const ArrowIcon = ({ rotation }: { rotation: number }) => (
<svg
style={{
transform: `rotate(${rotation}deg)`,
transition: "transform 0.15s ease",
}}
className="h-12 w-12"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="8" y="7" width="31" height="33" fill="black" />
<path
d="M24.0001 0C10.7501 0 0.00012207 10.75 0.00012207 24C0.00012207 37.25 10.7501 48 24.0001 48C37.2501 48 48.0001 37.25 48.0001 24C48.0001 10.75 37.2501 0 24.0001 0ZM37.5401 25.84C37.5401 26.4 37.0901 26.85 36.5301 26.85H20.5901C20.1401 26.85 19.9201 27.39 20.2301 27.71L27.6801 35.16C28.0801 35.56 28.0801 36.2 27.6801 36.59L25.0801 39.19C24.6801 39.59 24.0401 39.59 23.6501 39.19L12.4901 28.03L9.17012 24.71C8.77012 24.31 8.77012 23.67 9.17012 23.28L12.4901 19.96L23.6501 8.8C24.0501 8.4 24.6901 8.4 25.0801 8.8L27.6801 11.4C28.0801 11.8 28.0801 12.44 27.6801 12.83L20.2301 20.28C19.9101 20.6 20.1401 21.14 20.5901 21.14H36.5301C37.0901 21.14 37.5401 21.59 37.5401 22.15V25.82V25.84Z"
fill="white"
/>
</svg>
);
const LanguageSelector = observer(
({ onBack, isSidebarOpen = true }: LanguageSelectorProps) => {
const { setLanguage } = languageStore;
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!isOpen) {
return;
}
const handleOutside = (event: PointerEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("pointerdown", handleOutside);
return () => {
document.removeEventListener("pointerdown", handleOutside);
};
}, [isOpen]);
const handleSelect = (code: Language) => {
setLanguage(code);
setIsOpen(false);
};
const toggle = () => setIsOpen((prev) => !prev);
return (
<div
ref={containerRef}
className="pointer-events-auto"
style={{
width: "500px",
transition: "width 0.25s ease",
flex: "0 0 auto",
}}
>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 ">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onBack?.();
}}
className="flex h-12 w-12 items-center justify-center"
aria-label={
isOpen ? "Скрыть выбор языка" : "Показать выбор языка"
}
>
<ArrowIcon rotation={isSidebarOpen ? 0 : 180} />
</button>
{isOpen ? (
LANGUAGES.map((lang) => (
<button
key={lang}
type="button"
onClick={(event) => {
event.stopPropagation();
handleSelect(lang);
}}
className="flex h-12 w-12 items-center justify-center"
aria-label={`Переключить язык на ${lang.toUpperCase()}`}
>
{renderLanguageIcon(lang)}
</button>
))
) : (
<div
className="flex h-12 w-12 items-center justify-center"
onClick={toggle}
>
<CollapsedIcon />
</div>
)}
</div>
</div>
</div>
);
}
);
export default LanguageSelector;

View File

@@ -1,619 +0,0 @@
import { useState, useEffect } from "react";
import {
Stack,
Typography,
Accordion,
AccordionSummary,
AccordionDetails,
useTheme,
TextField,
Autocomplete,
TableCell,
TableContainer,
Table,
TableHead,
TableRow,
Paper,
TableBody,
Checkbox,
FormControlLabel,
Tabs,
Tab,
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import {
AnimatedCircleButton,
authInstance,
languageStore,
selectedCityStore,
} from "@shared";
type Field<T> = {
label: string;
data: keyof T;
render?: (value: any) => React.ReactNode;
};
type LinkedStationsProps<T> = {
parentId: string | number;
fields: Field<T>[];
setItemsParent?: (items: T[]) => void;
type: "show" | "edit";
onUpdate?: () => void;
disableCreation?: boolean;
updatedLinkedItems?: T[];
refresh?: number;
};
export const LinkedStations = <
T extends { id: number; name: string; [key: string]: any }
>(
props: LinkedStationsProps<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%">
<LinkedStationsContents {...props} />
</Stack>
</AccordionDetails>
</Accordion>
</>
);
};
const LinkedStationsContentsInner = <
T extends { id: number; name: string; [key: string]: any }
>({
parentId,
setItemsParent,
fields,
type,
onUpdate,
disableCreation = false,
updatedLinkedItems,
refresh,
}: LinkedStationsProps<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);
const [activeTab, setActiveTab] = useState(0);
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [selectedToDetach, setSelectedToDetach] = useState<Set<number>>(
new Set()
);
const [isLinkingSingle, setIsLinkingSingle] = useState(false);
const [isLinkingBulk, setIsLinkingBulk] = useState(false);
const [isBulkDetaching, setIsBulkDetaching] = useState(false);
const [detachingIds, setDetachingIds] = useState<Set<number>>(new Set());
useEffect(() => {}, [error]);
const parentResource = "sight";
const childResource = "station";
const buildPayload = (ids: number[]) => ({
[`${childResource}_ids`]: ids,
});
const availableItems = allItems
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
.filter((item) => {
const selectedCityId = selectedCityStore.selectedCityId;
if (selectedCityId && "city_id" in item) {
return item.city_id === selectedCityId;
}
return true;
})
.sort((a, b) => a.name.localeCompare(b.name));
const filteredAvailableItems = availableItems.filter((item) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
const name = String(item.name || "").toLowerCase();
const description = String(item.description || "").toLowerCase();
return name.includes(query) || description.includes(query);
});
useEffect(() => {
if (updatedLinkedItems) {
setLinkedItems(updatedLinkedItems);
}
}, [updatedLinkedItems]);
useEffect(() => {
setItemsParent?.(linkedItems);
}, [linkedItems, setItemsParent]);
useEffect(() => {
setSelectedToDetach((prev) => {
const updated = new Set<number>();
linkedItems.forEach((item) => {
if (prev.has(item.id)) {
updated.add(item.id);
}
});
return updated;
});
}, [linkedItems]);
const linkItem = () => {
if (selectedItemId !== null) {
setError(null);
const requestData = buildPayload([selectedItemId]);
setIsLinkingSingle(true);
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 station:", error);
setError("Failed to link station");
})
.finally(() => {
setIsLinkingSingle(false);
});
}
};
const deleteItem = (itemId: number) => {
setError(null);
setDetachingIds((prev) => {
const next = new Set(prev);
next.add(itemId);
return next;
});
authInstance
.delete(`/${parentResource}/${parentId}/${childResource}`, {
data: buildPayload([itemId]),
})
.then(() => {
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
onUpdate?.();
})
.catch((error) => {
console.error("Error deleting station:", error);
setError("Failed to delete station");
})
.finally(() => {
setDetachingIds((prev) => {
const next = new Set(prev);
next.delete(itemId);
return next;
});
});
};
const handleCheckboxChange = (itemId: number) => {
const updated = new Set(selectedItems);
if (updated.has(itemId)) {
updated.delete(itemId);
} else {
updated.add(itemId);
}
setSelectedItems(updated);
};
const handleBulkLink = async () => {
if (selectedItems.size === 0) return;
setError(null);
setIsLinkingBulk(true);
const idsToLink = Array.from(selectedItems);
try {
await authInstance.post(
`/${parentResource}/${parentId}/${childResource}`,
buildPayload(idsToLink)
);
const newItems = allItems.filter((item) => idsToLink.includes(item.id));
setLinkedItems((prev) => {
const existingIds = new Set(prev.map((item) => item.id));
const additions = newItems.filter((item) => !existingIds.has(item.id));
return [...prev, ...additions];
});
setSelectedItems((prev) => {
const remaining = new Set(prev);
idsToLink.forEach((id) => remaining.delete(id));
return remaining;
});
onUpdate?.();
} catch (error) {
console.error("Error linking stations:", error);
setError("Failed to link stations");
}
setIsLinkingBulk(false);
};
const toggleDetachSelection = (itemId: number) => {
const updated = new Set(selectedToDetach);
if (updated.has(itemId)) {
updated.delete(itemId);
} else {
updated.add(itemId);
}
setSelectedToDetach(updated);
};
const handleToggleAllDetach = (checked: boolean) => {
if (!checked) {
setSelectedToDetach(new Set());
return;
}
setSelectedToDetach(new Set(linkedItems.map((item) => item.id)));
};
const handleBulkDetach = async () => {
const idsToDetach = Array.from(selectedToDetach);
if (idsToDetach.length === 0) return;
setError(null);
setIsBulkDetaching(true);
setDetachingIds((prev) => {
const next = new Set(prev);
idsToDetach.forEach((id) => next.add(id));
return next;
});
try {
await authInstance.delete(
`/${parentResource}/${parentId}/${childResource}`,
{
data: buildPayload(idsToDetach),
}
);
setLinkedItems((prev) =>
prev.filter((item) => !idsToDetach.includes(item.id))
);
setSelectedToDetach((prev) => {
const remaining = new Set(prev);
idsToDetach.forEach((id) => remaining.delete(id));
return remaining;
});
onUpdate?.();
} catch (error) {
console.error("Error deleting stations:", error);
setError("Failed to delete stations");
}
setDetachingIds((prev) => {
const next = new Set(prev);
idsToDetach.forEach((id) => next.delete(id));
return next;
});
setIsBulkDetaching(false);
};
const allSelectedForDetach =
linkedItems.length > 0 &&
linkedItems.every((item) => selectedToDetach.has(item.id));
const isIndeterminateDetach =
selectedToDetach.size > 0 && !allSelectedForDetach;
useEffect(() => {
if (parentId) {
setIsLoading(true);
setError(null);
authInstance
.get(`/${parentResource}/${parentId}/${childResource}`)
.then((response) => {
setLinkedItems(response?.data || []);
})
.catch((error) => {
console.error("Error fetching linked stations:", 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 stations:", error);
setError(null);
setAllItems([]);
});
}
}, [type]);
return (
<>
{linkedItems?.length > 0 && (
<TableContainer component={Paper} sx={{ width: "100%" }}>
<Table sx={{ width: "100%" }}>
<TableHead>
<TableRow>
{type === "edit" && (
<TableCell width="50px">
<Checkbox
size="small"
checked={allSelectedForDetach}
indeterminate={isIndeterminateDetach}
onChange={(e) => handleToggleAllDetach(e.target.checked)}
/>
</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>
<TableBody>
{linkedItems.map((item, index) => (
<TableRow key={item.id} hover>
{type === "edit" && (
<TableCell>
<Checkbox
size="small"
checked={selectedToDetach.has(item.id)}
onChange={() => toggleDetachSelection(item.id)}
/>
</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>
<AnimatedCircleButton
variant="outlined"
color="error"
size="small"
onClick={(e) => {
e.stopPropagation();
deleteItem(item.id);
}}
disabled={detachingIds.has(item.id)}
loading={detachingIds.has(item.id)}
>
Отвязать
</AnimatedCircleButton>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
{type === "edit" && linkedItems.length > 0 && (
<Stack direction="row" gap={2} mt={2}>
<AnimatedCircleButton
variant="outlined"
color="error"
onClick={handleBulkDetach}
disabled={selectedToDetach.size === 0 || isBulkDetaching}
loading={isBulkDetaching}
>
Отвязать выбранные ({selectedToDetach.size})
</AnimatedCircleButton>
</Stack>
)}
{linkedItems.length === 0 && !isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
Остановки не найдены
</Typography>
)}
{type === "edit" && !disableCreation && (
<Stack gap={2} mt={2}>
<Typography variant="subtitle1">Добавить остановки</Typography>
<Tabs
value={activeTab}
onChange={(_, value) => setActiveTab(value)}
variant="fullWidth"
>
<Tab label="По одной" />
<Tab label="Массово" />
</Tabs>
<Box sx={{ mt: 1 }}>
{activeTab === 0 && (
<Stack gap={2}>
<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="Выберите остановку"
placeholder="Введите название или описание остановки..."
fullWidth
/>
)}
isOptionEqualToValue={(option, value) =>
option.id === value?.id
}
filterOptions={(options, { inputValue }) => {
if (!inputValue.trim()) return options;
const query = inputValue.toLowerCase();
return options.filter((option) => {
const name = String(option.name || "").toLowerCase();
const description = String(
option.description || ""
).toLowerCase();
return (
name.includes(query) || description.includes(query)
);
});
}}
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>
)}
/>
<AnimatedCircleButton
variant="contained"
onClick={linkItem}
disabled={!selectedItemId || isLinkingSingle}
loading={isLinkingSingle}
sx={{ alignSelf: "flex-start" }}
>
Добавить
</AnimatedCircleButton>
</Stack>
)}
{activeTab === 1 && (
<Stack gap={2}>
<TextField
fullWidth
label="Поиск остановок"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Введите название или описание остановки..."
size="small"
/>
<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={
<div className="flex justify-between items-center w-full gap-10">
<p>{String(item.name)}</p>
<p className="text-xs text-gray-500 max-w-[300px] truncate text-ellipsis">
{String(item.description)}
</p>
</div>
}
sx={{
margin: 0,
"& .MuiFormControlLabel-label": {
fontSize: "0.9rem",
width: "100%",
},
}}
/>
))}
{filteredAvailableItems.length === 0 && (
<Typography
color="textSecondary"
textAlign="center"
py={1}
>
{searchQuery.trim()
? "Остановки не найдены"
: "Нет доступных остановок"}
</Typography>
)}
</Stack>
</Paper>
<AnimatedCircleButton
variant="contained"
onClick={handleBulkLink}
disabled={selectedItems.size === 0 || isLinkingBulk}
loading={isLinkingBulk}
sx={{ alignSelf: "flex-start" }}
>
Добавить выбранные ({selectedItems.size})
</AnimatedCircleButton>
</Stack>
)}
</Box>
</Stack>
)}
{isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
Загрузка...
</Typography>
)}
{error && (
<Typography color="error" textAlign="center" py={2}>
{error}
</Typography>
)}
</>
);
};
export const LinkedStationsContents = observer(
LinkedStationsContentsInner
) as typeof LinkedStationsContentsInner;

View File

@@ -1,12 +1,10 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales"; import { ruRU } from "@mui/x-data-grid/locales";
import { import {
authStore,
cityStore, cityStore,
languageStore, languageStore,
sightsStore, sightsStore,
selectedCityStore, selectedCityStore,
SearchInput,
} from "@shared"; } from "@shared";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
@@ -17,32 +15,21 @@ import { Box, CircularProgress } from "@mui/material";
export const SightListPage = observer(() => { export const SightListPage = observer(() => {
const { sights, getSights, deleteListSight } = sightsStore; const { sights, getSights, deleteListSight } = sightsStore;
const { cities, getCities } = cityStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null); const [rowId, setRowId] = useState<string | null>(null);
const [ids, setIds] = useState<number[]>([]); const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const { language } = languageStore; const { language } = languageStore;
const canReadCities = authStore.canRead("cities");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => { useEffect(() => {
const fetchSights = async () => { const fetchSights = async () => {
setIsLoading(true); setIsLoading(true);
if (!authStore.me) { await getCities(language);
await authStore.getMeAction().catch(() => undefined);
}
if (authStore.canRead("cities")) {
await cityStore.getCities(language);
} else {
await authStore.fetchMeCities().catch(() => undefined);
}
await getSights(); await getSights();
setIsLoading(false); setIsLoading(false);
}; };
fetchSights(); fetchSights();
@@ -70,68 +57,61 @@ export const SightListPage = observer(() => {
headerName: "Город", headerName: "Город",
flex: 1, flex: 1,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
const lang = language as "ru" | "en" | "zh";
const cityName = canReadCities
? cityStore.cities[lang]?.data.find((c) => c.id === params.value)?.name
: authStore.meCities[lang]?.find((c) => c.city_id === params.value)?.name;
return ( return (
<div className="w-full h-full flex items-center"> <div className="w-full h-full flex items-center">
{cityName ?? <Minus size={20} className="text-red-500" />} {params.value ? (
cities[language].data.find((el) => el.id == params.value)?.name
) : (
<Minus size={20} className="text-red-500" />
)}
</div> </div>
); );
}, },
}, },
...(authStore.canWrite("sights") ? [{ {
field: "actions", field: "actions",
headerName: "Действия", headerName: "Действия",
align: "center" as const, align: "center",
headerAlign: "center" as const, headerAlign: "center",
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> renderCell: (params: GridRenderCellParams) => {
<button onClick={() => navigate(`/sight/${params.row.id}/edit`)}> return (
<Pencil size={20} className="text-blue-500" /> <div className="flex h-full gap-7 justify-center items-center">
</button> <button onClick={() => navigate(`/sight/${params.row.id}/edit`)}>
<button <Pencil size={20} className="text-blue-500" />
onClick={() => { </button>
setIsDeleteModalOpen(true); {/* <button onClick={() => navigate(`/sight/${params.row.id}`)}>
setRowId(params.row.id); <Eye size={20} className="text-green-500" />
}} </button> */}
> <button
<Trash2 size={20} className="text-red-500" /> onClick={() => {
</button> setIsDeleteModalOpen(true);
</div> setRowId(params.row.id);
), }}
}] : []), >
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
]; ];
// Фильтрация достопримечательностей по выбранному городу
const filteredSights = useMemo(() => { const filteredSights = useMemo(() => {
const { selectedCityId } = selectedCityStore; const { selectedCityId } = selectedCityStore;
const allowedCityIds = canReadCities if (!selectedCityId) {
? null return sights;
: authStore.meCities["ru"].map((c) => c.city_id); }
return sights.filter((sight: any) => sight.city_id === selectedCityId);
}, [sights, selectedCityStore.selectedCityId]);
return sights.filter((sight: any) => { const rows = filteredSights.map((sight) => ({
if (allowedCityIds && !allowedCityIds.includes(sight.city_id)) { id: sight.id,
return false; name: sight.name,
} city_id: sight.city_id,
if (selectedCityId && sight.city_id !== selectedCityId) { }));
return false;
}
return true;
});
}, [sights, selectedCityStore.selectedCityId, canReadCities, authStore.meCities]);
const query = searchQuery.trim().toLowerCase();
const rows = filteredSights
.filter((sight: any) => !query || (sight.name ?? "").toLowerCase().includes(query))
.map((sight) => ({
id: sight.id,
name: sight.name,
city_id: sight.city_id,
}));
const canWriteSights = authStore.canWrite("sights");
return ( return (
<> <>
@@ -140,58 +120,35 @@ export const SightListPage = observer(() => {
<div className="w-full"> <div className="w-full">
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Достопримечательности</h1> <h1 className="text-2xl">Достопримечательности</h1>
{canWriteSights && ( <CreateButton
<CreateButton label="Создать достопримечательность"
label="Создать достопримечательность" path="/sight/create"
path="/sight/create" />
/>
)}
</div> </div>
{canWriteSights && ids.length > 0 && ( <div
<div className="flex justify-end mb-5 duration-300"> className="flex justify-end mb-5 duration-300"
<button style={{ opacity: ids.length > 0 ? 1 : 0 }}
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" >
onClick={() => setIsBulkDeleteModalOpen(true)} <button
> className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
<Trash2 size={20} className="text-white" /> Удалить выбранные ( onClick={() => setIsBulkDeleteModalOpen(true)}
{ids.length}) >
</button> <Trash2 size={20} className="text-white" /> Удалить выбранные (
</div> {ids.length})
)} </button>
</div>
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
checkboxSelection={canWriteSights} hideFooter
disableRowSelectionExcludeModel checkboxSelection
loading={isLoading} loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={ onRowSelectionModelChange={(newSelection) => {
canWriteSights setIds(Array.from(newSelection.ids as unknown as number[]));
? (newSelection: any) => { }}
if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map(Number);
setIds(selectedIds);
} else if (
newSelection &&
typeof newSelection === "object" &&
"ids" in newSelection
) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map(Number);
setIds(selectedIds);
} else {
setIds([]);
}
}
: undefined
}
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>

View File

@@ -1,2 +1 @@
export * from "./SightListPage"; export * from "./SightListPage";
export { LinkedStations } from "./LinkedStations";

View File

@@ -5,10 +5,9 @@ import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { runInAction } from "mobx";
export const SnapshotCreatePage = observer(() => { export const SnapshotCreatePage = observer(() => {
const { createSnapshot, getSnapshotStatus, snapshotStatus } = snapshotStore; const { createSnapshot } = snapshotStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -25,7 +24,7 @@ export const SnapshotCreatePage = observer(() => {
Назад Назад
</button> </button>
</div> </div>
<h1 className="text-2xl font-bold">Создание экспорта медиа</h1> <h1 className="text-2xl font-bold">Создание снапшота</h1>
<div className="flex flex-col gap-10 w-full items-end"> <div className="flex flex-col gap-10 w-full items-end">
<TextField <TextField
className="w-full" className="w-full"
@@ -43,27 +42,13 @@ export const SnapshotCreatePage = observer(() => {
onClick={async () => { onClick={async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const id = await createSnapshot(name); await createSnapshot(name);
setIsLoading(false);
await getSnapshotStatus(id); toast.success("Снапшот успешно создан");
navigate(-1);
while (snapshotStore.snapshotStatus?.Status != "done") {
await new Promise((resolve) => setTimeout(resolve, 1000));
await getSnapshotStatus(id);
}
if (snapshotStore.snapshotStatus?.Status === "done") {
toast.success("Экспорт медиа успешно создан");
runInAction(() => {
snapshotStore.snapshotStatus = null;
});
navigate(-1);
}
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.error("Ошибка при создании экспорта медиа"); toast.error("Ошибка при создании снапшота");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -71,15 +56,7 @@ export const SnapshotCreatePage = observer(() => {
disabled={isLoading || !name.trim()} disabled={isLoading || !name.trim()}
> >
{isLoading ? ( {isLoading ? (
<div className="flex items-center gap-2"> <Loader2 size={20} className="animate-spin" />
<Loader2 size={20} className="animate-spin" />
<span>
{snapshotStatus?.Progress
? (snapshotStatus.Progress * 100).toFixed(2)
: 0}
%
</span>
</div>
) : ( ) : (
"Сохранить" "Сохранить"
)} )}

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales"; import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, languageStore, snapshotStore, SearchInput } from "@shared"; import { languageStore, snapshotStore } from "@shared";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { DatabaseBackup, Trash2 } from "lucide-react"; import { DatabaseBackup, Trash2 } from "lucide-react";
import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets"; import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets";
@@ -10,20 +10,12 @@ import { Box, CircularProgress } from "@mui/material";
export const SnapshotListPage = observer(() => { export const SnapshotListPage = observer(() => {
const { snapshots, getSnapshots, deleteSnapshot, restoreSnapshot } = const { snapshots, getSnapshots, deleteSnapshot, restoreSnapshot } =
snapshotStore; snapshotStore;
const canWriteDevices = authStore.canWrite("devices");
const canCreateSnapshot = authStore.hasRole("snapshot_create") && canWriteDevices;
const canManageSnapshots = authStore.canWrite("snapshot") && canWriteDevices;
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null); const [rowId, setRowId] = useState<string | null>(null); // Lifted state
const { language } = languageStore; const { language } = languageStore;
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false); const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
useEffect(() => { useEffect(() => {
const fetchSnapshots = async () => { const fetchSnapshots = async () => {
@@ -34,14 +26,6 @@ export const SnapshotListPage = observer(() => {
fetchSnapshots(); fetchSnapshots();
}, [language]); }, [language]);
const formatCreationTime = (isoString: string | undefined) => {
if (!isoString) return "";
const [datePart, timePartWithMs] = isoString.split("T");
if (!datePart || !timePartWithMs) return isoString;
const timePart = timePartWithMs.split(".")[0];
return `${datePart} - ${timePart}`;
};
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
field: "name", field: "name",
@@ -53,88 +37,64 @@ export const SnapshotListPage = observer(() => {
headerName: "Родитель", headerName: "Родитель",
flex: 1, flex: 1,
}, },
{ {
field: "created_at",
headerName: "Дата создания",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return <div>{params.value ? params.value : "-"}</div>;
},
},
...(canManageSnapshots ? [{
field: "actions", field: "actions",
headerName: "Действия", headerName: "Действия",
width: 300, width: 300,
headerAlign: "center" as const, headerAlign: "center",
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> renderCell: (params: GridRenderCellParams) => {
<button return (
onClick={() => { <div className="flex h-full gap-7 justify-center items-center">
setIsRestoreModalOpen(true); <button
setRowId(params.row.id); onClick={() => {
}} setIsRestoreModalOpen(true);
> setRowId(params.row.id);
<DatabaseBackup size={20} className="text-blue-500" /> }}
</button> >
<button <DatabaseBackup size={20} className="text-blue-500" />
onClick={() => { </button>
setIsDeleteModalOpen(true);
setRowId(params.row.id); <button
}} onClick={() => {
> setIsDeleteModalOpen(true);
<Trash2 size={20} className="text-red-500" /> setRowId(params.row.id);
</button> }}
</div> >
), <Trash2 size={20} className="text-red-500" />
}] : []), </button>
</div>
);
},
},
]; ];
const rows = useMemo(() => { const rows = snapshots.map((snapshot) => ({
const query = searchQuery.trim().toLowerCase(); id: snapshot.ID,
return snapshots name: snapshot.Name,
.filter( parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name,
(snapshot) => }));
!query ||
(snapshot.Name ?? "").toLowerCase().includes(query) ||
(snapshots.find((s) => s.ID === snapshot.ParentID)?.Name ?? "").toLowerCase().includes(query)
)
.map((snapshot) => ({
id: snapshot.ID,
name: snapshot.Name,
parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name,
created_at: formatCreationTime(snapshot.CreationTime),
}));
}, [snapshots, searchQuery]);
return ( return (
<> <>
<div style={{ width: "100%" }}> <div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl ">Экспорт Медиа</h1> <h1 className="text-2xl ">Снапшоты</h1>
<CreateButton label="Создать снапшот" path="/snapshot/create" />
{canCreateSnapshot && (
<CreateButton label="Создать экспорт медиа" path="/snapshot/create" />
)}
</div> </div>
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooterPagination
hideFooter
loading={isLoading} loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? ( {isLoading ? <CircularProgress size={20} /> : "Нет снапшотов"}
<CircularProgress size={20} />
) : (
"Нет экспортов медиа"
)}
</Box> </Box>
), ),
}} }}

View File

@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import { import {
Stack, Stack,
Typography, Typography,
Button,
Accordion, Accordion,
AccordionSummary, AccordionSummary,
AccordionDetails, AccordionDetails,
@@ -15,21 +16,11 @@ import {
TableRow, TableRow,
Paper, Paper,
TableBody, TableBody,
Checkbox,
FormControlLabel,
Tabs,
Tab,
Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { import { authInstance, languageStore, selectedCityStore } from "@shared";
AnimatedCircleButton,
authInstance,
languageStore,
selectedCityStore,
} from "@shared";
type Field<T> = { type Field<T> = {
label: string; label: string;
@@ -102,26 +93,12 @@ const LinkedSightsContentsInner = <
const [selectedItemId, setSelectedItemId] = useState<number | null>(null); const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState(0);
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [selectedToDetach, setSelectedToDetach] = useState<Set<number>>(
new Set()
);
const [isLinkingSingle, setIsLinkingSingle] = useState(false);
const [isLinkingBulk, setIsLinkingBulk] = useState(false);
const [isBulkDetaching, setIsBulkDetaching] = useState(false);
const [detachingIds, setDetachingIds] = useState<Set<number>>(new Set());
useEffect(() => {}, [error]); useEffect(() => {}, [error]);
const parentResource = "station"; const parentResource = "station";
const childResource = "sight"; const childResource = "sight";
const buildPayload = (ids: number[]) => ({
[`${childResource}_ids`]: ids,
});
const availableItems = allItems const availableItems = allItems
.filter((item) => !linkedItems.some((linked) => linked.id === item.id)) .filter((item) => !linkedItems.some((linked) => linked.id === item.id))
.filter((item) => { .filter((item) => {
@@ -134,11 +111,6 @@ const LinkedSightsContentsInner = <
}) })
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
const filteredAvailableItems = availableItems.filter((item) => {
if (!searchQuery.trim()) return true;
return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());
});
useEffect(() => { useEffect(() => {
if (updatedLinkedItems) { if (updatedLinkedItems) {
setLinkedItems(updatedLinkedItems); setLinkedItems(updatedLinkedItems);
@@ -149,24 +121,13 @@ const LinkedSightsContentsInner = <
setItemsParent?.(linkedItems); setItemsParent?.(linkedItems);
}, [linkedItems, setItemsParent]); }, [linkedItems, setItemsParent]);
useEffect(() => {
setSelectedToDetach((prev) => {
const updated = new Set<number>();
linkedItems.forEach((item) => {
if (prev.has(item.id)) {
updated.add(item.id);
}
});
return updated;
});
}, [linkedItems]);
const linkItem = () => { const linkItem = () => {
if (selectedItemId !== null) { if (selectedItemId !== null) {
setError(null); setError(null);
const requestData = buildPayload([selectedItemId]); const requestData = {
sight_id: selectedItemId,
};
setIsLinkingSingle(true);
authInstance authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, requestData) .post(`/${parentResource}/${parentId}/${childResource}`, requestData)
.then(() => { .then(() => {
@@ -180,23 +141,15 @@ const LinkedSightsContentsInner = <
.catch((error) => { .catch((error) => {
console.error("Error linking sight:", error); console.error("Error linking sight:", error);
setError("Failed to link sight"); setError("Failed to link sight");
})
.finally(() => {
setIsLinkingSingle(false);
}); });
} }
}; };
const deleteItem = (itemId: number) => { const deleteItem = (itemId: number) => {
setError(null); setError(null);
setDetachingIds((prev) => {
const next = new Set(prev);
next.add(itemId);
return next;
});
authInstance authInstance
.delete(`/${parentResource}/${parentId}/${childResource}`, { .delete(`/${parentResource}/${parentId}/${childResource}`, {
data: buildPayload([itemId]), data: { [`${childResource}_id`]: itemId },
}) })
.then(() => { .then(() => {
setLinkedItems(linkedItems.filter((item) => item.id !== itemId)); setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
@@ -205,125 +158,9 @@ const LinkedSightsContentsInner = <
.catch((error) => { .catch((error) => {
console.error("Error deleting sight:", error); console.error("Error deleting sight:", error);
setError("Failed to delete sight"); setError("Failed to delete sight");
})
.finally(() => {
setDetachingIds((prev) => {
const next = new Set(prev);
next.delete(itemId);
return next;
});
}); });
}; };
const handleCheckboxChange = (itemId: number) => {
const updated = new Set(selectedItems);
if (updated.has(itemId)) {
updated.delete(itemId);
} else {
updated.add(itemId);
}
setSelectedItems(updated);
};
const handleBulkLink = async () => {
if (selectedItems.size === 0) return;
setError(null);
setIsLinkingBulk(true);
const idsToLink = Array.from(selectedItems);
try {
await authInstance.post(
`/${parentResource}/${parentId}/${childResource}`,
buildPayload(idsToLink)
);
const newItems = allItems.filter((item) => idsToLink.includes(item.id));
setLinkedItems((prev) => {
const existingIds = new Set(prev.map((item) => item.id));
const additions = newItems.filter((item) => !existingIds.has(item.id));
return [...prev, ...additions];
});
setSelectedItems((prev) => {
const remaining = new Set(prev);
idsToLink.forEach((id) => remaining.delete(id));
return remaining;
});
onUpdate?.();
} catch (error) {
console.error("Error linking sights:", error);
setError("Failed to link sights");
}
setIsLinkingBulk(false);
};
const toggleDetachSelection = (itemId: number) => {
const updated = new Set(selectedToDetach);
if (updated.has(itemId)) {
updated.delete(itemId);
} else {
updated.add(itemId);
}
setSelectedToDetach(updated);
};
const handleToggleAllDetach = (checked: boolean) => {
if (!checked) {
setSelectedToDetach(new Set());
return;
}
setSelectedToDetach(new Set(linkedItems.map((item) => item.id)));
};
const handleBulkDetach = async () => {
const idsToDetach = Array.from(selectedToDetach);
if (idsToDetach.length === 0) return;
setError(null);
setIsBulkDetaching(true);
setDetachingIds((prev) => {
const next = new Set(prev);
idsToDetach.forEach((id) => next.add(id));
return next;
});
try {
await authInstance.delete(
`/${parentResource}/${parentId}/${childResource}`,
{
data: buildPayload(idsToDetach),
}
);
setLinkedItems((prev) =>
prev.filter((item) => !idsToDetach.includes(item.id))
);
setSelectedToDetach((prev) => {
const remaining = new Set(prev);
idsToDetach.forEach((id) => remaining.delete(id));
return remaining;
});
onUpdate?.();
} catch (error) {
console.error("Error deleting sights:", error);
setError("Failed to delete sights");
}
setDetachingIds((prev) => {
const next = new Set(prev);
idsToDetach.forEach((id) => next.delete(id));
return next;
});
setIsBulkDetaching(false);
};
const allSelectedForDetach =
linkedItems.length > 0 &&
linkedItems.every((item) => selectedToDetach.has(item.id));
const isIndeterminateDetach =
selectedToDetach.size > 0 && !allSelectedForDetach;
useEffect(() => { useEffect(() => {
if (parentId) { if (parentId) {
setIsLoading(true); setIsLoading(true);
@@ -354,7 +191,7 @@ const LinkedSightsContentsInner = <
}) })
.catch((error) => { .catch((error) => {
console.error("Error fetching all sights:", error); console.error("Error fetching all sights:", error);
setError(null); setError("Failed to load available sights");
setAllItems([]); setAllItems([]);
}); });
} }
@@ -367,16 +204,6 @@ const LinkedSightsContentsInner = <
<Table sx={{ width: "100%" }}> <Table sx={{ width: "100%" }}>
<TableHead> <TableHead>
<TableRow> <TableRow>
{type === "edit" && (
<TableCell width="50px">
<Checkbox
size="small"
checked={allSelectedForDetach}
indeterminate={isIndeterminateDetach}
onChange={(e) => handleToggleAllDetach(e.target.checked)}
/>
</TableCell>
)}
<TableCell key="id" width="60px"> <TableCell key="id" width="60px">
</TableCell> </TableCell>
@@ -392,15 +219,6 @@ const LinkedSightsContentsInner = <
<TableBody> <TableBody>
{linkedItems.map((item, index) => ( {linkedItems.map((item, index) => (
<TableRow key={item.id} hover> <TableRow key={item.id} hover>
{type === "edit" && (
<TableCell>
<Checkbox
size="small"
checked={selectedToDetach.has(item.id)}
onChange={() => toggleDetachSelection(item.id)}
/>
</TableCell>
)}
<TableCell>{index + 1}</TableCell> <TableCell>{index + 1}</TableCell>
{fields.map((field, idx) => ( {fields.map((field, idx) => (
<TableCell key={String(field.data) + String(idx)}> <TableCell key={String(field.data) + String(idx)}>
@@ -411,7 +229,7 @@ const LinkedSightsContentsInner = <
))} ))}
{type === "edit" && ( {type === "edit" && (
<TableCell> <TableCell>
<AnimatedCircleButton <Button
variant="outlined" variant="outlined"
color="error" color="error"
size="small" size="small"
@@ -419,11 +237,9 @@ const LinkedSightsContentsInner = <
e.stopPropagation(); e.stopPropagation();
deleteItem(item.id); deleteItem(item.id);
}} }}
disabled={detachingIds.has(item.id)}
loading={detachingIds.has(item.id)}
> >
Отвязать Отвязать
</AnimatedCircleButton> </Button>
</TableCell> </TableCell>
)} )}
</TableRow> </TableRow>
@@ -433,20 +249,6 @@ const LinkedSightsContentsInner = <
</TableContainer> </TableContainer>
)} )}
{type === "edit" && linkedItems.length > 0 && (
<Stack direction="row" gap={2} mt={2}>
<AnimatedCircleButton
variant="outlined"
color="error"
onClick={handleBulkDetach}
disabled={selectedToDetach.size === 0 || isBulkDetaching}
loading={isBulkDetaching}
>
Отвязать выбранные ({selectedToDetach.size})
</AnimatedCircleButton>
</Stack>
)}
{linkedItems.length === 0 && !isLoading && ( {linkedItems.length === 0 && !isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}> <Typography color="textSecondary" textAlign="center" py={2}>
Достопримечательности не найдены Достопримечательности не найдены
@@ -456,133 +258,53 @@ const LinkedSightsContentsInner = <
{type === "edit" && !disableCreation && ( {type === "edit" && !disableCreation && (
<Stack gap={2} mt={2}> <Stack gap={2} mt={2}>
<Typography variant="subtitle1"> <Typography variant="subtitle1">
Добавить достопримечательности Добавить достопримечательность
</Typography> </Typography>
<Tabs <Autocomplete
value={activeTab} fullWidth
onChange={(_, value) => setActiveTab(value)} value={
variant="fullWidth" 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" }}
> >
<Tab label="По одной" /> Добавить
<Tab label="Массово" /> </Button>
</Tabs>
<Box sx={{ mt: 1 }}>
{activeTab === 0 && (
<Stack gap={2}>
<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>
)}
/>
<AnimatedCircleButton
variant="contained"
onClick={linkItem}
disabled={!selectedItemId || isLinkingSingle}
loading={isLinkingSingle}
sx={{ alignSelf: "flex-start" }}
>
Добавить
</AnimatedCircleButton>
</Stack>
)}
{activeTab === 1 && (
<Stack gap={2}>
<TextField
fullWidth
label="Поиск достопримечательностей"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Введите название..."
size="small"
/>
<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.9rem",
},
}}
/>
))}
{filteredAvailableItems.length === 0 && (
<Typography
color="textSecondary"
textAlign="center"
py={1}
>
{searchQuery.trim()
? "Достопримечательности не найдены"
: "Нет доступных достопримечательностей"}
</Typography>
)}
</Stack>
</Paper>
<AnimatedCircleButton
variant="contained"
onClick={handleBulkLink}
disabled={selectedItems.size === 0 || isLinkingBulk}
loading={isLinkingBulk}
sx={{ alignSelf: "flex-start" }}
>
Добавить выбранные ({selectedItems.size})
</AnimatedCircleButton>
</Stack>
)}
</Box>
</Stack> </Stack>
)} )}

View File

@@ -1,34 +1,26 @@
import { import {
Button, Button,
Paper,
TextField, TextField,
Select, Select,
MenuItem, MenuItem,
FormControl, FormControl,
InputLabel, InputLabel,
Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { import {
stationsStore, stationsStore,
languageStore, languageStore,
cityStore, cityStore,
authStore,
mediaStore,
isMediaIdEmpty,
useSelectedCity, useSelectedCity,
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared"; } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import { LanguageSwitcher } from "@widgets";
ImageUploadCard, import { SaveWithoutCityAgree } from "@widgets";
LanguageSwitcher,
SaveWithoutCityAgree,
} from "@widgets";
export const StationCreatePage = observer(() => { export const StationCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -40,18 +32,10 @@ export const StationCreatePage = observer(() => {
createStation, createStation,
setLanguageCreateStationData, setLanguageCreateStationData,
} = stationsStore; } = stationsStore;
const { getCities } = cityStore; const { cities, getCities } = cityStore;
const canReadCities = authStore.canRead("cities");
const { selectedCityId, selectedCity } = useSelectedCity(); const { selectedCityId, selectedCity } = useSelectedCity();
const [coordinates, setCoordinates] = useState<string>(""); const [coordinates, setCoordinates] = useState<string>("");
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 [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false); const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => { useEffect(() => {
@@ -65,6 +49,7 @@ export const StationCreatePage = observer(() => {
} }
}, [createStationData.common.latitude, createStationData.common.longitude]); }, [createStationData.common.latitude, createStationData.common.longitude]);
// НОВАЯ ФУНКЦИЯ: Фактическое создание (вызывается после подтверждения)
const executeCreate = async () => { const executeCreate = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
@@ -79,13 +64,11 @@ export const StationCreatePage = observer(() => {
} }
}; };
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или создания
const handleCreate = async () => { const handleCreate = async () => {
const isCityMissing = !createStationData.common.city_id; const isCityMissing = !createStationData.common.city_id;
// Проверяем названия на всех языках
const isNameMissing = const isNameMissing = !createStationData.ru.name || !createStationData.en.name || !createStationData.zh.name;
!createStationData.ru.name ||
!createStationData.en.name ||
!createStationData.zh.name;
if (isCityMissing || isNameMissing) { if (isCityMissing || isNameMissing) {
setIsSaveWarningOpen(true); setIsSaveWarningOpen(true);
@@ -95,64 +78,28 @@ export const StationCreatePage = observer(() => {
await executeCreate(); await executeCreate();
}; };
// Обработчик "Да" в предупреждающем окне
const handleConfirmCreate = async () => { const handleConfirmCreate = async () => {
setIsSaveWarningOpen(false); setIsSaveWarningOpen(false);
await executeCreate(); await executeCreate();
}; };
// Обработчик "Нет" в предупреждающем окне
const handleCancelCreate = () => { const handleCancelCreate = () => {
setIsSaveWarningOpen(false); setIsSaveWarningOpen(false);
}; };
useEffect(() => { useEffect(() => {
const fetchCities = async () => { const fetchCities = async () => {
if (!authStore.me) { await getCities("ru");
await authStore.getMeAction().catch(() => undefined); await getCities("en");
} await getCities("zh");
if (authStore.canRead("cities")) {
await getCities("ru");
return;
}
await authStore.fetchMeCities().catch(() => undefined);
}; };
fetchCities(); fetchCities();
mediaStore.getMedia();
}, []); }, []);
const baseCities = canReadCities // Автоматически устанавливаем выбранный город при загрузке страницы
? cityStore.cities["ru"].data
: authStore.meCities["ru"].map((city) => ({
id: city.city_id,
name: city.name,
country: "",
country_code: "",
arms: "",
}));
const availableCities =
selectedCity?.id && !baseCities.some((city) => city.id === selectedCity.id)
? [selectedCity, ...baseCities]
: baseCities;
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setCreateCommonData({ icon: media.id });
};
const selectedMedia =
createStationData.common.icon &&
!isMediaIdEmpty(createStationData.common.icon)
? mediaStore.media.find((m) => m.id === createStationData.common.icon)
: null;
const effectiveIconUrl = isMediaIdEmpty(createStationData.common.icon)
? null
: selectedMedia?.id ?? createStationData.common.icon;
useEffect(() => { useEffect(() => {
if (selectedCityId && selectedCity && !createStationData.common.city_id) { if (selectedCityId && selectedCity && !createStationData.common.city_id) {
setCreateCommonData({ setCreateCommonData({
@@ -163,7 +110,7 @@ export const StationCreatePage = observer(() => {
}, [selectedCityId, selectedCity, createStationData.common.city_id]); }, [selectedCityId, selectedCity, createStationData.common.city_id]);
return ( return (
<Box className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher /> <LanguageSwitcher />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button <button
@@ -191,6 +138,23 @@ export const StationCreatePage = observer(() => {
} }
/> />
<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 <TextField
fullWidth fullWidth
label="Описание" label="Описание"
@@ -251,7 +215,7 @@ export const StationCreatePage = observer(() => {
value={createStationData.common.city_id || ""} value={createStationData.common.city_id || ""}
label="Город" label="Город"
onChange={(e) => { onChange={(e) => {
const selectedCity = availableCities.find( const selectedCity = cities["ru"].data.find(
(city) => city.id === e.target.value (city) => city.id === e.target.value
); );
setCreateCommonData({ setCreateCommonData({
@@ -260,7 +224,7 @@ export const StationCreatePage = observer(() => {
}); });
}} }}
> >
{availableCities.map((city) => ( {cities["ru"].data.map((city) => (
<MenuItem key={city.id} value={city.id}> <MenuItem key={city.id} value={city.id}>
{city.name} {city.name}
</MenuItem> </MenuItem>
@@ -268,36 +232,12 @@ export const StationCreatePage = observer(() => {
</Select> </Select>
</FormControl> </FormControl>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard
title="Иконка остановки"
imageKey="thumbnail"
imageUrl={effectiveIconUrl}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(effectiveIconUrl ?? "");
}}
onDeleteImageClick={() => {
setCreateCommonData({ icon: "" });
setActiveMenuType(null);
}}
onSelectFileClick={() => {
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("image");
}}
/>
</div>
<Button <Button
variant="contained" variant="contained"
className="w-min flex gap-2 items-center" className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />} startIcon={<Save size={20} />}
onClick={handleCreate} onClick={handleCreate}
disabled={isLoading} disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleCreate
> >
{isLoading ? ( {isLoading ? (
<Loader2 size={20} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />
@@ -308,28 +248,6 @@ export const StationCreatePage = observer(() => {
</div> </div>
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */} {/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={createStationData[language].name || "Остановка"}
contextType="station"
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
{isSaveWarningOpen && ( {isSaveWarningOpen && (
<SaveWithoutCityAgree <SaveWithoutCityAgree
blocker={{ blocker={{
@@ -338,6 +256,6 @@ export const StationCreatePage = observer(() => {
}} }}
/> />
)} )}
</Box> </Paper>
); );
}); });

View File

@@ -1,41 +1,26 @@
import { import {
Button, Button,
Paper,
TextField, TextField,
Select, Select,
MenuItem, MenuItem,
FormControl, FormControl,
InputLabel, InputLabel,
Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { import { stationsStore, languageStore, cityStore } from "@shared";
stationsStore,
languageStore,
cityStore,
authStore,
mediaStore,
isMediaIdEmpty,
LoadingSpinner,
SelectMediaDialog,
PreviewMediaDialog,
UploadMediaDialog,
} from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import { LanguageSwitcher } from "@widgets";
ImageUploadCard,
LanguageSwitcher,
SaveWithoutCityAgree,
DeleteModal,
} from "@widgets";
import { LinkedSights } from "../LinkedSights"; import { LinkedSights } from "../LinkedSights";
import { SaveWithoutCityAgree } from "@widgets";
export const StationEditPage = observer(() => { export const StationEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const { language } = languageStore; const { language } = languageStore;
const { id } = useParams(); const { id } = useParams();
const { const {
@@ -45,18 +30,9 @@ export const StationEditPage = observer(() => {
editStation, editStation,
setLanguageEditStationData, setLanguageEditStationData,
} = stationsStore; } = stationsStore;
const { getCities } = cityStore; const { cities, getCities } = cityStore;
const canReadCities = authStore.canRead("cities");
const [coordinates, setCoordinates] = useState<string>(""); const [coordinates, setCoordinates] = useState<string>("");
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); // НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [isDeleteIconModalOpen, setIsDeleteIconModalOpen] = useState(false);
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false); const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => { useEffect(() => {
@@ -74,6 +50,7 @@ export const StationEditPage = observer(() => {
} }
}, [editStationData.common.latitude, editStationData.common.longitude]); }, [editStationData.common.latitude, editStationData.common.longitude]);
// НОВАЯ ФУНКЦИЯ: Фактическое редактирование (вызывается после подтверждения)
const executeEdit = async () => { const executeEdit = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
@@ -87,9 +64,10 @@ export const StationEditPage = observer(() => {
} }
}; };
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или редактирования
const handleEdit = async () => { const handleEdit = async () => {
const isCityMissing = !editStationData.common.city_id; const isCityMissing = !editStationData.common.city_id;
// Проверяем названия на всех языках
const isNameMissing = const isNameMissing =
!editStationData.ru.name || !editStationData.ru.name ||
!editStationData.en.name || !editStationData.en.name ||
@@ -103,102 +81,33 @@ export const StationEditPage = observer(() => {
await executeEdit(); await executeEdit();
}; };
// Обработчик "Да" в предупреждающем окне
const handleConfirmEdit = async () => { const handleConfirmEdit = async () => {
setIsSaveWarningOpen(false); setIsSaveWarningOpen(false);
await executeEdit(); await executeEdit();
}; };
// Обработчик "Нет" в предупреждающем окне
const handleCancelEdit = () => { const handleCancelEdit = () => {
setIsSaveWarningOpen(false); setIsSaveWarningOpen(false);
}; };
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setEditCommonData({ icon: media.id });
};
const selectedMedia =
editStationData.common.icon && !isMediaIdEmpty(editStationData.common.icon)
? mediaStore.media.find((m) => m.id === editStationData.common.icon)
: null;
const effectiveIconUrl = isMediaIdEmpty(editStationData.common.icon)
? null
: selectedMedia?.id ?? editStationData.common.icon;
useEffect(() => { useEffect(() => {
const fetchAndSetStationData = async () => { const fetchAndSetStationData = async () => {
if (!id) { if (!id) return;
setIsLoadingData(false);
return;
}
setIsLoadingData(true); const stationId = Number(id);
try { await getEditStation(stationId);
const stationId = Number(id); await getCities("ru");
await getEditStation(stationId); await getCities("en");
if (!authStore.me) { await getCities("zh");
await authStore.getMeAction().catch(() => undefined);
}
if (authStore.canRead("cities")) {
await getCities("ru");
} else {
await authStore.fetchMeCities().catch(() => undefined);
}
await mediaStore.getMedia();
} finally {
setIsLoadingData(false);
}
}; };
fetchAndSetStationData(); fetchAndSetStationData();
}, [id]); }, [id]);
const baseCities = canReadCities
? cityStore.cities["ru"].data
: authStore.meCities["ru"].map((city) => ({
id: city.city_id,
name: city.name,
country: "",
country_code: "",
arms: "",
}));
const availableCities =
editStationData.common.city_id &&
!baseCities.some((city) => city.id === editStationData.common.city_id)
? [
{
id: editStationData.common.city_id,
name: editStationData.common.city || `Город ${editStationData.common.city_id}`,
country: "",
country_code: "",
arms: "",
},
...baseCities,
]
: baseCities;
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных станции..." />
</Box>
);
}
return ( return (
<Box className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher /> <LanguageSwitcher />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -227,6 +136,23 @@ export const StationEditPage = observer(() => {
} }
/> />
<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 <TextField
fullWidth fullWidth
label="Описание" label="Описание"
@@ -287,7 +213,7 @@ export const StationEditPage = observer(() => {
value={editStationData.common.city_id || ""} value={editStationData.common.city_id || ""}
label="Город" label="Город"
onChange={(e) => { onChange={(e) => {
const selectedCity = availableCities.find( const selectedCity = cities["ru"].data.find(
(city) => city.id === e.target.value (city) => city.id === e.target.value
); );
setEditCommonData({ setEditCommonData({
@@ -296,7 +222,7 @@ export const StationEditPage = observer(() => {
}); });
}} }}
> >
{availableCities.map((city) => ( {cities["ru"].data.map((city) => (
<MenuItem key={city.id} value={city.id}> <MenuItem key={city.id} value={city.id}>
{city.name} {city.name}
</MenuItem> </MenuItem>
@@ -304,29 +230,6 @@ export const StationEditPage = observer(() => {
</Select> </Select>
</FormControl> </FormControl>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard
title="Иконка остановки"
imageKey="thumbnail"
imageUrl={effectiveIconUrl}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(effectiveIconUrl ?? "");
}}
onDeleteImageClick={() => {
setIsDeleteIconModalOpen(true);
}}
onSelectFileClick={() => {
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("image");
}}
/>
</div>
{id && ( {id && (
<LinkedSights <LinkedSights
parentId={Number(id)} parentId={Number(id)}
@@ -340,7 +243,7 @@ export const StationEditPage = observer(() => {
className="w-min flex gap-2 items-center" className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />} startIcon={<Save size={20} />}
onClick={handleEdit} onClick={handleEdit}
disabled={isLoading} disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleEdit
> >
{isLoading ? ( {isLoading ? (
<Loader2 size={20} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />
@@ -350,38 +253,6 @@ export const StationEditPage = observer(() => {
</Button> </Button>
</div> </div>
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={editStationData[language].name || "Остановка"}
contextType="station"
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
<DeleteModal
open={isDeleteIconModalOpen}
onDelete={() => {
setEditCommonData({ icon: "" });
setIsDeleteIconModalOpen(false);
}}
onCancel={() => setIsDeleteIconModalOpen(false)}
edit
/>
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */} {/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
{isSaveWarningOpen && ( {isSaveWarningOpen && (
<SaveWithoutCityAgree <SaveWithoutCityAgree
@@ -391,6 +262,6 @@ export const StationEditPage = observer(() => {
}} }}
/> />
)} )}
</Box> </Paper>
); );
}); });

View File

@@ -1,22 +1,16 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales"; import { ruRU } from "@mui/x-data-grid/locales";
import { import {
authStore,
languageStore, languageStore,
stationsStore, stationsStore,
selectedCityStore, selectedCityStore,
SearchInput, cityStore,
} from "@shared"; } from "@shared";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus, Route } from "lucide-react"; import { Eye, Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
CreateButton,
DeleteModal,
LanguageSwitcher,
EditStationTransfersModal,
} from "@widgets";
import { Box, CircularProgress } from "@mui/material"; import { Box, CircularProgress } from "@mui/material";
export const StationListPage = observer(() => { export const StationListPage = observer(() => {
@@ -24,24 +18,15 @@ export const StationListPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
const [isTransfersModalOpen, setIsTransfersModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null); const [rowId, setRowId] = useState<number | null>(null);
const [selectedStationId, setSelectedStationId] = useState<number | null>(
null
);
const [ids, setIds] = useState<number[]>([]); const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const { language } = languageStore; const { language } = languageStore;
const canWriteStations = authStore.canWrite("stations");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => { useEffect(() => {
const fetchStations = async () => { const fetchStations = async () => {
setIsLoading(true); setIsLoading(true);
await cityStore.getCities(language);
await getStationList(); await getStationList();
setIsLoading(false); setIsLoading(false);
}; };
@@ -66,8 +51,8 @@ export const StationListPage = observer(() => {
}, },
}, },
{ {
field: "description", field: "system_name",
headerName: "Описание", headerName: "Системное название",
flex: 1, flex: 1,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
return ( return (
@@ -81,65 +66,73 @@ export const StationListPage = observer(() => {
); );
}, },
}, },
{
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", field: "actions",
headerName: "Действия", headerName: "Действия",
width: 200, width: 140,
align: "center" as const, align: "center",
headerAlign: "center" as const, headerAlign: "center",
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
return ( return (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
{canWriteStations && ( <button onClick={() => navigate(`/station/${params.row.id}/edit`)}>
<button onClick={() => navigate(`/station/${params.row.id}/edit`)}> <Pencil size={20} className="text-blue-500" />
<Pencil size={20} className="text-blue-500" /> </button>
</button> <button onClick={() => navigate(`/station/${params.row.id}`)}>
)} <Eye size={20} className="text-green-500" />
{canWriteStations && ( </button>
<button <button
onClick={() => { onClick={() => {
setSelectedStationId(params.row.id); setIsDeleteModalOpen(true);
setIsTransfersModalOpen(true); setRowId(params.row.id);
}} }}
title="Редактировать пересадки" >
> <Trash2 size={20} className="text-red-500" />
<Route size={20} className="text-purple-500" /> </button>
</button>
)}
{canWriteStations && (
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
)}
</div> </div>
); );
}, },
}, },
]; ];
const rows = useMemo(() => { // Фильтрация станций по выбранному городу
const filteredStations = () => {
const { selectedCityId } = selectedCityStore; const { selectedCityId } = selectedCityStore;
const query = searchQuery.trim().toLowerCase(); if (!selectedCityId) {
return stationLists[language].data return stationLists[language].data;
.filter((station: any) => !selectedCityId || station.city_id === selectedCityId) }
.filter( return stationLists[language].data.filter(
(station: any) => (station: any) => station.city_id === selectedCityId
!query || );
(station.name ?? "").toLowerCase().includes(query) || };
(station.description ?? "").toLowerCase().includes(query)
) const rows = filteredStations().map((station: any) => ({
.map((station: any) => ({ id: station.id,
id: station.id, name: station.name,
name: station.name, system_name: station.system_name,
description: station.description, direction: station.direction,
})); }));
}, [stationLists[language].data, selectedCityStore.selectedCityId, searchQuery]);
return ( return (
<> <>
@@ -148,76 +141,32 @@ export const StationListPage = observer(() => {
<div className="w-full"> <div className="w-full">
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Станции</h1> <h1 className="text-2xl">Станции</h1>
{canWriteStations && ( <CreateButton label="Создать остановки" path="/station/create" />
<CreateButton label="Создать остановки" path="/station/create" />
)}
</div> </div>
{canWriteStations && ids.length > 0 && ( <div
<div className="flex justify-end mb-5 duration-300"> className="flex justify-end mb-5 duration-300"
<button style={{ opacity: ids.length > 0 ? 1 : 0 }}
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" >
onClick={() => setIsBulkDeleteModalOpen(true)} <button
> className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
<Trash2 size={20} className="text-white" /> Удалить выбранные ( onClick={() => setIsBulkDeleteModalOpen(true)}
{ids.length}) >
</button> <Trash2 size={20} className="text-white" /> Удалить выбранные (
</div> {ids.length})
)} </button>
</div>
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
checkboxSelection={canWriteStations} hideFooterPagination
disableRowSelectionExcludeModel checkboxSelection
loading={isLoading} loading={isLoading}
paginationModel={paginationModel} onRowSelectionModelChange={(newSelection) => {
onPaginationModelChange={setPaginationModel} setIds(Array.from(newSelection.ids) as number[]);
pageSizeOptions={[50]} }}
onRowSelectionModelChange={ hideFooter
canWriteStations
? (newSelection: any) => {
if (Array.isArray(newSelection)) {
const selectedIds = newSelection
.map((id: string | number) => {
const numId =
typeof id === "string"
? Number.parseInt(id, 10)
: Number(id);
return numId;
})
.filter(
(id: number) =>
!Number.isNaN(id) && id !== null && id !== undefined
);
setIds(selectedIds);
} else if (
newSelection &&
typeof newSelection === "object" &&
"ids" in newSelection
) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet)
.map((id: string | number) => {
const numId =
typeof id === "string"
? Number.parseInt(id, 10)
: Number(id);
return numId;
})
.filter(
(id: number) =>
!Number.isNaN(id) && id !== null && id !== undefined
);
setIds(selectedIds);
} else {
setIds([]);
}
}
: undefined
}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (
@@ -256,15 +205,6 @@ export const StationListPage = observer(() => {
setIsBulkDeleteModalOpen(false); setIsBulkDeleteModalOpen(false);
}} }}
/> />
<EditStationTransfersModal
open={isTransfersModalOpen}
onClose={() => {
setIsTransfersModalOpen(false);
setSelectedStationId(null);
}}
stationId={selectedStationId}
/>
</> </>
); );
}); });

View File

@@ -1,9 +1,9 @@
import { Paper, Box } from "@mui/material"; import { Paper } from "@mui/material";
import { languageStore, stationsStore, LoadingSpinner } from "@shared"; import { languageStore, stationsStore } from "@shared";
import { LanguageSwitcher } from "@widgets"; import { LanguageSwitcher } from "@widgets";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { LinkedSights } from "../LinkedSights"; import { LinkedSights } from "../LinkedSights";
@@ -12,38 +12,15 @@ export const StationPreviewPage = observer(() => {
const { stationPreview, getStationPreview } = stationsStore; const { stationPreview, getStationPreview } = stationsStore;
const navigate = useNavigate(); const navigate = useNavigate();
const { language } = languageStore; const { language } = languageStore;
const [isLoadingData, setIsLoadingData] = useState(true);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
setIsLoadingData(true); await getStationPreview(Number(id));
try {
await getStationPreview(Number(id));
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
} }
})(); })();
}, [id, language]); }, [id, language]);
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных станции..." />
</Box>
);
}
return ( return (
<Paper className="w-full p-3 py-5 flex flex-col gap-10"> <Paper className="w-full p-3 py-5 flex flex-col gap-10">
<LanguageSwitcher /> <LanguageSwitcher />
@@ -67,6 +44,21 @@ export const StationPreviewPage = observer(() => {
<p>{stationPreview[id!]?.[language]?.data.system_name}</p> <p>{stationPreview[id!]?.[language]?.data.system_name}</p>
</div> </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 && ( {stationPreview[id!]?.[language]?.data.address && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Адрес</h1> <h1 className="text-lg font-bold">Адрес</h1>

View File

@@ -1,34 +1,22 @@
import { Button, Paper, TextField } from "@mui/material"; import {
Button,
Paper,
TextField,
Checkbox,
FormControlLabel,
} from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { import { userStore } from "@shared";
userStore, import { useState } from "react";
mediaStore,
isMediaIdEmpty,
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared";
import { useState, useEffect } from "react";
import { ImageUploadCard } from "@widgets";
export const UserCreatePage = observer(() => { export const UserCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const { createUserData, setCreateUserData, createUser } = userStore; const { createUserData, setCreateUserData, createUser } = userStore;
const [isLoading, setIsLoading] = useState(false); 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(() => {
mediaStore.getMedia();
}, []);
const handleCreate = async () => { const handleCreate = async () => {
try { try {
@@ -43,29 +31,6 @@ export const UserCreatePage = observer(() => {
} }
}; };
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setCreateUserData(
createUserData.name || "",
createUserData.email || "",
createUserData.password || "",
createUserData.is_admin || false,
media.id
);
};
const selectedMedia =
createUserData.icon && !isMediaIdEmpty(createUserData.icon)
? mediaStore.media.find((m) => m.id === createUserData.icon)
: null;
const effectiveIconUrl = isMediaIdEmpty(createUserData.icon)
? null
: selectedMedia?.id ?? createUserData.icon ?? null;
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -89,8 +54,7 @@ export const UserCreatePage = observer(() => {
e.target.value, e.target.value,
createUserData.email || "", createUserData.email || "",
createUserData.password || "", createUserData.password || "",
createUserData.is_admin || false, createUserData.is_admin || false
createUserData.icon
) )
} }
/> />
@@ -105,8 +69,7 @@ export const UserCreatePage = observer(() => {
createUserData.name || "", createUserData.name || "",
e.target.value, e.target.value,
createUserData.password || "", createUserData.password || "",
createUserData.is_admin || false, createUserData.is_admin || false
createUserData.icon
) )
} }
/> />
@@ -121,39 +84,27 @@ export const UserCreatePage = observer(() => {
createUserData.name || "", createUserData.name || "",
createUserData.email || "", createUserData.email || "",
e.target.value, e.target.value,
createUserData.is_admin || false, createUserData.is_admin || false
createUserData.icon
) )
} }
/> />
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto"> <div className="w-full flex flex-col items-start">
<ImageUploadCard <FormControlLabel
title="Аватар" control={
imageKey="thumbnail" <Checkbox
imageUrl={effectiveIconUrl} checked={createUserData.is_admin || false}
onImageClick={() => { onChange={(e) => {
setIsPreviewMediaOpen(true); setCreateUserData(
setMediaId(effectiveIconUrl ?? ""); createUserData.name || "",
}} createUserData.email || "",
onDeleteImageClick={() => { createUserData.password || "",
setCreateUserData( e.target.checked
createUserData.name || "", );
createUserData.email || "", }}
createUserData.password || "", />
createUserData.is_admin || false, }
"" label="Администратор"
);
setActiveMenuType(null);
}}
onSelectFileClick={() => {
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("image");
}}
/> />
</div> </div>
@@ -173,28 +124,6 @@ export const UserCreatePage = observer(() => {
)} )}
</Button> </Button>
</div> </div>
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={createUserData.name || "Пользователь"}
contextType="user"
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
</Paper> </Paper>
); );
}); });

View File

@@ -4,239 +4,68 @@ import {
Checkbox, Checkbox,
Paper, Paper,
TextField, TextField,
Box,
Typography,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Radio,
RadioGroup,
Divider,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { import { userStore, languageStore } from "@shared";
userStore,
languageStore,
LoadingSpinner,
mediaStore,
isMediaIdEmpty,
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
authStore,
cityStore,
MultiSelect,
type User,
type UserCity,
} from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ImageUploadCard, DeleteModal } from "@widgets";
const ROLE_RESOURCES = [
{ key: "snapshot", label: "Экспорт" },
{ key: "devices", label: "Устройства" },
{ key: "vehicles", label: "Транспорт" },
{ key: "users", label: "Пользователи" },
{ key: "sights", label: "Достопримечательности" },
{ key: "stations", label: "Остановки" },
{ key: "routes", label: "Маршруты" },
{ key: "countries", label: "Страны" },
{ key: "cities", label: "Города" },
{ key: "carriers", label: "Перевозчики" },
] as const;
type PermissionLevel = "none" | "ro" | "rw";
function getPermissionLevel(roles: string[], resource: string): PermissionLevel {
if (roles.includes(`${resource}_rw`)) return "rw";
if (roles.includes(`${resource}_ro`)) return "ro";
return "none";
}
function applyPermissionChange(
roles: string[],
resource: string,
level: PermissionLevel,
): string[] {
const filtered = roles.filter(
(r) => r !== `${resource}_ro` && r !== `${resource}_rw`,
);
if (level === "ro") return [...filtered, `${resource}_ro`];
if (level === "rw") return [...filtered, `${resource}_rw`];
return filtered;
}
export const UserEditPage = observer(() => { export const UserEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const { id } = useParams(); const { id } = useParams();
const { editUserData, editUser, getUser, setEditUserData, setEditUserRoles } = userStore; const { editUserData, editUser, getUser, setEditUserData } = userStore;
const canReadCities = authStore.canRead("cities");
const [localRoles, setLocalRoles] = useState<string[]>([]);
const [localCityIds, setLocalCityIds] = useState<number[]>([]);
const [initialUserCities, setInitialUserCities] = useState<UserCity[]>([]);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [isDeleteIconModalOpen, setIsDeleteIconModalOpen] = useState(false);
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
useEffect(() => { useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}, []); }, []);
useEffect(() => { const handleEdit = async () => {
(async () => {
if (id) {
setIsLoadingData(true);
try {
if (!authStore.me) {
await authStore.getMeAction().catch(() => undefined);
}
await Promise.all([
mediaStore.getMedia(),
authStore.canRead("cities")
? cityStore.getRuCities()
: authStore.fetchMeCities().catch(() => undefined),
]);
const data = (await getUser(Number(id))) as User | undefined;
if (data) {
setEditUserData(
data.name || "",
data.email || "",
data.password || "",
data.is_admin || false,
data.icon || "",
);
const roles = data.roles ?? [];
setLocalRoles(roles);
setEditUserRoles(roles);
const cityIds = (data.cities ?? []).map((c) => c.city_id);
setLocalCityIds(cityIds);
setInitialUserCities(data.cities ?? []);
}
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
}
})();
}, [id]);
const handleSave = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const mandatoryRoles = ["articles_ro", "articles_rw", "media_ro", "media_rw"];
const rolesToSave = Array.from(new Set([...localRoles, ...mandatoryRoles]));
setEditUserRoles(rolesToSave);
await editUser(Number(id)); await editUser(Number(id));
toast.success("Пользователь успешно обновлен");
await userStore.addUserCityAction({
id: Number(id),
city_ids: localCityIds,
});
toast.success("Пользователь успешно обновлён");
navigate("/user"); navigate("/user");
} catch { } catch (error) {
toast.error("Ошибка при обновлении пользователя"); toast.error("Ошибка при обновлении пользователя");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
const handleMediaSelect = (media: { useEffect(() => {
id: string; (async () => {
filename: string; if (id) {
media_name?: string; const data = await getUser(Number(id));
media_type: number;
}) => {
setEditUserData(
editUserData.name || "",
editUserData.email || "",
editUserData.password || "",
editUserData.is_admin || false,
media.id,
);
};
const selectedMedia = setEditUserData(
editUserData.icon && !isMediaIdEmpty(editUserData.icon) data?.name || "",
? mediaStore.media.find((m) => m.id === editUserData.icon) data?.email || "",
: null; data?.password || "",
const effectiveIconUrl = isMediaIdEmpty(editUserData.icon) data?.is_admin || false
? null );
: (selectedMedia?.id ?? editUserData.icon ?? null); }
})();
const cityOptionsMap = new Map<number, string>(); }, [id]);
const sourceCities: UserCity[] = canReadCities
? cityStore.ruCities.data
.filter((city) => city.id !== undefined)
.map((city) => ({
city_id: city.id as number,
name: city.name,
}))
: authStore.meCities.ru;
for (const city of sourceCities) {
cityOptionsMap.set(city.city_id, city.name);
}
for (const city of initialUserCities) {
if (!cityOptionsMap.has(city.city_id)) {
cityOptionsMap.set(city.city_id, city.name);
}
}
const cityOptions = Array.from(cityOptionsMap.entries()).map(([value, label]) => ({
value,
label,
}));
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных пользователя..." />
</Box>
);
}
return ( return (
<Paper className="w-full p-6 flex flex-col gap-8"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<button className="flex items-center gap-2 self-start" onClick={() => navigate(-1)}> <div className="flex items-center gap-4">
<ArrowLeft size={20} /> <button
Назад className="flex items-center gap-2"
</button> onClick={() => navigate(-1)}
>
{/* ── Основные данные ── */} <ArrowLeft size={20} />
<section className="flex flex-col gap-6"> Назад
<Typography variant="h6">Основные данные</Typography> </button>
</div>
<div className="flex flex-col gap-10 w-full items-start">
<TextField <TextField
fullWidth fullWidth
label="Имя" label="Имя"
@@ -247,8 +76,7 @@ export const UserEditPage = observer(() => {
e.target.value, e.target.value,
editUserData.email || "", editUserData.email || "",
editUserData.password || "", editUserData.password || "",
editUserData.is_admin || false, editUserData.is_admin || false
editUserData.icon,
) )
} }
/> />
@@ -262,265 +90,56 @@ export const UserEditPage = observer(() => {
editUserData.name || "", editUserData.name || "",
e.target.value, e.target.value,
editUserData.password || "", editUserData.password || "",
editUserData.is_admin || false, editUserData.is_admin || false
editUserData.icon,
) )
} }
/> />
<TextField <TextField
fullWidth fullWidth
label="Пароль" label="Пароль"
placeholder="Оставить пустым, чтобы не менять"
value={editUserData.password || ""} value={editUserData.password || ""}
required
onChange={(e) => onChange={(e) =>
setEditUserData( setEditUserData(
editUserData.name || "", editUserData.name || "",
editUserData.email || "", editUserData.email || "",
e.target.value, e.target.value,
editUserData.is_admin || false, editUserData.is_admin || false
editUserData.icon,
) )
} }
/> />
<div className="w-full flex flex-col gap-4 max-w-[300px]">
<ImageUploadCard
title="Аватар"
imageKey="thumbnail"
imageUrl={effectiveIconUrl}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(effectiveIconUrl ?? "");
}}
onDeleteImageClick={() => setIsDeleteIconModalOpen(true)}
onSelectFileClick={() => {
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("image");
}}
/>
</div>
</section>
<Divider />
{/* ── Права доступа ── */}
<section className="flex flex-col gap-4">
<Typography variant="h6">Права доступа</Typography>
<FormControlLabel <FormControlLabel
control={ control={
<Checkbox <Checkbox
checked={localRoles.includes("admin")} checked={editUserData.is_admin || false}
onChange={(e) => { onChange={(e) =>
if (e.target.checked) { setEditUserData(
setLocalRoles((prev) => { editUserData.name || "",
let next = prev.filter((r) => r !== "admin"); editUserData.email || "",
for (const { key } of ROLE_RESOURCES) { editUserData.password || "",
next = next.filter((r) => r !== `${key}_ro` && r !== `${key}_rw`); e.target.checked
next.push(`${key}_rw`); )
} }
if (!next.includes("snapshot_create")) {
next.push("snapshot_create");
}
next.push("admin");
return next;
});
} else {
setLocalRoles((prev) => prev.filter((r) => r !== "admin"));
}
}}
/> />
} }
label="Полный доступ (admin)" label="Администратор"
/> />
<Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1 }}> <Button
<Table size="small"> variant="contained"
<TableHead> className="w-min flex gap-2 items-center self-end"
<TableRow sx={{ bgcolor: "action.hover" }}> startIcon={<Save size={20} />}
<TableCell sx={{ fontWeight: 600, width: 220 }}>Ресурс</TableCell> onClick={handleEdit}
<TableCell align="center" sx={{ fontWeight: 600 }}>Нет доступа</TableCell> disabled={isLoading || !editUserData.name || !editUserData.email}
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение</TableCell> >
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение/Запись</TableCell> {isLoading ? (
<TableCell align="center" sx={{ fontWeight: 600 }}> <Loader2 size={20} className="animate-spin" />
Создание (snapshot_create) ) : (
</TableCell> "Сохранить"
</TableRow> )}
</TableHead> </Button>
<TableBody> </div>
{ROLE_RESOURCES.map(({ key, label }) => {
const level = getPermissionLevel(localRoles, key);
const isSnapshotResource = key === "snapshot";
const handleChange = (val: string) => {
setLocalRoles((prev) => {
let updated = applyPermissionChange(prev, key, val as PermissionLevel);
if (key === "devices") {
updated = applyPermissionChange(
updated,
"vehicles",
val as PermissionLevel,
);
}
const allRw = ROLE_RESOURCES.every(({ key: k }) =>
updated.includes(`${k}_rw`),
);
if (allRw && !updated.includes("admin")) {
const next = [...updated];
if (!next.includes("snapshot_create")) {
next.push("snapshot_create");
}
next.push("admin");
return next;
}
if (!allRw) {
return updated.filter((r) => r !== "admin");
}
return updated;
});
};
const handleSnapshotCreateChange = (checked: boolean) => {
if (!isSnapshotResource) {
return;
}
setLocalRoles((prev) => {
const withoutSnapshotCreate = prev.filter(
(role) => role !== "snapshot_create"
);
return checked
? [...withoutSnapshotCreate, "snapshot_create"]
: withoutSnapshotCreate;
});
};
return (
<TableRow key={key} hover>
<TableCell>{label}</TableCell>
<TableCell align="center" padding="checkbox">
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="none" size="small" />
</RadioGroup>
</TableCell>
<TableCell align="center" padding="checkbox">
{isSnapshotResource ? (
<Typography variant="body2" color="text.secondary">
-
</Typography>
) : (
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="ro" size="small" />
</RadioGroup>
)}
</TableCell>
<TableCell align="center" padding="checkbox">
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="rw" size="small" />
</RadioGroup>
</TableCell>
<TableCell align="center" padding="checkbox">
{isSnapshotResource ? (
<Checkbox
checked={localRoles.includes("snapshot_create")}
onChange={(e) =>
handleSnapshotCreateChange(e.target.checked)
}
size="small"
/>
) : (
<Typography variant="body2" color="text.secondary">
-
</Typography>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Box>
</section>
<Divider />
{/* ── Города ── */}
<section className="flex flex-col gap-4">
<Typography variant="h6">Города</Typography>
<MultiSelect
options={cityOptions}
value={localCityIds}
onChange={(ids) => setLocalCityIds(ids as number[])}
label="Города"
placeholder="Выберите города"
loading={canReadCities ? !cityStore.ruCities.loaded : isLoadingData}
/>
</section>
<Button
variant="contained"
className="self-end"
startIcon={isLoading ? <Loader2 size={20} className="animate-spin" /> : <Save size={20} />}
onClick={handleSave}
disabled={isLoading || !editUserData.name || !editUserData.email}
>
Сохранить
</Button>
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={editUserData.name || "Пользователь"}
contextType="user"
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
<DeleteModal
open={isDeleteIconModalOpen}
onDelete={() => {
setEditUserData(
editUserData.name || "",
editUserData.email || "",
editUserData.password || "",
editUserData.is_admin || false,
"",
);
setIsDeleteIconModalOpen(false);
}}
onCancel={() => setIsDeleteIconModalOpen(false)}
edit
/>
</Paper> </Paper>
); );
}); });

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales"; import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, userStore, SearchInput } from "@shared"; import { userStore } from "@shared";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react"; import { Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -16,12 +16,6 @@ export const UserListPage = observer(() => {
const [rowId, setRowId] = useState<number | null>(null); const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]); const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const canWriteUsers = authStore.canWrite("users");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => { useEffect(() => {
const fetchUsers = async () => { const fetchUsers = async () => {
@@ -83,104 +77,78 @@ export const UserListPage = observer(() => {
}, },
}, },
...(canWriteUsers ? [{ {
field: "actions", field: "actions",
headerName: "Действия", headerName: "Действия",
flex: 1, flex: 1,
align: "center" as const, align: "center",
headerAlign: "center" as const, headerAlign: "center",
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> renderCell: (params: GridRenderCellParams) => {
<button onClick={() => navigate(`/user/${params.row.id}/edit`)}> return (
<Pencil size={20} className="text-blue-500" /> <div className="flex h-full gap-7 justify-center items-center">
</button> <button>
<button <Pencil
onClick={() => { size={20}
setIsDeleteModalOpen(true); className="text-blue-500"
setRowId(params.row.id); onClick={() => {
}} navigate(`/user/${params.row.id}/edit`);
> }}
<Trash2 size={20} className="text-red-500" /> />
</button> </button>
</div> <button
), onClick={() => {
}] : []), setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
]; ];
const rows = useMemo(() => { const rows = users.data?.map((user) => ({
const query = searchQuery.trim().toLowerCase(); id: user.id,
return (users.data ?? []) email: user.email,
.filter((user) => is_admin: user.is_admin,
!query || name: user.name,
(user.name ?? "").toLowerCase().includes(query) || }));
(user.email ?? "").toLowerCase().includes(query)
)
.map((user) => ({
id: user.id,
email: user.email,
is_admin: user.is_admin || (user.roles ?? []).includes("admin"),
name: user.name,
}));
}, [users.data, searchQuery]);
return ( return (
<> <>
<div className="w-full"> <div className="w-full">
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Пользователи</h1> <h1 className="text-2xl">Пользователи</h1>
{canWriteUsers && ( <CreateButton label="Создать пользователя" path="/user/create" />
<CreateButton label="Создать пользователя" path="/user/create" />
)}
</div> </div>
{canWriteUsers && ids.length > 0 && ( <div
<div className="flex justify-end mb-5 duration-300"> className="flex justify-end mb-5 duration-300"
<button style={{ opacity: ids.length > 0 ? 1 : 0 }}
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" >
onClick={() => setIsBulkDeleteModalOpen(true)} <button
> className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
<Trash2 size={20} className="text-white" /> Удалить выбранные ( onClick={() => setIsBulkDeleteModalOpen(true)}
{ids.length}) >
</button> <Trash2 size={20} className="text-white" /> Удалить выбранные (
</div> {ids.length})
)} </button>
</div>
<SearchInput value={searchQuery} onChange={setSearchQuery} />
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
checkboxSelection={canWriteUsers} hideFooterPagination
disableRowSelectionExcludeModel checkboxSelection
loading={isLoading} loading={isLoading}
paginationModel={paginationModel} onRowSelectionModelChange={(newSelection) => {
onPaginationModelChange={setPaginationModel} setIds(Array.from(newSelection.ids) as number[]);
pageSizeOptions={[50]} }}
onRowSelectionModelChange={ hideFooter
canWriteUsers
? (newSelection: any) => {
if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map(
(id: string | number) => Number(id)
);
setIds(selectedIds);
} else if (
newSelection &&
typeof newSelection === "object" &&
"ids" in newSelection
) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map(
(id: string | number) => Number(id)
);
setIds(selectedIds);
} else {
setIds([]);
}
}
: undefined
}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (

View File

@@ -25,7 +25,6 @@ export const VehicleCreatePage = observer(() => {
const [tailNumber, setTailNumber] = useState(""); const [tailNumber, setTailNumber] = useState("");
const [type, setType] = useState(""); const [type, setType] = useState("");
const [carrierId, setCarrierId] = useState<number | null>(null); const [carrierId, setCarrierId] = useState<number | null>(null);
const [model, setModel] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore; const { language } = languageStore;
@@ -37,12 +36,11 @@ export const VehicleCreatePage = observer(() => {
try { try {
setIsLoading(true); setIsLoading(true);
await vehicleStore.createVehicle( await vehicleStore.createVehicle(
tailNumber, Number(tailNumber),
Number(type), Number(type),
carrierStore.carriers[language].data?.find((c) => c.id === carrierId) carrierStore.carriers[language].data?.find((c) => c.id === carrierId)
?.full_name as string, ?.full_name as string,
carrierId!, carrierId!
model || undefined,
); );
toast.success("Транспорт успешно создан"); toast.success("Транспорт успешно создан");
} catch (error) { } catch (error) {
@@ -105,14 +103,6 @@ export const VehicleCreatePage = observer(() => {
</Select> </Select>
</FormControl> </FormControl>
<TextField
fullWidth
label="Модель ТС"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="Произвольное название модели"
/>
<Button <Button
variant="contained" variant="contained"
className="w-min flex gap-2 items-center" className="w-min flex gap-2 items-center"

View File

@@ -6,9 +6,6 @@ import {
FormControl, FormControl,
InputLabel, InputLabel,
Button, Button,
Box,
FormControlLabel,
Checkbox,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react"; import { ArrowLeft, Loader2, Save } from "lucide-react";
@@ -19,7 +16,6 @@ import {
languageStore, languageStore,
VEHICLE_TYPES, VEHICLE_TYPES,
vehicleStore, vehicleStore,
LoadingSpinner,
} from "@shared"; } from "@shared";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@@ -35,8 +31,6 @@ export const VehicleEditPage = observer(() => {
} = vehicleStore; } = vehicleStore;
const { getCarriers } = carrierStore; const { getCarriers } = carrierStore;
const { language } = languageStore; const { language } = languageStore;
const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
useEffect(() => { useEffect(() => {
// Устанавливаем русский язык при загрузке страницы // Устанавливаем русский язык при загрузке страницы
@@ -44,61 +38,31 @@ export const VehicleEditPage = observer(() => {
}, []); }, []);
useEffect(() => { useEffect(() => {
const fetchAndSetVehicleData = async () => { (async () => {
if (!id) { await getVehicle(Number(id));
setIsLoadingData(false); await getCarriers(language);
return;
}
setIsLoadingData(true); setEditVehicleData({
try { tail_number: vehicle[Number(id)]?.vehicle.tail_number,
await getVehicle(Number(id)); type: vehicle[Number(id)]?.vehicle.type,
await getCarriers(language); carrier: vehicle[Number(id)]?.vehicle.carrier,
carrier_id: vehicle[Number(id)]?.vehicle.carrier_id,
setEditVehicleData({ });
tail_number: vehicle[Number(id)]?.vehicle.tail_number ?? "", })();
type: vehicle[Number(id)]?.vehicle.type ?? 0,
carrier: vehicle[Number(id)]?.vehicle.carrier ?? "",
carrier_id: vehicle[Number(id)]?.vehicle.carrier_id ?? 0,
model: vehicle[Number(id)]?.vehicle.model ?? "",
snapshot_update_blocked:
vehicle[Number(id)]?.vehicle.snapshot_update_blocked ?? false,
});
} finally {
setIsLoadingData(false);
}
};
fetchAndSetVehicleData();
}, [id, language]); }, [id, language]);
const [isLoading, setIsLoading] = useState(false);
const handleEdit = async () => { const handleEdit = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
await editVehicle(Number(id), editVehicleData); await editVehicle(Number(id), editVehicleData);
toast.success("Транспортное средство успешно обновлено"); toast.success("Транспортное средство успешно обновлено");
navigate("/devices"); navigate("/vehicle");
} catch (error) { } catch (error) {
toast.error("Ошибка при обновлении транспортного средства"); toast.error("Ошибка при обновлении транспортного средства");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных транспортного средства..." />
</Box>
);
}
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -120,7 +84,7 @@ export const VehicleEditPage = observer(() => {
onChange={(e) => onChange={(e) =>
setEditVehicleData({ setEditVehicleData({
...editVehicleData, ...editVehicleData,
tail_number: e.target.value, tail_number: Number(e.target.value),
}) })
} }
/> />
@@ -164,35 +128,6 @@ export const VehicleEditPage = observer(() => {
</Select> </Select>
</FormControl> </FormControl>
<TextField
fullWidth
label="Модель ТС"
value={editVehicleData.model}
onChange={(e) =>
setEditVehicleData({
...editVehicleData,
model: e.target.value,
})
}
placeholder="Произвольное название модели"
/>
<FormControlLabel
control={
<Checkbox
checked={editVehicleData.snapshot_update_blocked}
onChange={(e) =>
setEditVehicleData({
...editVehicleData,
snapshot_update_blocked: e.target.checked,
})
}
color="primary"
/>
}
label="Блокировка обновления ПО"
/>
<Button <Button
variant="contained" variant="contained"
className="w-min flex gap-2 items-center" className="w-min flex gap-2 items-center"

View File

@@ -1,7 +1,7 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales"; import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, carrierStore, languageStore, vehicleStore, SearchInput } from "@shared"; import { carrierStore, languageStore, vehicleStore } from "@shared";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Eye, Pencil, Trash2, Minus } from "lucide-react"; import { Eye, Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -18,13 +18,7 @@ export const VehicleListPage = observer(() => {
const [rowId, setRowId] = useState<number | null>(null); const [rowId, setRowId] = useState<number | null>(null);
const [ids, setIds] = useState<number[]>([]); const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const { language } = languageStore; const { language } = languageStore;
const canWriteVehicles = authStore.canWrite("devices");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
@@ -106,116 +100,77 @@ export const VehicleListPage = observer(() => {
field: "actions", field: "actions",
headerName: "Действия", headerName: "Действия",
width: 200, width: 200,
align: "center" as const, align: "center",
headerAlign: "center" as const, headerAlign: "center",
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
const canWrite = authStore.canWrite("devices");
return ( return (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
{canWrite && ( <button onClick={() => navigate(`/vehicle/${params.row.id}/edit`)}>
<button onClick={() => navigate(`/vehicle/${params.row.id}/edit`)}> <Pencil size={20} className="text-blue-500" />
<Pencil size={20} className="text-blue-500" /> </button>
</button>
)}
<button onClick={() => navigate(`/vehicle/${params.row.id}`)}> <button onClick={() => navigate(`/vehicle/${params.row.id}`)}>
<Eye size={20} className="text-green-500" /> <Eye size={20} className="text-green-500" />
</button> </button>
{canWrite && ( <button
<button onClick={() => {
onClick={() => { setIsDeleteModalOpen(true);
setIsDeleteModalOpen(true); setRowId(params.row.id);
setRowId(params.row.id); }}
}} >
> <Trash2 size={20} className="text-red-500" />
<Trash2 size={20} className="text-red-500" /> </button>
</button>
)}
</div> </div>
); );
}, },
}, },
]; ];
const rows = useMemo(() => { const rows = vehicles.data?.map((vehicle) => ({
const query = searchQuery.trim().toLowerCase(); id: vehicle.vehicle.id,
return (vehicles.data ?? []) tail_number: vehicle.vehicle.tail_number,
.filter( type: vehicle.vehicle.type,
(vehicle) => carrier: vehicle.vehicle.carrier,
!query || city: carriers[language].data?.find(
(vehicle.vehicle.tail_number ?? "").toLowerCase().includes(query) || (carrier) => carrier.id === vehicle.vehicle.carrier_id
(vehicle.vehicle.carrier ?? "").toLowerCase().includes(query) )?.city,
) }));
.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,
}));
}, [vehicles.data, carriers[language].data, searchQuery]);
return ( return (
<> <>
<div style={{ width: "100%" }}> <div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Транспортные средства</h1> <h1 className="text-2xl">Транспортные средства</h1>
<CreateButton <CreateButton
label="Создать транспортное средство" label="Создать транспортное средство"
path="/vehicle/create" path="/vehicle/create"
/> />
</div> </div>
<SearchInput value={searchQuery} onChange={setSearchQuery} /> <div
className="flex justify-end mb-5 duration-300"
{canWriteVehicles && ids.length > 0 && ( style={{ opacity: ids.length > 0 ? 1 : 0 }}
<div className="flex justify-end mb-5 duration-300"> >
<button <button
className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center" className="px-4 py-2 bg-red-500 text-white rounded flex gap-2 items-center"
onClick={() => setIsBulkDeleteModalOpen(true)} onClick={() => setIsBulkDeleteModalOpen(true)}
> >
<Trash2 size={20} className="text-white" /> Удалить выбранные ( <Trash2 size={20} className="text-white" /> Удалить выбранные (
{ids.length}) {ids.length})
</button> </button>
</div> </div>
)}
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
checkboxSelection={canWriteVehicles} hideFooterPagination
disableRowSelectionExcludeModel checkboxSelection
loading={isLoading} loading={isLoading}
paginationModel={paginationModel} onRowSelectionModelChange={(newSelection) => {
onPaginationModelChange={setPaginationModel} setIds(Array.from(newSelection.ids) as number[]);
pageSizeOptions={[50]} }}
onRowSelectionModelChange={ hideFooter
canWriteVehicles
? (newSelection: any) => {
if (Array.isArray(newSelection)) {
const selectedIds = newSelection.map(
(id: string | number) => Number(id)
);
setIds(selectedIds);
} else if (
newSelection &&
typeof newSelection === "object" &&
"ids" in newSelection
) {
const idsSet = newSelection.ids as Set<string | number>;
const selectedIds = Array.from(idsSet).map(
(id: string | number) => Number(id)
);
setIds(selectedIds);
} else {
setIds([]);
}
}
: undefined
}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (

View File

@@ -35,4 +35,3 @@ const languageInstance = (language: Language) => {
}; };
export { authInstance, languageInstance }; export { authInstance, languageInstance };
export { mobxFetch } from "./mobxFetch";

View File

@@ -1,183 +0,0 @@
import { runInAction } from "mobx";
type mobxFetchOptions<RequestType, ResponseType, Store> = {
store: Store;
value?: keyof Store;
values?: Array<keyof Store>;
loading?: keyof Store;
error?: keyof Store;
fn: RequestType extends void
? (signal?: AbortSignal) => Promise<ResponseType>
: (request: RequestType, signal?: AbortSignal) => Promise<ResponseType>;
pollingInterval?: number;
resetValue?: boolean;
transform?: (response: ResponseType) => Partial<Record<string, any>>;
onSuccess?: (response: ResponseType) => void;
};
type FetchFunction<RequestType, ResponseType> = RequestType extends void
? {
(): Promise<ResponseType | null>;
stopPolling?: () => void;
}
: {
(request: RequestType): Promise<ResponseType | null>;
stopPolling?: () => void;
};
export function mobxFetch<ResponseType, Store extends Record<string, any>>(
options: mobxFetchOptions<void, ResponseType, Store>
): FetchFunction<void, ResponseType>;
export function mobxFetch<
RequestType,
ResponseType,
Store extends Record<string, any>,
>(
options: mobxFetchOptions<RequestType, ResponseType, Store>
): FetchFunction<RequestType, ResponseType>;
export function mobxFetch<
RequestType,
ResponseType,
Store extends Record<string, any>,
>(
options: mobxFetchOptions<RequestType, ResponseType, Store>
): FetchFunction<RequestType, ResponseType> {
const {
store,
value,
values,
loading,
error,
fn,
pollingInterval,
resetValue,
transform,
onSuccess,
} = options;
let abortController: AbortController | undefined;
let pollingTimer: ReturnType<typeof setInterval> | undefined;
let currentRequest: RequestType | undefined;
const stopPolling = () => {
if (pollingTimer) {
clearInterval(pollingTimer);
pollingTimer = undefined;
}
abortController?.abort();
};
const fetch = async (request?: RequestType): Promise<ResponseType | null> => {
abortController?.abort();
abortController = new AbortController();
currentRequest = request as RequestType;
runInAction(() => {
if (value) {
(store[value] as any) = resetValue ? null : store[value];
}
if (values) {
values.forEach((key) => {
(store[key] as any) = resetValue ? null : store[key];
});
}
if (error) {
(store[error] as any) = null;
}
if (loading) {
(store[loading] as any) = true;
}
});
try {
const result = await (
fn as (
request?: RequestType,
signal?: AbortSignal
) => Promise<ResponseType>
)(request, abortController.signal);
runInAction(() => {
if (values && transform) {
const transformed = transform(result) as Record<string, any>;
values.forEach((key) => {
const k = key as string;
if (k in transformed) {
(store[key] as any) = transformed[k];
}
});
} else if (value) {
(store[value] as any) = result as ResponseType;
}
if (loading) {
(store[loading] as any) = false;
}
if (error) {
(store[error] as any) = null;
}
});
if (pollingInterval && !pollingTimer) {
pollingTimer = setInterval(() => {
if (currentRequest !== undefined) {
fetch(currentRequest);
} else {
fetch();
}
}, pollingInterval);
}
if (onSuccess) {
onSuccess(result);
}
return result;
} catch (err) {
if (!(err instanceof Error && err.name === "CanceledError")) {
runInAction(() => {
if (error) {
(store[error] as any) =
err instanceof Error ? err.message : String(err);
}
if (loading) {
(store[loading] as any) = false;
}
if (value) {
(store[value] as any) = null;
}
if (values) {
values.forEach((key) => {
(store[key] as any) = null;
});
}
});
throw err;
}
return null;
}
};
const fetchWithStopPolling = fetch as FetchFunction<
RequestType,
ResponseType
>;
if (pollingInterval) {
fetchWithStopPolling.stopPolling = stopPolling;
}
return fetchWithStopPolling;
}

View File

@@ -8,10 +8,13 @@ import {
Earth, Earth,
Landmark, Landmark,
GitBranch, GitBranch,
// Car,
Table, Table,
Split, Split,
// Newspaper,
PersonStanding, PersonStanding,
Cpu, Cpu,
// BookImage,
} from "lucide-react"; } from "lucide-react";
import carrierIcon from "./carrier.svg"; import carrierIcon from "./carrier.svg";
@@ -23,63 +26,12 @@ interface NavigationItem {
label: string; label: string;
icon?: LucideIcon | React.ReactNode; icon?: LucideIcon | React.ReactNode;
path?: string; path?: string;
requiredRoles?: string[]; for_admin?: boolean;
onClick?: () => void; onClick?: () => void;
nestedItems?: NavigationItem[]; nestedItems?: NavigationItem[];
isActive?: boolean; isActive?: boolean;
} }
export const ROUTE_REQUIRED_RESOURCES: Record<string, string[]> = {
"/": [],
"/sight": ["sights"],
"/sight/create": ["sights"],
"/sight/:id/edit": ["sights"],
"/devices": ["devices", "vehicles", "routes", "carriers", "snapshot_rw"],
"/map": ["map"],
"/media": ["sights"],
"/media/:id": ["sights"],
"/media/:id/edit": ["sights"],
"/country": ["countries"],
"/country/create": ["countries"],
"/country/add": ["countries"],
"/country/:id/edit": ["countries"],
"/city": ["cities", "countries"],
"/city/create": ["cities", "countries"],
"/city/:id/edit": ["cities", "countries"],
"/route": ["routes", "carriers"],
"/route/create": ["routes", "carriers"],
"/route/:id/edit": ["routes", "carriers"],
"/user": ["users"],
"/user/create": ["users"],
"/user/:id/edit": ["users"],
"/snapshot": ["snapshot_rw"],
"/snapshot/create": ["snapshot_create", "devices_rw"],
"/carrier": ["carriers"],
"/carrier/create": ["carriers"],
"/carrier/:id/edit": ["carriers"],
"/station": ["stations"],
"/station/create": ["stations"],
"/station/:id": ["stations"],
"/station/:id/edit": ["stations"],
"/vehicle/create": ["devices"],
"/vehicle/:id/edit": ["devices"],
"/article": ["sights"],
"/article/:id": ["sights"],
};
export const NAVIGATION_ITEMS: { export const NAVIGATION_ITEMS: {
primary: NavigationItem[]; primary: NavigationItem[];
secondary: NavigationItem[]; secondary: NavigationItem[];
@@ -87,10 +39,10 @@ export const NAVIGATION_ITEMS: {
primary: [ primary: [
{ {
id: "snapshots", id: "snapshots",
label: "Экспорт", label: "Снапшоты",
icon: GitBranch, icon: GitBranch,
path: "/snapshot", path: "/snapshot",
requiredRoles: ["snapshot_rw", "snapshot_create"], for_admin: true,
}, },
{ {
id: "map", id: "map",
@@ -103,40 +55,55 @@ export const NAVIGATION_ITEMS: {
label: "Устройства", label: "Устройства",
icon: Cpu, icon: Cpu,
path: "/devices", path: "/devices",
requiredRoles: ["devices_ro", "devices_rw"], for_admin: true,
}, },
// {
// id: "vehicles",
// label: "Транспорт",
// icon: Car,
// path: "/vehicle",
// },
{ {
id: "users", id: "users",
label: "Пользователи", label: "Пользователи",
icon: Users, icon: Users,
path: "/user", path: "/user",
requiredRoles: ["users_ro", "users_rw"], for_admin: true,
}, },
{ {
id: "all", id: "all",
label: "Справочник", label: "Справочник",
icon: Table, icon: Table,
nestedItems: [ nestedItems: [
// {
// id: "media",
// label: "Медиа",
// icon: BookImage,
// path: "/media",
// },
// {
// id: "articles",
// label: "Статьи",
// icon: Newspaper,
// path: "/article",
// },
{ {
id: "attractions", id: "attractions",
label: "Достопримечательности", label: "Достопримечательности",
icon: Landmark, icon: Landmark,
path: "/sight", path: "/sight",
requiredRoles: ["sights_ro", "sights_rw"],
}, },
{ {
id: "stations", id: "stations",
label: "Остановки", label: "Остановки",
icon: PersonStanding, icon: PersonStanding,
path: "/station", path: "/station",
requiredRoles: ["stations_ro", "stations_rw"],
}, },
{ {
id: "routes", id: "routes",
label: "Маршруты", label: "Маршруты",
icon: Split, icon: Split,
path: "/route", path: "/route",
requiredRoles: ["routes_ro", "routes_rw"],
}, },
{ {
@@ -144,22 +111,22 @@ export const NAVIGATION_ITEMS: {
label: "Страны", label: "Страны",
icon: Earth, icon: Earth,
path: "/country", path: "/country",
requiredRoles: ["countries_ro", "countries_rw"], for_admin: true,
}, },
{ {
id: "cities", id: "cities",
label: "Города", label: "Города",
icon: Building2, icon: Building2,
path: "/city", path: "/city",
requiredRoles: ["cities_ro", "cities_rw"], for_admin: true,
}, },
{ {
id: "carriers", id: "carriers",
label: "Перевозчики", label: "Перевозчики",
// @ts-ignore // @ts-ignore
icon: () => <img src={carrierIcon} alt="Перевозчики" />, icon: () => <img src={carrierIcon} alt="Перевозчики"/>,
path: "/carrier", path: "/carrier",
requiredRoles: ["carriers_ro", "carriers_rw"], for_admin: true,
}, },
], ],
}, },
@@ -177,31 +144,7 @@ export const NAVIGATION_ITEMS: {
], ],
}; };
function collectRoles(list: NavigationItem[]): string[] {
const roles = new Set<string>(["admin"]);
const walk = (items: NavigationItem[]) => {
for (const item of items) {
item.requiredRoles?.forEach((r) => roles.add(r));
item.nestedItems && walk(item.nestedItems);
}
};
walk(list);
return Array.from(roles);
}
export const ALL_ROLES = collectRoles(NAVIGATION_ITEMS.primary);
export const VEHICLE_TYPES = [ export const VEHICLE_TYPES = [
{ label: "Автобус", value: 3 },
{ label: "Троллейбус", value: 2 },
{ label: "Трамвай", value: 1 }, { label: "Трамвай", value: 1 },
{ label: "Электробус", value: 4 }, { label: "Троллейбус", value: 2 },
{ label: "Электричка", value: 5 },
{ label: "Вагон метро", value: 6 },
{ label: "Вагон ЖД", value: 7 },
]; ];
export const VEHICLE_MODELS = [
{ label: "71-431P «Довлатов»", value: "71-431P «Довлатов»" },
{ label: "71-638M-02 «Альтаир»", value: "71-638M-02 «Альтаир»" },
] as const;

View File

@@ -33,7 +33,6 @@ export const MEDIA_TYPE_VALUES = {
video: 2, video: 2,
icon: 3, icon: 3,
thumbnail: 3, thumbnail: 3,
alt_icon: 3,
watermark_lu: 4, watermark_lu: 4,
watermark_rd: 4, watermark_rd: 4,
panorama: 5, panorama: 5,
@@ -43,4 +42,4 @@ export const MEDIA_TYPE_VALUES = {
export const RU_COUNTRIES = generateCountriesList("ru"); export const RU_COUNTRIES = generateCountriesList("ru");
export const EN_COUNTRIES = generateCountriesList("en"); export const EN_COUNTRIES = generateCountriesList("en");
export const ZH_COUNTRIES = generateCountriesList("zh"); export const ZH_COUNTRIES = generateCountriesList("zh");

View File

@@ -1,3 +1,8 @@
/**
* Утилита для управления кешем GLTF и blob URL
*/
// Динамический импорт useGLTF для избежания проблем с SSR
let useGLTF: any = null; let useGLTF: any = null;
const initializeUseGLTF = async () => { const initializeUseGLTF = async () => {
@@ -15,6 +20,9 @@ const initializeUseGLTF = async () => {
return useGLTF; return useGLTF;
}; };
/**
* Очищает кеш GLTF для конкретного URL
*/
export const clearGLTFCacheForUrl = async (url: string) => { export const clearGLTFCacheForUrl = async (url: string) => {
try { try {
const gltf = await initializeUseGLTF(); const gltf = await initializeUseGLTF();
@@ -24,6 +32,9 @@ export const clearGLTFCacheForUrl = async (url: string) => {
} catch (error) {} } catch (error) {}
}; };
/**
* Очищает весь кеш GLTF
*/
export const clearAllGLTFCache = async () => { export const clearAllGLTFCache = async () => {
try { try {
const gltf = await initializeUseGLTF(); const gltf = await initializeUseGLTF();
@@ -33,6 +44,9 @@ export const clearAllGLTFCache = async () => {
} catch (error) {} } catch (error) {}
}; };
/**
* Очищает blob URL из памяти браузера
*/
export const revokeBlobURL = (url: string) => { export const revokeBlobURL = (url: string) => {
if (url && url.startsWith("blob:")) { if (url && url.startsWith("blob:")) {
try { try {
@@ -41,16 +55,25 @@ export const revokeBlobURL = (url: string) => {
} }
}; };
/**
* Комплексная очистка: blob URL + кеш GLTF
*/
export const clearBlobAndGLTFCache = async (url: string) => { export const clearBlobAndGLTFCache = async (url: string) => {
// Сначала отзываем blob URL
revokeBlobURL(url); revokeBlobURL(url);
// Затем очищаем кеш GLTF
await clearGLTFCacheForUrl(url); await clearGLTFCacheForUrl(url);
}; };
/**
* Очистка при смене медиа (для предотвращения конфликтов)
*/
export const clearMediaTransitionCache = async ( export const clearMediaTransitionCache = async (
previousMediaId: string | number | null, previousMediaId: string | number | null,
newMediaType?: number newMediaType?: number
) => { ) => {
// Если переключаемся с/на 3D модель, очищаем весь кеш
if (newMediaType === 6 || previousMediaId) { if (newMediaType === 6 || previousMediaId) {
await clearAllGLTFCache(); await clearAllGLTFCache();
} }

View File

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

View File

@@ -1,18 +0,0 @@
export const canRead = (roles: string[] | undefined, resource: string): boolean => {
if (!roles || roles.length === 0) return false;
return (
roles.includes("admin") ||
roles.includes(`${resource}_ro`) ||
roles.includes(`${resource}_rw`)
);
};
export const canWrite = (roles: string[] | undefined, resource: string): boolean => {
if (!roles || roles.length === 0) return false;
return roles.includes("admin") || roles.includes(`${resource}_rw`);
};
export const createPermissions = (roles: string[] | undefined) => ({
canRead: (resource: string) => canRead(roles, resource),
canWrite: (resource: string) => canWrite(roles, resource),
});

View File

@@ -523,6 +523,7 @@ export const ArticleSelectOrCreateDialog = observer(
article?.service_name?.toLowerCase().includes(searchQuery.toLowerCase()) article?.service_name?.toLowerCase().includes(searchQuery.toLowerCase())
); );
// Preview-by-click logic with request serialization to avoid concurrent requests
const [isPreviewLoading, setIsPreviewLoading] = useState(false); const [isPreviewLoading, setIsPreviewLoading] = useState(false);
const [queuedPreviewId, setQueuedPreviewId] = useState<number | null>(null); const [queuedPreviewId, setQueuedPreviewId] = useState<number | null>(null);
const clickTimerRef = (typeof window !== "undefined" const clickTimerRef = (typeof window !== "undefined"
@@ -550,7 +551,7 @@ export const ArticleSelectOrCreateDialog = observer(
if (queuedPreviewId && queuedPreviewId !== articleId) { if (queuedPreviewId && queuedPreviewId !== articleId) {
const nextId = queuedPreviewId; const nextId = queuedPreviewId;
setQueuedPreviewId(null); setQueuedPreviewId(null);
// Run the next queued preview
runPreviewFetch(nextId); runPreviewFetch(nextId);
} else { } else {
setQueuedPreviewId(null); setQueuedPreviewId(null);
@@ -559,6 +560,7 @@ export const ArticleSelectOrCreateDialog = observer(
}; };
const handleListItemClick = (articleId: number) => { const handleListItemClick = (articleId: number) => {
// Delay to allow double-click to cancel preview
if (clickTimerRef.current) clearTimeout(clickTimerRef.current); if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
clickTimerRef.current = setTimeout(() => { clickTimerRef.current = setTimeout(() => {
if (tabValue === 0 && !selectedArticleId && !tempArticleId) { if (tabValue === 0 && !selectedArticleId && !tempArticleId) {
@@ -568,6 +570,7 @@ export const ArticleSelectOrCreateDialog = observer(
}; };
const handleListItemDoubleClick = (articleId: number) => { const handleListItemDoubleClick = (articleId: number) => {
// Cancel pending single-click preview and proceed to select
if (clickTimerRef.current) { if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current); clearTimeout(clickTimerRef.current);
(clickTimerRef as any).current = null; (clickTimerRef as any).current = null;

View File

@@ -39,8 +39,6 @@ interface UploadMediaDialogProps {
afterUploadSight?: (id: string) => void; afterUploadSight?: (id: string) => void;
hardcodeType?: hardcodeType?:
| "thumbnail" | "thumbnail"
| "icon"
| "alt_icon"
| "watermark_lu" | "watermark_lu"
| "watermark_rd" | "watermark_rd"
| "image" | "image"
@@ -53,12 +51,10 @@ interface UploadMediaDialogProps {
| "carrier" | "carrier"
| "country" | "country"
| "vehicle" | "vehicle"
| "station" | "station";
| "route"
| "user";
isArticle?: boolean; isArticle?: boolean;
articleName?: string; articleName?: string;
initialFile?: File; initialFile?: File; // <--- добавлено
} }
export const UploadMediaDialog = observer( export const UploadMediaDialog = observer(
@@ -72,7 +68,7 @@ export const UploadMediaDialog = observer(
isArticle, isArticle,
articleName, articleName,
initialFile, initialFile, // <--- добавлено
}: UploadMediaDialogProps) => { }: UploadMediaDialogProps) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -91,6 +87,7 @@ export const UploadMediaDialog = observer(
useEffect(() => { useEffect(() => {
if (initialFile) { if (initialFile) {
// Очищаем предыдущий blob URL если он существует
if ( if (
previousMediaUrlRef.current && previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:") previousMediaUrlRef.current.startsWith("blob:")
@@ -109,6 +106,7 @@ export const UploadMediaDialog = observer(
} }
}, [initialFile]); }, [initialFile]);
// Очистка blob URL при размонтировании компонента
useEffect(() => { useEffect(() => {
return () => { return () => {
if ( if (
@@ -118,13 +116,13 @@ export const UploadMediaDialog = observer(
clearBlobAndGLTFCache(previousMediaUrlRef.current); clearBlobAndGLTFCache(previousMediaUrlRef.current);
} }
}; };
}, []); }, []); // Пустой массив зависимостей - выполняется только при размонтировании
useEffect(() => { useEffect(() => {
if (fileToUpload) { if (fileToUpload) {
setMediaFile(fileToUpload); setMediaFile(fileToUpload);
setMediaFilename(fileToUpload.name); setMediaFilename(fileToUpload.name);
// Try to determine media type from file extension
const extension = fileToUpload.name.split(".").pop()?.toLowerCase(); const extension = fileToUpload.name.split(".").pop()?.toLowerCase();
if (extension) { if (extension) {
if (["glb", "gltf"].includes(extension)) { if (["glb", "gltf"].includes(extension)) {
@@ -136,18 +134,22 @@ export const UploadMediaDialog = observer(
extension extension
) )
) { ) {
setAvailableMediaTypes([1, 3, 4, 5]); // Для изображений доступны все типы кроме видео
setMediaType(1); setAvailableMediaTypes([1, 3, 4, 5]); // Фото, Иконка, Водяной знак, Панорама, 3Д-модель
setMediaType(1); // По умолчанию Фото
} else if (["mp4", "webm", "mov"].includes(extension)) { } else if (["mp4", "webm", "mov"].includes(extension)) {
// Для видео только тип Видео
setAvailableMediaTypes([2]); setAvailableMediaTypes([2]);
setMediaType(2); setMediaType(2);
} }
} }
// Генерируем название по умолчанию если есть контекст
if (fileToUpload.name) { if (fileToUpload.name) {
let defaultName = ""; let defaultName = "";
if (isArticle && articleName && contextObjectName) { if (isArticle && articleName && contextObjectName) {
// Для статей: "Название достопримечательности_название файлаазвание статьи"
defaultName = generateDefaultMediaName( defaultName = generateDefaultMediaName(
contextObjectName, contextObjectName,
fileToUpload.name, fileToUpload.name,
@@ -155,9 +157,10 @@ export const UploadMediaDialog = observer(
true true
); );
} else if (contextObjectName && contextObjectName.trim() !== "") { } else if (contextObjectName && contextObjectName.trim() !== "") {
// Для обычных медиа с названием объекта
const currentMediaType = hardcodeType const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType] ? MEDIA_TYPE_VALUES[hardcodeType]
: 1; : 1; // По умолчанию фото
defaultName = generateDefaultMediaName( defaultName = generateDefaultMediaName(
contextObjectName, contextObjectName,
fileToUpload.name, fileToUpload.name,
@@ -165,9 +168,10 @@ export const UploadMediaDialog = observer(
false false
); );
} else { } else {
// Для медиа без названия объекта
const currentMediaType = hardcodeType const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType] ? MEDIA_TYPE_VALUES[hardcodeType]
: 1; : 1; // По умолчанию фото
defaultName = generateDefaultMediaName( defaultName = generateDefaultMediaName(
"", "",
fileToUpload.name, fileToUpload.name,
@@ -181,11 +185,13 @@ export const UploadMediaDialog = observer(
} }
}, [fileToUpload, contextObjectName, hardcodeType, isArticle, articleName]); }, [fileToUpload, contextObjectName, hardcodeType, isArticle, articleName]);
// Обновляем название при изменении типа медиа
useEffect(() => { useEffect(() => {
if (mediaFilename && mediaType > 0) { if (mediaFilename && mediaType > 0) {
let defaultName = ""; let defaultName = "";
if (isArticle && articleName && contextObjectName) { if (isArticle && articleName && contextObjectName) {
// Для статей: "Название достопримечательности_название файлаазвание статьи"
defaultName = generateDefaultMediaName( defaultName = generateDefaultMediaName(
contextObjectName, contextObjectName,
mediaFilename, mediaFilename,
@@ -193,6 +199,7 @@ export const UploadMediaDialog = observer(
true true
); );
} else if (contextObjectName && contextObjectName.trim() !== "") { } else if (contextObjectName && contextObjectName.trim() !== "") {
// Для обычных медиа с названием объекта
const currentMediaType = hardcodeType const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType] ? MEDIA_TYPE_VALUES[hardcodeType]
: mediaType; : mediaType;
@@ -203,6 +210,7 @@ export const UploadMediaDialog = observer(
false false
); );
} else { } else {
// Для медиа без названия объекта
const currentMediaType = hardcodeType const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType] ? MEDIA_TYPE_VALUES[hardcodeType]
: mediaType; : mediaType;
@@ -227,6 +235,7 @@ export const UploadMediaDialog = observer(
useEffect(() => { useEffect(() => {
if (mediaFile) { if (mediaFile) {
// Очищаем предыдущий blob URL и кеш GLTF если он существует
if ( if (
previousMediaUrlRef.current && previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:") previousMediaUrlRef.current.startsWith("blob:")
@@ -236,10 +245,22 @@ export const UploadMediaDialog = observer(
const newBlobUrl = URL.createObjectURL(mediaFile as Blob); const newBlobUrl = URL.createObjectURL(mediaFile as Blob);
setMediaUrl(newBlobUrl); setMediaUrl(newBlobUrl);
previousMediaUrlRef.current = newBlobUrl; previousMediaUrlRef.current = newBlobUrl; // Сохраняем новый URL в ref
setIsPreviewLoaded(false); setIsPreviewLoaded(false); // Сбрасываем состояние загрузки при смене файла
} }
}, [mediaFile]); }, [mediaFile]); // Убираем mediaUrl из зависимостей чтобы избежать зацикливания
// const fileFormat = useEffect(() => {
// const handleKeyPress = (event: KeyboardEvent) => {
// if (event.key.toLowerCase() === "enter" && !event.ctrlKey) {
// event.preventDefault();
// onClose();
// }
// };
// window.addEventListener("keydown", handleKeyPress);
// return () => window.removeEventListener("keydown", handleKeyPress);
// }, [onClose]);
const handleSave = async () => { const handleSave = async () => {
if (!mediaFile) return; if (!mediaFile) return;
@@ -264,10 +285,10 @@ export const UploadMediaDialog = observer(
} }
} }
setSuccess(true); setSuccess(true);
// Закрываем модальное окно после успешного сохранения
setTimeout(() => { setTimeout(() => {
handleClose(); handleClose();
}, 1000); }, 1000); // Небольшая задержка, чтобы пользователь увидел сообщение об успехе
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to save media"); setError(err instanceof Error ? err.message : "Failed to save media");
} finally { } finally {
@@ -276,6 +297,7 @@ export const UploadMediaDialog = observer(
}; };
const handleClose = () => { const handleClose = () => {
// Очищаем blob URL и кеш GLTF при закрытии диалога
if ( if (
previousMediaUrlRef.current && previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:") previousMediaUrlRef.current.startsWith("blob:")
@@ -288,7 +310,7 @@ export const UploadMediaDialog = observer(
setMediaUrl(null); setMediaUrl(null);
setMediaFile(null); setMediaFile(null);
setIsPreviewLoaded(false); setIsPreviewLoaded(false);
previousMediaUrlRef.current = null; previousMediaUrlRef.current = null; // Очищаем ref
onClose(); onClose();
}; };

View File

@@ -1,25 +0,0 @@
import { languageInstance } from "@shared";
import { User, UserCity } from "../UserStore";
export const getMeApi = async (): Promise<User> => {
const response = await languageInstance("ru").get("/auth/me");
return response.data as User;
};
export const getMeCitiesApi = async (): Promise<{
ru: UserCity[];
en: UserCity[];
zh: UserCity[];
}> => {
const [ru, en, zh] = await Promise.all([
languageInstance("ru").get("/auth/me"),
languageInstance("en").get("/auth/me"),
languageInstance("zh").get("/auth/me"),
]);
return {
ru: ((ru.data as User).cities ?? []),
en: ((en.data as User).cities ?? []),
zh: ((zh.data as User).cities ?? []),
};
};

View File

@@ -1,13 +1,15 @@
import { API_URL, decodeJWT, mobxFetch } from "@shared"; import { API_URL, decodeJWT } from "@shared";
import { canRead as checkCanRead, canWrite as checkCanWrite } from "../../lib/permissions";
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
import axios, { AxiosError } from "axios"; import axios, { AxiosError } from "axios";
import { User, UserCity } from "../UserStore";
import { getMeApi, getMeCitiesApi } from "./api";
type LoginResponse = { type LoginResponse = {
token: string; token: string;
user: Pick<User, "id" | "name" | "email" | "is_admin" | "cities">; user: {
id: number;
name: string;
email: string;
is_admin: boolean;
};
}; };
class AuthStore { class AuthStore {
@@ -46,7 +48,7 @@ class AuthStore {
{ {
email, email,
password, password,
}, }
); );
const data = response.data; const data = response.data;
@@ -87,76 +89,6 @@ class AuthStore {
get user() { get user() {
return this.payload?.user; return this.payload?.user;
} }
get isAdmin(): boolean {
return (
this.me?.is_admin === true ||
(this.me?.roles ?? []).includes("admin")
);
}
me: User | null = null;
meLoading = false;
meError: string | null = null;
meCities: { ru: UserCity[]; en: UserCity[]; zh: UserCity[] } = {
ru: [],
en: [],
zh: [],
};
getMeAction = mobxFetch<void, User, AuthStore>({
store: this,
value: "me",
loading: "meLoading",
error: "meError",
fn: getMeApi,
onSuccess: () => {
this.fetchMeCities();
},
});
fetchMeCities = async () => {
const cities = await getMeCitiesApi();
runInAction(() => {
this.meCities = cities;
});
};
canWrite = (resource: string): boolean => {
const roles = this.me?.roles ?? [];
if (roles.includes("admin")) {
return true;
}
if (resource === "map") {
return roles.some((role) =>
["routes_rw", "stations_rw", "sights_rw"].includes(role),
);
}
return checkCanWrite(roles, resource);
};
hasRole = (role: string): boolean => {
const roles = this.me?.roles ?? [];
return roles.includes("admin") || roles.includes(role);
};
canRead = (resource: string): boolean => {
if (resource === "map") {
return this.canWrite("map");
}
return checkCanRead(this.me?.roles, resource);
};
canAccess = (permission: string): boolean => {
if (permission.includes("_")) {
return this.hasRole(permission);
}
return this.canRead(permission);
};
} }
export const authStore = new AuthStore(); export const authStore = new AuthStore();

View File

@@ -1,6 +1,5 @@
import { import {
authInstance, authInstance,
authStore,
cityStore, cityStore,
languageStore, languageStore,
languageInstance, languageInstance,
@@ -146,58 +145,19 @@ class CarrierStore {
}; };
}; };
private resolveCityName = (cityId: number, preferredLanguage: Language) => {
if (!cityId) {
return "";
}
const languages: Language[] = ["ru", "en", "zh"];
const fromCityStorePreferred = cityStore.cities[preferredLanguage].data.find(
(city) => city.id === cityId
)?.name;
if (fromCityStorePreferred) {
return fromCityStorePreferred;
}
for (const language of languages) {
const cityName = cityStore.cities[language].data.find(
(city) => city.id === cityId
)?.name;
if (cityName) {
return cityName;
}
}
const fromMePreferred = authStore.meCities[preferredLanguage].find(
(city) => city.city_id === cityId
)?.name;
if (fromMePreferred) {
return fromMePreferred;
}
for (const language of languages) {
const cityName = authStore.meCities[language].find(
(city) => city.city_id === cityId
)?.name;
if (cityName) {
return cityName;
}
}
return "";
};
createCarrier = async () => { createCarrier = async () => {
const { language } = languageStore; const { language } = languageStore;
const cityName = this.resolveCityName(this.createCarrierData.city_id, language); const cityName =
cityStore.cities[language].data.find(
(city) => city.id === this.createCarrierData.city_id
)?.name || "";
const payload = { const payload = {
full_name: (this.createCarrierData[language].full_name || "").trim(), full_name: this.createCarrierData[language].full_name,
short_name: (this.createCarrierData[language].short_name || "").trim(), short_name: this.createCarrierData[language].short_name,
city: cityName, city: cityName,
city_id: this.createCarrierData.city_id, city_id: this.createCarrierData.city_id,
slogan: (this.createCarrierData[language].slogan || "").trim(), slogan: this.createCarrierData[language].slogan,
...(this.createCarrierData.logo ...(this.createCarrierData.logo
? { logo: this.createCarrierData.logo } ? { logo: this.createCarrierData.logo }
: {}), : {}),
@@ -211,20 +171,17 @@ class CarrierStore {
this.carriers[language].data.push(response.data); this.carriers[language].data.push(response.data);
}); });
// Create translations for other languages
for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) { for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) {
const cityNameForLang = this.resolveCityName(
this.createCarrierData.city_id,
lang as Language
);
const patchPayload = { const patchPayload = {
// @ts-ignore // @ts-ignore
full_name: ((this.createCarrierData[lang as any].full_name as string) || "").trim(), full_name: this.createCarrierData[lang as any].full_name as string,
// @ts-ignore // @ts-ignore
short_name: ((this.createCarrierData[lang as any].short_name as string) || "").trim(), short_name: this.createCarrierData[lang as any].short_name as string,
city: cityNameForLang || cityName, city: cityName,
city_id: this.createCarrierData.city_id, city_id: this.createCarrierData.city_id,
// @ts-ignore // @ts-ignore
slogan: ((this.createCarrierData[lang as any].slogan as string) || "").trim(), slogan: this.createCarrierData[lang as any].slogan as string,
...(this.createCarrierData.logo ...(this.createCarrierData.logo
? { logo: this.createCarrierData.logo } ? { logo: this.createCarrierData.logo }
: {}), : {}),
@@ -317,13 +274,15 @@ class CarrierStore {
}; };
editCarrier = async (id: number) => { 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) { for (const lang of ["ru", "en", "zh"] as const) {
const cityName = this.resolveCityName(this.editCarrierData.city_id, lang);
const response = await languageInstance(lang).patch(`/carrier/${id}`, { const response = await languageInstance(lang).patch(`/carrier/${id}`, {
...this.editCarrierData[lang], ...this.editCarrierData[lang],
full_name: (this.editCarrierData[lang].full_name || "").trim(),
short_name: (this.editCarrierData[lang].short_name || "").trim(),
slogan: (this.editCarrierData[lang].slogan || "").trim(),
city: cityName, city: cityName,
city_id: this.editCarrierData.city_id, city_id: this.editCarrierData.city_id,
...(this.editCarrierData.logo ...(this.editCarrierData.logo

View File

@@ -171,7 +171,7 @@ class CityStore {
try { try {
// Create city in primary language // Create city in primary language
const cityPayload = { const cityPayload = {
name: name.trim(), name,
country: country:
countryStore.countries[language as keyof CashedCountries]?.data.find( countryStore.countries[language as keyof CashedCountries]?.data.find(
(c) => c.code === country_code (c) => c.code === country_code
@@ -200,7 +200,7 @@ class CityStore {
)?.name || ""; )?.name || "";
const patchPayload = { const patchPayload = {
name: (secondaryName || "").trim(), name: secondaryName || "",
country: countryName, country: countryName,
country_code: country_code || "", country_code: country_code || "",
...(arms ? { arms } : {}), ...(arms ? { arms } : {}),
@@ -285,7 +285,7 @@ class CityStore {
); );
await languageInstance(language as Language).patch(`/city/${code}`, { await languageInstance(language as Language).patch(`/city/${code}`, {
name: (name || "").trim(), name,
country: country?.name || "", country: country?.name || "",
country_code: country_code, country_code: country_code,
arms, arms,

View File

@@ -136,7 +136,7 @@ class CountryStore {
if (code && this.createCountryData[language].name) { if (code && this.createCountryData[language].name) {
await languageInstance(language as Language).post("/country", { await languageInstance(language as Language).post("/country", {
code: code, code: code,
name: name.trim(), name: name,
}); });
runInAction(() => { runInAction(() => {
@@ -156,7 +156,7 @@ class CountryStore {
await languageInstance(secondaryLanguage as Language).patch( await languageInstance(secondaryLanguage as Language).patch(
`/country/${code}`, `/country/${code}`,
{ {
name: name.trim(), name: name,
} }
); );
} }
@@ -212,7 +212,7 @@ class CountryStore {
if (name) { if (name) {
await languageInstance(language as Language).patch(`/country/${code}`, { await languageInstance(language as Language).patch(`/country/${code}`, {
name: name.trim(), name: name,
}); });
runInAction(() => { runInAction(() => {

View File

@@ -1,3 +1,4 @@
// @shared/stores/createSightStore.ts
import { import {
articlesStore, articlesStore,
Language, Language,
@@ -26,21 +27,21 @@ type SightLanguageInfo = {
}; };
type SightCommonInfo = { type SightCommonInfo = {
// id: number; // ID is 0 until created
city_id: number; city_id: number;
city: string; city: string;
latitude: number; latitude: number;
longitude: number; longitude: number;
is_default_icon: boolean;
thumbnail: string | null; thumbnail: string | null;
icon: string | null;
alt_icon: string | null;
watermark_lu: string | null; watermark_lu: string | null;
watermark_rd: string | null; watermark_rd: string | null;
left_article: number; left_article: number; // Can be 0 or a real ID, or placeholder like 10000000
preview_media: string | null; preview_media: string | null;
video_preview: string | null; video_preview: string | null;
}; };
// SightBaseInfo combines common info with language-specific info
// The 'id' for the sight itself will be assigned upon creation by the backend.
type SightBaseInfo = SightCommonInfo & { type SightBaseInfo = SightCommonInfo & {
[key in Language]: SightLanguageInfo; [key in Language]: SightLanguageInfo;
}; };
@@ -50,10 +51,7 @@ const initialSightState: SightBaseInfo = {
city: "", city: "",
latitude: 0, latitude: 0,
longitude: 0, longitude: 0,
is_default_icon: false,
thumbnail: null, thumbnail: null,
icon: null,
alt_icon: null,
watermark_lu: null, watermark_lu: null,
watermark_rd: null, watermark_rd: null,
left_article: 0, left_article: 0,
@@ -80,7 +78,7 @@ const initialSightState: SightBaseInfo = {
}; };
class CreateSightStore { class CreateSightStore {
sight: SightBaseInfo = JSON.parse(JSON.stringify(initialSightState)); sight: SightBaseInfo = JSON.parse(JSON.stringify(initialSightState)); // Deep copy for reset
uploadMediaOpen = false; uploadMediaOpen = false;
setUploadMediaOpen = (open: boolean) => { setUploadMediaOpen = (open: boolean) => {
@@ -95,7 +93,9 @@ class CreateSightStore {
makeAutoObservable(this); makeAutoObservable(this);
} }
// --- Right Article Management ---
createNewRightArticle = async () => { createNewRightArticle = async () => {
// Create article in DB for all languages
const articleRuData = { const articleRuData = {
heading: "Новый заголовок (RU)", heading: "Новый заголовок (RU)",
body: "Новый текст (RU)", body: "Новый текст (RU)",
@@ -125,7 +125,7 @@ class CreateSightStore {
}, },
}, },
}); });
const { id } = articleRes.data; const { id } = articleRes.data; // New article's ID
runInAction(() => { runInAction(() => {
const newArticleEntry = { id, media: [] }; const newArticleEntry = { id, media: [] };
@@ -133,7 +133,7 @@ class CreateSightStore {
this.sight.en.right.push({ ...newArticleEntry, ...articleEnData }); this.sight.en.right.push({ ...newArticleEntry, ...articleEnData });
this.sight.zh.right.push({ ...newArticleEntry, ...articleZhData }); this.sight.zh.right.push({ ...newArticleEntry, ...articleZhData });
}); });
return id; return id; // Return ID for potential immediate use
} catch (error) { } catch (error) {
console.error("Error creating new right article:", error); console.error("Error creating new right article:", error);
throw error; throw error;
@@ -169,7 +169,7 @@ class CreateSightStore {
}); });
}); });
return articleId; return articleId; // Return the linked article ID
} catch (error) { } catch (error) {
console.error("Error linking existing right article:", error); console.error("Error linking existing right article:", error);
throw error; throw error;
@@ -188,7 +188,9 @@ class CreateSightStore {
} }
}; };
// "Unlink" in create mode means just removing from the list to be created with the sight
unlinkRightAritcle = (articleId: number) => { unlinkRightAritcle = (articleId: number) => {
// Changed from 'unlinkRightAritcle' spelling
runInAction(() => { runInAction(() => {
this.sight.ru.right = this.sight.ru.right.filter( this.sight.ru.right = this.sight.ru.right.filter(
(article) => article.id !== articleId (article) => article.id !== articleId
@@ -200,12 +202,16 @@ class CreateSightStore {
(article) => article.id !== articleId (article) => article.id !== articleId
); );
}); });
// Note: If this article was created via createNewRightArticle, it still exists in the DB.
// Consider if an orphaned article should be deleted here or managed separately.
// For now, it just removes it from the list associated with *this specific sight creation process*.
}; };
deleteRightArticle = async (articleId: number) => { deleteRightArticle = async (articleId: number) => {
try { try {
await authInstance.delete(`/article/${articleId}`); await authInstance.delete(`/article/${articleId}`); // Delete from backend
runInAction(() => { runInAction(() => {
// Remove from local store for all languages
this.sight.ru.right = this.sight.ru.right.filter( this.sight.ru.right = this.sight.ru.right.filter(
(article) => article.id !== articleId (article) => article.id !== articleId
); );
@@ -222,11 +228,12 @@ class CreateSightStore {
} }
}; };
// --- Right Article Media Management ---
createLinkWithRightArticle = async (media: MediaItem, articleId: number) => { createLinkWithRightArticle = async (media: MediaItem, articleId: number) => {
try { try {
await authInstance.post(`/article/${articleId}/media`, { await authInstance.post(`/article/${articleId}/media`, {
media_id: media.id, media_id: media.id,
media_order: 1, media_order: 1, // Or calculate based on existing media.length + 1
}); });
runInAction(() => { runInAction(() => {
(["ru", "en", "zh"] as Language[]).forEach((lang) => { (["ru", "en", "zh"] as Language[]).forEach((lang) => {
@@ -235,7 +242,7 @@ class CreateSightStore {
); );
if (article) { if (article) {
if (!article.media) article.media = []; if (!article.media) article.media = [];
article.media.unshift(media); article.media.unshift(media); // Add to the beginning
} }
}); });
}); });
@@ -266,6 +273,7 @@ class CreateSightStore {
} }
}; };
// --- Left Article Management (largely unchanged from your provided store) ---
updateLeftInfo = (language: Language, heading: string, body: string) => { updateLeftInfo = (language: Language, heading: string, body: string) => {
this.sight[language].left.heading = heading; this.sight[language].left.heading = heading;
this.sight[language].left.body = body; this.sight[language].left.body = body;
@@ -315,7 +323,7 @@ class CreateSightStore {
deleteLeftArticle = async (articleId: number) => { deleteLeftArticle = async (articleId: number) => {
/* ... your existing logic ... */ /* ... your existing logic ... */
await authInstance.delete(`/article/${articleId}`); await authInstance.delete(`/article/${articleId}`);
// articlesStore.getArticles(languageStore.language); // If still neede
runInAction(() => { runInAction(() => {
articlesStore.articles.ru = articlesStore.articles.ru.filter( articlesStore.articles.ru = articlesStore.articles.ru.filter(
(article) => article.id !== articleId (article) => article.id !== articleId
@@ -336,6 +344,7 @@ class CreateSightStore {
const enName = (this.sight.en.name || "").trim(); const enName = (this.sight.en.name || "").trim();
const zhName = (this.sight.zh.name || "").trim(); const zhName = (this.sight.zh.name || "").trim();
// If all names are empty, skip defaulting and use empty headings
const hasAnyName = !!(ruName || enName || zhName); const hasAnyName = !!(ruName || enName || zhName);
const response = await languageInstance("ru").post("/article", { const response = await languageInstance("ru").post("/article", {
@@ -354,7 +363,7 @@ class CreateSightStore {
}); });
runInAction(() => { runInAction(() => {
this.sight.left_article = newLeftArticleId; this.sight.left_article = newLeftArticleId; // Store the actual ID
this.sight.ru.left = { this.sight.ru.left = {
heading: hasAnyName ? ruName : "", heading: hasAnyName ? ruName : "",
body: "", body: "",
@@ -393,8 +402,9 @@ class CreateSightStore {
return newLeftArticleId; return newLeftArticleId;
}; };
// Placeholder for a "new" unsaved left article
setNewLeftArticlePlaceholder = () => { setNewLeftArticlePlaceholder = () => {
this.sight.left_article = 10000000; this.sight.left_article = 10000000; // Special placeholder ID
this.sight.ru.left = { this.sight.ru.left = {
heading: "Новая левая статья", heading: "Новая левая статья",
body: "Заполните контентом", body: "Заполните контентом",
@@ -412,6 +422,7 @@ class CreateSightStore {
}; };
}; };
// --- Sight Preview Media ---
linkPreviewMedia = (mediaId: string) => { linkPreviewMedia = (mediaId: string) => {
this.sight.preview_media = mediaId; this.sight.preview_media = mediaId;
}; };
@@ -420,27 +431,32 @@ class CreateSightStore {
this.sight.preview_media = null; this.sight.preview_media = null;
}; };
// --- General Store Methods ---
clearCreateSight = () => { clearCreateSight = () => {
this.needLeaveAgree = false; this.needLeaveAgree = false;
this.sight = JSON.parse(JSON.stringify(initialSightState)); this.sight = JSON.parse(JSON.stringify(initialSightState)); // Reset to initial
}; };
updateSightInfo = ( updateSightInfo = (
content: Partial<SightLanguageInfo | SightCommonInfo>, content: Partial<SightLanguageInfo | SightCommonInfo>, // Corrected types
language?: Language language?: Language
) => { ) => {
this.needLeaveAgree = true; this.needLeaveAgree = true;
if (language) { if (language) {
this.sight[language] = { ...this.sight[language], ...content }; this.sight[language] = { ...this.sight[language], ...content };
} else { } else {
// Assuming content here is for SightCommonInfo
this.sight = { ...this.sight, ...(content as Partial<SightCommonInfo>) }; this.sight = { ...this.sight, ...(content as Partial<SightCommonInfo>) };
} }
}; };
// --- Main Sight Creation Logic ---
createSight = async (primaryLanguage: Language) => { createSight = async (primaryLanguage: Language) => {
let finalLeftArticleId = this.sight.left_article; let finalLeftArticleId = this.sight.left_article;
// 1. Handle Left Article (Create if new, or use existing ID)
if (this.sight.left_article === 10000000) { if (this.sight.left_article === 10000000) {
// Placeholder for new
const res = await languageInstance("ru").post("/article", { const res = await languageInstance("ru").post("/article", {
heading: this.sight.ru.left.heading, heading: this.sight.ru.left.heading,
body: this.sight.ru.left.body, body: this.sight.ru.left.body,
@@ -458,6 +474,7 @@ class CreateSightStore {
this.sight.left_article !== 0 && this.sight.left_article !== 0 &&
this.sight.left_article !== null this.sight.left_article !== null
) { ) {
// Existing, ensure it's up-to-date
await languageInstance("ru").patch( await languageInstance("ru").patch(
`/article/${this.sight.left_article}`, `/article/${this.sight.left_article}`,
{ heading: this.sight.ru.left.heading, body: this.sight.ru.left.body } { heading: this.sight.ru.left.heading, body: this.sight.ru.left.body }
@@ -471,7 +488,10 @@ class CreateSightStore {
{ heading: this.sight.zh.left.heading, body: this.sight.zh.left.body } { heading: this.sight.zh.left.heading, body: this.sight.zh.left.body }
); );
} }
// else: left_article is 0, so no left article
// 2. Right articles are already created in DB and their IDs are in this.sight[lang].right.
// We just need to update their content if changed before saving the sight.
for (const lang of ["ru", "en", "zh"] as Language[]) { for (const lang of ["ru", "en", "zh"] as Language[]) {
for (const article of this.sight[lang].right) { for (const article of this.sight[lang].right) {
if (article.id == 0 || article.id == null) { if (article.id == 0 || article.id == null) {
@@ -481,23 +501,22 @@ class CreateSightStore {
heading: article.heading, heading: article.heading,
body: article.body, body: article.body,
}); });
// Media for these articles are already linked via createLinkWithRightArticle
} }
} }
const rightArticleIdsForLink = this.sight[primaryLanguage].right.map( const rightArticleIdsForLink = this.sight[primaryLanguage].right.map(
(a) => a.id (a) => a.id
); );
// 3. Create Sight object in DB
const sightPayload = { const sightPayload = {
city_id: this.sight.city_id, city_id: this.sight.city_id,
city: this.sight.city, city: this.sight.city,
latitude: this.sight.latitude, latitude: this.sight.latitude,
longitude: this.sight.longitude, longitude: this.sight.longitude,
is_default_icon: this.sight.is_default_icon, name: this.sight[primaryLanguage].name,
name: (this.sight[primaryLanguage].name || "").trim(),
address: this.sight[primaryLanguage].address, address: this.sight[primaryLanguage].address,
thumbnail: this.sight.thumbnail, thumbnail: this.sight.thumbnail,
icon: this.sight.icon,
alt_icon: this.sight.alt_icon,
watermark_lu: this.sight.watermark_lu, watermark_lu: this.sight.watermark_lu,
watermark_rd: this.sight.watermark_rd, watermark_rd: this.sight.watermark_rd,
left_article: finalLeftArticleId === 0 ? null : finalLeftArticleId, left_article: finalLeftArticleId === 0 ? null : finalLeftArticleId,
@@ -509,8 +528,9 @@ class CreateSightStore {
"/sight", "/sight",
sightPayload sightPayload
); );
const newSightId = response.data.id; const newSightId = response.data.id; // ID of the newly created sight
// 4. Update other languages for the sight
const otherLanguages = (["ru", "en", "zh"] as Language[]).filter( const otherLanguages = (["ru", "en", "zh"] as Language[]).filter(
(l) => l !== primaryLanguage (l) => l !== primaryLanguage
); );
@@ -520,12 +540,9 @@ class CreateSightStore {
city: this.sight.city, city: this.sight.city,
latitude: this.sight.latitude, latitude: this.sight.latitude,
longitude: this.sight.longitude, longitude: this.sight.longitude,
is_default_icon: this.sight.is_default_icon, name: this.sight[lang].name,
name: (this.sight[lang].name || "").trim(),
address: this.sight[lang].address, address: this.sight[lang].address,
thumbnail: this.sight.thumbnail, thumbnail: this.sight.thumbnail,
icon: this.sight.icon,
alt_icon: this.sight.alt_icon,
watermark_lu: this.sight.watermark_lu, watermark_lu: this.sight.watermark_lu,
watermark_rd: this.sight.watermark_rd, watermark_rd: this.sight.watermark_rd,
left_article: finalLeftArticleId === 0 ? null : finalLeftArticleId, left_article: finalLeftArticleId === 0 ? null : finalLeftArticleId,
@@ -534,17 +551,20 @@ class CreateSightStore {
}); });
} }
// 5. Link Right Articles to the new Sight
for (let i = 0; i < rightArticleIdsForLink.length; i++) { for (let i = 0; i < rightArticleIdsForLink.length; i++) {
await authInstance.post(`/sight/${newSightId}/article`, { await authInstance.post(`/sight/${newSightId}/article`, {
article_id: rightArticleIdsForLink[i], article_id: rightArticleIdsForLink[i],
page_num: i + 1, page_num: i + 1, // Or other logic for page_num
}); });
} }
// Optionally: this.clearCreateSight(); // To reset form after successful creation
this.needLeaveAgree = false; this.needLeaveAgree = false;
return newSightId; return newSightId;
}; };
// --- Media Upload (Generic, used by dialogs) ---
uploadMedia = async ( uploadMedia = async (
filename: string, filename: string,
type: number, type: number,
@@ -563,12 +583,12 @@ class CreateSightStore {
this.fileToUpload = null; this.fileToUpload = null;
this.uploadMediaOpen = false; this.uploadMediaOpen = false;
}); });
mediaStore.getMedia(); mediaStore.getMedia(); // Refresh global media list
return { return {
id: response.data.id, id: response.data.id,
filename: filename, filename: filename, // Or response.data.filename if backend returns it
media_name: media_name, media_name: media_name, // Or response.data.media_name
media_type: type, media_type: type, // Or response.data.type
}; };
} catch (error) { } catch (error) {
console.error("Error uploading media:", error); console.error("Error uploading media:", error);
@@ -576,12 +596,15 @@ class CreateSightStore {
} }
}; };
// For Left Article Media
createLinkWithLeftArticle = async (media: MediaItem) => { createLinkWithLeftArticle = async (media: MediaItem) => {
if (!this.sight.left_article || this.sight.left_article === 10000000) { if (!this.sight.left_article || this.sight.left_article === 10000000) {
console.warn( console.warn(
"Left article not selected or is a placeholder. Cannot link media yet." "Left article not selected or is a placeholder. Cannot link media yet."
); );
// If it's a placeholder, we could store the media temporarily and link it after the article is created.
// For simplicity, we'll assume the article must exist.
// A more robust solution might involve creating the article first if it's a placeholder.
return; return;
} }
try { try {
@@ -640,7 +663,7 @@ class CreateSightStore {
this.sight.ru.right = sortArticles(this.sight.ru.right); this.sight.ru.right = sortArticles(this.sight.ru.right);
this.sight.en.right = sortArticles(this.sight.en.right); this.sight.en.right = sortArticles(this.sight.en.right);
this.sight.zh.right = sortArticles(this.sight.zh.right); this.sight.zh.right = sortArticles(this.sight.zh.right); // теперь zh тоже сортируется одинаково
this.needLeaveAgree = true; this.needLeaveAgree = true;
}; };

View File

@@ -1,3 +1,4 @@
// @shared/stores/editSightStore.ts
import { import {
articlesStore, articlesStore,
authInstance, authInstance,
@@ -30,10 +31,7 @@ export type SightCommonInfo = {
city: string; city: string;
latitude: number; latitude: number;
longitude: number; longitude: number;
is_default_icon: boolean;
thumbnail: string | null; thumbnail: string | null;
icon: string | null;
alt_icon: string | null;
watermark_lu: string | null; watermark_lu: string | null;
watermark_rd: string | null; watermark_rd: string | null;
left_article: number; left_article: number;
@@ -54,10 +52,7 @@ class EditSightStore {
city: "", city: "",
latitude: 0, latitude: 0,
longitude: 0, longitude: 0,
is_default_icon: false,
thumbnail: null, thumbnail: null,
icon: null,
alt_icon: null,
watermark_lu: null, watermark_lu: null,
watermark_rd: null, watermark_rd: null,
left_article: 0, left_article: 0,
@@ -92,35 +87,30 @@ class EditSightStore {
} }
hasLoadedCommon = false; hasLoadedCommon = false;
isLoading = false;
getSightInfo = async (id: number, language: Language) => { getSightInfo = async (id: number, language: Language) => {
this.isLoading = true; const response = await languageInstance(language).get(`/sight/${id}`);
try { const data = response.data;
const response = await languageInstance(language).get(`/sight/${id}`);
const data = response.data;
if (data.left_article != 0 && data.left_article != null) { if (data.left_article != 0 && data.left_article != null) {
await this.getLeftArticle(data.left_article); await this.getLeftArticle(data.left_article);
} }
runInAction(() => { runInAction(() => {
this.sight[language] = { // Обновляем языковую часть
...this.sight[language], this.sight[language] = {
...this.sight[language],
...data,
};
// Только при первом запросе обновляем общую часть
if (!this.hasLoadedCommon) {
this.sight.common = {
...this.sight.common,
...data, ...data,
}; };
this.hasLoadedCommon = true;
if (!this.hasLoadedCommon) { }
this.sight.common = { });
...this.sight.common,
...data,
};
this.hasLoadedCommon = true;
}
});
} finally {
this.isLoading = false;
}
}; };
updateLeftInfo = (language: Language, heading: string, body: string) => { updateLeftInfo = (language: Language, heading: string, body: string) => {
@@ -133,6 +123,7 @@ class EditSightStore {
let responseEn = await languageInstance("en").get(`/sight/${id}/article`); let responseEn = await languageInstance("en").get(`/sight/${id}/article`);
let responseZh = await languageInstance("zh").get(`/sight/${id}/article`); let responseZh = await languageInstance("zh").get(`/sight/${id}/article`);
// Create a map of article IDs to their media
const mediaMap = new Map(); const mediaMap = new Map();
for (const article of responseRu.data) { for (const article of responseRu.data) {
const responseMedia = await authInstance.get( const responseMedia = await authInstance.get(
@@ -141,6 +132,7 @@ class EditSightStore {
mediaMap.set(article.id, responseMedia.data); mediaMap.set(article.id, responseMedia.data);
} }
// Function to add media to articles
const addMediaToArticles = (articles: any[]) => { const addMediaToArticles = (articles: any[]) => {
return articles.map((article) => ({ return articles.map((article) => ({
...article, ...article,
@@ -181,8 +173,6 @@ class EditSightStore {
clearSightInfo = () => { clearSightInfo = () => {
this.needLeaveAgree = false; this.needLeaveAgree = false;
this.hasLoadedCommon = false;
this.isLoading = false;
this.sight = { this.sight = {
common: { common: {
id: 0, id: 0,
@@ -190,10 +180,7 @@ class EditSightStore {
city: "", city: "",
latitude: 0, latitude: 0,
longitude: 0, longitude: 0,
is_default_icon: false,
thumbnail: null, thumbnail: null,
icon: null,
alt_icon: null,
watermark_lu: null, watermark_lu: null,
watermark_rd: null, watermark_rd: null,
left_article: 0, left_article: 0,
@@ -299,9 +286,9 @@ class EditSightStore {
...this.sight.common, ...this.sight.common,
translations: { translations: {
name: { name: {
ru: (this.sight.ru.name || "").trim(), ru: this.sight.ru.name,
en: (this.sight.en.name || "").trim(), en: this.sight.en.name,
zh: (this.sight.zh.name || "").trim(), zh: this.sight.zh.name,
}, },
address: { address: {
ru: this.sight.ru.address, ru: this.sight.ru.address,
@@ -340,6 +327,28 @@ class EditSightStore {
articles: articleIdsInObject, articles: articleIdsInObject,
}); });
// await languageInstance("ru").patch(
// `/sight/${this.sight.common.left_article}/article`,
// {
// heading: this.sight.ru.left.heading,
// body: this.sight.ru.left.body,
// }
// );
// await languageInstance("en").patch(
// `/sight/${this.sight.common.left_article}/article`,
// {
// heading: this.sight.en.left.heading,
// body: this.sight.en.left.body,
// }
// );
// await languageInstance("zh").patch(
// `/sight/${this.sight.common.left_article}/article`,
// {
// heading: this.sight.zh.left.heading,
// body: this.sight.zh.left.body,
// }
// );
this.needLeaveAgree = false; this.needLeaveAgree = false;
}; };
@@ -497,19 +506,18 @@ class EditSightStore {
formData.append("media_name", media_name); formData.append("media_name", media_name);
} }
formData.append("type", type.toString()); formData.append("type", type.toString());
try {
const response = await authInstance.post(`/media`, formData); const response = await authInstance.post(`/media`, formData);
this.fileToUpload = null; this.fileToUpload = null;
this.uploadMediaOpen = false; this.uploadMediaOpen = false;
mediaStore.getMedia();
mediaStore.getMedia(); return {
id: response.data.id,
return { filename: filename,
id: response.data.id, media_name: media_name,
filename: filename, media_type: type,
media_name: media_name, };
media_type: type, } catch (error) {}
};
}; };
createLinkWithArticle = async (media: { createLinkWithArticle = async (media: {
@@ -581,7 +589,7 @@ class EditSightStore {
}); });
}); });
return article_id; return article_id; // Return the linked article ID
}; };
deleteRightArticleMedia = async (article_id: number, media_id: string) => { deleteRightArticleMedia = async (article_id: number, media_id: string) => {
@@ -687,7 +695,7 @@ class EditSightStore {
}); });
}); });
return id; return id; // Return the ID of the newly created article
}; };
createLinkWithRightArticle = async ( createLinkWithRightArticle = async (
@@ -762,7 +770,7 @@ class EditSightStore {
this.sight.ru.right = sortArticles(this.sight.ru.right); this.sight.ru.right = sortArticles(this.sight.ru.right);
this.sight.en.right = sortArticles(this.sight.en.right); this.sight.en.right = sortArticles(this.sight.en.right);
this.sight.zh.right = sortArticles(this.sight.zh.right); this.sight.zh.right = sortArticles(this.sight.zh.right); // теперь zh тоже сортируется одинаково
this.needLeaveAgree = true; this.needLeaveAgree = true;
}; };

View File

@@ -6,24 +6,10 @@ class LanguageStore {
constructor() { constructor() {
makeAutoObservable(this); makeAutoObservable(this);
if (typeof window !== "undefined") {
const storedLanguage = window.localStorage.getItem("appLanguage");
if (
storedLanguage &&
["ru", "en", "zh"].includes(storedLanguage.toLowerCase())
) {
this.language = storedLanguage.toLowerCase() as Language;
}
}
} }
setLanguage = (language: Language) => { setLanguage = (language: Language) => {
this.language = language; this.language = language;
if (typeof window !== "undefined") {
window.localStorage.setItem("appLanguage", language);
}
}; };
} }

View File

@@ -39,11 +39,12 @@ class MediaStore {
updateMedia = async (id: string, data: Partial<Media>) => { updateMedia = async (id: string, data: Partial<Media>) => {
const response = await authInstance.patch(`/media/${id}`, data); const response = await authInstance.patch(`/media/${id}`, data);
runInAction(() => { runInAction(() => {
// Update in media array
const index = this.media.findIndex((m) => m.id === id); const index = this.media.findIndex((m) => m.id === id);
if (index !== -1) { if (index !== -1) {
this.media[index] = { ...this.media[index], ...response.data }; this.media[index] = { ...this.media[index], ...response.data };
} }
// Update oneMedia if it's the current media being viewed
if (this.oneMedia?.id === id) { if (this.oneMedia?.id === id) {
this.oneMedia = { ...this.oneMedia, ...response.data }; this.oneMedia = { ...this.oneMedia, ...response.data };
} }
@@ -63,11 +64,12 @@ class MediaStore {
}); });
runInAction(() => { runInAction(() => {
// Update in media array
const index = this.media.findIndex((m) => m.id === id); const index = this.media.findIndex((m) => m.id === id);
if (index !== -1) { if (index !== -1) {
this.media[index] = { ...this.media[index], ...response.data }; this.media[index] = { ...this.media[index], ...response.data };
} }
// Update oneMedia if it's the current media being viewed
if (this.oneMedia?.id === id) { if (this.oneMedia?.id === id) {
this.oneMedia = { ...this.oneMedia, ...response.data }; this.oneMedia = { ...this.oneMedia, ...response.data };
} }

View File

@@ -15,6 +15,7 @@ class ModelLoadingStore {
makeAutoObservable(this); makeAutoObservable(this);
} }
// Начать отслеживание загрузки модели
startLoading(modelId: string) { startLoading(modelId: string) {
this.loadingStates.set(modelId, { this.loadingStates.set(modelId, {
isLoading: true, isLoading: true,
@@ -24,6 +25,7 @@ class ModelLoadingStore {
}); });
} }
// Обновить прогресс загрузки
updateProgress(modelId: string, progress: number) { updateProgress(modelId: string, progress: number) {
const state = this.loadingStates.get(modelId); const state = this.loadingStates.get(modelId);
if (state) { if (state) {
@@ -31,6 +33,7 @@ class ModelLoadingStore {
} }
} }
// Завершить загрузку модели
finishLoading(modelId: string) { finishLoading(modelId: string) {
const state = this.loadingStates.get(modelId); const state = this.loadingStates.get(modelId);
if (state) { if (state) {
@@ -39,10 +42,12 @@ class ModelLoadingStore {
} }
} }
// Остановить загрузку (в случае ошибки)
stopLoading(modelId: string) { stopLoading(modelId: string) {
this.loadingStates.delete(modelId); this.loadingStates.delete(modelId);
} }
// Обработать ошибку загрузки
handleError(modelId: string, error?: string) { handleError(modelId: string, error?: string) {
const state = this.loadingStates.get(modelId); const state = this.loadingStates.get(modelId);
if (state) { if (state) {
@@ -51,22 +56,26 @@ class ModelLoadingStore {
} }
} }
// Получить состояние загрузки для конкретной модели
getLoadingState(modelId: string): ModelLoadingState | undefined { getLoadingState(modelId: string): ModelLoadingState | undefined {
return this.loadingStates.get(modelId); return this.loadingStates.get(modelId);
} }
// Проверить, загружается ли какая-либо модель
get isAnyModelLoading(): boolean { get isAnyModelLoading(): boolean {
return Array.from(this.loadingStates.values()).some( return Array.from(this.loadingStates.values()).some(
(state) => state.isLoading (state) => state.isLoading
); );
} }
// Получить все загружающиеся модели
get loadingModels(): ModelLoadingState[] { get loadingModels(): ModelLoadingState[] {
return Array.from(this.loadingStates.values()).filter( return Array.from(this.loadingStates.values()).filter(
(state) => state.isLoading (state) => state.isLoading
); );
} }
// Получить общий прогресс всех загружающихся моделей
get overallProgress(): number { get overallProgress(): number {
const loadingModels = this.loadingModels; const loadingModels = this.loadingModels;
if (loadingModels.length === 0) return 100; if (loadingModels.length === 0) return 100;
@@ -78,10 +87,12 @@ class ModelLoadingStore {
return Math.round(totalProgress / loadingModels.length); return Math.round(totalProgress / loadingModels.length);
} }
// Проверить, заблокировано ли сохранение (есть ли загружающиеся модели)
get isSaveBlocked(): boolean { get isSaveBlocked(): boolean {
return this.isAnyModelLoading; return this.isAnyModelLoading;
} }
// Очистить все состояния загрузки
clearAll() { clearAll() {
this.loadingStates.clear(); this.loadingStates.clear();
} }

View File

@@ -1,10 +1,5 @@
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
import { import { authInstance } from "@shared";
authInstance,
languageInstance,
languageStore,
isMediaIdEmpty,
} from "@shared";
export type Route = { export type Route = {
route_name: string; route_name: string;
@@ -14,7 +9,6 @@ export type Route = {
center_longitude: number; center_longitude: number;
governor_appeal: number; governor_appeal: number;
id: number; id: number;
icon: string;
path: number[][]; path: number[][];
rotate: number; rotate: number;
route_direction: boolean; route_direction: boolean;
@@ -95,43 +89,11 @@ class RouteStore {
}; };
saveRouteStations = async (routeId: number, stationId: number) => { saveRouteStations = async (routeId: number, stationId: number) => {
const { language } = languageStore; await authInstance.patch(`/route/${routeId}/station`, {
...this.routeStations[routeId]?.find(
// Получаем актуальные данные станции с сервера (station) => station.id === stationId
const stationResponse = await languageInstance(language).get( ),
`/station/${stationId}`
);
const fullStationData = stationResponse.data;
// Получаем отредактированные данные из локального кеша
const editedStationData = this.routeStations[routeId]?.find(
(station) => station.id === stationId
);
// Формируем данные для отправки: все поля станции + отредактированные offset
const dataToSend: any = {
station_id: stationId, station_id: stationId,
offset_x: editedStationData?.offset_x ?? fullStationData.offset_x ?? 0,
offset_y: editedStationData?.offset_y ?? fullStationData.offset_y ?? 0,
align: editedStationData?.align ?? fullStationData.align ?? 0,
transfers: fullStationData.transfers || {},
};
await authInstance.patch(`/route/${routeId}/station`, dataToSend);
// Обновляем локальный кеш после успешного сохранения
runInAction(() => {
if (this.routeStations[routeId]) {
this.routeStations[routeId] = this.routeStations[routeId].map(
(station) =>
station.id === stationId
? {
...station,
...dataToSend,
}
: station
);
}
}); });
}; };
@@ -143,7 +105,6 @@ class RouteStore {
center_longitude: "", center_longitude: "",
governor_appeal: 0, governor_appeal: 0,
id: 0, id: 0,
icon: "",
path: [] as number[][], path: [] as number[][],
rotate: 0, rotate: 0,
route_direction: false, route_direction: false,
@@ -159,30 +120,14 @@ class RouteStore {
}; };
editRoute = async (id: number) => { editRoute = async (id: number) => {
if ( if (!this.editRouteData.video_preview) {
!this.editRouteData.video_preview ||
isMediaIdEmpty(this.editRouteData.video_preview)
) {
delete this.editRouteData.video_preview; delete this.editRouteData.video_preview;
} }
if (!this.editRouteData.icon || isMediaIdEmpty(this.editRouteData.icon)) { const response = await authInstance.patch(`/route/${id}`, {
delete (this.editRouteData as any).icon;
}
const dataToSend: any = {
...this.editRouteData, ...this.editRouteData,
route_name: (this.editRouteData.route_name || "").trim(),
route_number: (this.editRouteData.route_number || "").trim(),
route_sys_number: (this.editRouteData.route_sys_number || "").trim(),
center_latitude: parseFloat(this.editRouteData.center_latitude), center_latitude: parseFloat(this.editRouteData.center_latitude),
center_longitude: parseFloat(this.editRouteData.center_longitude), center_longitude: parseFloat(this.editRouteData.center_longitude),
}; });
if (
this.editRouteData.governor_appeal === 0 ||
!this.editRouteData.governor_appeal
) {
dataToSend.governor_appeal = null;
}
const response = await authInstance.patch(`/route/${id}`, dataToSend);
runInAction(() => { runInAction(() => {
this.route[id] = response.data; this.route[id] = response.data;

View File

@@ -23,10 +23,7 @@ export type Sight = {
address: string; address: string;
latitude: number; latitude: number;
longitude: number; longitude: number;
is_default_icon: boolean;
thumbnail: string | null; thumbnail: string | null;
icon: string | null;
alt_icon: string | null;
watermark_lu: string | null; watermark_lu: string | null;
watermark_rd: string | null; watermark_rd: string | null;
left_article: number; left_article: number;
@@ -61,6 +58,41 @@ class SightsStore {
}); });
}; };
// getSight = async (id: number) => {
// const response = await authInstance.get(`/sight/${id}`);
// runInAction(() => {
// this.sight = response.data;
// editSightStore.sightInfo = {
// ...editSightStore.sightInfo,
// id: response.data.id,
// city_id: response.data.city_id,
// city: response.data.city,
// latitude: response.data.latitude,
// longitude: response.data.longitude,
// thumbnail: response.data.thumbnail,
// watermark_lu: response.data.watermark_lu,
// watermark_rd: response.data.watermark_rd,
// left_article: response.data.left_article,
// preview_media: response.data.preview_media,
// video_preview: response.data.video_preview,
// [languageStore.language]: {
// info: {
// name: response.data.name,
// address: response.data.address,
// },
// left: {
// heading: articlesStore.articles[languageStore.language].find(
// (article) => article.id === response.data.left_article
// )?.heading,
// body: articlesStore.articles[languageStore.language].find(
// },
// },
// };
// });
// };
createSightAction = async ( createSightAction = async (
city: number, city: number,
coordinates: { latitude: number; longitude: number } coordinates: { latitude: number; longitude: number }
@@ -177,10 +209,7 @@ class SightsStore {
city_id: this.sight?.city_id, city_id: this.sight?.city_id,
latitude: this.sight?.latitude, latitude: this.sight?.latitude,
longitude: this.sight?.longitude, longitude: this.sight?.longitude,
is_default_icon: this.sight?.is_default_icon,
thumbnail: this.sight?.thumbnail, thumbnail: this.sight?.thumbnail,
icon: this.sight?.icon,
alt_icon: this.sight?.alt_icon,
watermark_lu: this.sight?.watermark_lu, watermark_lu: this.sight?.watermark_lu,
watermark_rd: this.sight?.watermark_rd, watermark_rd: this.sight?.watermark_rd,
left_article: this.sight?.left_article, left_article: this.sight?.left_article,

View File

@@ -1,6 +1,8 @@
import { authInstance } from "@shared"; import { authInstance } from "@shared";
import { v4 as uuidv4 } from "uuid";
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
// Импорт функции сброса кешей карты
// import { clearMapCaches } from "../../pages/MapPage";
import { import {
articlesStore, articlesStore,
cityStore, cityStore,
@@ -25,24 +27,17 @@ type Snapshot = {
CreationTime: string; CreationTime: string;
}; };
type SnapshotStatus = {
ID: string;
Status: string;
Progress: number;
Error: string;
};
class SnapshotStore { class SnapshotStore {
snapshots: Snapshot[] = []; snapshots: Snapshot[] = [];
snapshot: Snapshot | null = null; snapshot: Snapshot | null = null;
lastRequestId: string | null = null;
snapshotStatus: SnapshotStatus | null = null;
constructor() { constructor() {
makeAutoObservable(this); makeAutoObservable(this);
} }
// Функция для сброса всех кешей в приложении
private clearAllCaches = () => { private clearAllCaches = () => {
// Сброс кешей статей
articlesStore.articleList = { articlesStore.articleList = {
ru: { data: [], loaded: false }, ru: { data: [], loaded: false },
en: { data: [], loaded: false }, en: { data: [], loaded: false },
@@ -52,6 +47,7 @@ class SnapshotStore {
articlesStore.articleData = null; articlesStore.articleData = null;
articlesStore.articleMedia = null; articlesStore.articleMedia = null;
// Сброс кешей городов
cityStore.cities = { cityStore.cities = {
ru: { data: [], loaded: false }, ru: { data: [], loaded: false },
en: { data: [], loaded: false }, en: { data: [], loaded: false },
@@ -60,18 +56,21 @@ class SnapshotStore {
cityStore.ruCities = { data: [], loaded: false }; cityStore.ruCities = { data: [], loaded: false };
cityStore.city = {}; cityStore.city = {};
// Сброс кешей стран
countryStore.countries = { countryStore.countries = {
ru: { data: [], loaded: false }, ru: { data: [], loaded: false },
en: { data: [], loaded: false }, en: { data: [], loaded: false },
zh: { data: [], loaded: false }, zh: { data: [], loaded: false },
}; };
// Сброс кешей перевозчиков
carrierStore.carriers = { carrierStore.carriers = {
ru: { data: [], loaded: false }, ru: { data: [], loaded: false },
en: { data: [], loaded: false }, en: { data: [], loaded: false },
zh: { data: [], loaded: false }, zh: { data: [], loaded: false },
}; };
// Сброс кешей станций
stationsStore.stationLists = { stationsStore.stationLists = {
ru: { data: [], loaded: false }, ru: { data: [], loaded: false },
en: { data: [], loaded: false }, en: { data: [], loaded: false },
@@ -79,28 +78,31 @@ class SnapshotStore {
}; };
stationsStore.stationPreview = {}; stationsStore.stationPreview = {};
// Сброс кешей достопримечательностей
sightsStore.sights = []; sightsStore.sights = [];
sightsStore.sight = null; sightsStore.sight = null;
// Сброс кешей маршрутов
routeStore.routes = { data: [], loaded: false }; routeStore.routes = { data: [], loaded: false };
// Сброс кешей транспорта
vehicleStore.vehicles = { data: [], loaded: false }; vehicleStore.vehicles = { data: [], loaded: false };
// Сброс кешей пользователей
userStore.users = { data: [], loaded: false }; userStore.users = { data: [], loaded: false };
// Сброс кешей медиа
mediaStore.media = []; mediaStore.media = [];
mediaStore.oneMedia = null; mediaStore.oneMedia = null;
// Сброс кешей создания и редактирования достопримечательностей
createSightStore.sight = JSON.parse( createSightStore.sight = JSON.parse(
JSON.stringify({ JSON.stringify({
city_id: 0, city_id: 0,
city: "", city: "",
latitude: 0, latitude: 0,
longitude: 0, longitude: 0,
is_default_icon: false,
thumbnail: null, thumbnail: null,
icon: null,
alt_icon: null,
watermark_lu: null, watermark_lu: null,
watermark_rd: null, watermark_rd: null,
left_article: 0, left_article: 0,
@@ -137,10 +139,7 @@ class SnapshotStore {
city: "", city: "",
latitude: 0, latitude: 0,
longitude: 0, longitude: 0,
is_default_icon: false,
thumbnail: null, thumbnail: null,
icon: null,
alt_icon: null,
watermark_lu: null, watermark_lu: null,
watermark_rd: null, watermark_rd: null,
left_article: 0, left_article: 0,
@@ -174,21 +173,26 @@ class SnapshotStore {
editSightStore.fileToUpload = null; editSightStore.fileToUpload = null;
editSightStore.needLeaveAgree = false; editSightStore.needLeaveAgree = false;
// Сброс кешей устройств
devicesStore.devices = []; devicesStore.devices = [];
devicesStore.uuid = null; devicesStore.uuid = null;
devicesStore.sendSnapshotModalOpen = false; devicesStore.sendSnapshotModalOpen = false;
// Сброс кешей авторизации (кроме токена)
authStore.payload = null; authStore.payload = null;
authStore.error = null; authStore.error = null;
authStore.isLoading = false; authStore.isLoading = false;
// Сброс кешей карты (если они загружены)
try { try {
// Сбрасываем кеши mapStore если он доступен
if (typeof window !== "undefined" && (window as any).mapStore) { if (typeof window !== "undefined" && (window as any).mapStore) {
(window as any).mapStore.routes = []; (window as any).mapStore.routes = [];
(window as any).mapStore.stations = []; (window as any).mapStore.stations = [];
(window as any).mapStore.sights = []; (window as any).mapStore.sights = [];
} }
// Сбрасываем кеши MapService если он доступен
if (typeof window !== "undefined" && (window as any).mapServiceInstance) { if (typeof window !== "undefined" && (window as any).mapServiceInstance) {
(window as any).mapServiceInstance.clearCaches(); (window as any).mapServiceInstance.clearCaches();
} }
@@ -196,6 +200,7 @@ class SnapshotStore {
console.warn("Не удалось сбросить кеши карты:", error); console.warn("Не удалось сбросить кеши карты:", error);
} }
// Сброс localStorage кешей (кроме токена авторизации)
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
const rememberedEmail = localStorage.getItem("rememberedEmail"); const rememberedEmail = localStorage.getItem("rememberedEmail");
const rememberedPassword = localStorage.getItem("rememberedPassword"); const rememberedPassword = localStorage.getItem("rememberedPassword");
@@ -203,12 +208,14 @@ class SnapshotStore {
localStorage.clear(); localStorage.clear();
sessionStorage.clear(); sessionStorage.clear();
// Восстанавливаем важные данные
if (token) localStorage.setItem("token", token); if (token) localStorage.setItem("token", token);
if (rememberedEmail) if (rememberedEmail)
localStorage.setItem("rememberedEmail", rememberedEmail); localStorage.setItem("rememberedEmail", rememberedEmail);
if (rememberedPassword) if (rememberedPassword)
localStorage.setItem("rememberedPassword", rememberedPassword); localStorage.setItem("rememberedPassword", rememberedPassword);
// Сброс кешей карты (если они есть)
const mapPositionKey = "mapPosition"; const mapPositionKey = "mapPosition";
const activeSectionKey = "mapActiveSection"; const activeSectionKey = "mapActiveSection";
if (localStorage.getItem(mapPositionKey)) { if (localStorage.getItem(mapPositionKey)) {
@@ -218,6 +225,7 @@ class SnapshotStore {
localStorage.removeItem(activeSectionKey); localStorage.removeItem(activeSectionKey);
} }
// Попытка очистить кеш браузера (если поддерживается)
if ("caches" in window) { if ("caches" in window) {
try { try {
caches.keys().then((cacheNames) => { caches.keys().then((cacheNames) => {
@@ -232,6 +240,7 @@ class SnapshotStore {
} }
} }
// Попытка очистить IndexedDB (если поддерживается)
if ("indexedDB" in window) { if ("indexedDB" in window) {
try { try {
indexedDB.databases().then((databases) => { indexedDB.databases().then((databases) => {
@@ -275,29 +284,15 @@ class SnapshotStore {
}; };
restoreSnapshot = async (id: string) => { restoreSnapshot = async (id: string) => {
// Сначала сбрасываем все кеши
this.clearAllCaches(); this.clearAllCaches();
// Затем восстанавливаем снапшот
await authInstance.post(`/snapshots/${id}/restore`); await authInstance.post(`/snapshots/${id}/restore`);
}; };
createSnapshot = async (name: string) => { createSnapshot = async (name: string) => {
this.lastRequestId = uuidv4(); await authInstance.post(`/snapshots`, { name });
const response = await authInstance.post(
`/snapshots`,
{ name: name.trim() },
{ headers: { "X-Request-ID": this.lastRequestId } }
);
return response.data.ID;
};
getSnapshotStatus = async (id: string) => {
const response = await authInstance.get(`/snapshots/status/${id}`);
runInAction(() => {
this.snapshotStatus = response.data;
});
}; };
} }

View File

@@ -1,6 +1,5 @@
import { authInstance, languageInstance, languageStore } from "@shared"; import { authInstance, languageInstance, languageStore } from "@shared";
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
import { routeStore } from "../RouteStore";
type Language = "ru" | "en" | "zh"; type Language = "ru" | "en" | "zh";
@@ -8,11 +7,12 @@ type StationLanguageData = {
name: string; name: string;
system_name: string; system_name: string;
address: string; address: string;
loaded: boolean; loaded: boolean; // Indicates if this language's data has been loaded/modified
}; };
type StationCommonData = { type StationCommonData = {
city_id: number; city_id: number;
direction: boolean;
description: string; description: string;
icon: string; icon: string;
latitude: number; latitude: number;
@@ -43,6 +43,7 @@ type Station = {
city: string; city: string;
city_id: number; city_id: number;
description: string; description: string;
direction: boolean;
icon: string; icon: string;
latitude: number; latitude: number;
longitude: number; longitude: number;
@@ -91,6 +92,7 @@ class StationsStore {
}, },
}; };
// This will store the full station data, keyed by ID and then by language
stationPreview: Record< stationPreview: Record<
string, string,
Record<string, { loaded: boolean; data: Station }> Record<string, { loaded: boolean; data: Station }>
@@ -121,6 +123,7 @@ class StationsStore {
common: { common: {
city: "", city: "",
city_id: 0, city_id: 0,
direction: false,
description: "", description: "",
icon: "", icon: "",
latitude: 0, latitude: 0,
@@ -166,6 +169,7 @@ class StationsStore {
common: { common: {
city: "", city: "",
city_id: 0, city_id: 0,
direction: false,
description: "", description: "",
icon: "", icon: "",
latitude: 0, latitude: 0,
@@ -248,6 +252,7 @@ class StationsStore {
common: { common: {
city: ruResponse.data.city, city: ruResponse.data.city,
city_id: ruResponse.data.city_id, city_id: ruResponse.data.city_id,
direction: ruResponse.data.direction,
description: ruResponse.data.description, description: ruResponse.data.description,
icon: ruResponse.data.icon, icon: ruResponse.data.icon,
latitude: ruResponse.data.latitude, latitude: ruResponse.data.latitude,
@@ -259,6 +264,7 @@ class StationsStore {
}; };
}; };
// Sets language-specific station data
setLanguageEditStationData = ( setLanguageEditStationData = (
language: Language, language: Language,
data: Partial<StationLanguageData> data: Partial<StationLanguageData>
@@ -272,6 +278,7 @@ class StationsStore {
editStation = async (id: number) => { editStation = async (id: number) => {
const commonDataPayload = { const commonDataPayload = {
city_id: this.editStationData.common.city_id, city_id: this.editStationData.common.city_id,
direction: this.editStationData.common.direction,
icon: this.editStationData.common.icon, icon: this.editStationData.common.icon,
latitude: this.editStationData.common.latitude, latitude: this.editStationData.common.latitude,
longitude: this.editStationData.common.longitude, longitude: this.editStationData.common.longitude,
@@ -287,15 +294,16 @@ class StationsStore {
const response = await languageInstance(language).patch( const response = await languageInstance(language).patch(
`/station/${id}`, `/station/${id}`,
{ {
name: (name || "").trim(), name: name || "",
system_name: (name || "").trim(), system_name: name || "", // system_name is often derived from name
description: (description || "").trim(), description: description || "",
address: (address || "").trim(), address: address || "",
...commonDataPayload, ...commonDataPayload,
} }
); );
runInAction(() => { runInAction(() => {
// Update the cached preview data and station lists after successful patch
if (this.stationPreview[id]) { if (this.stationPreview[id]) {
this.stationPreview[id][language] = { this.stationPreview[id][language] = {
loaded: true, loaded: true,
@@ -335,11 +343,11 @@ class StationsStore {
runInAction(() => { runInAction(() => {
this.stations = this.stations.filter((station) => station.id !== id); this.stations = this.stations.filter((station) => station.id !== id);
// Also clear from stationPreview cache
if (this.stationPreview[id]) { if (this.stationPreview[id]) {
delete this.stationPreview[id]; delete this.stationPreview[id];
} }
// Clear from stationLists as well for all languages
for (const lang of ["ru", "en", "zh"] as const) { for (const lang of ["ru", "en", "zh"] as const) {
if (this.stationLists[lang].data) { if (this.stationLists[lang].data) {
this.stationLists[lang].data = this.stationLists[lang].data.filter( this.stationLists[lang].data = this.stationLists[lang].data.filter(
@@ -399,6 +407,7 @@ class StationsStore {
const { language } = languageStore; const { language } = languageStore;
let commonDataPayload: Partial<StationCommonData> = { let commonDataPayload: Partial<StationCommonData> = {
city_id: this.createStationData.common.city_id, city_id: this.createStationData.common.city_id,
direction: this.createStationData.common.direction,
icon: this.createStationData.common.icon, icon: this.createStationData.common.icon,
latitude: this.createStationData.common.latitude, latitude: this.createStationData.common.latitude,
longitude: this.createStationData.common.longitude, longitude: this.createStationData.common.longitude,
@@ -412,13 +421,14 @@ class StationsStore {
delete commonDataPayload.icon; delete commonDataPayload.icon;
} }
// First create station in Russian
const { name, address } = this.createStationData[language]; const { name, address } = this.createStationData[language];
const description = this.createStationData.common.description; const description = this.createStationData.common.description;
const response = await languageInstance(language).post("/station", { const response = await languageInstance(language).post("/station", {
name: (name || "").trim(), name: name || "",
system_name: (name || "").trim(), system_name: name || "", // system_name is often derived from name
description: (description || "").trim(), description: description || "",
address: (address || "").trim(), address: address || "",
...commonDataPayload, ...commonDataPayload,
}); });
@@ -428,6 +438,7 @@ class StationsStore {
const stationId = response.data.id; const stationId = response.data.id;
// Then update for other languages
for (const lang of ["ru", "en", "zh"].filter( for (const lang of ["ru", "en", "zh"].filter(
(lang) => lang !== language (lang) => lang !== language
) as Language[]) { ) as Language[]) {
@@ -436,10 +447,10 @@ class StationsStore {
const response = await languageInstance(lang).patch( const response = await languageInstance(lang).patch(
`/station/${stationId}`, `/station/${stationId}`,
{ {
name: (name || "").trim(), name: name || "",
system_name: (name || "").trim(), system_name: name || "", // system_name is often derived from name
description: (description || "").trim(), description: description || "",
address: (address || "").trim(), address: address || "",
...commonDataPayload, ...commonDataPayload,
} }
); );
@@ -472,6 +483,7 @@ class StationsStore {
common: { common: {
city: "", city: "",
city_id: 0, city_id: 0,
direction: false,
icon: "", icon: "",
latitude: 0, latitude: 0,
description: "", description: "",
@@ -495,6 +507,7 @@ class StationsStore {
return response.data; return response.data;
}; };
// Reset editStationData when navigating away or after saving
resetEditStationData = () => { resetEditStationData = () => {
this.editStationData = { this.editStationData = {
ru: { ru: {
@@ -518,6 +531,7 @@ class StationsStore {
common: { common: {
city: "", city: "",
city_id: 0, city_id: 0,
direction: false,
description: "", description: "",
icon: "", icon: "",
latitude: 0, latitude: 0,
@@ -538,98 +552,6 @@ class StationsStore {
}, },
}; };
}; };
updateStationTransfers = async (
id: number,
transfers: {
bus: string;
metro_blue: string;
metro_green: string;
metro_orange: string;
metro_purple: string;
metro_red: string;
train: string;
tram: string;
trolleybus: string;
}
) => {
const { language } = languageStore;
// Получаем данные станции для текущего языка
const response = await languageInstance(language).get(`/station/${id}`);
const stationData = response.data as Station;
if (!stationData) {
throw new Error("Station not found");
}
// Формируем commonDataPayload как в editStation, с обновленными transfers
const commonDataPayload = {
city_id: stationData.city_id,
latitude: stationData.latitude,
longitude: stationData.longitude,
offset_x: stationData.offset_x,
offset_y: stationData.offset_y,
transfers: transfers,
city: stationData.city || "",
};
// Отправляем один PATCH запрос, так как пересадки общие для всех языков
const patchResponse = await languageInstance(language).patch(
`/station/${id}`,
{
name: stationData.name || "",
system_name: stationData.system_name || "",
description: stationData.description || "",
address: stationData.address || "",
...commonDataPayload,
}
);
// Обновляем данные для всех языков в локальном состоянии
runInAction(() => {
const updatedTransfers = patchResponse.data.transfers;
for (const lang of ["ru", "en", "zh"] as const) {
if (this.stationPreview[id]) {
this.stationPreview[id][lang] = {
...this.stationPreview[id][lang],
data: {
...this.stationPreview[id][lang].data,
transfers: updatedTransfers,
},
};
}
if (this.stationLists[lang].data) {
this.stationLists[lang].data = this.stationLists[lang].data.map(
(station: Station) =>
station.id === id
? {
...station,
transfers: updatedTransfers,
}
: station
);
}
}
// Обновляем пересадки в RouteStore.routeStations для всех маршрутов
if (routeStore?.routeStations) {
for (const routeId in routeStore.routeStations) {
routeStore.routeStations[routeId] = routeStore.routeStations[
routeId
].map((station: any) =>
station.id === id
? {
...station,
transfers: updatedTransfers,
}
: station
);
}
}
});
};
} }
export const stationsStore = new StationsStore(); export const stationsStore = new StationsStore();

View File

@@ -1,14 +0,0 @@
import { authInstance } from "@shared";
import { User } from "./index";
export const addUserCityApi = async (
{ id, city_ids }: { id: number; city_ids: number[] },
signal?: AbortSignal,
): Promise<User> => {
const response = await authInstance.patch(
`/user/${id}/city`,
{ city_ids },
{ signal },
);
return response.data as User;
};

View File

@@ -1,11 +1,5 @@
import { authInstance, mobxFetch } from "@shared"; import { authInstance } from "@shared";
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
import { addUserCityApi } from "./api";
export type UserCity = {
city_id: number;
name: string;
};
export type User = { export type User = {
id: number; id: number;
@@ -13,9 +7,6 @@ export type User = {
is_admin: boolean; is_admin: boolean;
name: string; name: string;
password?: string; password?: string;
icon?: string;
roles?: string[];
cities?: UserCity[];
}; };
class UserStore { class UserStore {
@@ -66,25 +57,15 @@ class UserStore {
email: "", email: "",
password: "", password: "",
is_admin: false, is_admin: false,
icon: "",
roles: ["articles_ro", "articles_rw", "media_ro", "media_rw"],
}; };
setCreateUserData = ( setCreateUserData = (
name: string, name: string,
email: string, email: string,
password: string, password: string,
is_admin: boolean, is_admin: boolean
icon?: string,
) => { ) => {
this.createUserData = { this.createUserData = { name, email, password, is_admin };
...this.createUserData,
name,
email,
password,
is_admin,
icon: icon ?? "",
};
}; };
createUser = async () => { createUser = async () => {
@@ -92,19 +73,7 @@ class UserStore {
if (this.users.data.length > 0) { if (this.users.data.length > 0) {
id = this.users.data[this.users.data.length - 1].id + 1; id = this.users.data[this.users.data.length - 1].id + 1;
} }
const payload: Partial<User> = { const response = await authInstance.post("/user", this.createUserData);
...this.createUserData,
name: (this.createUserData.name || "").trim(),
email: (this.createUserData.email || "").trim(),
};
const baseRoles = new Set<string>(payload.roles ?? []);
baseRoles.add("articles_ro");
baseRoles.add("articles_rw");
baseRoles.add("media_ro");
baseRoles.add("media_rw");
payload.roles = Array.from(baseRoles);
if (!payload.icon) delete payload.icon;
const response = await authInstance.post("/user", payload);
runInAction(() => { runInAction(() => {
this.users.data.push({ this.users.data.push({
@@ -119,75 +88,27 @@ class UserStore {
email: "", email: "",
password: "", password: "",
is_admin: false, is_admin: false,
icon: "",
roles: [],
}; };
setEditUserData = ( setEditUserData = (
name: string, name: string,
email: string, email: string,
password: string, password: string,
is_admin: boolean, is_admin: boolean
icon?: string,
) => { ) => {
this.editUserData = { this.editUserData = { name, email, password, is_admin };
...this.editUserData,
name,
email,
password,
is_admin,
icon: icon ?? "",
};
};
setEditUserRoles = (roles: string[]) => {
this.editUserData = { ...this.editUserData, roles };
}; };
editUser = async (id: number) => { editUser = async (id: number) => {
const payload = { const response = await authInstance.patch(`/user/${id}`, this.editUserData);
...this.editUserData,
name: (this.editUserData.name || "").trim(),
email: (this.editUserData.email || "").trim(),
};
if (!payload.icon) delete payload.icon;
if (!payload.password?.trim()) delete payload.password;
const response = await authInstance.patch(`/user/${id}`, payload);
runInAction(() => { runInAction(() => {
this.users.data = this.users.data.map((user) => this.users.data = this.users.data.map((user) =>
user.id === id ? { ...user, ...response.data } : user, user.id === id ? { ...user, ...response.data } : user
); );
this.user[id] = { ...this.user[id], ...response.data }; this.user[id] = { ...this.user[id], ...response.data };
}); });
}; };
addUserCityResult: User | null = null;
addUserCityLoading = false;
addUserCityError: string | null = null;
addUserCityAction = mobxFetch<
{ id: number; city_ids: number[] },
User,
UserStore
>({
store: this,
value: "addUserCityResult",
loading: "addUserCityLoading",
error: "addUserCityError",
fn: addUserCityApi,
onSuccess: (result) => {
runInAction(() => {
this.users.data = this.users.data.map((user) =>
user.id === result.id ? { ...user, ...result } : user,
);
if (this.user[result.id]) {
this.user[result.id] = { ...this.user[result.id], ...result };
}
});
},
});
} }
export const userStore = new UserStore(); export const userStore = new UserStore();

View File

@@ -1,13 +0,0 @@
import { languageInstance } from "@shared";
import { VehicleMaintenanceSession } from "./types";
export const getVehicleSessionsApi = async (
id: number,
signal?: AbortSignal,
): Promise<VehicleMaintenanceSession[]> => {
const response = await languageInstance("ru").get(`/vehicle/${id}/sessions`, {
signal,
});
return Array.isArray(response.data) ? response.data : [];
};

View File

@@ -1,9 +1,24 @@
import { authInstance, languageInstance, mobxFetch } from "@shared"; import { languageInstance } from "@shared";
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
import { getVehicleSessionsApi } from "./api";
import { Vehicle, VehicleMaintenanceSession } from "./types";
export type { Vehicle, VehicleMaintenanceSession } from "./types"; export type Vehicle = {
vehicle: {
id: number;
tail_number: number;
type: number;
carrier_id: number;
carrier: string;
uuid?: string;
};
device_status?: {
device_uuid: string;
online: boolean;
gps_ok: boolean;
media_service_ok: boolean;
last_update: string;
is_connected: boolean;
};
};
class VehicleStore { class VehicleStore {
vehicles: { vehicles: {
@@ -14,83 +29,16 @@ class VehicleStore {
loaded: false, loaded: false,
}; };
vehicle: Record<string, Vehicle> = {}; vehicle: Record<string, Vehicle> = {};
vehicleSessions: VehicleMaintenanceSession[] | null = null;
vehicleSessionsLoading = false;
vehicleSessionsError: string | null = null;
constructor() { constructor() {
makeAutoObservable(this); makeAutoObservable(this);
} }
private normalizeVehicleItem = (item: any): Vehicle => {
if (item && typeof item === "object" && "vehicle" in item) {
return {
vehicle: item.vehicle ?? {},
device_status: item.device_status,
} as Vehicle;
}
return {
vehicle: item ?? {},
} as Vehicle;
};
private mergeVehicleInCaches = (updatedVehicle: any) => {
if (!updatedVehicle) return;
const updatedId = updatedVehicle.id;
const updatedUuid = updatedVehicle.uuid;
const mergeItem = (item: Vehicle): Vehicle => ({
...item,
vehicle: {
...item.vehicle,
...updatedVehicle,
},
});
this.vehicles.data = this.vehicles.data.map((item) => {
const sameId = updatedId != null && item.vehicle.id === updatedId;
const sameUuid =
updatedUuid != null &&
item.vehicle.uuid != null &&
item.vehicle.uuid === updatedUuid;
if (!sameId && !sameUuid) return item;
return mergeItem(item);
});
if (updatedId != null) {
const existing = this.vehicle[updatedId];
this.vehicle[updatedId] = existing
? mergeItem(existing)
: ({ vehicle: updatedVehicle } as Vehicle);
return;
}
if (updatedUuid != null) {
const entry = Object.entries(this.vehicle).find(
([, item]) => item.vehicle.uuid === updatedUuid,
);
if (entry) {
const [key, item] = entry;
this.vehicle[key] = mergeItem(item);
}
}
};
getVehicles = async () => { getVehicles = async () => {
const response = await languageInstance("ru").get(`/vehicle`); const response = await languageInstance("ru").get(`/vehicle`);
const vehiclesList = Array.isArray(response.data)
? response.data
: Array.isArray(response.data?.vehicles)
? response.data.vehicles
: [];
runInAction(() => { runInAction(() => {
this.vehicles.data = vehiclesList.map(this.normalizeVehicleItem); this.vehicles.data = response.data;
this.vehicles.loaded = true; this.vehicles.loaded = true;
}); });
}; };
@@ -100,69 +48,63 @@ class VehicleStore {
runInAction(() => { runInAction(() => {
this.vehicles.data = this.vehicles.data.filter( this.vehicles.data = this.vehicles.data.filter(
(vehicle) => vehicle.vehicle.id !== id, (vehicle) => vehicle.vehicle.id !== id
); );
}); });
}; };
getVehicle = async (id: number) => { getVehicle = async (id: number) => {
const response = await languageInstance("ru").get(`/vehicle/${id}`); const response = await languageInstance("ru").get(`/vehicle/${id}`);
const normalizedVehicle = this.normalizeVehicleItem(response.data);
runInAction(() => { runInAction(() => {
this.vehicle[id] = normalizedVehicle; this.vehicle[id] = response.data;
}); });
}; };
createVehicle = async ( createVehicle = async (
tailNumber: string, tailNumber: number,
type: number, type: number,
carrier: string, carrier: string,
carrierId: number, carrierId: number
model?: string,
) => { ) => {
const payload: Record<string, unknown> = { const response = await languageInstance("ru").post("/vehicle", {
tail_number: tailNumber.trim(), tail_number: tailNumber,
type, type,
carrier: carrier.trim(), carrier,
carrier_id: carrierId, carrier_id: carrierId,
}; });
// TODO: когда будет бекенд — добавить model в payload и в ответ
if (model != null && model !== "") payload.model = model;
const response = await languageInstance("ru").post("/vehicle", payload);
const normalizedVehicle = this.normalizeVehicleItem(response.data);
runInAction(() => { runInAction(() => {
this.vehicles.data.push(normalizedVehicle); this.vehicles.data.push({
if (normalizedVehicle.vehicle?.id != null) { vehicle: {
this.vehicle[normalizedVehicle.vehicle.id] = normalizedVehicle; id: response.data.id,
} tail_number: response.data.tail_number,
type: response.data.type,
carrier_id: response.data.carrier_id,
carrier: response.data.carrier,
uuid: response.data.uuid,
},
});
}); });
}; };
editVehicleData: { editVehicleData: {
tail_number: string; tail_number: number;
type: number; type: number;
carrier: string; carrier: string;
carrier_id: number; carrier_id: number;
model: string;
snapshot_update_blocked: boolean;
} = { } = {
tail_number: "", tail_number: 0,
type: 0, type: 0,
carrier: "", carrier: "",
carrier_id: 0, carrier_id: 0,
model: "",
snapshot_update_blocked: false,
}; };
setEditVehicleData = (data: { setEditVehicleData = (data: {
tail_number: string; tail_number: number;
type: number; type: number;
carrier: string; carrier: string;
carrier_id: number; carrier_id: number;
model?: string;
snapshot_update_blocked?: boolean;
}) => { }) => {
this.editVehicleData = { this.editVehicleData = {
...this.editVehicleData, ...this.editVehicleData,
@@ -173,91 +115,36 @@ class VehicleStore {
editVehicle = async ( editVehicle = async (
id: number, id: number,
data: { data: {
tail_number: string; tail_number: number;
type: number; type: number;
carrier: string; carrier: string;
carrier_id: number; carrier_id: number;
model?: string; }
snapshot_update_blocked?: boolean;
},
) => { ) => {
const payload: Record<string, unknown> = { const response = await languageInstance("ru").patch(`/vehicle/${id}`, {
tail_number: data.tail_number.trim(), tail_number: data.tail_number,
type: data.type, type: data.type,
carrier: data.carrier.trim(), carrier: data.carrier,
carrier_id: data.carrier_id, carrier_id: data.carrier_id,
}; });
if (data.model != null && data.model !== "") payload.model = data.model;
if (data.snapshot_update_blocked != null)
payload.snapshot_update_blocked = data.snapshot_update_blocked;
const response = await languageInstance("ru").patch(
`/vehicle/${id}`,
payload,
);
const normalizedVehicle = this.normalizeVehicleItem(response.data);
const updatedVehiclePayload = {
...normalizedVehicle.vehicle,
model: normalizedVehicle.vehicle.model ?? data.model,
snapshot_update_blocked:
normalizedVehicle.vehicle.snapshot_update_blocked ??
data.snapshot_update_blocked,
};
runInAction(() => { runInAction(() => {
this.mergeVehicleInCaches({ this.vehicle[id] = {
...updatedVehiclePayload, vehicle: {
id, ...this.vehicle[id].vehicle,
}); ...response.data,
},
};
this.vehicles.data = this.vehicles.data.map((vehicle) =>
vehicle.vehicle.id === id
? {
...vehicle,
...response.data,
}
: vehicle
);
}); });
}; };
setMaintenanceMode = async (uuid: string, enabled: boolean) => {
const response = await authInstance.post(
`/devices/${uuid}/maintenance-mode`,
{
enabled,
},
);
const normalizedVehicle = this.normalizeVehicleItem(response.data);
runInAction(() => {
this.mergeVehicleInCaches({
...normalizedVehicle.vehicle,
uuid,
maintenance_mode_on:
normalizedVehicle.vehicle.maintenance_mode_on ?? enabled,
});
});
};
setDemoMode = async (uuid: string, enabled: boolean) => {
const response = await authInstance.post(`/devices/${uuid}/demo-mode`, {
enabled,
});
const normalizedVehicle = this.normalizeVehicleItem(response.data);
runInAction(() => {
this.mergeVehicleInCaches({
...normalizedVehicle.vehicle,
uuid,
demo_mode_enabled:
normalizedVehicle.vehicle.demo_mode_enabled ?? enabled,
});
});
};
getVehicleSessions = mobxFetch<
number,
VehicleMaintenanceSession[],
VehicleStore
>({
store: this,
value: "vehicleSessions",
loading: "vehicleSessionsLoading",
error: "vehicleSessionsError",
resetValue: true,
fn: getVehicleSessionsApi,
});
} }
export const vehicleStore = new VehicleStore(); export const vehicleStore = new VehicleStore();

View File

@@ -1,33 +0,0 @@
export type Vehicle = {
vehicle: {
id: number;
tail_number: string;
type: number;
carrier_id: number;
carrier: string;
uuid?: string;
model?: string;
current_snapshot_uuid?: string;
snapshot_update_blocked?: boolean;
demo_mode_enabled?: boolean;
maintenance_mode_on?: boolean;
city_id?: number;
};
device_status?: {
device_uuid: string;
online: boolean;
gps_ok: boolean;
media_service_ok: boolean;
last_update: string;
is_connected: boolean;
current_route_id?: number;
};
};
export type VehicleMaintenanceSession = {
duration_seconds: number;
ended_at: string;
id: number;
started_at: string;
vehicle_id: number;
};

View File

@@ -1,171 +0,0 @@
import { forwardRef } from "react";
import { Button, ButtonProps, CircularProgress } from "@mui/material";
import { alpha, keyframes, styled } from "@mui/material/styles";
import type { Theme } from "@mui/material/styles";
type AnimatedCircleButtonProps = ButtonProps & {
disableAnimation?: boolean;
loading?: boolean;
};
type StyledButtonProps = AnimatedCircleButtonProps & { theme: Theme };
const loadingPulse = keyframes`
0% {
transform: translate(-50%, -50%) scale(0.6);
opacity: 0.35;
}
50% {
transform: translate(-50%, -50%) scale(1.45);
opacity: 0.15;
}
100% {
transform: translate(-50%, -50%) scale(0.6);
opacity: 0;
}
`;
const StyledButton = styled(Button, {
shouldForwardProp: (prop) =>
prop !== "disableAnimation" && prop !== "loading",
})<AnimatedCircleButtonProps>((props: StyledButtonProps) => {
const {
theme,
disableAnimation = false,
color,
variant = "text",
disabled = false,
loading = false,
} = props;
const shouldAnimate = !disableAnimation && (!disabled || loading);
const pointerBlocked = loading;
const paletteMainMap: Record<string, string> = {
primary: theme.palette.primary.main,
secondary: theme.palette.secondary.main,
error: theme.palette.error.main,
warning: theme.palette.warning.main,
info: theme.palette.info.main,
success: theme.palette.success.main,
inherit: theme.palette.primary.main,
};
const paletteMain =
(color && paletteMainMap[String(color)]) ?? theme.palette.primary.main;
const pulseColor =
variant === "outlined" || variant === "text"
? alpha(paletteMain, 0.18)
: alpha(paletteMain, 0.3);
return {
position: "relative",
overflow: "hidden",
borderRadius: 5,
zIndex: 0,
transition: "transform 0.2s ease, box-shadow 0.2s ease",
pointerEvents: pointerBlocked ? "none" : undefined,
"&::after": shouldAnimate
? {
content: '""',
position: "absolute",
width: "12px",
height: "12px",
backgroundColor: pulseColor,
borderRadius: "50%",
top: "50%",
left: "50%",
pointerEvents: "none",
zIndex: 0,
...(loading
? {
opacity: 0.35,
transform: "translate(-50%, -50%) scale(0.6)",
animation: `${loadingPulse} 1.2s ease-in-out infinite`,
}
: {
opacity: 0,
transform: "translate(-50%, -50%) scale(0)",
transition: "transform 0.45s ease, opacity 0.45s ease",
}),
}
: {},
...(loading
? {}
: {
"&:hover": {
transform: "translateY(-1px)",
boxShadow: theme.shadows[4],
},
"&:hover::after": shouldAnimate
? {
transform: "translate(-50%, -50%) scale(15)",
opacity: 1,
}
: {},
"&:active": {
transform: "translateY(0)",
boxShadow: theme.shadows[2],
},
"&:active::after": shouldAnimate
? {
transform: "translate(-50%, -50%) scale(18)",
opacity: 0.4,
}
: {},
}),
"&.Mui-disabled": {
boxShadow: "none",
transform: "none",
...(loading && shouldAnimate
? {}
: {
"&::after": {
opacity: 0,
},
}),
},
...(disabled && {
boxShadow: "none",
transform: "none",
}),
"& > *": {
position: "relative",
zIndex: 1,
},
};
});
export const AnimatedCircleButton = forwardRef<
HTMLButtonElement,
AnimatedCircleButtonProps
>((props, ref) => {
const {
loading = false,
disabled,
children,
startIcon,
endIcon,
...rest
} = props;
const effectiveStartIcon = loading ? (
<CircularProgress size={16} color="inherit" />
) : (
startIcon
);
return (
<StyledButton
ref={ref}
loading={loading}
disabled={loading ? true : disabled}
startIcon={effectiveStartIcon}
endIcon={loading ? undefined : endIcon}
{...rest}
>
{children}
</StyledButton>
);
});

View File

@@ -1,11 +1,10 @@
import { Modal as MuiModal, Typography, Box, SxProps, Theme } from "@mui/material"; import { Modal as MuiModal, Typography, Box } from "@mui/material";
interface ModalProps { interface ModalProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
children: React.ReactNode; children: React.ReactNode;
title?: string; title?: string;
sx?: SxProps<Theme>;
} }
const style = { const style = {
@@ -20,7 +19,7 @@ const style = {
borderRadius: 2, borderRadius: 2,
}; };
export const Modal = ({ open, onClose, children, title, sx }: ModalProps) => { export const Modal = ({ open, onClose, children, title }: ModalProps) => {
return ( return (
<MuiModal <MuiModal
open={open} open={open}
@@ -28,7 +27,7 @@ export const Modal = ({ open, onClose, children, title, sx }: ModalProps) => {
aria-labelledby="modal-modal-title" aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description" aria-describedby="modal-modal-description"
> >
<Box sx={{ ...style, ...sx }}> <Box sx={style}>
{title && ( {title && (
<Typography <Typography
id="modal-modal-title" id="modal-modal-title"

View File

@@ -1,95 +0,0 @@
import {
Autocomplete,
Checkbox,
CircularProgress,
TextField,
} from "@mui/material";
import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank";
import CheckBoxIcon from "@mui/icons-material/CheckBox";
export interface MultiSelectOption<TValue = number | string> {
readonly value: TValue;
readonly label: string;
}
interface MultiSelectProps<TValue = number | string> {
readonly options: MultiSelectOption<TValue>[];
readonly value: TValue[];
readonly onChange: (values: TValue[]) => void;
readonly label?: string;
readonly placeholder?: string;
readonly loading?: boolean;
readonly disabled?: boolean;
readonly error?: boolean;
readonly helperText?: string;
readonly size?: "small" | "medium";
readonly fullWidth?: boolean;
}
export function MultiSelect<TValue = number | string>({
options,
value,
onChange,
label,
placeholder,
loading = false,
disabled = false,
error = false,
helperText,
size = "small",
fullWidth = true,
}: MultiSelectProps<TValue>) {
const selectedOptions = options.filter((opt) => value.includes(opt.value));
return (
<Autocomplete
multiple
disableCloseOnSelect
fullWidth={fullWidth}
size={size}
disabled={disabled}
loading={loading}
options={options}
value={selectedOptions}
getOptionLabel={(option) => option.label}
isOptionEqualToValue={(option, selected) => option.value === selected.value}
onChange={(_, newSelected) => {
onChange(newSelected.map((opt) => opt.value));
}}
renderOption={(props, option, { selected }) => {
const { key, ...rest } = props as React.HTMLAttributes<HTMLLIElement> & { key: React.Key };
return (
<li key={key} {...rest}>
<Checkbox
icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
checkedIcon={<CheckBoxIcon fontSize="small" />}
style={{ marginRight: 8 }}
checked={selected}
/>
{option.label}
</li>
);
}}
renderInput={(params) => (
<TextField
{...params}
label={label}
placeholder={selectedOptions.length === 0 ? placeholder : undefined}
error={error}
helperText={helperText}
slotProps={{
input: {
...params.InputProps,
endAdornment: (
<>
{loading && <CircularProgress color="inherit" size={16} />}
{params.InputProps.endAdornment}
</>
),
},
}}
/>
)}
/>
);
}

View File

@@ -1,37 +0,0 @@
import { Search, X } from "lucide-react";
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export const SearchInput = ({
value,
onChange,
placeholder = "Поиск...",
}: SearchInputProps) => {
return (
<div className="relative mb-4 w-full max-w-sm">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
/>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full pl-9 pr-8 py-2 border border-gray-300 rounded-md text-sm outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-white"
/>
{value && (
<button
onClick={() => onChange("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<X size={14} />
</button>
)}
</div>
);
};

View File

@@ -2,7 +2,3 @@ export * from "./TabPanel";
export * from "./BackButton"; export * from "./BackButton";
export * from "./Modal"; export * from "./Modal";
export * from "./CoordinatesInput"; export * from "./CoordinatesInput";
export * from "./AnimatedCircleButton";
export * from "./LoadingSpinner";
export * from "./MultiSelect";
export * from "./SearchInput";

1
src/vite-env.d.ts vendored
View File

@@ -1,2 +1 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare const __APP_VERSION__: string;

View File

@@ -8,40 +8,16 @@ import {
Box, Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { authStore, cityStore, selectedCityStore, type City } from "@shared"; import { cityStore, selectedCityStore } from "@shared";
import { MapPin } from "lucide-react"; import { MapPin } from "lucide-react";
export const CitySelector: React.FC = observer(() => { export const CitySelector: React.FC = observer(() => {
const { getCities, cities } = cityStore;
const { selectedCity, setSelectedCity } = selectedCityStore; const { selectedCity, setSelectedCity } = selectedCityStore;
const canLoadAllCities = authStore.isAdmin && authStore.canRead("cities");
useEffect(() => { useEffect(() => {
if (canLoadAllCities) { getCities("ru");
cityStore.getCities("ru"); }, []);
return;
}
authStore.fetchMeCities().catch(() => undefined);
}, [canLoadAllCities]);
const baseCities: City[] = canLoadAllCities
? cityStore.cities["ru"].data
: authStore.meCities["ru"].map((uc) => ({
id: uc.city_id,
name: uc.name,
country: "",
country_code: "",
arms: "",
}));
const currentCities: City[] = selectedCity?.id
? (() => {
const exists = baseCities.some((city) => city.id === selectedCity.id);
if (exists) {
return baseCities;
}
return [selectedCity, ...baseCities];
})()
: baseCities;
const handleCityChange = (event: SelectChangeEvent<string>) => { const handleCityChange = (event: SelectChangeEvent<string>) => {
const cityId = event.target.value; const cityId = event.target.value;
@@ -50,12 +26,14 @@ export const CitySelector: React.FC = observer(() => {
return; return;
} }
const city = currentCities.find((c) => c.id === Number(cityId)); const city = cities["ru"].data.find((c) => c.id === Number(cityId));
if (city) { if (city) {
setSelectedCity(city); setSelectedCity(city);
} }
}; };
const currentCities = cities["ru"].data;
return ( return (
<Box className="flex items-center gap-2"> <Box className="flex items-center gap-2">
<MapPin size={16} className="text-white" /> <MapPin size={16} className="text-white" />
@@ -73,13 +51,16 @@ export const CitySelector: React.FC = observer(() => {
"&:hover .MuiOutlinedInput-notchedOutline": { "&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255, 255, 255, 0.5)", borderColor: "rgba(255, 255, 255, 0.5)",
}, },
"&.Mui.focused .MuiOutlinedInput-notchedOutline": { "&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: "white", borderColor: "white",
}, },
"& .MuiSvgIcon-root": { "& .MuiSvgIcon-root": {
color: "white", color: "white",
}, },
}} }}
MenuProps={{
PaperProps: {},
}}
> >
<MenuItem value=""> <MenuItem value="">
<Typography variant="body2">Выберите город</Typography> <Typography variant="body2">Выберите город</Typography>

View File

@@ -1,338 +0,0 @@
import { API_URL, authInstance, Modal } from "@shared";
import { Button, CircularProgress, TextField } from "@mui/material";
import { useEffect, useState, useMemo } from "react";
import { toast } from "react-toastify";
interface DeviceLogChunk {
date?: string;
lines?: string[];
}
interface DeviceLogsModalProps {
open: boolean;
deviceUuid: string | null;
onClose: () => void;
}
const toYYYYMMDD = (d: Date) => d.toISOString().slice(0, 10);
const shiftYYYYMMDD = (value: string, days: number) => {
const d = new Date(`${value}T00:00:00Z`);
if (Number.isNaN(d.getTime())) return value;
d.setUTCDate(d.getUTCDate() + days);
return toYYYYMMDD(d);
};
type LogLevel = "info" | "warn" | "error" | "debug" | "fatal" | "unknown";
const LOG_LEVEL_STYLES: Record<LogLevel, { badge: string; text: string }> = {
info: {
badge: "bg-blue-100 text-blue-700",
text: "text-[#000000BF]",
},
debug: {
badge: "bg-gray-100 text-gray-600",
text: "text-gray-600",
},
warn: {
badge: "bg-amber-100 text-amber-700",
text: "text-amber-800",
},
error: {
badge: "bg-red-100 text-red-700",
text: "text-red-700",
},
fatal: {
badge: "bg-red-200 text-red-900",
text: "text-red-900 font-semibold",
},
unknown: {
badge: "bg-gray-100 text-gray-500",
text: "text-[#000000BF]",
},
};
const formatTs = (raw: string): string => {
try {
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return raw;
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
} catch {
return raw;
}
};
const parseJsonLogLine = (line: string) => {
try {
const obj = JSON.parse(line);
if (obj && typeof obj === "object") {
const level: LogLevel =
obj.level && obj.level in LOG_LEVEL_STYLES
? (obj.level as LogLevel)
: "unknown";
const ts: string = obj.ts ? formatTs(obj.ts) : "";
const msg: string = obj.msg ?? "";
const extra: Record<string, unknown> = { ...obj };
delete extra.level;
delete extra.ts;
delete extra.msg;
delete extra.caller;
const extraStr = Object.keys(extra).length
? " " +
Object.entries(extra)
.map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`)
.join(" ")
: "";
return { ts, level, msg, extraStr };
}
} catch {
return null;
}
return null;
};
const parseLogLine = (line: string, index: number) => {
const json = parseJsonLogLine(line);
if (json) {
return {
id: index,
time: json.ts,
text: json.msg + json.extraStr,
level: json.level,
sortKey: json.ts,
};
}
const bracketMatch = line.match(/^(\[[^\]]+\])\s*(.*)$/);
if (bracketMatch) {
const rawTime = bracketMatch[1].replace(/^\[|\]$/g, "").trim();
return {
id: index,
time: formatTs(rawTime),
text: bracketMatch[2].trim() || line,
level: "unknown" as LogLevel,
sortKey: rawTime,
};
}
return {
id: index,
time: "",
text: line,
level: "unknown" as LogLevel,
sortKey: "",
};
};
export const DeviceLogsModal = ({
open,
deviceUuid,
onClose,
}: DeviceLogsModalProps) => {
const [chunks, setChunks] = useState<DeviceLogChunk[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const today = new Date();
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
const [dateFrom, setDateFrom] = useState(toYYYYMMDD(yesterday));
const [dateTo, setDateTo] = useState(toYYYYMMDD(today));
const dateToMin = shiftYYYYMMDD(dateFrom, 1);
const dateFromMax = shiftYYYYMMDD(dateTo, -1);
const handleDateFromChange = (value: string) => {
setDateFrom(value);
if (!dateTo || dateTo <= value) {
setDateTo(shiftYYYYMMDD(value, 1));
}
};
const handleDateToChange = (value: string) => {
if (value <= dateFrom) {
toast.info("Дата 'До' должна быть позже даты 'От'");
setDateTo(shiftYYYYMMDD(dateFrom, 1));
return;
}
setDateTo(value);
};
useEffect(() => {
if (!open || !deviceUuid) return;
const fetchLogs = async () => {
setIsLoading(true);
setError(null);
try {
const { data } = await authInstance.get<DeviceLogChunk[]>(
`${API_URL}/devices/${deviceUuid}/logs`,
{
params: {
from: dateFrom,
to: toYYYYMMDD(new Date(new Date(dateTo).getTime() + 86400000)),
},
}
);
setChunks(Array.isArray(data) ? data : []);
} catch (err: unknown) {
const msg =
err && typeof err === "object" && "message" in err
? String((err as { message?: string }).message)
: "Ошибка загрузки логов";
setError(msg);
toast.error(msg);
} finally {
setIsLoading(false);
}
};
fetchLogs();
}, [open, deviceUuid, dateFrom, dateTo]);
const logs = useMemo(() => {
const parsed = chunks.flatMap((chunk, chunkIdx) =>
(chunk.lines ?? []).map((line, i) =>
parseLogLine(line, chunkIdx * 10000 + i)
)
);
parsed.sort((a, b) => {
if (!a.sortKey && !b.sortKey) return 0;
if (!a.sortKey) return 1;
if (!b.sortKey) return -1;
return b.sortKey.localeCompare(a.sortKey);
});
return parsed;
}, [chunks]);
const logsText = useMemo(
() =>
logs
.map((log) => {
const level = log.level === "unknown" ? "LOG" : log.level.toUpperCase();
const time = log.time ? `[${log.time}] ` : "";
return `${time}${level}: ${log.text}`;
})
.join("\n"),
[logs]
);
const handleDownloadLogs = () => {
if (!logsText) {
toast.info("Нет логов для сохранения");
return;
}
try {
const safeDeviceUuid = (deviceUuid ?? "device").replace(
/[^a-zA-Z0-9_-]/g,
"_"
);
const fileName = `logs_${safeDeviceUuid}_${dateFrom}_${dateTo}.txt`;
const blob = new Blob([`\uFEFF${logsText}`], {
type: "text/plain;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success("Логи сохранены в .txt");
} catch {
toast.error("Не удалось сохранить логи");
}
};
return (
<Modal open={open} onClose={onClose} sx={{ width: "80vw", p: 3 }}>
<div className="flex flex-col gap-6 h-[85vh]">
<div className="flex gap-4 items-center justify-between w-full flex-wrap">
<h2 className="text-2xl font-semibold text-[#000000BF]">Логи</h2>
<div className="flex gap-4 items-center">
<TextField
type="date"
label="От"
size="small"
value={dateFrom}
onChange={(e) => handleDateFromChange(e.target.value)}
slotProps={{
inputLabel: { shrink: true },
htmlInput: { max: dateFromMax },
}}
/>
<TextField
type="date"
label="До"
size="small"
value={dateTo}
onChange={(e) => handleDateToChange(e.target.value)}
slotProps={{
inputLabel: { shrink: true },
htmlInput: { min: dateToMin },
}}
/>
<Button
variant="outlined"
size="small"
onClick={handleDownloadLogs}
disabled={isLoading || Boolean(error) || logs.length === 0}
>
Скачать .txt
</Button>
</div>
</div>
<div className="flex-1 flex flex-col min-h-0 w-full">
{isLoading && (
<div className="w-full h-full flex items-center justify-center">
<CircularProgress />
</div>
)}
{!isLoading && error && (
<div className="w-full h-full flex items-center justify-center text-red-500">
{error}
</div>
)}
{!isLoading && !error && (
<div className="w-full h-full overflow-y-auto rounded-xl">
<div className="flex flex-col gap-0.5 font-mono text-[13px]">
{logs.length > 0 ? (
logs.map((log) => {
const style = LOG_LEVEL_STYLES[log.level];
return (
<div
key={log.id}
className={`flex gap-3 items-start px-2 py-1 rounded ${style.text}`}
>
<span
className={`shrink-0 px-1.5 py-0.5 rounded text-[11px] font-semibold uppercase ${style.badge}`}
>
{log.level === "unknown" ? "LOG" : log.level}
</span>
<span className="text-gray-400 shrink-0 whitespace-nowrap">
{log.time || null}
</span>
<span className="break-all">{log.text}</span>
</div>
);
})
) : (
<div className="h-full flex items-center justify-center text-gray-500 py-10">
Логи отсутствуют.
</div>
)}
</div>
</div>
)}
</div>
</div>
</Modal>
);
};

View File

@@ -1,180 +0,0 @@
import { Modal, vehicleStore } from "@shared";
import {
Box,
Button,
CircularProgress,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { useEffect } from "react";
interface VehicleSessionsModalProps {
open: boolean;
vehicleId: number | null;
tailNumber?: string | null;
onClose: () => void;
}
const formatDateTime = (value: string) => {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(date);
};
const formatDuration = (durationSeconds: number) => {
if (!Number.isFinite(durationSeconds) || durationSeconds < 0) {
return "-";
}
const hours = Math.floor(durationSeconds / 3600);
const minutes = Math.floor((durationSeconds % 3600) / 60);
const seconds = durationSeconds % 60;
return [hours, minutes, seconds]
.map((part) => String(part).padStart(2, "0"))
.join(":");
};
export const VehicleSessionsModal = observer(
({ open, vehicleId, tailNumber, onClose }: VehicleSessionsModalProps) => {
const {
vehicleSessions,
vehicleSessionsLoading,
vehicleSessionsError,
getVehicleSessions,
} = vehicleStore;
useEffect(() => {
if (!open || vehicleId == null) return;
getVehicleSessions(vehicleId).catch(() => undefined);
}, [open, vehicleId, getVehicleSessions]);
const title =
tailNumber && tailNumber !== ""
? `Сессии ТО: ${tailNumber}`
: "Сессии ТО";
return (
<Modal
open={open}
onClose={onClose}
sx={{ width: "min(1080px, 95vw)", p: 3 }}
>
<div className="flex flex-col gap-4 max-h-[82vh]">
<Typography variant="h6" component="h2">
{title}
</Typography>
<Box sx={{ minHeight: 220 }}>
{vehicleSessionsLoading && (
<Box
sx={{
minHeight: 220,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<CircularProgress size={24} />
</Box>
)}
{!vehicleSessionsLoading && vehicleSessionsError && (
<Box
sx={{
minHeight: 220,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "error.main",
}}
>
{vehicleSessionsError}
</Box>
)}
{!vehicleSessionsLoading &&
!vehicleSessionsError &&
vehicleSessions &&
vehicleSessions.length === 0 && (
<Box
sx={{
minHeight: 220,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "text.secondary",
}}
>
По этому транспорту нет сессий ТО.
</Box>
)}
{!vehicleSessionsLoading &&
!vehicleSessionsError &&
vehicleSessions &&
vehicleSessions.length > 0 && (
<TableContainer
sx={{ maxHeight: "60vh", border: 1, borderColor: "divider" }}
>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Начало</TableCell>
<TableCell>Окончание</TableCell>
<TableCell align="right">Длительность</TableCell>
</TableRow>
</TableHead>
<TableBody>
{vehicleSessions.map((session) => (
<TableRow key={session.id} hover>
<TableCell>{session.id}</TableCell>
<TableCell>
{formatDateTime(session.started_at)}
</TableCell>
<TableCell>
{formatDateTime(session.ended_at)}
</TableCell>
<TableCell align="right">
{formatDuration(session.duration_seconds)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
<Button
variant="outlined"
color="inherit"
onClick={onClose}
fullWidth
>
Закрыть
</Button>
</div>
</Modal>
);
},
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,11 @@
import React, { useRef, DragEvent } from "react"; import React, { useRef, DragEvent } from "react";
import { Paper, Box, Typography, Button, Tooltip } from "@mui/material"; import { Paper, Box, Typography, Button, Tooltip } from "@mui/material";
import { X, Info, Plus } from "lucide-react"; import { X, Info, Plus } from "lucide-react"; // Assuming lucide-react for icons
import { editSightStore } from "@shared"; import { editSightStore } from "@shared";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
interface ImageUploadCardProps { interface ImageUploadCardProps {
title: string; title: string;
imageKey?: imageKey?: "thumbnail" | "watermark_lu" | "watermark_rd" | "image";
| "thumbnail"
| "icon"
| "alt_icon"
| "watermark_lu"
| "watermark_rd"
| "image";
imageUrl: string | null | undefined; imageUrl: string | null | undefined;
onImageClick: () => void; onImageClick: () => void;
onDeleteImageClick: () => void; onDeleteImageClick: () => void;
@@ -56,14 +50,14 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
toast.error("Пожалуйста, выберите изображение"); toast.error("Пожалуйста, выберите изображение");
} }
} }
// Reset the input value so selecting the same file again triggers change
event.target.value = ""; event.target.value = "";
}; };
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
// --- Drag and Drop Handlers ---
const handleDragOver = (event: DragEvent<HTMLDivElement>) => { const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); event.preventDefault(); // Crucial to allow a drop
event.stopPropagation(); event.stopPropagation();
}; };
@@ -73,7 +67,7 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
}; };
const handleDrop = async (event: DragEvent<HTMLDivElement>) => { const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); event.preventDefault(); // Crucial to allow a drop
event.stopPropagation(); event.stopPropagation();
const files = event.dataTransfer.files; const files = event.dataTransfer.files;
@@ -126,6 +120,7 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
cursor: imageUrl ? "pointer" : "default", cursor: imageUrl ? "pointer" : "default",
}} }}
onClick={onImageClick} onClick={onImageClick}
// Removed onClick on the main Box to avoid conflicts
> >
{imageUrl && ( {imageUrl && (
<button <button
@@ -158,7 +153,7 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
borderRadius: 1, borderRadius: 1,
cursor: "pointer", cursor: "pointer",
}} }}
onClick={handleZoneClick} onClick={handleZoneClick} // Click handler for the zone
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
@@ -172,8 +167,8 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
color="primary" color="primary"
startIcon={<Plus color="white" size={18} />} startIcon={<Plus color="white" size={18} />}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation(); // Prevent `handleZoneClick` from firing
onSelectFileClick(); onSelectFileClick(); // This button might trigger a different modal
}} }}
> >
Выбрать файл Выбрать файл
@@ -184,7 +179,7 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
ref={fileInputRef} ref={fileInputRef}
onChange={handleFileInputChange} onChange={handleFileInputChange}
style={{ display: "none" }} style={{ display: "none" }}
accept="image/*" accept="image/*" // Accept only image files
/> />
</div> </div>
)} )}

View File

@@ -8,7 +8,7 @@ import { AppBar } from "./ui/AppBar";
import { Drawer } from "./ui/Drawer"; import { Drawer } from "./ui/Drawer";
import { DrawerHeader } from "./ui/DrawerHeader"; import { DrawerHeader } from "./ui/DrawerHeader";
import { NavigationList } from "@features"; import { NavigationList } from "@features";
import { authStore, menuStore, isMediaIdEmpty } from "@shared"; import { authStore, userStore, menuStore } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect } from "react"; import { useEffect } from "react";
import { Typography } from "@mui/material"; import { Typography } from "@mui/material";
@@ -27,10 +27,13 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
setIsMenuOpen(open); setIsMenuOpen(open);
}, [open]); }, [open]);
const { getMeAction, me } = authStore; const { getUsers, users } = userStore;
useEffect(() => { useEffect(() => {
getMeAction(); const fetchUsers = async () => {
await getUsers();
};
fetchUsers();
}, []); }, []);
const handleDrawerOpen = () => { const handleDrawerOpen = () => {
@@ -64,14 +67,18 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
</div> </div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
{(() => { <div className="flex flex-col gap-1">
const hasAvatar = me?.icon && !isMediaIdEmpty(me.icon); {(() => {
const token = localStorage.getItem("token"); return (
<>
return ( <p className=" text-white">
<> {
<div className="flex flex-col gap-1"> users?.data?.find(
<p className="text-white">{me?.name}</p> // @ts-ignore
(user) => user.id === authStore.payload?.user_id
)?.name
}
</p>
<div <div
className="text-center text-xs" className="text-center text-xs"
style={{ style={{
@@ -81,27 +88,18 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
padding: "2px 10px", padding: "2px 10px",
}} }}
> >
{me?.roles?.includes("admin") {/* @ts-ignore */}
{authStore.payload?.is_admin
? "Администратор" ? "Администратор"
: "Режим пользователя"} : "Режим пользователя"}
</div> </div>
</div> </>
<div className="w-10 h-10 rounded-full flex items-center justify-center overflow-hidden bg-gray-600 shrink-0"> );
{hasAvatar ? ( })()}
<img </div>
src={`${ <div className="w-10 h-10 bg-gray-600 rounded-full flex items-center justify-center">
import.meta.env.VITE_KRBL_MEDIA <User />
}${me?.icon}/download?token=${token}`} </div>
alt="Аватар"
className="w-full h-full object-cover"
/>
) : (
<User className="text-white" size={20} />
)}
</div>
</>
);
})()}
</div> </div>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
@@ -140,9 +138,6 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
)} )}
</DrawerHeader> </DrawerHeader>
<NavigationList open={open} onDrawerOpen={handleDrawerOpen} /> <NavigationList open={open} onDrawerOpen={handleDrawerOpen} />
<div className="mt-auto flex justify-center items-center pb-5 text-sm text-gray-300">
v.{__APP_VERSION__}
</div>
</Drawer> </Drawer>
<Box <Box
component="main" component="main"

View File

@@ -10,25 +10,23 @@ import { observer } from "mobx-react-lite";
import { useState, DragEvent, useRef } from "react"; import { useState, DragEvent, useRef } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
interface MediaAreaProps {
articleId: number;
mediaIds: { id: string; media_type: number; filename: string }[];
deleteMedia: (id: number, media_id: string) => void;
onFilesDrop?: (files: File[]) => void;
setSelectMediaDialogOpen: (open: boolean) => void;
}
export const MediaArea = observer( export const MediaArea = observer(
({ ({
articleId, articleId,
mediaIds, mediaIds,
deleteMedia, deleteMedia,
onFilesDrop, onFilesDrop, // 👈 Проп для обработки загруженных файлов
setSelectMediaDialogOpen, setSelectMediaDialogOpen,
}: MediaAreaProps) => { }: {
articleId: number;
mediaIds: { id: string; media_type: number; filename: string }[];
deleteMedia: (id: number, media_id: string) => void;
onFilesDrop?: (files: File[]) => void;
setSelectMediaDialogOpen: (open: boolean) => void;
}) => {
const [mediaModal, setMediaModal] = useState<boolean>(false); const [mediaModal, setMediaModal] = useState<boolean>(false);
const [mediaId, setMediaId] = useState<string>(""); const [mediaId, setMediaId] = useState<string>("");
const [isDragging, setIsDragging] = useState<boolean>(false); const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const handleMediaModal = (mediaId: string) => { const handleMediaModal = (mediaId: string) => {
@@ -36,29 +34,23 @@ export const MediaArea = observer(
setMediaId(mediaId); setMediaId(mediaId);
}; };
const processFiles = (files: File[]) => {
if (!files.length || !onFilesDrop) {
return;
}
const { validFiles, errors } = filterValidFiles(files);
if (errors.length > 0) {
errors.forEach((error) => toast.error(error));
}
if (validFiles.length > 0) {
onFilesDrop(validFiles);
}
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => { const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setIsDragging(false); setIsDragging(false);
const files = Array.from(e.dataTransfer.files); const files = Array.from(e.dataTransfer.files);
processFiles(files); if (files.length && onFilesDrop) {
const { validFiles, errors } = filterValidFiles(files);
if (errors.length > 0) {
errors.forEach((error) => toast.error(error));
}
if (validFiles.length > 0) {
onFilesDrop(validFiles);
}
}
}; };
const handleDragOver = (e: DragEvent<HTMLDivElement>) => { const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
@@ -76,11 +68,19 @@ export const MediaArea = observer(
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []); const files = Array.from(event.target.files || []);
processFiles(files); if (files.length && onFilesDrop) {
const { validFiles, errors } = filterValidFiles(files);
if (event.target) { if (errors.length > 0) {
event.target.value = ""; errors.forEach((error) => toast.error(error));
}
if (validFiles.length > 0) {
onFilesDrop(validFiles);
}
} }
// Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
event.target.value = "";
}; };
return ( return (
@@ -96,7 +96,7 @@ export const MediaArea = observer(
<Box className="w-full flex flex-col items-center justify-center border rounded-md p-4"> <Box className="w-full flex flex-col items-center justify-center border rounded-md p-4">
<div className="w-full flex flex-col items-center justify-center"> <div className="w-full flex flex-col items-center justify-center">
<div <div
className={`w-full h-40 flex flex-col justify-center items-center text-gray-400 border-dashed border-2 rounded-md border-gray-400 p-4 cursor-pointer hover:bg-gray-50 transition-colors ${ className={`w-full h-40 flex flex-col justify-center items-center text-gray-400 border-dashed border-2 rounded-md border-gray-400 p-4 cursor-pointer hover:bg-gray-50 ${
isDragging ? "bg-blue-100 border-blue-400" : "" isDragging ? "bg-blue-100 border-blue-400" : ""
}`} }`}
onDrop={handleDrop} onDrop={handleDrop}
@@ -105,11 +105,9 @@ export const MediaArea = observer(
onClick={handleClick} onClick={handleClick}
> >
<Upload size={32} className="mb-2" /> <Upload size={32} className="mb-2" />
<span className="text-center"> Перетащите медиа файлы сюда или нажмите для выбора
Перетащите медиа файлы сюда или нажмите для выбора
</span>
</div> </div>
<div className="my-2">или</div> <div>или</div>
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"
@@ -119,38 +117,33 @@ export const MediaArea = observer(
</Button> </Button>
</div> </div>
{mediaIds.length > 0 && ( <div className="w-full flex flex-start flex-wrap gap-2 mt-4 py-10">
<div className="w-full flex flex-start flex-wrap gap-2 mt-4 py-10"> {mediaIds.map((m) => (
{mediaIds.map((m) => ( <button
className="relative w-20 h-20"
key={m.id}
onClick={() => handleMediaModal(m.id)}
>
<MediaViewer
media={{
id: m.id,
media_type: m.media_type,
filename: m.filename,
}}
height="40px"
/>
<button <button
className="relative w-[100px] h-[80px]" className="absolute top-2 right-2"
key={m.id} onClick={(e) => {
onClick={() => handleMediaModal(m.id)} e.stopPropagation();
type="button" deleteMedia(articleId, m.id);
}}
> >
<MediaViewer <X size={16} color="red" />
media={{
id: m.id,
media_type: m.media_type,
filename: m.filename,
}}
compact
/>
<button
className="absolute top-2 right-2 bg-white rounded-full p-1 shadow-md hover:shadow-lg transition-shadow"
onClick={(e) => {
e.stopPropagation();
deleteMedia(articleId, m.id);
}}
type="button"
aria-label="Удалить медиа"
>
<X size={16} color="red" />
</button>
</button> </button>
))} </button>
</div> ))}
)} </div>
</Box> </Box>
<PreviewMediaDialog <PreviewMediaDialog

View File

@@ -11,72 +11,52 @@ import { observer } from "mobx-react-lite";
import { useState, DragEvent, useRef } from "react"; import { useState, DragEvent, useRef } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
type ContextType =
| "sight"
| "city"
| "carrier"
| "country"
| "vehicle"
| "station";
interface MediaAreaForSightProps {
onFilesDrop?: (files: File[]) => void;
onFinishUpload?: (mediaId: string) => void;
contextObjectName?: string;
contextType?: ContextType;
isArticle?: boolean;
articleName?: string;
}
export const MediaAreaForSight = observer( export const MediaAreaForSight = observer(
({ ({
onFilesDrop, onFilesDrop, // 👈 Проп для обработки загруженных файлов
onFinishUpload, onFinishUpload,
contextObjectName, contextObjectName,
contextType, contextType,
isArticle, isArticle,
articleName, articleName,
}: MediaAreaForSightProps) => { }: {
const [selectMediaDialogOpen, setSelectMediaDialogOpen] = onFilesDrop?: (files: File[]) => void;
useState<boolean>(false); onFinishUpload?: (mediaId: string) => void;
const [uploadMediaDialogOpen, setUploadMediaDialogOpen] = contextObjectName?: string;
useState<boolean>(false); contextType?:
const [isDragging, setIsDragging] = useState<boolean>(false); | "sight"
| "city"
| "carrier"
| "country"
| "vehicle"
| "station";
isArticle?: boolean;
articleName?: string;
}) => {
const [selectMediaDialogOpen, setSelectMediaDialogOpen] = useState(false);
const [uploadMediaDialogOpen, setUploadMediaDialogOpen] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { setFileToUpload } = editSightStore; const { setFileToUpload } = editSightStore;
const processFiles = (files: File[]) => {
if (!files.length) {
return;
}
const { validFiles, errors } = filterValidFiles(files);
if (errors.length > 0) {
errors.forEach((error: string) => toast.error(error));
}
if (validFiles.length > 0) {
// Сохраняем первый файл для загрузки
setFileToUpload(validFiles[0]);
// Вызываем колбэк, если он передан
if (onFilesDrop) {
onFilesDrop(validFiles);
}
// Открываем диалог загрузки
setUploadMediaDialogOpen(true);
}
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => { const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setIsDragging(false); setIsDragging(false);
const files = Array.from(e.dataTransfer.files); const files = Array.from(e.dataTransfer.files);
processFiles(files); if (files.length) {
const { validFiles, errors } = filterValidFiles(files);
if (errors.length > 0) {
errors.forEach((error: string) => toast.error(error));
}
if (validFiles.length > 0 && onFilesDrop) {
setFileToUpload(validFiles[0]);
setUploadMediaDialogOpen(true);
}
}
}; };
const handleDragOver = (e: DragEvent<HTMLDivElement>) => { const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
@@ -94,12 +74,22 @@ export const MediaAreaForSight = observer(
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []); const files = Array.from(event.target.files || []);
processFiles(files); if (files.length) {
const { validFiles, errors } = filterValidFiles(files);
if (errors.length > 0) {
errors.forEach((error: string) => toast.error(error));
}
if (validFiles.length > 0 && onFilesDrop) {
setFileToUpload(validFiles[0]);
onFilesDrop(validFiles);
setUploadMediaDialogOpen(true);
}
}
// Сбрасываем значение input, чтобы можно было выбрать тот же файл снова // Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
if (event.target) { event.target.value = "";
event.target.value = "";
}
}; };
return ( return (
@@ -115,7 +105,7 @@ export const MediaAreaForSight = observer(
<Box className="w-full flex flex-col items-center justify-center border rounded-md p-4"> <Box className="w-full flex flex-col items-center justify-center border rounded-md p-4">
<div className="w-full flex flex-col items-center justify-center"> <div className="w-full flex flex-col items-center justify-center">
<div <div
className={`w-full h-40 flex flex-col justify-center items-center text-gray-400 border-dashed border-2 rounded-md border-gray-400 p-4 cursor-pointer hover:bg-gray-50 transition-colors ${ className={`w-full h-40 flex text-center flex-col justify-center items-center text-gray-400 border-dashed border-2 rounded-md border-gray-400 p-4 cursor-pointer hover:bg-gray-50 ${
isDragging ? "bg-blue-100 border-blue-400" : "" isDragging ? "bg-blue-100 border-blue-400" : ""
}`} }`}
onDrop={handleDrop} onDrop={handleDrop}
@@ -124,11 +114,9 @@ export const MediaAreaForSight = observer(
onClick={handleClick} onClick={handleClick}
> >
<Upload size={32} className="mb-2" /> <Upload size={32} className="mb-2" />
<span className="text-center"> Перетащите медиа файлы сюда или нажмите для выбора
Перетащите медиа файлы сюда или нажмите для выбора
</span>
</div> </div>
<div className="my-2">или</div> <div>или</div>
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"

View File

@@ -3,9 +3,11 @@ import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
import { useEffect, Suspense } from "react"; import { useEffect, Suspense } from "react";
import { Box, CircularProgress, Typography } from "@mui/material"; import { Box, CircularProgress, Typography } from "@mui/material";
// Утилита для очистки кеша GLTF
const clearGLTFCache = (url?: string) => { const clearGLTFCache = (url?: string) => {
try { try {
if (url) { if (url) {
// Если это blob URL, очищаем его из кеша
if (url.startsWith("blob:")) { if (url.startsWith("blob:")) {
useGLTF.clear(url); useGLTF.clear(url);
} else { } else {
@@ -17,23 +19,29 @@ const clearGLTFCache = (url?: string) => {
} }
}; };
// Утилита для проверки типа файла
const isValid3DFile = (url: string): boolean => { const isValid3DFile = (url: string): boolean => {
try { try {
const urlObj = new URL(url); const urlObj = new URL(url);
const pathname = urlObj.pathname.toLowerCase(); const pathname = urlObj.pathname.toLowerCase();
const searchParams = urlObj.searchParams; const searchParams = urlObj.searchParams;
// Проверяем расширение файла в пути
const validExtensions = [".glb", ".gltf"]; const validExtensions = [".glb", ".gltf"];
const hasValidExtension = validExtensions.some((ext) => const hasValidExtension = validExtensions.some((ext) =>
pathname.endsWith(ext) pathname.endsWith(ext)
); );
// Проверяем параметры запроса на наличие типа файла
const fileType = searchParams.get("type") || searchParams.get("format"); const fileType = searchParams.get("type") || searchParams.get("format");
const hasValidType = const hasValidType =
fileType && ["glb", "gltf"].includes(fileType.toLowerCase()); fileType && ["glb", "gltf"].includes(fileType.toLowerCase());
// Если это blob URL, считаем его валидным (пользователь выбрал файл)
const isBlobUrl = url.startsWith("blob:"); const isBlobUrl = url.startsWith("blob:");
// Если это URL с токеном и нет явного расширения, считаем валидным
// (предполагаем что сервер вернет правильный файл)
const hasToken = searchParams.has("token"); const hasToken = searchParams.has("token");
const isServerUrl = hasToken && !hasValidExtension; const isServerUrl = hasToken && !hasValidExtension;
@@ -43,7 +51,7 @@ const isValid3DFile = (url: string): boolean => {
return isValid; return isValid;
} catch (error) { } catch (error) {
console.warn("⚠️ isValid3DFile: Ошибка при проверке URL", error); console.warn("⚠️ isValid3DFile: Ошибка при проверке URL", error);
// В случае ошибки парсинга URL, считаем валидным (пусть useGLTF сам разберется)
return true; return true;
} }
}; };
@@ -55,10 +63,13 @@ type ModelViewerProps = {
}; };
const Model = ({ fileUrl }: { fileUrl: string }) => { const Model = ({ fileUrl }: { fileUrl: string }) => {
// Очищаем кеш перед загрузкой новой модели
useEffect(() => { useEffect(() => {
// Очищаем кеш для текущего URL
clearGLTFCache(fileUrl); clearGLTFCache(fileUrl);
}, [fileUrl]); }, [fileUrl]);
// Проверяем валидность файла перед загрузкой (только для blob URL)
if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) { if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) {
console.warn("⚠️ Model: Попытка загрузить невалидный 3D файл", { fileUrl }); console.warn("⚠️ Model: Попытка загрузить невалидный 3D файл", { fileUrl });
throw new Error(`Файл не является корректной 3D моделью: ${fileUrl}`); throw new Error(`Файл не является корректной 3D моделью: ${fileUrl}`);
@@ -103,13 +114,16 @@ export const ThreeView = ({
height = "100%", height = "100%",
width = "100%", width = "100%",
}: ModelViewerProps) => { }: ModelViewerProps) => {
// Проверяем валидность файла (только для blob URL)
useEffect(() => { useEffect(() => {
if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) { if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) {
console.warn("⚠️ ThreeView: Невалидный 3D файл", { fileUrl }); console.warn("⚠️ ThreeView: Невалидный 3D файл", { fileUrl });
} }
}, [fileUrl]); }, [fileUrl]);
// Очищаем кеш при размонтировании и при смене URL
useEffect(() => { useEffect(() => {
// Очищаем кеш сразу при монтировании компонента
clearGLTFCache(fileUrl); clearGLTFCache(fileUrl);
return () => { return () => {
@@ -129,7 +143,7 @@ export const ThreeView = ({
> >
<ambientLight /> <ambientLight />
<directionalLight /> <directionalLight />
<Stage environment={null} intensity={0.6} adjustCamera={false}> <Stage environment="city" intensity={0.6} adjustCamera={false}>
<Model fileUrl={fileUrl} /> <Model fileUrl={fileUrl} />
</Stage> </Stage>
<OrbitControls /> <OrbitControls />

View File

@@ -35,6 +35,7 @@ export class ThreeViewErrorBoundary extends Component<Props, State> {
props: Props, props: Props,
state: State state: State
): Partial<State> | null { ): Partial<State> | null {
// Сбрасываем ошибку ТОЛЬКО при смене медиа (когда меняется ID в resetKey)
if ( if (
props.resetKey !== state.lastResetKey && props.resetKey !== state.lastResetKey &&
state.lastResetKey !== undefined state.lastResetKey !== undefined
@@ -42,6 +43,7 @@ export class ThreeViewErrorBoundary extends Component<Props, State> {
const oldMediaId = String(state.lastResetKey).split("-")[0]; const oldMediaId = String(state.lastResetKey).split("-")[0];
const newMediaId = String(props.resetKey).split("-")[0]; const newMediaId = String(props.resetKey).split("-")[0];
// Сбрасываем ошибку только если изменился ID медиа (пользователь выбрал другую модель)
if (oldMediaId !== newMediaId) { if (oldMediaId !== newMediaId) {
return { return {
hasError: false, hasError: false,
@@ -50,6 +52,9 @@ export class ThreeViewErrorBoundary extends Component<Props, State> {
}; };
} }
// Если изменился только счетчик (нажата кнопка "Попробовать снова"), обновляем lastResetKey
// но не сбрасываем ошибку автоматически - ждем результата загрузки
return { return {
lastResetKey: props.resetKey, lastResetKey: props.resetKey,
}; };
@@ -122,12 +127,15 @@ export class ThreeViewErrorBoundary extends Component<Props, State> {
}; };
handleReset = () => { handleReset = () => {
// Сначала сбрасываем состояние ошибки
this.setState( this.setState(
{ {
hasError: false, hasError: false,
error: null, error: null,
}, },
() => { () => {
// После того как состояние обновилось, вызываем callback для изменения resetKey
// Это приведет к пересозданию компонента и новой попытке загрузки
this.props.onReset?.(); this.props.onReset?.();
} }
); );

View File

@@ -1,6 +1,5 @@
import { Box, Typography } from "@mui/material"; import { Box } from "@mui/material";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Cuboid } from "lucide-react";
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer"; import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
import { ThreeView } from "./ThreeView"; import { ThreeView } from "./ThreeView";
@@ -20,7 +19,6 @@ export function MediaViewer({
width, width,
fullWidth, fullWidth,
fullHeight, fullHeight,
compact,
}: Readonly<{ }: Readonly<{
media?: MediaData; media?: MediaData;
className?: string; className?: string;
@@ -28,8 +26,6 @@ export function MediaViewer({
width?: string; width?: string;
fullWidth?: boolean; fullWidth?: boolean;
fullHeight?: boolean; fullHeight?: boolean;
/** В компактном режиме (миниатюры) 3D модели не рендерятся — показывается placeholder */
compact?: boolean;
}>) { }>) {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
const [resetKey, setResetKey] = useState(0); const [resetKey, setResetKey] = useState(0);
@@ -80,9 +76,8 @@ export function MediaViewer({
}/download?token=${token}`} }/download?token=${token}`}
alt={media?.filename} alt={media?.filename}
style={{ style={{
height: compact ? "80px" : fullHeight ? "100%" : height ? height : "auto", height: fullHeight ? "100%" : height ? height : "auto",
width: compact ? "100px" : fullWidth ? "100%" : width ? width : "auto", width: fullWidth ? "100%" : width ? width : "auto",
objectFit: "cover",
}} }}
/> />
)} )}
@@ -93,8 +88,8 @@ export function MediaViewer({
media?.id media?.id
}/download?token=${token}`} }/download?token=${token}`}
style={{ style={{
width: compact ? "100px" : width ? width : "100%", width: width ? width : "100%",
height: compact ? "80px" : height ? height : "100%", height: height ? height : "100%",
objectFit: "cover", objectFit: "cover",
borderRadius: 8, borderRadius: 8,
}} }}
@@ -110,9 +105,8 @@ export function MediaViewer({
}/download?token=${token}`} }/download?token=${token}`}
alt={media?.filename} alt={media?.filename}
style={{ style={{
height: compact ? "80px" : fullHeight ? "100%" : height ? height : "auto", height: fullHeight ? "100%" : height ? height : "auto",
width: compact ? "100px" : fullWidth ? "100%" : width ? width : "auto", width: fullWidth ? "100%" : width ? width : "auto",
objectFit: "cover",
}} }}
/> />
)} )}
@@ -123,8 +117,6 @@ export function MediaViewer({
}/download?token=${token}`} }/download?token=${token}`}
alt={media?.filename} alt={media?.filename}
style={{ style={{
width: compact ? "100px" : "100%",
height: compact ? "80px" : undefined,
objectFit: "cover", objectFit: "cover",
}} }}
/> />
@@ -135,46 +127,26 @@ export function MediaViewer({
src={`${import.meta.env.VITE_KRBL_MEDIA}${ src={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id media?.id
}/download?token=${token}`} }/download?token=${token}`}
width={compact ? "100px" : fullWidth ? "100%" : width ? width : "500px"} width={width ? width : "500px"}
height={compact ? "80px" : fullHeight ? "100%" : height ? height : "300px"} height={height ? height : "300px"}
/> />
)} )}
{media?.media_type === 6 && {media?.media_type === 6 && (
(compact ? ( <ThreeViewErrorBoundary
<Box onReset={handleReset}
sx={{ resetKey={`${media?.id}-${resetKey}`}
width: "100px", >
height: "80px", <ThreeView
display: "flex", key={`3d-model-${media?.id}-${resetKey}`}
flexDirection: "column", fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${
alignItems: "center", media?.id
justifyContent: "center", }/download?token=${token}`}
backgroundColor: "action.hover", height={height ? height : "500px"}
borderRadius: 5, width={width ? width : "500px"}
color: "text.secondary", />
}} </ThreeViewErrorBoundary>
> )}
<Cuboid size={24} />
<Typography variant="caption" sx={{ mt: 0.5 }}>
3D
</Typography>
</Box>
) : (
<ThreeViewErrorBoundary
onReset={handleReset}
resetKey={`${media?.id}-${resetKey}`}
>
<ThreeView
key={`3d-model-${media?.id}-${resetKey}`}
fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id
}/download?token=${token}`}
height={height ? height : "500px"}
width={width ? width : "500px"}
/>
</ThreeViewErrorBoundary>
))}
</Box> </Box>
); );
} }

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