fix: Update map with tables fixes

This commit is contained in:
2025-07-09 18:56:18 +03:00
parent 78800ee2ae
commit e2547cb571
87 changed files with 5392 additions and 1410 deletions

View File

@ -1 +0,0 @@

View File

@ -26,7 +26,7 @@ import {
CountryCreatePage, CountryCreatePage,
CityCreatePage, CityCreatePage,
CarrierCreatePage, CarrierCreatePage,
// VehicleCreatePage, VehicleCreatePage,
CountryEditPage, CountryEditPage,
CityEditPage, CityEditPage,
UserCreatePage, UserCreatePage,
@ -40,6 +40,7 @@ import {
RoutePreview, RoutePreview,
RouteEditPage, RouteEditPage,
ArticlePreviewPage, ArticlePreviewPage,
CountryAddPage,
} from "@pages"; } from "@pages";
import { authStore, createSightStore, editSightStore } from "@shared"; import { authStore, createSightStore, editSightStore } from "@shared";
import { Layout } from "@widgets"; import { Layout } from "@widgets";
@ -57,7 +58,7 @@ import {
const PublicRoute = ({ children }: { children: React.ReactNode }) => { const PublicRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated } = authStore; const { isAuthenticated } = authStore;
if (isAuthenticated) { if (isAuthenticated) {
return <Navigate to="/sight" replace />; return <Navigate to="/map" replace />;
} }
return <>{children}</>; return <>{children}</>;
}; };
@ -69,7 +70,7 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
return <Navigate to="/login" replace />; return <Navigate to="/login" replace />;
} }
if (location.pathname === "/") { if (location.pathname === "/") {
return <Navigate to="/sight" replace />; return <Navigate to="/map" replace />;
} }
return <>{children}</>; return <>{children}</>;
}; };
@ -134,6 +135,7 @@ const router = createBrowserRouter([
// Country // Country
{ path: "country", element: <CountryListPage /> }, { path: "country", element: <CountryListPage /> },
{ path: "country/create", element: <CountryCreatePage /> }, { path: "country/create", element: <CountryCreatePage /> },
{ path: "country/add", element: <CountryAddPage /> },
// { path: "country/:id", element: <CountryPreviewPage /> }, // { path: "country/:id", element: <CountryPreviewPage /> },
{ path: "country/:id/edit", element: <CountryEditPage /> }, { path: "country/:id/edit", element: <CountryEditPage /> },
// City // City
@ -166,7 +168,7 @@ const router = createBrowserRouter([
{ path: "station/:id/edit", element: <StationEditPage /> }, { path: "station/:id/edit", element: <StationEditPage /> },
// Vehicle // Vehicle
// { path: "vehicle", element: <VehicleListPage /> }, // { path: "vehicle", element: <VehicleListPage /> },
// { path: "vehicle/create", element: <VehicleCreatePage /> }, { path: "vehicle/create", element: <VehicleCreatePage /> },
// { path: "vehicle/:id", element: <VehiclePreviewPage /> }, // { path: "vehicle/:id", element: <VehiclePreviewPage /> },
// { path: "vehicle/:id/edit", element: <VehicleEditPage /> }, // { path: "vehicle/:id/edit", element: <VehicleEditPage /> },
// Article // Article

View File

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

View File

@ -2,7 +2,7 @@ import React, { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { LanguageSwitcher } from "@widgets"; import { LanguageSwitcher } from "@widgets";
import { articlesStore } from "@shared"; import { articlesStore, languageStore } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
const ArticleEditPage: React.FC = observer(() => { const ArticleEditPage: React.FC = observer(() => {
@ -11,6 +11,11 @@ const ArticleEditPage: React.FC = observer(() => {
const { articleData, getArticle } = articlesStore; const { articleData, getArticle } = articlesStore;
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
useEffect(() => { useEffect(() => {
if (id) { if (id) {
// Fetch data for all languages // Fetch data for all languages

View File

@ -1,10 +1,12 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { articlesStore, languageStore } from "@shared"; import { articlesStore, languageStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { 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";
import { DeleteModal, LanguageSwitcher } from "@widgets"; import { DeleteModal, LanguageSwitcher } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const ArticleListPage = observer(() => { export const ArticleListPage = observer(() => {
const { articleList, getArticleList, deleteArticles } = articlesStore; const { articleList, getArticleList, deleteArticles } = articlesStore;
@ -14,9 +16,15 @@ export const ArticleListPage = observer(() => {
const [rowId, setRowId] = useState<string | null>(null); const [rowId, setRowId] = useState<string | null>(null);
const { language } = languageStore; const { language } = languageStore;
const [ids, setIds] = useState<number[]>([]); const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => { useEffect(() => {
getArticleList(); const fetchArticles = async () => {
setIsLoading(true);
await getArticleList();
setIsLoading(false);
};
fetchArticles();
}, [language]); }, [language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
@ -93,10 +101,21 @@ export const ArticleListPage = observer(() => {
columns={columns} columns={columns}
hideFooterPagination hideFooterPagination
checkboxSelection checkboxSelection
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => { onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]); setIds(Array.from(newSelection.ids) as number[]);
}} }}
hideFooter hideFooter
slots={{
noRowsOverlay: () => (
<Box
sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}
>
{isLoading ? <CircularProgress size={20} /> : "Нет статей"}
</Box>
),
}}
/> />
</div> </div>
</div> </div>

View File

@ -38,12 +38,14 @@ export const CarrierCreatePage = observer(() => {
useEffect(() => { useEffect(() => {
cityStore.getCities("ru"); cityStore.getCities("ru");
mediaStore.getMedia(); mediaStore.getMedia();
languageStore.setLanguage("ru");
}, []); }, []);
const handleCreate = async () => { const handleCreate = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
await carrierStore.createCarrier(); await carrierStore.createCarrier();
toast.success("Перевозчик успешно создан"); toast.success("Перевозчик успешно создан");
navigate("/carrier"); navigate("/carrier");
} catch (error) { } catch (error) {
@ -229,6 +231,8 @@ export const CarrierCreatePage = observer(() => {
<UploadMediaDialog <UploadMediaDialog
open={isUploadMediaOpen} open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)} onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={createCarrierData[language].full_name}
contextType="carrier"
afterUpload={handleMediaSelect} afterUpload={handleMediaSelect}
hardcodeType={activeMenuType} hardcodeType={activeMenuType}
/> />

View File

@ -14,11 +14,11 @@ import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { carrierStore, cityStore, mediaStore, languageStore } from "@shared"; import { carrierStore, cityStore, mediaStore, languageStore } from "@shared";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher } from "@widgets"; import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets";
import { import {
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
UploadMediaDialog,
} from "@shared"; } from "@shared";
export const CarrierEditPage = observer(() => { export const CarrierEditPage = observer(() => {
@ -32,6 +32,7 @@ export const CarrierEditPage = observer(() => {
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 [activeMenuType, setActiveMenuType] = useState< const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null); >(null);
@ -72,6 +73,9 @@ export const CarrierEditPage = observer(() => {
mediaStore.getMedia(); mediaStore.getMedia();
})(); })();
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, [id]); }, [id]);
const handleEdit = async () => { const handleEdit = async () => {
@ -209,15 +213,7 @@ export const CarrierEditPage = observer(() => {
setMediaId(selectedMedia?.id ?? ""); setMediaId(selectedMedia?.id ?? "");
}} }}
onDeleteImageClick={() => { onDeleteImageClick={() => {
setEditCarrierData( setIsDeleteLogoModalOpen(true);
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
"",
language
);
setActiveMenuType(null);
}} }}
onSelectFileClick={() => { onSelectFileClick={() => {
setActiveMenuType("image"); setActiveMenuType("image");
@ -244,7 +240,7 @@ export const CarrierEditPage = observer(() => {
{isLoading ? ( {isLoading ? (
<Loader2 size={20} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />
) : ( ) : (
"Обновить" "Сохранить"
)} )}
</Button> </Button>
</div> </div>
@ -259,6 +255,8 @@ export const CarrierEditPage = observer(() => {
<UploadMediaDialog <UploadMediaDialog
open={isUploadMediaOpen} open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)} onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={editCarrierData[language].full_name}
contextType="carrier"
afterUpload={handleMediaSelect} afterUpload={handleMediaSelect}
hardcodeType={activeMenuType} hardcodeType={activeMenuType}
/> />
@ -268,6 +266,23 @@ export const CarrierEditPage = observer(() => {
onClose={() => setIsPreviewMediaOpen(false)} onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId} mediaId={mediaId}
/> />
<DeleteModal
open={isDeleteLogoModalOpen}
onDelete={() => {
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
"",
language
);
setIsDeleteLogoModalOpen(false);
}}
onCancel={() => setIsDeleteLogoModalOpen(false)}
edit
/>
</Paper> </Paper>
); );
}); });

View File

@ -1,10 +1,12 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { carrierStore, cityStore, languageStore } from "@shared"; import { carrierStore, cityStore, languageStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { 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";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const CarrierListPage = observer(() => { export const CarrierListPage = observer(() => {
const { carriers, getCarriers, deleteCarrier } = carrierStore; const { carriers, getCarriers, deleteCarrier } = carrierStore;
@ -14,15 +16,19 @@ export const CarrierListPage = observer(() => {
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 { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
(async () => { const fetchData = async () => {
setIsLoading(true);
await getCities("ru"); await getCities("ru");
await getCities("en"); await getCities("en");
await getCities("zh"); await getCities("zh");
await getCarriers(language); await getCarriers(language);
})(); setIsLoading(false);
};
fetchData();
}, [language]); }, [language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
@ -63,11 +69,13 @@ export const CarrierListPage = observer(() => {
headerName: "Город", headerName: "Город",
flex: 1, flex: 1,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
const city = cities[language]?.data.find(
(city) => city.id == params.value
);
return ( return (
<div className="w-full h-full flex items-center"> <div className="w-full h-full flex items-center">
{params.value ? ( {city && city.name ? (
cities[language].data.find((city) => city.id == params.value) city.name
?.name
) : ( ) : (
<Minus size={20} className="text-red-500" /> <Minus size={20} className="text-red-500" />
)} )}
@ -136,12 +144,24 @@ export const CarrierListPage = observer(() => {
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooterPagination
checkboxSelection
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter hideFooter
checkboxSelection
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? (
<CircularProgress size={20} />
) : (
"Нет перевозчиков"
)}
</Box>
),
}}
/> />
</div> </div>

View File

@ -157,15 +157,8 @@ export const CityCreatePage = observer(() => {
setIsUploadMediaOpen(true); setIsUploadMediaOpen(true);
setActiveMenuType("image"); setActiveMenuType("image");
}} }}
setHardcodeType={(type) => { setHardcodeType={() => {
setActiveMenuType( setActiveMenuType("image");
type as
| "thumbnail"
| "watermark_lu"
| "watermark_rd"
| "image"
| null
);
}} }}
/> />
</div> </div>
@ -195,6 +188,8 @@ export const CityCreatePage = observer(() => {
<UploadMediaDialog <UploadMediaDialog
open={isUploadMediaOpen} open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)} onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={createCityData[language]?.name}
contextType="city"
afterUpload={handleMediaSelect} afterUpload={handleMediaSelect}
hardcodeType={ hardcodeType={
activeMenuType as "thumbnail" | "watermark_lu" | "watermark_rd" | null activeMenuType as "thumbnail" | "watermark_lu" | "watermark_rd" | null

View File

@ -43,6 +43,11 @@ export const CityEditPage = observer(() => {
const { getCountries } = countryStore; const { getCountries } = countryStore;
const { getMedia, getOneMedia } = mediaStore; const { getMedia, getOneMedia } = mediaStore;
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
const handleEdit = async () => { const handleEdit = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
@ -58,6 +63,7 @@ export const CityEditPage = observer(() => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
await getCountries("ru");
// Fetch data for all languages // Fetch data for all languages
const ruData = await getCity(id as string, "ru"); const ruData = await getCity(id as string, "ru");
const enData = await getCity(id as string, "en"); const enData = await getCity(id as string, "en");
@ -69,7 +75,7 @@ export const CityEditPage = observer(() => {
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh"); setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
await getOneMedia(ruData.arms as string); await getOneMedia(ruData.arms as string);
await getCountries("ru");
await getMedia(); await getMedia();
} }
})(); })();
@ -174,15 +180,8 @@ export const CityEditPage = observer(() => {
setIsUploadMediaOpen(true); setIsUploadMediaOpen(true);
setActiveMenuType("image"); setActiveMenuType("image");
}} }}
setHardcodeType={(type) => { setHardcodeType={() => {
setActiveMenuType( setActiveMenuType("image");
type as
| "thumbnail"
| "watermark_lu"
| "watermark_rd"
| "image"
| null
);
}} }}
/> />
</div> </div>
@ -199,7 +198,7 @@ export const CityEditPage = observer(() => {
{isLoading ? ( {isLoading ? (
<Loader2 size={20} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />
) : ( ) : (
"Обновить" "Сохранить"
)} )}
</Button> </Button>
</div> </div>
@ -214,6 +213,8 @@ export const CityEditPage = observer(() => {
<UploadMediaDialog <UploadMediaDialog
open={isUploadMediaOpen} open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)} onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={editCityData[language].name}
contextType="city"
afterUpload={handleMediaSelect} afterUpload={handleMediaSelect}
hardcodeType={ hardcodeType={
activeMenuType as activeMenuType as

View File

@ -1,11 +1,13 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, cityStore } from "@shared"; import { ruRU } from "@mui/x-data-grid/locales";
import { languageStore, cityStore, countryStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { 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";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { Box, CircularProgress } from "@mui/material";
export const CityListPage = observer(() => { export const CityListPage = observer(() => {
const { cities, getCities, deleteCity } = cityStore; const { cities, getCities, deleteCity } = cityStore;
@ -14,12 +16,43 @@ export const CityListPage = observer(() => {
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 [rows, setRows] = useState<any[]>([]);
const { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
getCities(language); const fetchData = async () => {
setIsLoading(true);
await countryStore.getCountries("ru");
await countryStore.getCountries("en");
await countryStore.getCountries("zh");
await getCities(language);
setIsLoading(false);
};
fetchData();
}, [language]); }, [language]);
useEffect(() => {
let newRows = cities[language]?.data?.map((city) => ({
id: city.id,
name: city.name,
country: city.country_code,
}));
let newRows2: any[] = [];
for (const city of newRows) {
const name = countryStore.countries[language]?.data?.find(
(country) => country.code === city.country
)?.name;
if (name) {
newRows2.push(city);
}
}
setRows(newRows2 || []);
console.log(newRows2);
}, [cities, countryStore.countries, language, isLoading]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
field: "country", field: "country",
@ -29,7 +62,9 @@ export const CityListPage = observer(() => {
return ( return (
<div className="w-full h-full flex items-center"> <div className="w-full h-full flex items-center">
{params.value ? ( {params.value ? (
params.value countryStore.countries[language]?.data?.find(
(country) => country.code === params.value
)?.name
) : ( ) : (
<Minus size={20} className="text-red-500" /> <Minus size={20} className="text-red-500" />
)} )}
@ -83,12 +118,6 @@ export const CityListPage = observer(() => {
}, },
]; ];
const rows = cities[language]?.data?.map((city) => ({
id: city.id,
name: city.name,
country: city.country,
}));
return ( return (
<> <>
<LanguageSwitcher /> <LanguageSwitcher />
@ -115,12 +144,20 @@ export const CityListPage = observer(() => {
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooterPagination
hideFooter hideFooter
checkboxSelection checkboxSelection
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => { onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids as unknown as number[])); setIds(Array.from(newSelection.ids as unknown as number[]));
}} }}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет городов"}
</Box>
),
}}
/> />
</div> </div>

View File

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

View File

@ -16,6 +16,11 @@ export const CountryEditPage = observer(() => {
const { editCountryData, editCountry, getCountry, setEditCountryData } = const { editCountryData, editCountry, getCountry, setEditCountryData } =
countryStore; countryStore;
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
const handleEdit = async () => { const handleEdit = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
@ -88,7 +93,7 @@ export const CountryEditPage = observer(() => {
{isLoading ? ( {isLoading ? (
<Loader2 size={20} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />
) : ( ) : (
"Обновить" "Сохранить"
)} )}
</Button> </Button>
</div> </div>

View File

@ -1,22 +1,30 @@
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 { countryStore, languageStore } from "@shared"; import { countryStore, languageStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react"; import { Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const CountryListPage = observer(() => { export const CountryListPage = observer(() => {
const { countries, getCountries, deleteCountry } = countryStore; const { countries, getCountries, deleteCountry } = countryStore;
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 { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
getCountries(language); const fetchCountries = async () => {
setIsLoading(true);
await getCountries(language);
setIsLoading(false);
};
fetchCountries();
}, [language]); }, [language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
@ -45,11 +53,11 @@ export const CountryListPage = observer(() => {
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">
<button {/* <button
onClick={() => navigate(`/country/${params.row.code}/edit`)} onClick={() => navigate(`/country/${params.row.code}/edit`)}
> >
<Pencil size={20} className="text-blue-500" /> <Pencil size={20} className="text-blue-500" />
</button> </button> */}
{/* <button onClick={() => navigate(`/country/${params.row.code}`)}> {/* <button onClick={() => navigate(`/country/${params.row.code}`)}>
<Eye size={20} className="text-green-500" /> <Eye size={20} className="text-green-500" />
</button> */} </button> */}
@ -81,7 +89,7 @@ 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>
<CreateButton label="Создать страну" path="/country/create" /> <CreateButton label="Добавить страну" path="/country/add" />
</div> </div>
<div <div
@ -98,14 +106,22 @@ export const CountryListPage = observer(() => {
</div> </div>
<DataGrid <DataGrid
rows={rows} rows={rows || []}
columns={columns} columns={columns}
hideFooter hideFooter
checkboxSelection checkboxSelection
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => { onRowSelectionModelChange={(newSelection) => {
console.log(newSelection);
setIds(Array.from(newSelection.ids as unknown as number[])); setIds(Array.from(newSelection.ids as unknown as number[]));
}} }}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет стран"}
</Box>
),
}}
/> />
</div> </div>

View File

@ -2,3 +2,4 @@ export * from "./CountryListPage";
export * from "./CountryPreviewPage"; export * from "./CountryPreviewPage";
export * from "./CountryCreatePage"; export * from "./CountryCreatePage";
export * from "./CountryEditPage"; export * from "./CountryEditPage";
export * from "./CountryAddPage";

View File

@ -19,7 +19,7 @@ export const EditSightPage = observer(() => {
const { getArticles } = articlesStore; const { getArticles } = articlesStore;
const { id } = useParams(); const { id } = useParams();
const { getRuCities } = cityStore; const { getCities } = cityStore;
let blocker = useBlocker( let blocker = useBlocker(
({ currentLocation, nextLocation }) => ({ currentLocation, nextLocation }) =>
@ -33,13 +33,13 @@ export const EditSightPage = observer(() => {
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
if (id) { if (id) {
await getCities("ru");
await getSightInfo(+id, "ru"); await getSightInfo(+id, "ru");
await getSightInfo(+id, "en"); await getSightInfo(+id, "en");
await getSightInfo(+id, "zh"); await getSightInfo(+id, "zh");
await getArticles("ru"); await getArticles("ru");
await getArticles("en"); await getArticles("en");
await getArticles("zh"); await getArticles("zh");
await getRuCities();
} }
}; };
fetchData(); fetchData();

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -55,7 +55,7 @@ class MapStore {
getRoutes = async () => { getRoutes = async () => {
const response = await languageInstance("ru").get("/route"); const response = await languageInstance("ru").get("/route");
console.log(response.data);
const routesIds = response.data.map((route: any) => route.id); const routesIds = response.data.map((route: any) => route.id);
for (const id of routesIds) { for (const id of routesIds) {
const route = await languageInstance("ru").get(`/route/${id}`); const route = await languageInstance("ru").get(`/route/${id}`);
@ -116,7 +116,6 @@ class MapStore {
const updatedStations: any[] = []; const updatedStations: any[] = [];
const parsedJSON = JSON.parse(json); const parsedJSON = JSON.parse(json);
console.log("Данные для сохранения (GeoJSON):", parsedJSON);
for (const feature of parsedJSON.features) { for (const feature of parsedJSON.features) {
const { geometry, properties, id } = feature; const { geometry, properties, id } = feature;
@ -211,13 +210,6 @@ class MapStore {
const requests: Promise<any>[] = []; const requests: Promise<any>[] = [];
console.log(
`К созданию: ${newStations.length} станций, ${newRoutes.length} маршрутов, ${newSights.length} достопримечательностей.`
);
console.log(
`К обновлению: ${updatedStations.length} станций, ${updatedRoutes.length} маршрутов, ${updatedSights.length} достопримечательностей.`
);
newStations.forEach((data) => newStations.forEach((data) =>
requests.push(languageInstance("ru").post("/station", data)) requests.push(languageInstance("ru").post("/station", data))
); );
@ -239,13 +231,11 @@ class MapStore {
); );
if (requests.length === 0) { if (requests.length === 0) {
console.log("Нет изменений для сохранения.");
return; return;
} }
try { try {
await Promise.all(requests); await Promise.all(requests);
console.log("Все изменения успешно сохранены!");
await Promise.all([ await Promise.all([
this.getRoutes(), this.getRoutes(),

View File

@ -16,7 +16,12 @@ import {
Snackbar, Snackbar,
} from "@mui/material"; } from "@mui/material";
import { Save, ArrowLeft } from "lucide-react"; import { Save, ArrowLeft } from "lucide-react";
import { authInstance, mediaStore, MEDIA_TYPE_LABELS } from "@shared"; import {
authInstance,
mediaStore,
MEDIA_TYPE_LABELS,
languageStore,
} from "@shared";
import { MediaViewer } from "@widgets"; import { MediaViewer } from "@widgets";
export const MediaEditPage = observer(() => { export const MediaEditPage = observer(() => {
@ -64,6 +69,11 @@ export const MediaEditPage = observer(() => {
} }
}, [media]); }, [media]);
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
// const handleDrop = (e: DragEvent<HTMLDivElement>) => { // const handleDrop = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault(); // e.preventDefault();
// e.stopPropagation(); // e.stopPropagation();

View File

@ -1,10 +1,12 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared"; import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Eye, Trash2, Minus } from "lucide-react"; import { Eye, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal } from "@widgets"; import { DeleteModal } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const MediaListPage = observer(() => { export const MediaListPage = observer(() => {
const { media, getMedia, deleteMedia } = mediaStore; const { media, getMedia, deleteMedia } = mediaStore;
@ -13,10 +15,16 @@ export const MediaListPage = observer(() => {
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<string[]>([]); const [ids, setIds] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
getMedia(); const fetchMedia = async () => {
setIsLoading(true);
await getMedia();
setIsLoading(false);
};
fetchMedia();
}, [language]); }, [language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
@ -91,11 +99,6 @@ export const MediaListPage = observer(() => {
return ( return (
<> <>
<div className="w-full"> <div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Медиа</h1>
<CreateButton label="Создать медиа" path="/media/create" />
</div>
<div <div
className="flex justify-end mb-5 duration-300" className="flex justify-end mb-5 duration-300"
style={{ opacity: ids.length > 0 ? 1 : 0 }} style={{ opacity: ids.length > 0 ? 1 : 0 }}
@ -114,10 +117,19 @@ export const MediaListPage = observer(() => {
columns={columns} columns={columns}
hideFooterPagination hideFooterPagination
checkboxSelection checkboxSelection
loading={isLoading}
onRowSelectionModelChange={(newSelection) => { onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as string[]); setIds(Array.from(newSelection.ids) as string[]);
}} }}
hideFooter hideFooter
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет медиафайлов"}
</Box>
),
}}
/> />
</div> </div>

View File

@ -15,13 +15,14 @@ export const MediaPreviewPage = observer(() => {
}, []); }, []);
return ( return (
<div className="w-full h-[80vh] flex flex-col justify-center items-center gap-4"> <div className="w-full flex flex-col justify-center items-center gap-4">
<div className="w-full h-full flex justify-center items-center"> <div className="w-full flex flex-col justify-center items-center gap-4">
<MediaViewer className="w-full h-full" media={oneMedia!} /> <div className="flex justify-center items-center max-w-[60%]">
<MediaViewer media={oneMedia!} />
</div> </div>
{oneMedia && ( {oneMedia && (
<div className="flex flex-col items-center gap-4 bg-[#998879] p-4 rounded-md"> <div className="flex-1 flex flex-col items-center gap-4 bg-[#998879] p-4 rounded-md">
<p className="text-white text-center"> <p className="text-white text-center">
Чтобы скачать файл, нажмите на кнопку ниже Чтобы скачать файл, нажмите на кнопку ниже
</p> </p>
@ -40,5 +41,6 @@ export const MediaPreviewPage = observer(() => {
</div> </div>
)} )}
</div> </div>
</div>
); );
}); });

View File

@ -18,6 +18,11 @@ import {
Paper, Paper,
TableBody, TableBody,
IconButton, IconButton,
Checkbox,
FormControlLabel,
Tabs,
Tab,
Box,
} from "@mui/material"; } from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
@ -28,7 +33,8 @@ import {
DropResult, DropResult,
} from "@hello-pangea/dnd"; } from "@hello-pangea/dnd";
import { authInstance, languageStore } from "@shared"; import { authInstance, languageStore, routeStore } from "@shared";
import { EditStationModal } from "../../widgets/modals/EditStationModal";
// Helper function to insert an item at a specific position (1-based index) // 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[] {
@ -68,6 +74,7 @@ type LinkedItemsProps<T> = {
updatedLinkedItems?: T[]; updatedLinkedItems?: T[];
refresh?: number; refresh?: number;
cityId?: number; cityId?: number;
routeDirection?: boolean;
}; };
export const LinkedItems = < export const LinkedItems = <
@ -118,6 +125,7 @@ export const LinkedItemsContents = <
updatedLinkedItems, updatedLinkedItems,
refresh, refresh,
cityId, cityId,
routeDirection,
}: LinkedItemsProps<T>) => { }: LinkedItemsProps<T>) => {
const { language } = languageStore; const { language } = languageStore;
@ -127,6 +135,10 @@ export const LinkedItemsContents = <
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 [isModalOpen, setIsModalOpen] = useState(false);
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [activeTab, setActiveTab] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => { useEffect(() => {
console.log(error); console.log(error);
@ -137,8 +149,25 @@ export const LinkedItemsContents = <
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) => {
// Если направление маршрута не указано, показываем все станции
if (routeDirection === undefined) return true;
// Фильтруем станции по направлению маршрута
return item.direction === routeDirection;
})
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
// Фильтрация по поиску для массового режима
const filteredAvailableItems = availableItems.filter((item) => {
if (!cityId || item.city_id == cityId) {
if (!searchQuery.trim()) return true;
return String(item.name)
.toLowerCase()
.includes(searchQuery.toLowerCase());
}
return false;
});
useEffect(() => { useEffect(() => {
if (updatedLinkedItems) { if (updatedLinkedItems) {
setLinkedItems(updatedLinkedItems); setLinkedItems(updatedLinkedItems);
@ -250,12 +279,57 @@ export const LinkedItemsContents = <
data: { [`${childResource}_id`]: itemId }, data: { [`${childResource}_id`]: itemId },
}) })
.then(() => { .then(() => {
setLinkedItems((prev) => prev.filter((item) => item.id !== itemId)); setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
onUpdate?.(); onUpdate?.();
}) })
.catch((error) => { .catch((error) => {
console.error("Error unlinking item:", error); console.error("Error deleting item:", error);
setError("Failed to unlink station"); setError("Failed to delete station");
});
};
const handleStationClick = (item: T) => {
routeStore.setSelectedStationId(item.id);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
};
const handleCheckboxChange = (itemId: number) => {
const newSelected = new Set(selectedItems);
if (newSelected.has(itemId)) {
newSelected.delete(itemId);
} else {
newSelected.add(itemId);
}
setSelectedItems(newSelected);
};
const handleBulkLink = () => {
if (selectedItems.size === 0) return;
setError(null);
const selectedStations = Array.from(selectedItems).map((id) => ({ id }));
const requestData = {
stations: [
...linkedItems.map((item) => ({ id: item.id })),
...selectedStations,
],
};
authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
.then(() => {
const newItems = allItems.filter((item) => selectedItems.has(item.id));
setLinkedItems([...linkedItems, ...newItems]);
setSelectedItems(new Set());
onUpdate?.();
})
.catch((error) => {
console.error("Error linking items:", error);
setError("Failed to link stations");
}); });
}; };
@ -306,6 +380,7 @@ export const LinkedItemsContents = <
ref={provided.innerRef} ref={provided.innerRef}
{...provided.draggableProps} {...provided.draggableProps}
hover hover
onClick={() => handleStationClick(item)}
> >
{type === "edit" && dragAllowed && ( {type === "edit" && dragAllowed && (
<TableCell {...provided.dragHandleProps}> <TableCell {...provided.dragHandleProps}>
@ -358,21 +433,49 @@ export const LinkedItemsContents = <
{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
value={activeTab}
onChange={(_, newValue) => setActiveTab(newValue)}
>
<Tab label="По одной" />
<Tab label="Массово" />
</Tabs>
<Box sx={{ mt: 2 }}>
{activeTab === 0 && (
<Stack gap={2}>
<Autocomplete <Autocomplete
fullWidth fullWidth
value={ value={
availableItems?.find((item) => item.id === selectedItemId) || null availableItems?.find(
(item) => item.id === selectedItemId
) || null
}
onChange={(_, newValue) =>
setSelectedItemId(newValue?.id || null)
} }
onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)}
options={availableItems.filter( options={availableItems.filter(
(item) => !cityId || item.city_id == cityId (item) => !cityId || item.city_id == cityId
)} )}
getOptionLabel={(item) => String(item.name)} getOptionLabel={(item) => String(item.name)}
renderInput={(params) => ( renderInput={(params) => (
<TextField {...params} label="Выберите станцию" fullWidth /> <TextField
{...params}
label="Выберите остановку"
fullWidth
/>
)} )}
isOptionEqualToValue={(option, value) => option.id === value?.id} isOptionEqualToValue={(option, value) =>
option.id === value?.id
}
filterOptions={(options, { inputValue }) => { filterOptions={(options, { inputValue }) => {
const searchWords = inputValue const searchWords = inputValue
.toLowerCase() .toLowerCase()
@ -389,7 +492,12 @@ export const LinkedItemsContents = <
}} }}
renderOption={(props, option) => ( renderOption={(props, option) => (
<li {...props} key={option.id}> <li {...props} key={option.id}>
{String(option.name)} <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> </li>
)} )}
/> />
@ -424,6 +532,70 @@ export const LinkedItemsContents = <
</Button> </Button>
</Stack> </Stack>
)} )}
{activeTab === 1 && (
<Stack gap={2}>
{/* Поле поиска */}
<TextField
fullWidth
label="Поиск остановок"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Введите название остановки..."
size="small"
sx={{ mb: 1 }}
/>
{/* Список доступных остановок с чекбоксами */}
<Paper sx={{ maxHeight: 300, overflow: "auto", p: 2 }}>
<Stack gap={1}>
{filteredAvailableItems.map((item) => (
<FormControlLabel
key={item.id}
control={
<Checkbox
checked={selectedItems.has(item.id)}
onChange={() => handleCheckboxChange(item.id)}
size="small"
/>
}
label={String(item.name)}
sx={{
margin: 0,
"& .MuiFormControlLabel-label": {
fontSize: "0.875rem",
},
}}
/>
))}
{filteredAvailableItems.length === 0 && (
<Typography
color="textSecondary"
textAlign="center"
py={1}
>
{searchQuery.trim()
? "Остановки не найдены"
: "Нет доступных остановок"}
</Typography>
)}
</Stack>
</Paper>
<Button
variant="contained"
onClick={handleBulkLink}
disabled={selectedItems.size === 0}
sx={{ alignSelf: "flex-start" }}
>
Добавить выбранные ({selectedItems.size})
</Button>
</Stack>
)}
</Box>
</Stack>
)}
<EditStationModal open={isModalOpen} onClose={handleCloseModal} />
</> </>
); );
}; };

View File

@ -6,19 +6,23 @@ import {
MenuItem, MenuItem,
FormControl, FormControl,
InputLabel, InputLabel,
// Typography, Typography,
Box, Box,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material"; } from "@mui/material";
import { LanguageSwitcher } from "@widgets"; import { MediaViewer } from "@widgets";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react"; import { ArrowLeft, Loader2, Save, Plus } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, 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 { carrierStore } from "../../../shared/store/CarrierStore"; import { carrierStore } from "../../../shared/store/CarrierStore";
import { articlesStore } from "../../../shared/store/ArticlesStore"; import { articlesStore } from "../../../shared/store/ArticlesStore";
import { Route, routeStore } from "../../../shared/store/RouteStore"; import { Route, routeStore } from "../../../shared/store/RouteStore";
import { languageStore } from "@shared"; import { languageStore, SelectArticleModal, SelectMediaDialog } from "@shared";
export const RouteCreatePage = observer(() => { export const RouteCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -33,7 +37,12 @@ export const RouteCreatePage = observer(() => {
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 [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
useState(false);
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
@ -78,6 +87,25 @@ export const RouteCreatePage = observer(() => {
} }
}; };
const handleArticleSelect = (articleId: number) => {
setGovernorAppeal(articleId.toString());
setIsSelectArticleDialogOpen(false);
};
const handleVideoSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setVideoPreview(media.id);
setIsSelectVideoDialogOpen(false);
};
const handleVideoPreviewClick = () => {
setIsVideoPreviewOpen(true);
};
const handleCreateRoute = async () => { const handleCreateRoute = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
@ -126,6 +154,8 @@ export const RouteCreatePage = observer(() => {
center_latitude, center_latitude,
center_longitude, center_longitude,
path, path,
video_preview:
videoPreview && videoPreview !== "" ? videoPreview : undefined,
}; };
await routeStore.createRoute(newRoute); await routeStore.createRoute(newRoute);
@ -139,9 +169,13 @@ export const RouteCreatePage = observer(() => {
} }
}; };
// Получаем название выбранной статьи для отображения
const selectedArticle = articlesStore.articleList.ru.data.find(
(article) => article.id === Number(governorAppeal)
);
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 />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button <button
className="flex items-center gap-2" className="flex items-center gap-2"
@ -184,9 +218,11 @@ export const RouteCreatePage = observer(() => {
onChange={(e) => setRouteNumber(e.target.value)} onChange={(e) => setRouteNumber(e.target.value)}
/> />
<TextField <TextField
className="w-full"
label="Координаты маршрута"
multiline multiline
className="w-full max-h-[300px] overflow-y-scroll" minRows={2}
minRows={4} maxRows={10}
value={routeCoords} value={routeCoords}
onChange={(e) => { onChange={(e) => {
const newValue = e.target.value; const newValue = e.target.value;
@ -209,10 +245,25 @@ export const RouteCreatePage = observer(() => {
helperText={ helperText={
typeof validateCoordinates(routeCoords) === "string" typeof validateCoordinates(routeCoords) === "string"
? validateCoordinates(routeCoords) ? validateCoordinates(routeCoords)
: "Введите координаты в формате: широта долгота (можно использовать запятые или пробелы)" : "Формат: широта долгота"
} }
placeholder="55.7558 37.6173 placeholder="55.7558 37.6173&#10;55.7539 37.6208"
55.7539 37.6208" sx={{
"& .MuiInputBase-root": {
maxHeight: "500px",
overflow: "auto",
},
"& .MuiInputBase-input": {
fontFamily: "monospace",
fontSize: "0.8rem",
lineHeight: "1.2",
padding: "8px 12px",
},
"& .MuiFormHelperText-root": {
fontSize: "0.75rem",
marginTop: "2px",
},
}}
/> />
<TextField <TextField
className="w-full" className="w-full"
@ -221,24 +272,75 @@ export const RouteCreatePage = observer(() => {
value={govRouteNumber} value={govRouteNumber}
onChange={(e) => setGovRouteNumber(e.target.value)} onChange={(e) => setGovRouteNumber(e.target.value)}
/> />
<FormControl fullWidth required>
<InputLabel>Обращение губернатора</InputLabel> {/* Заменяем Select на кнопку для выбора статьи */}
<Select <Box className="flex flex-col gap-2">
value={governorAppeal} <label className="text-sm font-medium text-gray-700">
label="Обращение губернатора" Обращение к пассажирам
onChange={(e) => setGovernorAppeal(e.target.value as string)} </label>
disabled={articlesStore.articleList.ru.data.length === 0} <Box className="flex gap-2">
<TextField
className="flex-1"
value={selectedArticle?.heading || "Статья не выбрана"}
placeholder="Выберите статью"
disabled
sx={{
"& .MuiInputBase-input": {
color: selectedArticle ? "inherit" : "#999",
},
}}
/>
<Button
variant="outlined"
onClick={() => setIsSelectArticleDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
> >
<MenuItem value="">Не выбрано</MenuItem> Выбрать
{articlesStore.articleList.ru.data.map( </Button>
(a: (typeof articlesStore.articleList.ru.data)[number]) => ( </Box>
<MenuItem key={a.id} value={a.id}> </Box>
{a.heading}
</MenuItem> {/* Селектор видео превью */}
) <Box className="flex flex-col gap-2">
)} <label className="text-sm font-medium text-gray-700">
</Select> Видео превью
</FormControl> </label>
<Box className="flex gap-2">
<Typography
variant="body1"
onClick={handleVideoPreviewClick}
component="span"
className="flex-1"
sx={{
color:
videoPreview && videoPreview !== "" ? "inherit" : "#999",
cursor:
videoPreview && videoPreview !== "" ? "pointer" : "default",
border: "1px solid #ccc",
borderRadius: "4px",
padding: "16.5px 14px",
display: "flex",
alignItems: "center",
minHeight: "56px",
backgroundColor: "#fff",
}}
>
{videoPreview && videoPreview !== ""
? "Видео выбрано"
: "Видео не выбрано"}
</Typography>
<Button
variant="outlined"
onClick={() => setIsSelectVideoDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
</Box>
<FormControl fullWidth required> <FormControl fullWidth required>
<InputLabel>Прямой/обратный маршрут</InputLabel> <InputLabel>Прямой/обратный маршрут</InputLabel>
<Select <Select
@ -298,6 +400,49 @@ export const RouteCreatePage = observer(() => {
</Button> </Button>
</div> </div>
</div> </div>
{/* Модальное окно выбора статьи */}
<SelectArticleModal
open={isSelectArticleDialogOpen}
onClose={() => setIsSelectArticleDialogOpen(false)}
onSelectArticle={handleArticleSelect}
/>
{/* Модальное окно выбора видео */}
<SelectMediaDialog
open={isSelectVideoDialogOpen}
onClose={() => setIsSelectVideoDialogOpen(false)}
onSelectMedia={handleVideoSelect}
mediaType={2}
/>
{/* Модальное окно предпросмотра видео */}
{videoPreview && videoPreview !== "" && (
<Dialog
open={isVideoPreviewOpen}
onClose={() => setIsVideoPreviewOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>Предпросмотр видео</DialogTitle>
<DialogContent>
<Box className="flex justify-center items-center p-4">
<MediaViewer
media={{
id: videoPreview,
media_type: 2,
filename: "video_preview",
}}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsVideoPreviewOpen(false)}>
Закрыть
</Button>
</DialogActions>
</Dialog>
)}
</Paper> </Paper>
); );
}); });

View File

@ -6,34 +6,55 @@ import {
MenuItem, MenuItem,
FormControl, FormControl,
InputLabel, InputLabel,
// Typography, Typography,
Box, Box,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material"; } from "@mui/material";
import { LanguageSwitcher } from "@widgets"; import { MediaViewer } from "@widgets";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } 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 { carrierStore } from "../../../shared/store/CarrierStore"; import { carrierStore } from "../../../shared/store/CarrierStore";
import { articlesStore } from "../../../shared/store/ArticlesStore"; import { articlesStore } from "../../../shared/store/ArticlesStore";
import { routeStore } from "../../../shared/store/RouteStore"; import {
routeStore,
languageStore,
SelectArticleModal,
SelectMediaDialog,
} from "@shared";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { languageStore, stationsStore } from "@shared"; import { stationsStore } from "@shared";
import { LinkedItems } from "../LinekedStations"; import { LinkedItems } from "../LinekedStations";
export const RouteEditPage = observer(() => { export const RouteEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams(); const { id } = useParams();
const { editRouteData } = routeStore; const { editRouteData, copyRouteAction } = routeStore;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
useState(false);
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const { language } = languageStore; const { language } = languageStore;
const [coordinates, setCoordinates] = useState<string>(""); const [coordinates, setCoordinates] = useState<string>("");
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
// Устанавливаем русский язык при загрузке страницы
const response = await routeStore.getRoute(Number(id)); const response = await routeStore.getRoute(Number(id));
routeStore.setEditRouteData(response); routeStore.setEditRouteData(response);
languageStore.setLanguage("ru");
};
fetchData();
}, []);
useEffect(() => {
const fetchData = async () => {
carrierStore.getCarriers(language); carrierStore.getCarriers(language);
stationsStore.getStations(); stationsStore.getStations();
articlesStore.getArticleList(); articlesStore.getArticleList();
@ -94,9 +115,43 @@ export const RouteEditPage = observer(() => {
} }
}; };
const handleCopy = async () => {
await copyRouteAction(Number(id));
toast.success("Маршрут успешно скопирован");
};
const handleArticleSelect = (articleId: number) => {
routeStore.setEditRouteData({
governor_appeal: articleId,
});
setIsSelectArticleDialogOpen(false);
};
const handleVideoSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
routeStore.setEditRouteData({
video_preview: media.id,
});
setIsSelectVideoDialogOpen(false);
};
const handleVideoPreviewClick = () => {
if (editRouteData.video_preview && editRouteData.video_preview !== "") {
setIsVideoPreviewOpen(true);
}
};
// Получаем название выбранной статьи для отображения
const selectedArticle = articlesStore.articleList.ru.data.find(
(article) => article.id === editRouteData.governor_appeal
);
return ( 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 />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button <button
className="flex items-center gap-2" className="flex items-center gap-2"
@ -152,9 +207,11 @@ export const RouteEditPage = observer(() => {
} }
/> />
<TextField <TextField
className="w-full max-h-[300px] overflow-y-scroll -mt-5 h-full" className="w-full"
label="Координаты маршрута"
multiline multiline
minRows={4} minRows={2}
maxRows={10}
value={coordinates} value={coordinates}
onChange={(e) => { onChange={(e) => {
const newValue = e.target.value; const newValue = e.target.value;
@ -190,10 +247,25 @@ export const RouteEditPage = observer(() => {
helperText={ helperText={
typeof validateCoordinates(coordinates) === "string" typeof validateCoordinates(coordinates) === "string"
? validateCoordinates(coordinates) ? validateCoordinates(coordinates)
: "Введите координаты в формате: широта долгота (можно использовать запятые или пробелы)" : "Формат: широта долгота"
} }
placeholder="55.7558 37.6173 placeholder="55.7558 37.6173&#10;55.7539 37.6208"
55.7539 37.6208" sx={{
"& .MuiInputBase-root": {
maxHeight: "500px",
overflow: "auto",
},
"& .MuiInputBase-input": {
fontFamily: "monospace",
fontSize: "0.8rem",
lineHeight: "1.2",
padding: "8px 12px",
},
"& .MuiFormHelperText-root": {
fontSize: "0.75rem",
marginTop: "2px",
},
}}
/> />
<TextField <TextField
className="w-full" className="w-full"
@ -206,28 +278,75 @@ export const RouteEditPage = observer(() => {
}) })
} }
/> />
<FormControl fullWidth required>
<InputLabel>Обращение губернатора</InputLabel> {/* Заменяем Select на кнопку для выбора статьи */}
<Select <Box className="flex flex-col gap-2">
value={editRouteData.governor_appeal || ""} <label className="text-sm font-medium text-gray-700">
label="Обращение губернатора" Обращение к пассажирам
onChange={(e) => </label>
routeStore.setEditRouteData({ <Box className="flex gap-2">
governor_appeal: Number(e.target.value), <TextField
}) className="flex-1"
} value={selectedArticle?.heading || "Статья не выбрана"}
disabled={articlesStore.articleList.ru.data.length === 0} placeholder="Выберите статью"
disabled
sx={{
"& .MuiInputBase-input": {
color: selectedArticle ? "inherit" : "#999",
},
}}
/>
<Button
variant="outlined"
onClick={() => setIsSelectArticleDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
> >
<MenuItem value="">Не выбрано</MenuItem> Выбрать
{articlesStore.articleList.ru.data.map( </Button>
(a: (typeof articlesStore.articleList.ru.data)[number]) => ( </Box>
<MenuItem key={a.id} value={a.id}> </Box>
{a.heading}
</MenuItem> {/* Селектор видео превью */}
) <Box className="flex flex-col gap-2">
)} <label className="text-sm font-medium text-gray-700">
</Select> Видео превью
</FormControl> </label>
<Box className="flex gap-2">
<Typography
variant="body1"
onClick={handleVideoPreviewClick}
component="span"
className="flex-1"
sx={{
color:
editRouteData.video_preview &&
editRouteData.video_preview !== ""
? "inherit"
: "#999",
cursor:
editRouteData.video_preview &&
editRouteData.video_preview !== ""
? "pointer"
: "default",
}}
>
{editRouteData.video_preview &&
editRouteData.video_preview !== ""
? "Видео выбрано"
: "Видео не выбрано"}
</Typography>
<Button
variant="outlined"
onClick={() => setIsSelectVideoDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
</Box>
<FormControl fullWidth required> <FormControl fullWidth required>
<InputLabel>Прямой/обратный маршрут</InputLabel> <InputLabel>Прямой/обратный маршрут</InputLabel>
<Select <Select
@ -311,9 +430,21 @@ export const RouteEditPage = observer(() => {
onUpdate={() => { onUpdate={() => {
routeStore.getRoute(Number(id)); routeStore.getRoute(Number(id));
}} }}
routeDirection={editRouteData.route_direction}
/> />
<div className="flex w-full justify-end"> <div className="flex w-full justify-between">
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Copy size={20} />}
onClick={handleCopy}
disabled={isLoading}
>
Скопировать
</Button>
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"
@ -326,6 +457,45 @@ export const RouteEditPage = observer(() => {
</Button> </Button>
</div> </div>
</div> </div>
{/* Модальное окно выбора статьи */}
<SelectArticleModal
open={isSelectArticleDialogOpen}
onClose={() => setIsSelectArticleDialogOpen(false)}
onSelectArticle={handleArticleSelect}
/>
{/* Модальное окно выбора видео */}
<SelectMediaDialog
open={isSelectVideoDialogOpen}
onClose={() => setIsSelectVideoDialogOpen(false)}
onSelectMedia={handleVideoSelect}
mediaType={2}
/>
{/* Модальное окно предпросмотра видео */}
<Dialog
open={isVideoPreviewOpen}
onClose={() => setIsVideoPreviewOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>Предпросмотр видео</DialogTitle>
<DialogContent>
<Box className="flex justify-center items-center p-4">
<MediaViewer
media={{
id: editRouteData.video_preview,
media_type: 2,
filename: "video_preview",
}}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsVideoPreviewOpen(false)}>Закрыть</Button>
</DialogActions>
</Dialog>
</Paper> </Paper>
); );
}); });

View File

@ -1,11 +1,12 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { carrierStore, languageStore, routeStore } from "@shared"; import { carrierStore, languageStore, routeStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { 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";
import { CreateButton, DeleteModal } from "@widgets"; import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { LanguageSwitcher } from "@widgets"; import { Box, CircularProgress } from "@mui/material";
export const RouteListPage = observer(() => { export const RouteListPage = observer(() => {
const { routes, getRoutes, deleteRoute } = routeStore; const { routes, getRoutes, deleteRoute } = routeStore;
@ -15,14 +16,17 @@ export const RouteListPage = observer(() => {
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 { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
setIsLoading(true);
await getCarriers("ru"); await getCarriers("ru");
await getCarriers("en"); await getCarriers("en");
await getCarriers("zh"); await getCarriers("zh");
await getRoutes(); await getRoutes();
setIsLoading(false);
}; };
fetchData(); fetchData();
}, [language]); }, [language]);
@ -145,12 +149,20 @@ export const RouteListPage = observer(() => {
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooterPagination
checkboxSelection
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter hideFooter
checkboxSelection
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет маршрутов"}
</Box>
),
}}
/> />
</div> </div>

View File

@ -2,7 +2,7 @@ export const UP_SCALE = 30000;
export const PATH_WIDTH = 15; export const PATH_WIDTH = 15;
export const STATION_RADIUS = 20; export const STATION_RADIUS = 20;
export const STATION_OUTLINE_WIDTH = 10; export const STATION_OUTLINE_WIDTH = 10;
export const SIGHT_SIZE = 60; export const SIGHT_SIZE = 40;
export const SCALE_FACTOR = 50; export const SCALE_FACTOR = 50;
export const BACKGROUND_COLOR = 0x111111; export const BACKGROUND_COLOR = 0x111111;

View File

@ -37,7 +37,7 @@ export function InfiniteCanvas({
setScreenCenter, setScreenCenter,
screenCenter, screenCenter,
} = useTransform(); } = useTransform();
const { routeData, originalRouteData } = useMapData(); const { routeData, originalRouteData, setSelectedSight } = useMapData();
const applicationRef = useApplication(); const applicationRef = useApplication();
@ -45,6 +45,7 @@ export function InfiniteCanvas({
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 }); const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
const [startRotation, setStartRotation] = useState(0); const [startRotation, setStartRotation] = useState(0);
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 }); const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
const [isPointerDown, setIsPointerDown] = useState(false);
// Флаг для предотвращения конфликта между пользовательским вводом и данными маршрута // Флаг для предотвращения конфликта между пользовательским вводом и данными маршрута
const [isUserInteracting, setIsUserInteracting] = useState(false); const [isUserInteracting, setIsUserInteracting] = useState(false);
@ -65,7 +66,8 @@ export function InfiniteCanvas({
}, [applicationRef?.app.canvas, setScreenCenter]); }, [applicationRef?.app.canvas, setScreenCenter]);
const handlePointerDown = (e: FederatedMouseEvent) => { const handlePointerDown = (e: FederatedMouseEvent) => {
setIsDragging(true); setIsPointerDown(true);
setIsDragging(false);
setIsUserInteracting(true); // Устанавливаем флаг взаимодействия пользователя setIsUserInteracting(true); // Устанавливаем флаг взаимодействия пользователя
setStartPosition({ setStartPosition({
x: position.x, x: position.x,
@ -93,7 +95,18 @@ export function InfiniteCanvas({
}, [originalRouteData?.rotate, isUserInteracting, setRotation]); }, [originalRouteData?.rotate, isUserInteracting, setRotation]);
const handlePointerMove = (e: FederatedMouseEvent) => { const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isDragging) return; if (!isPointerDown) return;
// Проверяем, началось ли перетаскивание
if (!isDragging) {
const dx = e.globalX - startMousePosition.x;
const dy = e.globalY - startMousePosition.y;
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
setIsDragging(true);
} else {
return;
}
}
if (e.shiftKey) { if (e.shiftKey) {
const center = screenCenter ?? { x: 0, y: 0 }; const center = screenCenter ?? { x: 0, y: 0 };
@ -136,6 +149,12 @@ export function InfiniteCanvas({
}; };
const handlePointerUp = (e: FederatedMouseEvent) => { const handlePointerUp = (e: FederatedMouseEvent) => {
// Если не было перетаскивания, то это простой клик - закрываем виджет
if (!isDragging) {
setSelectedSight(undefined);
}
setIsPointerDown(false);
setIsDragging(false); setIsDragging(false);
// Сбрасываем флаг взаимодействия через небольшую задержку // Сбрасываем флаг взаимодействия через небольшую задержку
// чтобы избежать немедленного срабатывания useEffect // чтобы избежать немедленного срабатывания useEffect
@ -185,7 +204,6 @@ export function InfiniteCanvas({
useEffect(() => { useEffect(() => {
applicationRef?.app.render(); applicationRef?.app.render();
console.log(position, scale, rotation);
}, [position, scale, rotation]); }, [position, scale, rotation]);
return ( return (

View File

@ -1,10 +1,30 @@
import { 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 { useMapData } from "./MapDataContext";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { authInstance } from "@shared";
export function LeftSidebar() { export const LeftSidebar = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const navigationType = useNavigationType(); // PUSH, POP, REPLACE const navigationType = useNavigationType(); // PUSH, POP, REPLACE
const { routeData } = useMapData();
const [carrierThumbnail, setCarrierThumbnail] = useState<string | null>(null);
const [carrierLogo, setCarrierLogo] = useState<string | null>(null);
useEffect(() => {
async function fetchCarrierThumbnail() {
if (routeData?.carrier_id) {
const { city_id, logo } = (
await authInstance.get(`/carrier/${routeData.carrier_id}`)
).data;
const { arms } = (await authInstance.get(`/city/${city_id}`)).data;
setCarrierThumbnail(arms);
setCarrierLogo(logo);
}
}
fetchCarrierThumbnail();
}, [routeData?.carrier_id]);
const handleBack = () => { const handleBack = () => {
if (navigationType === "PUSH") { if (navigationType === "PUSH") {
@ -27,6 +47,7 @@ export function LeftSidebar() {
color: "#fff", color: "#fff",
backgroundColor: "#222", backgroundColor: "#222",
borderRadius: 10, borderRadius: 10,
height: 40,
width: "100%", width: "100%",
border: "none", border: "none",
cursor: "pointer", cursor: "pointer",
@ -41,10 +62,30 @@ export function LeftSidebar() {
justifyContent="center" justifyContent="center"
my={10} my={10}
> >
<img src={"/Emblem.svg"} alt="logo" width={100} height={100} /> <div
style={{
maxWidth: 200,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 10,
}}
>
{carrierThumbnail && (
<MediaViewer
media={{
id: carrierThumbnail,
media_type: 1, // Тип "Фото" для логотипа
filename: "route_thumbnail",
}}
fullWidth
fullHeight
/>
)}
<Typography sx={{ mb: 2, color: "#fff" }} textAlign="center"> <Typography sx={{ mb: 2, color: "#fff" }} textAlign="center">
При поддержке Правительства Санкт-Петербурга При поддержке Правительства
</Typography> </Typography>{" "}
</div>
</Stack> </Stack>
<Stack <Stack
@ -65,15 +106,20 @@ export function LeftSidebar() {
<Stack <Stack
direction="column" direction="column"
alignItems="center" alignItems="center"
maxHeight={150}
justifyContent="center" justifyContent="center"
my={10} my={10}
> >
<img {carrierLogo && (
src={"/GET.png"} <MediaViewer
alt="logo" media={{
width="80%" id: carrierLogo,
style={{ margin: "0 auto" }} media_type: 1, // Тип "Фото" для логотипа
filename: "route_thumbnail_logo",
}}
fullHeight
/> />
)}
</Stack> </Stack>
<Typography <Typography
@ -86,4 +132,4 @@ export function LeftSidebar() {
</Typography> </Typography>
</Stack> </Stack>
); );
} });

View File

@ -29,10 +29,13 @@ const MapDataContext = createContext<{
isRouteLoading: boolean; isRouteLoading: boolean;
isStationLoading: boolean; isStationLoading: boolean;
isSightLoading: boolean; isSightLoading: boolean;
selectedSight?: SightData;
setSelectedSight: (sight?: SightData) => void;
setScaleRange: (min: number, max: number) => void; setScaleRange: (min: number, max: number) => void;
setMapRotation: (rotation: number) => void; setMapRotation: (rotation: number) => void;
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;
setSightCoordinates: ( setSightCoordinates: (
sightId: number, sightId: number,
latitude: number, latitude: number,
@ -50,10 +53,13 @@ const MapDataContext = createContext<{
isRouteLoading: true, isRouteLoading: true,
isStationLoading: true, isStationLoading: true,
isSightLoading: true, isSightLoading: true,
selectedSight: undefined,
setSelectedSight: () => {},
setScaleRange: () => {}, setScaleRange: () => {},
setMapRotation: () => {}, setMapRotation: () => {},
setMapCenter: () => {}, setMapCenter: () => {},
setStationOffset: () => {}, setStationOffset: () => {},
setStationAlign: () => {},
setSightCoordinates: () => {}, setSightCoordinates: () => {},
saveChanges: () => {}, saveChanges: () => {},
}); });
@ -87,6 +93,7 @@ export const MapDataProvider = observer(
const [isRouteLoading, setIsRouteLoading] = useState(true); const [isRouteLoading, setIsRouteLoading] = useState(true);
const [isStationLoading, setIsStationLoading] = useState(true); const [isStationLoading, setIsStationLoading] = useState(true);
const [isSightLoading, setIsSightLoading] = useState(true); const [isSightLoading, setIsSightLoading] = useState(true);
const [selectedSight, setSelectedSight] = useState<SightData>();
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
@ -106,17 +113,18 @@ export const MapDataProvider = observer(
languageInstance("ru").get(`/route/${routeId}/station`), languageInstance("ru").get(`/route/${routeId}/station`),
languageInstance("en").get(`/route/${routeId}/station`), languageInstance("en").get(`/route/${routeId}/station`),
languageInstance("zh").get(`/route/${routeId}/station`), languageInstance("zh").get(`/route/${routeId}/station`),
authInstance.get(`/route/${routeId}/sight`), languageInstance("ru").get(`/route/${routeId}/sight`),
]); ]);
setOriginalRouteData(routeResponse.data as RouteData); const routeData = routeResponse.data as RouteData;
setOriginalRouteData(routeData);
setOriginalStationData(ruStationResponse.data as StationData[]); setOriginalStationData(ruStationResponse.data as StationData[]);
setStationData({ setStationData({
ru: ruStationResponse.data as StationData[], ru: ruStationResponse.data as StationData[],
en: enStationResponse.data as StationData[], en: enStationResponse.data as StationData[],
zh: zhStationResponse.data as StationData[], zh: zhStationResponse.data as StationData[],
}); });
setOriginalSightData(sightResponse as unknown as SightData[]); setOriginalSightData(sightResponse.data as SightData[]);
setIsRouteLoading(false); setIsRouteLoading(false);
setIsStationLoading(false); setIsStationLoading(false);
@ -176,42 +184,135 @@ export const MapDataProvider = observer(
} }
async function saveSightChanges() { async function saveSightChanges() {
console.log("sightChanges", sightChanges);
for (const sight of sightChanges) { for (const sight of sightChanges) {
await authInstance.patch(`/route/${routeId}/sight`, sight); await authInstance.patch(`/route/${routeId}/sight`, sight);
} }
} }
function setStationOffset(stationId: number, x: number, y: number) { function setStationOffset(stationId: number, x: number, y: number) {
setStationChanges((prev) => { const currentStation = stationData.ru?.find(
let found = prev.find((station) => station.station_id === stationId);
if (found) {
found.offset_x = x;
found.offset_y = y;
return prev.map((station) => {
if (station.station_id === stationId) {
return found;
}
return station;
});
} else {
const foundStation = stationData.ru?.find(
(station) => station.id === stationId (station) => station.id === stationId
); );
if (foundStation) { if (
currentStation &&
Math.abs(currentStation.offset_x - x) < 0.01 &&
Math.abs(currentStation.offset_y - y) < 0.01
) {
return;
}
setStationChanges((prev) => {
const existingIndex = prev.findIndex(
(station) => station.station_id === stationId
);
if (existingIndex !== -1) {
const newChanges = [...prev];
newChanges[existingIndex] = {
...newChanges[existingIndex],
offset_x: x,
offset_y: y,
};
return newChanges;
} else {
const originalStation = originalStationData?.find(
(s) => s.id === stationId
);
return [ return [
...prev, ...prev,
{ {
station_id: stationId, station_id: stationId,
offset_x: x, offset_x: x,
offset_y: y, offset_y: y,
transfers: foundStation.transfers, align: originalStation?.align ?? 1,
transfers: originalStation?.transfers ?? {
bus: "",
metro_blue: "",
metro_green: "",
metro_orange: "",
metro_purple: "",
metro_red: "",
train: "",
tram: "",
trolleybus: "",
},
}, },
]; ];
} }
return prev; });
setStationData((prev) => {
const updated = { ...prev };
Object.keys(updated).forEach((lang) => {
updated[lang] = updated[lang].map((station) => {
if (station.id === stationId) {
return { ...station, offset_x: x, offset_y: y };
} }
return station;
});
});
return updated;
});
}
function setStationAlign(stationId: number, align: number) {
const currentStation = stationData.ru?.find(
(station) => station.id === stationId
);
if (currentStation && currentStation.align === align) {
return;
}
setStationChanges((prev) => {
const existingIndex = prev.findIndex(
(station) => station.station_id === stationId
);
if (existingIndex !== -1) {
const newChanges = [...prev];
newChanges[existingIndex] = {
...newChanges[existingIndex],
align: align,
};
return newChanges;
} else {
const originalStation = originalStationData?.find(
(s) => s.id === stationId
);
return [
...prev,
{
station_id: stationId,
align: align,
offset_x: originalStation?.offset_x ?? 0,
offset_y: originalStation?.offset_y ?? 0,
transfers: originalStation?.transfers ?? {
bus: "",
metro_blue: "",
metro_green: "",
metro_orange: "",
metro_purple: "",
metro_red: "",
train: "",
tram: "",
trolleybus: "",
},
},
];
}
});
setStationData((prev) => {
const updated = { ...prev };
Object.keys(updated).forEach((lang) => {
updated[lang] = updated[lang].map((station) => {
if (station.id === stationId) {
return { ...station, align: align };
}
return station;
});
});
return updated;
}); });
} }
@ -221,14 +322,18 @@ export const MapDataProvider = observer(
longitude: number longitude: number
) { ) {
setSightChanges((prev) => { setSightChanges((prev) => {
let found = prev.find((sight) => sight.sight_id === sightId); const existingIndex = prev.findIndex(
if (found) { (sight) => sight.sight_id === sightId
found.latitude = latitude; );
found.longitude = longitude;
return prev.map((sight) => { if (existingIndex !== -1) {
if (sight.sight_id === sightId) { return prev.map((sight, index) => {
return found; if (index === existingIndex) {
return {
...sight,
latitude,
longitude,
};
} }
return sight; return sight;
}); });
@ -249,9 +354,7 @@ export const MapDataProvider = observer(
}); });
} }
useEffect(() => { useEffect(() => {}, [sightChanges]);
console.log("sightChanges", sightChanges);
}, [sightChanges]);
const value = useMemo( const value = useMemo(
() => ({ () => ({
@ -264,11 +367,14 @@ export const MapDataProvider = observer(
isRouteLoading, isRouteLoading,
isStationLoading, isStationLoading,
isSightLoading, isSightLoading,
selectedSight,
setSelectedSight,
setScaleRange, setScaleRange,
setMapRotation, setMapRotation,
setMapCenter, setMapCenter,
saveChanges, saveChanges,
setStationOffset, setStationOffset,
setStationAlign,
setSightCoordinates, setSightCoordinates,
}), }),
[ [
@ -281,6 +387,7 @@ export const MapDataProvider = observer(
isRouteLoading, isRouteLoading,
isStationLoading, isStationLoading,
isSightLoading, isSightLoading,
selectedSight,
] ]
); );

View File

@ -1,8 +1,9 @@
import { Button, Stack, TextField, Typography } from "@mui/material"; import { Button, Stack, TextField, Typography, Slider } 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 } from "./utils"; import { coordinatesToLocal, localToCoordinates } from "./utils";
import { SCALE_FACTOR } from "./Constants";
export function RightSidebar() { export function RightSidebar() {
const { const {
@ -15,24 +16,36 @@ export function RightSidebar() {
} = useMapData(); } = useMapData();
const { const {
rotation, rotation,
// position, position,
// screenToLocal, screenToLocal,
// screenCenter, screenCenter,
rotateToAngle, rotateToAngle,
setTransform, setTransform,
scale,
setScaleAtCenter,
} = useTransform(); } = useTransform();
const [minScale, setMinScale] = useState<number>(1); const [minScale, setMinScale] = useState<number>(1);
const [maxScale, setMaxScale] = useState<number>(10); const [maxScale, setMaxScale] = useState<number>(5);
const [localCenter, setLocalCenter] = useState<{ x: number; y: number }>({ const [localCenter, setLocalCenter] = useState<{ x: number; y: number }>({
x: 0, x: 0,
y: 0, y: 0,
}); });
const [rotationDegrees, setRotationDegrees] = useState<number>(0); const [rotationDegrees, setRotationDegrees] = useState<number>(0);
const [isUserEditing, setIsUserEditing] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
if (originalRouteData) { if (originalRouteData) {
setMinScale(originalRouteData.scale_min ?? 1); // Проверяем и сбрасываем минимальный масштаб если нужно
setMaxScale(originalRouteData.scale_max ?? 10); const originalMinScale = originalRouteData.scale_min ?? 1;
const resetMinScale = originalMinScale < 1 ? 1 : originalMinScale;
// Проверяем и сбрасываем максимальный масштаб если нужно
const originalMaxScale = originalRouteData.scale_max ?? 5;
const resetMaxScale = originalMaxScale < 3 ? 3 : originalMaxScale;
setMinScale(resetMinScale);
setMaxScale(resetMaxScale);
setRotationDegrees(originalRouteData.rotate ?? 0); setRotationDegrees(originalRouteData.rotate ?? 0);
setLocalCenter({ setLocalCenter({
x: originalRouteData.center_latitude ?? 0, x: originalRouteData.center_latitude ?? 0,
@ -52,16 +65,26 @@ export function RightSidebar() {
((Math.round((rotation * 180) / Math.PI) % 360) + 360) % 360 ((Math.round((rotation * 180) / Math.PI) % 360) + 360) % 360
); );
}, [rotation]); }, [rotation]);
useEffect(() => { useEffect(() => {
setMapRotation(rotationDegrees); setMapRotation(rotationDegrees);
}, [rotationDegrees]); }, [rotationDegrees]);
// useEffect(() => { useEffect(() => {
// const center = screenCenter ?? { x: 0, y: 0 }; if (!isUserEditing) {
// const localCenter = screenToLocal(center.x, center.y); const center = screenCenter ?? { x: 0, y: 0 };
// const coordinates = localToCoordinates(localCenter.x, localCenter.y); const localCenter = screenToLocal(center.x, center.y);
// setLocalCenter({ x: coordinates.latitude, y: coordinates.longitude }); const coordinates = localToCoordinates(localCenter.x, localCenter.y);
// }, [position]); setLocalCenter({ x: coordinates.latitude, y: coordinates.longitude });
}
}, [
position,
screenCenter,
screenToLocal,
localToCoordinates,
setLocalCenter,
isUserEditing,
]);
useEffect(() => { useEffect(() => {
setMapCenter(localCenter.x, localCenter.y); setMapCenter(localCenter.x, localCenter.y);
@ -104,7 +127,30 @@ export function RightSidebar() {
label="Минимальный масштаб" label="Минимальный масштаб"
variant="filled" variant="filled"
value={minScale} value={minScale}
onChange={(e) => setMinScale(Number(e.target.value))} onChange={(e) => {
let newMinScale = Number(e.target.value);
// Сбрасываем к 1 если меньше
if (newMinScale < 1) {
newMinScale = 1;
}
setMinScale(newMinScale);
if (maxScale - newMinScale < 2) {
let newMaxScale = newMinScale + 2;
// Сбрасываем максимальный к 3 если меньше минимального
if (newMaxScale < 3) {
newMaxScale = 3;
setMinScale(1); // Сбрасываем минимальный к 1
}
setMaxScale(newMaxScale);
}
if (newMinScale > scale * SCALE_FACTOR) {
setScaleAtCenter(newMinScale / SCALE_FACTOR);
}
}}
style={{ backgroundColor: "#222", borderRadius: 4 }} style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{ sx={{
"& .MuiInputLabel-root": { "& .MuiInputLabel-root": {
@ -116,7 +162,8 @@ export function RightSidebar() {
}} }}
slotProps={{ slotProps={{
input: { input: {
min: 0.1, min: 1,
max: 10,
}, },
}} }}
/> />
@ -125,7 +172,30 @@ export function RightSidebar() {
label="Максимальный масштаб" label="Максимальный масштаб"
variant="filled" variant="filled"
value={maxScale} value={maxScale}
onChange={(e) => setMaxScale(Number(e.target.value))} onChange={(e) => {
let newMaxScale = Number(e.target.value);
// Сбрасываем к 3 если меньше минимального
if (newMaxScale < 3) {
newMaxScale = 3;
}
setMaxScale(newMaxScale);
if (newMaxScale - minScale < 2) {
let newMinScale = newMaxScale - 2;
// Сбрасываем минимальный к 1 если меньше
if (newMinScale < 1) {
newMinScale = 1;
setMaxScale(3); // Сбрасываем максимальный к минимальному значению
}
setMinScale(newMinScale);
}
if (newMaxScale < scale * SCALE_FACTOR) {
setScaleAtCenter(newMaxScale / SCALE_FACTOR);
}
}}
style={{ backgroundColor: "#222", borderRadius: 4, color: "#fff" }} style={{ backgroundColor: "#222", borderRadius: 4, color: "#fff" }}
sx={{ sx={{
"& .MuiInputLabel-root": { "& .MuiInputLabel-root": {
@ -137,12 +207,71 @@ export function RightSidebar() {
}} }}
slotProps={{ slotProps={{
input: { input: {
min: 0.1, min: 3,
max: 10,
}, },
}} }}
/> />
</Stack> </Stack>
<Typography variant="body2" sx={{ color: "#fff", textAlign: "center" }}>
Текущий масштаб: {Math.round(scale * SCALE_FACTOR * 100) / 100}
</Typography>
<Slider
value={scale * SCALE_FACTOR}
onChange={(_, newValue) => {
if (typeof newValue === "number") {
setScaleAtCenter(newValue / SCALE_FACTOR);
}
}}
min={minScale}
max={maxScale}
step={0.1}
sx={{
color: "#fff",
"& .MuiSlider-thumb": {
backgroundColor: "#fff",
},
"& .MuiSlider-track": {
backgroundColor: "#fff",
},
"& .MuiSlider-rail": {
backgroundColor: "#666",
},
}}
/>
<TextField
type="number"
label="Текущий масштаб"
variant="filled"
value={Math.round(scale * SCALE_FACTOR * 100) / 100}
onChange={(e) => {
const newScale = Number(e.target.value);
if (
!isNaN(newScale) &&
newScale >= minScale &&
newScale <= maxScale
) {
setScaleAtCenter(newScale / SCALE_FACTOR);
}
}}
style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{
"& .MuiInputLabel-root": {
color: "#fff",
},
"& .MuiInputBase-input": {
color: "#fff",
},
}}
inputProps={{
min: minScale,
max: maxScale,
}}
/>
<TextField <TextField
type="number" type="number"
label="Поворот (в градусах)" label="Поворот (в градусах)"
@ -181,11 +310,13 @@ export function RightSidebar() {
type="number" type="number"
label="Центр карты, широта" label="Центр карты, широта"
variant="filled" variant="filled"
value={Math.round(localCenter.x * 100000) / 100000} value={Math.round(localCenter.x * 1000) / 1000}
onChange={(e) => { onChange={(e) => {
setIsUserEditing(true);
setLocalCenter((prev) => ({ ...prev, x: Number(e.target.value) })); setLocalCenter((prev) => ({ ...prev, x: Number(e.target.value) }));
pan({ x: Number(e.target.value), y: localCenter.y }); pan({ x: Number(e.target.value), y: localCenter.y });
}} }}
onBlur={() => setIsUserEditing(false)}
style={{ backgroundColor: "#222", borderRadius: 4 }} style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{ sx={{
"& .MuiInputLabel-root": { "& .MuiInputLabel-root": {
@ -195,16 +326,21 @@ export function RightSidebar() {
color: "#fff", color: "#fff",
}, },
}} }}
inputProps={{
step: 0.001,
}}
/> />
<TextField <TextField
type="number" type="number"
label="Центр карты, высота" label="Центр карты, высота"
variant="filled" variant="filled"
value={Math.round(localCenter.y * 100000) / 100000} value={Math.round(localCenter.y * 1000) / 1000}
onChange={(e) => { onChange={(e) => {
setIsUserEditing(true);
setLocalCenter((prev) => ({ ...prev, y: Number(e.target.value) })); setLocalCenter((prev) => ({ ...prev, y: Number(e.target.value) }));
pan({ x: localCenter.x, y: Number(e.target.value) }); pan({ x: localCenter.x, y: Number(e.target.value) });
}} }}
onBlur={() => setIsUserEditing(false)}
style={{ backgroundColor: "#222", borderRadius: 4 }} style={{ backgroundColor: "#222", borderRadius: 4 }}
sx={{ sx={{
"& .MuiInputLabel-root": { "& .MuiInputLabel-root": {
@ -214,6 +350,9 @@ export function RightSidebar() {
color: "#fff", color: "#fff",
}, },
}} }}
inputProps={{
step: 0.001,
}}
/> />
</Stack> </Stack>

View File

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

View File

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

View File

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

View File

@ -26,9 +26,12 @@ const TransformContext = createContext<{
rotationDegrees?: number, rotationDegrees?: number,
scale?: number scale?: number
) => void; ) => void;
setScaleOnly: (newScale: number) => void;
setScaleWithoutMovingCenter: (newScale: number) => void;
setScreenCenter: React.Dispatch< setScreenCenter: React.Dispatch<
React.SetStateAction<{ x: number; y: number } | undefined> React.SetStateAction<{ x: number; y: number } | undefined>
>; >;
setScaleAtCenter: (newScale: number) => void;
}>({ }>({
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
scale: 1, scale: 1,
@ -41,7 +44,10 @@ const TransformContext = createContext<{
localToScreen: () => ({ x: 0, y: 0 }), localToScreen: () => ({ x: 0, y: 0 }),
rotateToAngle: () => {}, rotateToAngle: () => {},
setTransform: () => {}, setTransform: () => {},
setScaleOnly: () => {},
setScaleWithoutMovingCenter: () => {},
setScreenCenter: () => {}, setScreenCenter: () => {},
setScaleAtCenter: () => {},
}); });
// Provider component // Provider component
@ -136,8 +142,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
useScale !== undefined ? useScale / SCALE_FACTOR : scale; useScale !== undefined ? useScale / SCALE_FACTOR : scale;
const center = screenCenter ?? { x: 0, y: 0 }; const center = screenCenter ?? { x: 0, y: 0 };
console.log("center", center.x, center.y);
const newPosition = { const newPosition = {
x: -latitude * UP_SCALE * selectedScale, x: -latitude * UP_SCALE * selectedScale,
y: -longitude * UP_SCALE * selectedScale, y: -longitude * UP_SCALE * selectedScale,
@ -160,6 +164,37 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
[rotation, scale, screenCenter] [rotation, scale, screenCenter]
); );
const setScaleAtCenter = useCallback(
(newScale: number) => {
if (scale === newScale) return;
const center = screenCenter ?? { x: 0, y: 0 };
const actualZoomFactor = newScale / scale;
const newPosition = {
x: position.x + (center.x - position.x) * (1 - actualZoomFactor),
y: position.y + (center.y - position.y) * (1 - actualZoomFactor),
};
setPosition(newPosition);
setScale(newScale);
},
[position, scale, screenCenter]
);
const setScaleOnly = useCallback((newScale: number) => {
// Изменяем только масштаб, не трогая позицию и поворот
setScale(newScale);
}, []);
const setScaleWithoutMovingCenter = useCallback(
(newScale: number) => {
setScale(newScale);
},
[setScale]
);
const value = useMemo( const value = useMemo(
() => ({ () => ({
position, position,
@ -173,17 +208,25 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
screenToLocal, screenToLocal,
localToScreen, localToScreen,
setTransform, setTransform,
setScaleOnly,
setScaleWithoutMovingCenter,
setScreenCenter, setScreenCenter,
setScaleAtCenter,
}), }),
[ [
position, position,
scale, scale,
rotation, rotation,
screenCenter, screenCenter,
setScale,
rotateToAngle, rotateToAngle,
screenToLocal, screenToLocal,
localToScreen, localToScreen,
setTransform, setTransform,
setScaleOnly,
setScaleWithoutMovingCenter,
setScreenCenter,
setScaleAtCenter,
] ]
); );

View File

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

View File

@ -1,5 +1,5 @@
import { useRef, useEffect, useState } from "react"; import { useRef, useEffect, useState } from "react";
import { Widgets } from "./Widgets";
import { Application, extend } from "@pixi/react"; import { Application, extend } from "@pixi/react";
import { import {
Container, Container,
@ -14,16 +14,18 @@ import { MapDataProvider, useMapData } from "./MapDataContext";
import { TransformProvider, useTransform } from "./TransformContext"; import { TransformProvider, useTransform } from "./TransformContext";
import { InfiniteCanvas } from "./InfiniteCanvas"; import { InfiniteCanvas } from "./InfiniteCanvas";
import { UP_SCALE } from "./Constants";
import { Station } from "./Station";
import { TravelPath } from "./TravelPath"; import { TravelPath } from "./TravelPath";
import { LeftSidebar } from "./LeftSidebar"; import { LeftSidebar } from "./LeftSidebar";
import { RightSidebar } from "./RightSidebar"; import { RightSidebar } from "./RightSidebar";
import { Widgets } from "./Widgets";
import { coordinatesToLocal } from "./utils"; import { coordinatesToLocal } from "./utils";
import { LanguageSwitcher } from "@widgets"; import { LanguageSwitcher } from "@widgets";
import { languageStore } from "@shared"; 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";
extend({ extend({
Container, Container,
@ -43,8 +45,8 @@ export const RoutePreview = () => {
<LeftSidebar /> <LeftSidebar />
<Stack direction="row" flex={1} position="relative" height="100%"> <Stack direction="row" flex={1} position="relative" height="100%">
<Widgets />
<RouteMap /> <RouteMap />
<Widgets />
<RightSidebar /> <RightSidebar />
</Stack> </Stack>
</Stack> </Stack>
@ -55,15 +57,27 @@ export const RoutePreview = () => {
export const RouteMap = observer(() => { export const RouteMap = observer(() => {
const { language } = languageStore; const { language } = languageStore;
const { setPosition, screenToLocal, setTransform, screenCenter } = const { setPosition, setTransform, screenCenter } = useTransform();
useTransform(); const {
const { routeData, stationData, sightData, originalRouteData } = useMapData(); routeData,
console.log(stationData); 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);
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "auto";
};
}, []);
useEffect(() => { useEffect(() => {
if (originalRouteData) { if (originalRouteData) {
const path = originalRouteData?.path; const path = originalRouteData?.path;
@ -146,20 +160,14 @@ export const RouteMap = observer(() => {
key={obj.id} key={obj.id}
ruLabel={ ruLabel={
language === "ru" language === "ru"
? stationData.en[index].name ? stationData.ru[index].name
: stationData.ru[index].name : stationData.ru[index].name
} }
/> />
))} ))}
{originalSightData?.map((sight: SightData, index: number) => {
<pixiGraphics return <Sight sight={sight} id={index} key={sight.id} />;
draw={(g) => { })}
g.clear();
const localCenter = screenToLocal(0, 0);
g.circle(localCenter.x, localCenter.y, 10);
g.fill("#fff");
}}
/>
</InfiniteCanvas> </InfiniteCanvas>
</Application> </Application>
</div> </div>

View File

@ -12,6 +12,7 @@ export interface RouteData {
route_sys_number: string; route_sys_number: string;
scale_max: number; scale_max: number;
scale_min: number; scale_min: number;
thumbnail?: string; // uuid логотипа маршрута
} }
export interface StationTransferData { export interface StationTransferData {
@ -38,12 +39,14 @@ export interface StationData {
offset_y: number; offset_y: number;
system_name: string; system_name: string;
transfers: StationTransferData; transfers: StationTransferData;
align: number;
} }
export interface StationPatchData { export interface StationPatchData {
station_id: number; station_id: number;
offset_x: number; offset_x: number;
offset_y: number; offset_y: number;
align: number;
transfers: StationTransferData; transfers: StationTransferData;
} }

View File

@ -1,10 +1,12 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { languageStore, sightsStore } from "@shared"; import { languageStore, sightsStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { 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";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const SightListPage = observer(() => { export const SightListPage = observer(() => {
const { sights, getSights, deleteListSight } = sightsStore; const { sights, getSights, deleteListSight } = sightsStore;
@ -13,10 +15,16 @@ export const SightListPage = observer(() => {
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 { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
getSights(); const fetchSights = async () => {
setIsLoading(true);
await getSights();
setIsLoading(false);
};
fetchSights();
}, [language]); }, [language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
@ -116,12 +124,25 @@ export const SightListPage = observer(() => {
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
hideFooterPagination
checkboxSelection
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]);
}}
hideFooter hideFooter
checkboxSelection
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => {
console.log(newSelection);
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? (
<CircularProgress size={20} />
) : (
"Нет достопримечательностей"
)}
</Box>
),
}}
/> />
</div> </div>

View File

@ -1,10 +1,11 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { languageStore, snapshotStore } from "@shared"; import { languageStore, snapshotStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { DatabaseBackup, Trash2 } from "lucide-react"; import { DatabaseBackup, Trash2 } from "lucide-react";
import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets"; import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const SnapshotListPage = observer(() => { export const SnapshotListPage = observer(() => {
const { snapshots, getSnapshots, deleteSnapshot, restoreSnapshot } = const { snapshots, getSnapshots, deleteSnapshot, restoreSnapshot } =
@ -14,9 +15,15 @@ export const SnapshotListPage = observer(() => {
const [rowId, setRowId] = useState<string | null>(null); // Lifted state 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);
useEffect(() => { useEffect(() => {
getSnapshots(); const fetchSnapshots = async () => {
setIsLoading(true);
await getSnapshots();
setIsLoading(false);
};
fetchSnapshots();
}, [language]); }, [language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
@ -81,6 +88,15 @@ export const SnapshotListPage = observer(() => {
columns={columns} columns={columns}
hideFooterPagination hideFooterPagination
hideFooter hideFooter
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет снапшотов"}
</Box>
),
}}
/> />
</div> </div>

View File

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

View File

@ -44,7 +44,7 @@ export const StationCreatePage = observer(() => {
try { try {
setIsLoading(true); setIsLoading(true);
await createStation(); await createStation();
toast.success("Станция успешно создана"); toast.success("Остановка успешно создана");
navigate("/station"); navigate("/station");
} catch (error) { } catch (error) {
console.error("Error creating station:", error); console.error("Error creating station:", error);
@ -79,7 +79,7 @@ export const StationCreatePage = observer(() => {
<div className="flex flex-col gap-10 w-full items-end"> <div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%] self-start"> <div className="flex gap-10 items-center mb-5 max-w-[80%] self-start">
<h1 className="text-3xl break-words">Создание станции</h1> <h1 className="text-3xl break-words">Создание остановки</h1>
</div> </div>
<TextField <TextField
fullWidth fullWidth
@ -113,15 +113,15 @@ export const StationCreatePage = observer(() => {
<TextField <TextField
fullWidth fullWidth
label="Описание" label="Описание"
value={createStationData[language].description || ""} value={createStationData.common.description || ""}
onChange={(e) => onChange={(e) =>
setLanguageCreateStationData(language, { setCreateCommonData({
description: e.target.value, description: e.target.value,
}) })
} }
/> />
<TextField {/* <TextField
fullWidth fullWidth
label="Адрес" label="Адрес"
value={createStationData[language].address || ""} value={createStationData[language].address || ""}
@ -130,7 +130,7 @@ export const StationCreatePage = observer(() => {
address: e.target.value, address: e.target.value,
}) })
} }
/> /> */}
<TextField <TextField
fullWidth fullWidth

View File

@ -15,6 +15,7 @@ import { toast } from "react-toastify";
import { stationsStore, languageStore, cityStore } from "@shared"; import { stationsStore, languageStore, cityStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets"; import { LanguageSwitcher } from "@widgets";
import { LinkedSights } from "../LinkedSights";
export const StationEditPage = observer(() => { export const StationEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -31,6 +32,11 @@ export const StationEditPage = observer(() => {
const { cities, getCities } = cityStore; const { cities, getCities } = cityStore;
const [coordinates, setCoordinates] = useState<string>(""); const [coordinates, setCoordinates] = useState<string>("");
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
useEffect(() => { useEffect(() => {
if ( if (
editStationData.common.latitude !== 0 || editStationData.common.latitude !== 0 ||
@ -46,7 +52,7 @@ export const StationEditPage = observer(() => {
try { try {
setIsLoading(true); setIsLoading(true);
await editStation(Number(id)); await editStation(Number(id));
toast.success("Станция успешно обновлена"); toast.success("Остановка успешно обновлена");
} catch (error) { } catch (error) {
console.error("Error updating station:", error); console.error("Error updating station:", error);
toast.error("Ошибка при обновлении станции"); toast.error("Ошибка при обновлении станции");
@ -118,15 +124,15 @@ export const StationEditPage = observer(() => {
<TextField <TextField
fullWidth fullWidth
label="Описание" label="Описание"
value={editStationData[language].description || ""} value={editStationData.common.description || ""}
onChange={(e) => onChange={(e) =>
setLanguageEditStationData(language, { setEditCommonData({
description: e.target.value, description: e.target.value,
}) })
} }
/> />
<TextField {/* <TextField
fullWidth fullWidth
label="Адрес" label="Адрес"
value={editStationData[language].address || ""} value={editStationData[language].address || ""}
@ -135,7 +141,7 @@ export const StationEditPage = observer(() => {
address: e.target.value, address: e.target.value,
}) })
} }
/> /> */}
<TextField <TextField
fullWidth fullWidth
@ -192,6 +198,14 @@ export const StationEditPage = observer(() => {
</Select> </Select>
</FormControl> </FormControl>
{id && (
<LinkedSights
parentId={Number(id)}
fields={[{ label: "Название", data: "name" }]}
type="edit"
/>
)}
<Button <Button
variant="contained" variant="contained"
className="w-min flex gap-2 items-center" className="w-min flex gap-2 items-center"
@ -202,7 +216,7 @@ export const StationEditPage = observer(() => {
{isLoading ? ( {isLoading ? (
<Loader2 size={20} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />
) : ( ) : (
"Обновить" "Сохранить"
)} )}
</Button> </Button>
</div> </div>

View File

@ -1,10 +1,12 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { languageStore, stationsStore } from "@shared"; import { languageStore, stationsStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Eye, Pencil, Trash2, Minus } from "lucide-react"; import { Eye, Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const StationListPage = observer(() => { export const StationListPage = observer(() => {
const { stationLists, getStationList, deleteStation } = stationsStore; const { stationLists, getStationList, deleteStation } = stationsStore;
@ -13,10 +15,16 @@ export const StationListPage = observer(() => {
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 { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
getStationList(); const fetchStations = async () => {
setIsLoading(true);
await getStationList();
setIsLoading(false);
};
fetchStations();
}, [language]); }, [language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
@ -115,7 +123,7 @@ 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>
<CreateButton label="Создать станцию" path="/station/create" /> <CreateButton label="Создать остановки" path="/station/create" />
</div> </div>
<div <div
@ -136,10 +144,19 @@ export const StationListPage = observer(() => {
columns={columns} columns={columns}
hideFooterPagination hideFooterPagination
checkboxSelection checkboxSelection
loading={isLoading}
onRowSelectionModelChange={(newSelection) => { onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]); setIds(Array.from(newSelection.ids) as number[]);
}} }}
hideFooter hideFooter
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет станций"}
</Box>
),
}}
/> />
</div> </div>

View File

@ -5,6 +5,7 @@ import { observer } from "mobx-react-lite";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { LinkedSights } from "../LinkedSights";
export const StationPreviewPage = observer(() => { export const StationPreviewPage = observer(() => {
const { id } = useParams(); const { id } = useParams();
@ -71,6 +72,17 @@ export const StationPreviewPage = observer(() => {
<p>{stationPreview[id!]?.[language]?.data.description}</p> <p>{stationPreview[id!]?.[language]?.data.description}</p>
</div> </div>
)} )}
{id && (
<LinkedSights
parentId={Number(id)}
fields={[
{ label: "Название", data: "name" },
{ label: "Описание", data: "description" },
]}
type="show"
/>
)}
</div> </div>
</Paper> </Paper>
); );

View File

@ -2,3 +2,4 @@ export * from "./StationListPage";
export * from "./StationCreatePage"; export * from "./StationCreatePage";
export * from "./StationPreviewPage"; export * from "./StationPreviewPage";
export * from "./StationEditPage"; export * from "./StationEditPage";
export * from "./LinkedSights";

View File

@ -10,15 +10,21 @@ 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 { userStore } from "@shared"; import { userStore, languageStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
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 { id } = useParams(); const { id } = useParams();
const { editUserData, editUser, getUser, setEditUserData } = userStore; const { editUserData, editUser, getUser, setEditUserData } = userStore;
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
const handleEdit = async () => { const handleEdit = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
@ -130,7 +136,7 @@ export const UserEditPage = observer(() => {
{isLoading ? ( {isLoading ? (
<Loader2 size={20} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />
) : ( ) : (
"Обновить" "Сохранить"
)} )}
</Button> </Button>
</div> </div>

View File

@ -1,11 +1,12 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { userStore } from "@shared"; import { userStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { 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";
import { CreateButton, DeleteModal } from "@widgets"; import { CreateButton, DeleteModal } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
export const UserListPage = observer(() => { export const UserListPage = observer(() => {
const { users, getUsers, deleteUser } = userStore; const { users, getUsers, deleteUser } = userStore;
@ -14,9 +15,15 @@ export const UserListPage = observer(() => {
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);
useEffect(() => { useEffect(() => {
getUsers(); const fetchUsers = async () => {
setIsLoading(true);
await getUsers();
setIsLoading(false);
};
fetchUsers();
}, []); }, []);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
@ -136,10 +143,23 @@ export const UserListPage = observer(() => {
columns={columns} columns={columns}
hideFooterPagination hideFooterPagination
checkboxSelection checkboxSelection
loading={isLoading}
onRowSelectionModelChange={(newSelection) => { onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]); setIds(Array.from(newSelection.ids) as number[]);
}} }}
hideFooter hideFooter
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? (
<CircularProgress size={20} />
) : (
"Нет пользователей"
)}
</Box>
),
}}
/> />
</div> </div>

View File

@ -31,6 +31,12 @@ export const VehicleEditPage = observer(() => {
} = vehicleStore; } = vehicleStore;
const { getCarriers } = carrierStore; const { getCarriers } = carrierStore;
const { language } = languageStore; const { language } = languageStore;
useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru");
}, []);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
await getVehicle(Number(id)); await getVehicle(Number(id));

View File

@ -1,4 +1,5 @@
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 { carrierStore, languageStore, vehicleStore } from "@shared"; import { carrierStore, languageStore, vehicleStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
@ -6,6 +7,7 @@ import { Eye, Pencil, Trash2, Minus } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal } from "@widgets"; import { CreateButton, DeleteModal } from "@widgets";
import { VEHICLE_TYPES } from "@shared"; import { VEHICLE_TYPES } from "@shared";
import { Box, CircularProgress } from "@mui/material";
export const VehicleListPage = observer(() => { export const VehicleListPage = observer(() => {
const { vehicles, getVehicles, deleteVehicle } = vehicleStore; const { vehicles, getVehicles, deleteVehicle } = vehicleStore;
@ -15,11 +17,17 @@ export const VehicleListPage = observer(() => {
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 { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
getVehicles(); const fetchData = async () => {
getCarriers(language); setIsLoading(true);
await getVehicles();
await getCarriers(language);
setIsLoading(false);
};
fetchData();
}, [language]); }, [language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
@ -157,10 +165,23 @@ export const VehicleListPage = observer(() => {
columns={columns} columns={columns}
hideFooterPagination hideFooterPagination
checkboxSelection checkboxSelection
loading={isLoading}
onRowSelectionModelChange={(newSelection) => { onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids) as number[]); setIds(Array.from(newSelection.ids) as number[]);
}} }}
hideFooter hideFooter
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? (
<CircularProgress size={20} />
) : (
"Нет транспортных средств"
)}
</Box>
),
}}
/> />
</div> </div>

View File

@ -11,10 +11,10 @@ import {
// Car, // Car,
Table, Table,
Split, Split,
Newspaper, // Newspaper,
PersonStanding, PersonStanding,
Cpu, Cpu,
BookImage, // BookImage,
} from "lucide-react"; } from "lucide-react";
import { CarrierSvg } from "./CarrierSvg"; import { CarrierSvg } from "./CarrierSvg";
@ -70,18 +70,18 @@ export const NAVIGATION_ITEMS: {
label: "Справочник", label: "Справочник",
icon: Table, icon: Table,
nestedItems: [ nestedItems: [
{ // {
id: "media", // id: "media",
label: "Медиа", // label: "Медиа",
icon: BookImage, // icon: BookImage,
path: "/media", // path: "/media",
}, // },
{ // {
id: "articles", // id: "articles",
label: "Статьи", // label: "Статьи",
icon: Newspaper, // icon: Newspaper,
path: "/article", // path: "/article",
}, // },
{ {
id: "attractions", id: "attractions",
label: "Достопримечательности", label: "Достопримечательности",

View File

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

View File

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

View File

@ -75,6 +75,7 @@ export const PreviewMediaDialog = observer(
setError(err instanceof Error ? err.message : "Failed to save media"); setError(err instanceof Error ? err.message : "Failed to save media");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
onClose();
} }
}; };
@ -96,7 +97,6 @@ export const PreviewMediaDialog = observer(
className="flex gap-4" className="flex gap-4"
dividers dividers
sx={{ sx={{
height: "600px",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: 2, gap: 2,
@ -149,6 +149,8 @@ export const PreviewMediaDialog = observer(
media_type: media.media_type, media_type: media.media_type,
filename: media.filename, filename: media.filename,
}} }}
className="h-full w-full object-contain"
fullHeight
/> />
</Paper> </Paper>

View File

@ -38,7 +38,9 @@ export const SelectArticleModal = observer(
onSelectArticle, onSelectArticle,
linkedArticleIds = [], linkedArticleIds = [],
}: SelectArticleModalProps) => { }: SelectArticleModalProps) => {
const { articles, getArticle, getArticleMedia } = articlesStore; const { language } = languageStore;
const { articles, getArticle, getArticleMedia, getArticles } =
articlesStore;
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [selectedArticleId, setSelectedArticleId] = useState<number | null>( const [selectedArticleId, setSelectedArticleId] = useState<number | null>(
null null
@ -54,6 +56,21 @@ export const SelectArticleModal = observer(
} }
}, [open]); }, [open]);
useEffect(() => {
const fetchData = async () => {
await getArticles("ru");
await getArticles("en");
await getArticles("zh");
};
fetchData();
}, []);
useEffect(() => {
if (selectedArticleId) {
handleArticleClick(selectedArticleId);
}
}, [language]);
useEffect(() => { useEffect(() => {
const handleKeyPress = async (event: KeyboardEvent) => { const handleKeyPress = async (event: KeyboardEvent) => {
if (event.key.toLowerCase() === "enter") { if (event.key.toLowerCase() === "enter") {
@ -273,6 +290,25 @@ export const SelectArticleModal = observer(
fontSize: "24px", fontSize: "24px",
fontWeight: 700, fontWeight: 700,
lineHeight: "120%", lineHeight: "120%",
cursor: "pointer",
"&:hover": {
textDecoration: "underline",
},
}}
onDoubleClick={async () => {
if (selectedArticleId) {
const media = await authInstance.get(
`/article/${selectedArticleId}/media`
);
onSelectArticle(
selectedArticleId,
articlesStore.articleData?.heading || "",
articlesStore.articleData?.body || "",
media.data || []
);
onClose();
setSelectedArticleId(null);
}
}} }}
> >
{articlesStore.articleData?.heading || "Название cтатьи"} {articlesStore.articleData?.heading || "Название cтатьи"}

View File

@ -1,4 +1,9 @@
import { MEDIA_TYPE_LABELS, MEDIA_TYPE_VALUES, editSightStore } from "@shared"; import {
MEDIA_TYPE_LABELS,
MEDIA_TYPE_VALUES,
editSightStore,
generateDefaultMediaName,
} from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
@ -32,6 +37,16 @@ interface UploadMediaDialogProps {
}) => void; }) => void;
afterUploadSight?: (id: string) => void; afterUploadSight?: (id: string) => void;
hardcodeType?: "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null; hardcodeType?: "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null;
contextObjectName?: string;
contextType?:
| "sight"
| "city"
| "carrier"
| "country"
| "vehicle"
| "station";
isArticle?: boolean;
articleName?: string;
} }
export const UploadMediaDialog = observer( export const UploadMediaDialog = observer(
@ -41,6 +56,10 @@ export const UploadMediaDialog = observer(
afterUpload, afterUpload,
afterUploadSight, afterUploadSight,
hardcodeType, hardcodeType,
contextObjectName,
isArticle,
articleName,
}: 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);
@ -66,7 +85,7 @@ export const UploadMediaDialog = observer(
setAvailableMediaTypes([6]); setAvailableMediaTypes([6]);
setMediaType(6); setMediaType(6);
} }
if (["jpg", "jpeg", "png", "gif"].includes(extension)) { if (["jpg", "jpeg", "png", "gif", "svg"].includes(extension)) {
// Для изображений доступны все типы кроме видео // Для изображений доступны все типы кроме видео
setAvailableMediaTypes([1, 3, 4, 5]); // Фото, Иконка, Водяной знак, Панорама, 3Д-модель setAvailableMediaTypes([1, 3, 4, 5]); // Фото, Иконка, Водяной знак, Панорама, 3Д-модель
setMediaType(1); // По умолчанию Фото setMediaType(1); // По умолчанию Фото
@ -76,8 +95,95 @@ export const UploadMediaDialog = observer(
setMediaType(2); setMediaType(2);
} }
} }
// Генерируем название по умолчанию если есть контекст
if (fileToUpload.name) {
let defaultName = "";
if (isArticle && articleName && contextObjectName) {
// Для статей: "Название достопримечательности_название файлаазвание статьи"
defaultName = generateDefaultMediaName(
contextObjectName,
fileToUpload.name,
articleName,
true
);
} else if (contextObjectName && contextObjectName.trim() !== "") {
// Для обычных медиа с названием объекта
const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType]
: 1; // По умолчанию фото
defaultName = generateDefaultMediaName(
contextObjectName,
fileToUpload.name,
currentMediaType,
false
);
} else {
// Для медиа без названия объекта
const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType]
: 1; // По умолчанию фото
defaultName = generateDefaultMediaName(
"",
fileToUpload.name,
currentMediaType,
false
);
} }
}, [fileToUpload]);
setMediaName(defaultName);
}
}
}, [fileToUpload, contextObjectName, hardcodeType, isArticle, articleName]);
// Обновляем название при изменении типа медиа
useEffect(() => {
if (mediaFilename && mediaType > 0) {
let defaultName = "";
if (isArticle && articleName && contextObjectName) {
// Для статей: "Название достопримечательности_название файлаазвание статьи"
defaultName = generateDefaultMediaName(
contextObjectName,
mediaFilename,
articleName,
true
);
} else if (contextObjectName && contextObjectName.trim() !== "") {
// Для обычных медиа с названием объекта
const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType]
: mediaType;
defaultName = generateDefaultMediaName(
contextObjectName,
mediaFilename,
currentMediaType,
false
);
} else {
// Для медиа без названия объекта
const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType]
: mediaType;
defaultName = generateDefaultMediaName(
"",
mediaFilename,
currentMediaType,
false
);
}
setMediaName(defaultName);
}
}, [
mediaType,
contextObjectName,
mediaFilename,
hardcodeType,
isArticle,
articleName,
]);
useEffect(() => { useEffect(() => {
if (mediaFile) { if (mediaFile) {
@ -141,7 +247,6 @@ export const UploadMediaDialog = observer(
className="flex gap-4" className="flex gap-4"
dividers dividers
sx={{ sx={{
height: "600px",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: 2, gap: 2,
@ -200,13 +305,16 @@ export const UploadMediaDialog = observer(
height: "100%", height: "100%",
}} }}
> >
{/* <MediaViewer {mediaType == 2 && mediaUrl && (
media={{ <video
id: "", src={mediaUrl}
media_type: mediaType, autoPlay
filename: mediaFilename, muted
}} loop
/> */} controls
style={{ maxWidth: "100%", maxHeight: "100%" }}
/>
)}
{mediaType === 6 && mediaUrl && ( {mediaType === 6 && mediaUrl && (
<ModelViewer3D fileUrl={mediaUrl} height="100%" /> <ModelViewer3D fileUrl={mediaUrl} height="100%" />
)} )}
@ -215,8 +323,7 @@ export const UploadMediaDialog = observer(
src={mediaUrl ?? ""} src={mediaUrl ?? ""}
alt="Uploaded media" alt="Uploaded media"
style={{ style={{
maxWidth: "100%", height: "100%",
maxHeight: "100%",
objectFit: "contain", objectFit: "contain",
}} }}
/> />

View File

@ -109,7 +109,8 @@ class ArticlesStore {
getArticles = async (language: Language) => { getArticles = async (language: Language) => {
this.articleLoading = true; this.articleLoading = true;
const response = await authInstance.get("/article");
const response = await languageInstance(language).get("/article");
runInAction(() => { runInAction(() => {
this.articles[language] = response.data; this.articles[language] = response.data;
@ -119,8 +120,9 @@ class ArticlesStore {
getArticle = async (id: number, language?: Language) => { getArticle = async (id: number, language?: Language) => {
this.articleLoading = true; this.articleLoading = true;
let response: any;
if (language) { if (language) {
const response = await languageInstance(language).get(`/article/${id}`); response = await languageInstance(language).get(`/article/${id}`);
runInAction(() => { runInAction(() => {
if (!this.articleData) { if (!this.articleData) {
this.articleData = { id, heading: "", body: "", service_name: "" }; this.articleData = { id, heading: "", body: "", service_name: "" };
@ -131,11 +133,12 @@ class ArticlesStore {
}; };
}); });
} else { } else {
const response = await authInstance.get(`/article/${id}`); response = await authInstance.get(`/article/${id}`);
runInAction(() => { runInAction(() => {
this.articleData = response.data; this.articleData = response.data;
}); });
} }
return response;
this.articleLoading = false; this.articleLoading = false;
}; };

View File

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

View File

@ -187,7 +187,7 @@ class CarrierStore {
: {}), : {}),
}; };
await languageInstance(lang as Language).patch( const response = await languageInstance(lang as Language).patch(
`/carrier/${carrierId}`, `/carrier/${carrierId}`,
patchPayload patchPayload
); );
@ -196,6 +196,26 @@ class CarrierStore {
this.carriers[lang as keyof Carriers].data.push(response.data); this.carriers[lang as keyof Carriers].data.push(response.data);
}); });
} }
this.createCarrierData = {
city_id: 0,
logo: "",
ru: {
full_name: "",
short_name: "",
slogan: "",
},
en: {
full_name: "",
short_name: "",
slogan: "",
},
zh: {
full_name: "",
short_name: "",
slogan: "",
},
};
}; };
editCarrierData = { editCarrierData = {
@ -265,7 +285,9 @@ class CarrierStore {
...this.editCarrierData[lang], ...this.editCarrierData[lang],
city: cityName, city: cityName,
city_id: this.editCarrierData.city_id, city_id: this.editCarrierData.city_id,
logo: this.editCarrierData.logo, ...(this.editCarrierData.logo
? { logo: this.editCarrierData.logo }
: {}),
}); });
runInAction(() => { runInAction(() => {

View File

@ -284,7 +284,6 @@ class CityStore {
(country) => country.code === country_code (country) => country.code === country_code
); );
if (name) {
await languageInstance(language as Language).patch(`/city/${code}`, { await languageInstance(language as Language).patch(`/city/${code}`, {
name, name,
country: country?.name || "", country: country?.name || "",
@ -319,7 +318,6 @@ class CityStore {
} }
}); });
} }
}
}; };
} }

View File

@ -68,14 +68,7 @@ class CountryStore {
}; };
getCountry = async (code: string, language: keyof CashedCountries) => { getCountry = async (code: string, language: keyof CashedCountries) => {
if ( const response = await languageInstance(language).get(`/country/${code}`);
this.country[code]?.[language] &&
this.country[code][language] !== null
) {
return;
}
const response = await authInstance.get(`/country/${code}`);
runInAction(() => { runInAction(() => {
if (!this.country[code]) { if (!this.country[code]) {

View File

@ -1,5 +1,11 @@
// @shared/stores/createSightStore.ts // @shared/stores/createSightStore.ts
import { Language, authInstance, languageInstance, mediaStore } from "@shared"; import {
articlesStore,
Language,
authInstance,
languageInstance,
mediaStore,
} from "@shared";
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
type MediaItem = { type MediaItem = {
@ -162,6 +168,8 @@ class CreateSightStore {
media: mediaData, media: mediaData,
}); });
}); });
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;
@ -315,7 +323,18 @@ 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 needed // articlesStore.getArticles(languageStore.language); // If still neede
runInAction(() => {
articlesStore.articles.ru = articlesStore.articles.ru.filter(
(article) => article.id !== articleId
);
articlesStore.articles.en = articlesStore.articles.en.filter(
(article) => article.id !== articleId
);
articlesStore.articles.zh = articlesStore.articles.zh.filter(
(article) => article.id !== articleId
);
});
this.unlinkLeftArticle(); this.unlinkLeftArticle();
}; };
@ -352,6 +371,25 @@ class CreateSightStore {
body: "填写内容", body: "填写内容",
media: [], media: [],
}; };
articlesStore.articles.ru.push({
id: newLeftArticleId,
heading: "Новая левая статья",
body: "Заполните контентом",
service_name: "Новая левая статья",
});
articlesStore.articles.en.push({
id: newLeftArticleId,
heading: "New Left Article",
body: "Fill with content",
service_name: "New Left Article",
});
articlesStore.articles.zh.push({
id: newLeftArticleId,
heading: "新的左侧文章",
body: "填写内容",
service_name: "新的左侧文章",
});
}); });
return newLeftArticleId; return newLeftArticleId;
}; };

View File

@ -1,5 +1,11 @@
// @shared/stores/editSightStore.ts // @shared/stores/editSightStore.ts
import { authInstance, Language, languageInstance, mediaStore } from "@shared"; import {
articlesStore,
authInstance,
Language,
languageInstance,
mediaStore,
} from "@shared";
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
export type SightLanguageInfo = { export type SightLanguageInfo = {
@ -82,11 +88,7 @@ class EditSightStore {
hasLoadedCommon = false; hasLoadedCommon = false;
getSightInfo = async (id: number, language: Language) => { getSightInfo = async (id: number, language: Language) => {
if (this.sight[language].id === id) { const response = await languageInstance(language).get(`/sight/${id}`);
return;
}
const response = await authInstance.get(`/sight/${id}`);
const data = response.data; const data = response.data;
if (data.left_article != 0 && data.left_article != null) { if (data.left_article != 0 && data.left_article != null) {
@ -376,6 +378,18 @@ class EditSightStore {
deleteLeftArticle = async (id: number) => { deleteLeftArticle = async (id: number) => {
await authInstance.delete(`/article/${id}`); await authInstance.delete(`/article/${id}`);
runInAction(() => {
articlesStore.articles.ru = articlesStore.articles.ru.filter(
(article) => article.id !== id
);
articlesStore.articles.en = articlesStore.articles.en.filter(
(article) => article.id !== id
);
articlesStore.articles.zh = articlesStore.articles.zh.filter(
(article) => article.id !== id
);
});
this.sight.common.left_article = 0; this.sight.common.left_article = 0;
this.sight.ru.left.heading = ""; this.sight.ru.left.heading = "";
this.sight.en.left.heading = ""; this.sight.en.left.heading = "";
@ -556,6 +570,8 @@ class EditSightStore {
media: mediaIds.data, media: mediaIds.data,
}); });
}); });
return article_id; // Return the linked article ID
}; };
deleteRightArticleMedia = async (article_id: number, media_id: string) => { deleteRightArticleMedia = async (article_id: number, media_id: string) => {
@ -639,6 +655,29 @@ class EditSightStore {
body: articleZhData.body, body: articleZhData.body,
media: [], media: [],
}); });
runInAction(() => {
articlesStore.articles.ru.push({
id: id,
heading: articleRuData.heading,
body: articleRuData.body,
service_name: articleRuData.heading,
});
articlesStore.articles.en.push({
id: id,
heading: articleEnData.heading,
body: articleEnData.body,
service_name: articleEnData.heading,
});
articlesStore.articles.zh.push({
id: id,
heading: articleZhData.heading,
body: articleZhData.body,
service_name: articleZhData.heading,
});
});
return id; // Return the ID of the newly created article
}; };
createLinkWithRightArticle = async ( createLinkWithRightArticle = async (

View File

@ -66,17 +66,41 @@ class RouteStore {
}); });
}; };
routeStations: Record<string, any[]> = {};
getRoute = async (id: number) => { getRoute = async (id: number) => {
if (this.route[id]) return this.route[id]; if (this.route[id]) return this.route[id];
const response = await authInstance.get(`/route/${id}`); const response = await authInstance.get(`/route/${id}`);
const stations = await authInstance.get(`/route/${id}/station`);
runInAction(() => { runInAction(() => {
this.route[id] = response.data; this.route[id] = response.data;
this.routeStations[id] = stations.data;
}); });
return response.data; return response.data;
}; };
setRouteStations = (routeId: number, stationId: number, data: any) => {
console.log(
this.routeStations[routeId],
stationId,
this.routeStations[routeId].find((station) => station.id === stationId)
);
this.routeStations[routeId] = this.routeStations[routeId]?.map((station) =>
station.id === stationId ? { ...station, ...data } : station
);
};
saveRouteStations = async (routeId: number, stationId: number) => {
await authInstance.patch(`/route/${routeId}/station`, {
...this.routeStations[routeId]?.find(
(station) => station.id === stationId
),
station_id: stationId,
});
};
editRouteData = { editRouteData = {
carrier: "", carrier: "",
carrier_id: 0, carrier_id: 0,
@ -112,6 +136,21 @@ class RouteStore {
); );
}); });
}; };
copyRouteAction = async (id: number) => {
const response = await authInstance.post(`/route/${id}/copy`);
console.log(response);
runInAction(() => {
this.routes.data = [...this.routes.data, response.data];
});
};
selectedStationId = 0;
setSelectedStationId = (id: number) => {
this.selectedStationId = id;
};
} }
export const routeStore = new RouteStore(); export const routeStore = new RouteStore();

View File

@ -6,7 +6,6 @@ type Language = "ru" | "en" | "zh";
type StationLanguageData = { type StationLanguageData = {
name: string; name: string;
system_name: string; system_name: string;
description: string;
address: string; address: string;
loaded: boolean; // Indicates if this language's data has been loaded/modified loaded: boolean; // Indicates if this language's data has been loaded/modified
}; };
@ -14,6 +13,7 @@ type StationLanguageData = {
type StationCommonData = { type StationCommonData = {
city_id: number; city_id: number;
direction: boolean; direction: boolean;
description: string;
icon: string; icon: string;
latitude: number; latitude: number;
longitude: number; longitude: number;
@ -102,21 +102,21 @@ class StationsStore {
ru: { ru: {
name: "", name: "",
system_name: "", system_name: "",
description: "",
address: "", address: "",
loaded: false, loaded: false,
}, },
en: { en: {
name: "", name: "",
system_name: "", system_name: "",
description: "",
address: "", address: "",
loaded: false, loaded: false,
}, },
zh: { zh: {
name: "", name: "",
system_name: "", system_name: "",
description: "",
address: "", address: "",
loaded: false, loaded: false,
}, },
@ -124,6 +124,7 @@ class StationsStore {
city: "", city: "",
city_id: 0, city_id: 0,
direction: false, direction: false,
description: "",
icon: "", icon: "",
latitude: 0, latitude: 0,
longitude: 0, longitude: 0,
@ -147,21 +148,21 @@ class StationsStore {
ru: { ru: {
name: "", name: "",
system_name: "", system_name: "",
description: "",
address: "", address: "",
loaded: false, loaded: false,
}, },
en: { en: {
name: "", name: "",
system_name: "", system_name: "",
description: "",
address: "", address: "",
loaded: false, loaded: false,
}, },
zh: { zh: {
name: "", name: "",
system_name: "", system_name: "",
description: "",
address: "", address: "",
loaded: false, loaded: false,
}, },
@ -169,6 +170,7 @@ class StationsStore {
city: "", city: "",
city_id: 0, city_id: 0,
direction: false, direction: false,
description: "",
icon: "", icon: "",
latitude: 0, latitude: 0,
longitude: 0, longitude: 0,
@ -229,21 +231,21 @@ class StationsStore {
ru: { ru: {
name: ruResponse.data.name, name: ruResponse.data.name,
system_name: ruResponse.data.system_name, system_name: ruResponse.data.system_name,
description: ruResponse.data.description,
address: ruResponse.data.address, address: ruResponse.data.address,
loaded: true, loaded: true,
}, },
en: { en: {
name: enResponse.data.name, name: enResponse.data.name,
system_name: enResponse.data.system_name, system_name: enResponse.data.system_name,
description: enResponse.data.description,
address: enResponse.data.address, address: enResponse.data.address,
loaded: true, loaded: true,
}, },
zh: { zh: {
name: zhResponse.data.name, name: zhResponse.data.name,
system_name: zhResponse.data.system_name, system_name: zhResponse.data.system_name,
description: zhResponse.data.description,
address: zhResponse.data.address, address: zhResponse.data.address,
loaded: true, loaded: true,
}, },
@ -251,6 +253,7 @@ class StationsStore {
city: ruResponse.data.city, city: ruResponse.data.city,
city_id: ruResponse.data.city_id, city_id: ruResponse.data.city_id,
direction: ruResponse.data.direction, direction: ruResponse.data.direction,
description: ruResponse.data.description,
icon: ruResponse.data.icon, icon: ruResponse.data.icon,
latitude: ruResponse.data.latitude, latitude: ruResponse.data.latitude,
longitude: ruResponse.data.longitude, longitude: ruResponse.data.longitude,
@ -286,7 +289,8 @@ class StationsStore {
}; };
for (const language of ["ru", "en", "zh"] as const) { for (const language of ["ru", "en", "zh"] as const) {
const { name, description, address } = this.editStationData[language]; const { name, address } = this.editStationData[language];
const description = this.editStationData.common.description;
const response = await languageInstance(language).patch( const response = await languageInstance(language).patch(
`/station/${id}`, `/station/${id}`,
{ {
@ -323,7 +327,7 @@ class StationsStore {
...station, ...station,
name: response.data.name, name: response.data.name,
system_name: response.data.system_name, system_name: response.data.system_name,
description: response.data.description, description: description || "",
address: response.data.address, address: response.data.address,
...commonDataPayload, ...commonDataPayload,
} as Station) } as Station)
@ -418,7 +422,8 @@ class StationsStore {
} }
// First create station in Russian // First create station in Russian
const { name, description, address } = this.createStationData[language]; const { name, address } = this.createStationData[language];
const description = this.createStationData.common.description;
const response = await languageInstance(language).post("/station", { const response = await languageInstance(language).post("/station", {
name: name || "", name: name || "",
system_name: name || "", // system_name is often derived from name system_name: name || "", // system_name is often derived from name
@ -437,7 +442,8 @@ class StationsStore {
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[]) {
const { name, description, address } = this.createStationData[lang]; const { name, address } = this.createStationData[lang];
const description = this.createStationData.common.description;
const response = await languageInstance(lang).patch( const response = await languageInstance(lang).patch(
`/station/${stationId}`, `/station/${stationId}`,
{ {
@ -459,21 +465,18 @@ class StationsStore {
ru: { ru: {
name: "", name: "",
system_name: "", system_name: "",
description: "",
address: "", address: "",
loaded: false, loaded: false,
}, },
en: { en: {
name: "", name: "",
system_name: "", system_name: "",
description: "",
address: "", address: "",
loaded: false, loaded: false,
}, },
zh: { zh: {
name: "", name: "",
system_name: "", system_name: "",
description: "",
address: "", address: "",
loaded: false, loaded: false,
}, },
@ -483,6 +486,7 @@ class StationsStore {
direction: false, direction: false,
icon: "", icon: "",
latitude: 0, latitude: 0,
description: "",
longitude: 0, longitude: 0,
offset_x: 0, offset_x: 0,
offset_y: 0, offset_y: 0,
@ -509,21 +513,18 @@ class StationsStore {
ru: { ru: {
name: "", name: "",
system_name: "", system_name: "",
description: "",
address: "", address: "",
loaded: false, loaded: false,
}, },
en: { en: {
name: "", name: "",
system_name: "", system_name: "",
description: "",
address: "", address: "",
loaded: false, loaded: false,
}, },
zh: { zh: {
name: "", name: "",
system_name: "", system_name: "",
description: "",
address: "", address: "",
loaded: false, loaded: false,
}, },
@ -531,6 +532,7 @@ class StationsStore {
city: "", city: "",
city_id: 0, city_id: 0,
direction: false, direction: false,
description: "",
icon: "", icon: "",
latitude: 0, latitude: 0,
longitude: 0, longitude: 0,

View File

@ -24,8 +24,6 @@ class UserStore {
} }
getUsers = async () => { getUsers = async () => {
if (this.users.loaded) return;
const response = await authInstance.get("/user"); const response = await authInstance.get("/user");
runInAction(() => { runInAction(() => {
@ -35,7 +33,7 @@ class UserStore {
}; };
getUser = async (id: number) => { getUser = async (id: number) => {
if (this.user[id]) return; if (this.user[id]) return this.user[id];
const response = await authInstance.get(`/user/${id}`); const response = await authInstance.get(`/user/${id}`);
runInAction(() => { runInAction(() => {

View File

@ -35,8 +35,6 @@ class VehicleStore {
} }
getVehicles = async () => { getVehicles = async () => {
if (this.vehicles.loaded) return;
const response = await languageInstance("ru").get(`/vehicle`); const response = await languageInstance("ru").get(`/vehicle`);
runInAction(() => { runInAction(() => {

View File

@ -3,21 +3,25 @@ import { Button } from "@mui/material";
export const DeleteModal = ({ export const DeleteModal = ({
onDelete, onDelete,
onCancel, onCancel,
edit = false,
open, open,
}: { }: {
onDelete: () => void; onDelete: () => void;
onCancel: () => void; onCancel: () => void;
edit?: boolean;
open: boolean; open: boolean;
}) => { }) => {
return ( return (
<div <div
className={`fixed top-0 left-0 w-screen h-screen flex justify-center items-center z-10000 bg-black/30 transition-all duration-300 ${ className={`fixed top-0 left-0 w-screen h-screen flex justify-center items-center z-1000000000000 bg-black/30 transition-all duration-300 ${
open ? "block" : "hidden" open ? "block" : "hidden"
}`} }`}
> >
<div className="bg-white p-4 w-100 rounded-lg flex flex-col gap-4 items-center"> <div className="bg-white p-4 w-100 rounded-lg flex flex-col gap-4 items-center">
<p className="text-black w-100 text-center"> <p className="text-black w-100 text-center">
Вы уверены, что хотите удалить этот элемент? {`Вы уверены, что хотите ${
edit ? "убрать" : "удалить"
} этот элемент?`}
</p> </p>
<div className="flex gap-4 justify-center"> <div className="flex gap-4 justify-center">
<Button variant="contained" color="error" onClick={onDelete}> <Button variant="contained" color="error" onClick={onDelete}>

View File

@ -18,6 +18,7 @@ import { observer } from "mobx-react-lite";
import { Button, Checkbox, Typography } from "@mui/material"; import { Button, Checkbox, Typography } from "@mui/material";
import { Vehicle } from "@shared"; import { Vehicle } from "@shared";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { useNavigate } from "react-router-dom";
export type ConnectedDevice = string; export type ConnectedDevice = string;
@ -116,6 +117,7 @@ export const DevicesTable = observer(() => {
const { snapshots, getSnapshots } = snapshotStore; const { snapshots, getSnapshots } = snapshotStore;
const { getVehicles, vehicles } = vehicleStore; // Removed as devicesStore.devices should be the source of truth const { getVehicles, vehicles } = vehicleStore; // Removed as devicesStore.devices should be the source of truth
const { devices } = devicesStore; const { devices } = devicesStore;
const navigate = useNavigate();
const [selectedDeviceUuids, setSelectedDeviceUuids] = useState<string[]>([]); const [selectedDeviceUuids, setSelectedDeviceUuids] = useState<string[]>([]);
// Transform the raw devices data into rows suitable for the table // Transform the raw devices data into rows suitable for the table
@ -182,13 +184,25 @@ export const DevicesTable = observer(() => {
const handleSendSnapshotAction = async (snapshotId: string) => { const handleSendSnapshotAction = async (snapshotId: string) => {
if (selectedDeviceUuids.length === 0) return; if (selectedDeviceUuids.length === 0) return;
const send = async (deviceUuid: string) => {
try {
await authInstance.post(
`/devices/${deviceUuid}/force-snapshot-update`,
{
snapshot_id: snapshotId,
}
);
toast.success(`Снапшот отправлен на устройство `);
} catch (error) {
console.error(`Error sending snapshot to device ${deviceUuid}:`, error);
toast.error(`Не удалось отправить снапшот на устройство`);
}
};
try { try {
// Create an array of promises for all snapshot requests // Create an array of promises for all snapshot requests
const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => { const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => {
console.log(`Sending snapshot ${snapshotId} to device ${deviceUuid}`); console.log(`Sending snapshot ${snapshotId} to device ${deviceUuid}`);
return authInstance.post(`/devices/${deviceUuid}/force-snapshot`, { return send(deviceUuid);
snapshot_id: snapshotId,
});
}); });
// Wait for all promises to settle (either resolve or reject) // Wait for all promises to settle (either resolve or reject)
@ -209,6 +223,16 @@ export const DevicesTable = observer(() => {
return ( return (
<> <>
<TableContainer component={Paper} sx={{ mt: 2 }}> <TableContainer component={Paper} sx={{ mt: 2 }}>
<div className="flex justify-end p-3 gap-2 ">
<Button
variant="contained"
color="primary"
size="small"
onClick={() => navigate("/vehicle/create")}
>
Добавить устройство
</Button>
</div>
<div className="flex justify-end p-3 gap-2"> <div className="flex justify-end p-3 gap-2">
<Button <Button
variant="outlined" // Changed to outlined for distinction variant="outlined" // Changed to outlined for distinction
@ -269,6 +293,23 @@ export const DevicesTable = observer(() => {
'input[type="checkbox"]' 'input[type="checkbox"]'
) === null ) === null
) { ) {
if (event.shiftKey) {
if (row.device_uuid) {
navigator.clipboard
.writeText(row.device_uuid)
.then(() => {
toast.success(`UUID скопирован`);
})
.catch(() => {
toast.error("Не удалось скопировать UUID");
});
} else {
toast.warning("Устройство не имеет UUID");
}
}
// Only toggle checkbox if Shift key is not pressed
if (!event.shiftKey) {
handleSelectDevice( handleSelectDevice(
{ {
target: { target: {
@ -280,6 +321,7 @@ export const DevicesTable = observer(() => {
row.device_uuid ?? "" row.device_uuid ?? ""
); );
} }
}
}} }}
sx={{ sx={{
cursor: "pointer", cursor: "pointer",

View File

@ -46,11 +46,17 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
) => { ) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (file) { if (file) {
if (file.type.startsWith("image/") && file.type !== "image/gif") {
setFileToUpload(file); setFileToUpload(file);
setUploadMediaOpen(true); setUploadMediaOpen(true);
if (imageKey && setHardcodeType) { if (imageKey && setHardcodeType) {
setHardcodeType(imageKey); setHardcodeType(imageKey);
} }
} else if (file.type === "image/gif") {
toast.error("GIF файлы не поддерживаются");
} else {
toast.error("Пожалуйста, выберите изображение");
}
} }
// Reset the input value so selecting the same file again triggers change // Reset the input value so selecting the same file again triggers change
event.target.value = ""; event.target.value = "";
@ -78,9 +84,11 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
const files = event.dataTransfer.files; const files = event.dataTransfer.files;
if (files && files.length > 0) { if (files && files.length > 0) {
const file = files[0]; const file = files[0];
if (file.type.startsWith("image/")) { if (file.type.startsWith("image/") && file.type !== "image/gif") {
setFileToUpload(file); setFileToUpload(file);
setUploadMediaOpen(true); setUploadMediaOpen(true);
} else if (file.type === "image/gif") {
toast.error("GIF файлы не поддерживаются");
} else { } else {
toast.error("Пожалуйста, выберите изображение"); toast.error("Пожалуйста, выберите изображение");
} }

View File

@ -44,14 +44,14 @@ export const LanguageSwitcher = observer(() => {
}; };
return ( return (
<div className="fixed bottom-0 left-1/2 -translate-x-1/2 flex gap-2 p-4 z-100000000"> <div className="fixed top-0 left-1/2 -translate-x-1/2 flex gap-2 p-4 z-100000">
{/* Added some styling for better visualization */} {/* Added some styling for better visualization */}
{LANGUAGES.map((lang) => ( {LANGUAGES.map((lang) => (
<Button <Button
key={lang} key={lang}
onClick={() => handleLanguageChange(lang)} onClick={() => handleLanguageChange(lang)}
variant={language === lang ? "contained" : "outlined"} // Highlight the active language variant={"contained"} // Highlight the active language
color="primary" color={language === lang ? "primary" : "secondary"}
sx={{ minWidth: "60px" }} // Give buttons a consistent width sx={{ minWidth: "60px" }} // Give buttons a consistent width
> >
{getLanguageLabel(lang)} {getLanguageLabel(lang)}

View File

@ -2,20 +2,32 @@ import * as React from "react";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Toolbar from "@mui/material/Toolbar"; import Toolbar from "@mui/material/Toolbar";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import { Menu, ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; import { Menu, ChevronLeftIcon, ChevronRightIcon, User } from "lucide-react";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { AppBar } from "./ui/AppBar"; import { AppBar } from "./ui/AppBar";
import { Drawer } from "./ui/Drawer"; import { Drawer } from "./ui/Drawer";
import { DrawerHeader } from "./ui/DrawerHeader"; import { DrawerHeader } from "./ui/DrawerHeader";
import { NavigationList } from "@features"; import { NavigationList } from "@features";
import { authStore, userStore } from "@shared";
import { observer } from "mobx-react-lite";
import { useEffect } from "react";
import { Typography } from "@mui/material";
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
} }
export const Layout: React.FC<LayoutProps> = ({ children }) => { export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
const theme = useTheme(); const theme = useTheme();
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(true);
const { getUsers, users } = userStore;
useEffect(() => {
const fetchUsers = async () => {
await getUsers();
};
fetchUsers();
}, []);
const handleDrawerOpen = () => { const handleDrawerOpen = () => {
setOpen(true); setOpen(true);
@ -28,7 +40,7 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
return ( return (
<Box sx={{ display: "flex" }}> <Box sx={{ display: "flex" }}>
<AppBar position="fixed" open={open}> <AppBar position="fixed" open={open}>
<Toolbar> <Toolbar className="flex justify-between">
<IconButton <IconButton
color="inherit" color="inherit"
aria-label="open drawer" aria-label="open drawer"
@ -43,10 +55,70 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
> >
<Menu /> <Menu />
</IconButton> </IconButton>
<div></div>
<div className="flex gap-2 items-center">
<div className="flex flex-col gap-1">
{(() => {
console.log(authStore.payload);
return (
<>
<p className=" text-white">
{
users?.data?.find(
// @ts-ignore
(user) => user.id === authStore.payload?.user_id
)?.name
}
</p>
<div
className="text-center text-xs"
style={{
backgroundColor: "#877361",
borderRadius: "4px",
color: "white",
padding: "2px 10px",
}}
>
{/* @ts-ignore */}
{authStore.payload?.is_admin
? "Администратор"
: "Режим пользователя"}
</div>
</>
);
})()}
</div>
<div className="w-10 h-10 bg-gray-600 rounded-full flex items-center justify-center">
<User />
</div>
</div>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
<Drawer variant="permanent" open={open}> <Drawer variant="permanent" open={open}>
<DrawerHeader> <DrawerHeader>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
cursor: "pointer",
}}
onClick={() => {
setOpen(!open);
}}
>
<img
src="/favicon_ship.png"
alt="logo"
width={40}
height={40}
style={{ filter: "brightness(0)", marginLeft: "-5px" }}
/>
<Typography variant="h6" component="h1">
Белые ночи
</Typography>
</Box>
{open && (
<IconButton onClick={handleDrawerClose}> <IconButton onClick={handleDrawerClose}>
{theme.direction === "rtl" ? ( {theme.direction === "rtl" ? (
<ChevronRightIcon /> <ChevronRightIcon />
@ -54,6 +126,7 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
<ChevronLeftIcon /> <ChevronLeftIcon />
)} )}
</IconButton> </IconButton>
)}
</DrawerHeader> </DrawerHeader>
<NavigationList open={open} onDrawerOpen={handleDrawerOpen} /> <NavigationList open={open} onDrawerOpen={handleDrawerOpen} />
</Drawer> </Drawer>
@ -67,10 +140,9 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
maxWidth: "100vw", maxWidth: "100vw",
}} }}
> >
<DrawerHeader /> <div className="mt-16"></div>
{children} {children}
</Box> </Box>
</Box> </Box>
); );
}; });

View File

@ -24,4 +24,12 @@ export const AppBar = styled(MuiAppBar, {
duration: theme.transitions.duration.enteringScreen, duration: theme.transitions.duration.enteringScreen,
}), }),
}), }),
...(!open && {
marginLeft: theme.spacing(7),
width: `calc(100% - ${theme.spacing(7)})`,
[theme.breakpoints.up("sm")]: {
marginLeft: theme.spacing(8),
width: `calc(100% - ${theme.spacing(8)})`,
},
}),
})); }));

View File

@ -1,10 +1,12 @@
import { styled } from "@mui/material/styles"; import { styled } from "@mui/material/styles";
import type { Theme } from "@mui/material/styles"; import type { Theme } from "@mui/material/styles";
import Box from "@mui/material/Box";
export const DrawerHeader = styled("div")(({ theme }: { theme: Theme }) => ({ export const DrawerHeader = styled(Box)(({ theme }: { theme: Theme }) => ({
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "flex-end", justifyContent: "space-between",
padding: theme.spacing(0, 1), padding: theme.spacing(2),
...theme.mixins.toolbar, ...theme.mixins.toolbar,
borderBottom: "1px solid rgba(0, 0, 0, 0.12)",
})); }));

View File

@ -8,9 +8,23 @@ export const MediaAreaForSight = observer(
({ ({
onFilesDrop, // 👈 Проп для обработки загруженных файлов onFilesDrop, // 👈 Проп для обработки загруженных файлов
onFinishUpload, onFinishUpload,
contextObjectName,
contextType,
isArticle,
articleName,
}: { }: {
onFilesDrop?: (files: File[]) => void; onFilesDrop?: (files: File[]) => void;
onFinishUpload?: (mediaId: string) => void; onFinishUpload?: (mediaId: string) => void;
contextObjectName?: string;
contextType?:
| "sight"
| "city"
| "carrier"
| "country"
| "vehicle"
| "station";
isArticle?: boolean;
articleName?: string;
}) => { }) => {
const [selectMediaDialogOpen, setSelectMediaDialogOpen] = useState(false); const [selectMediaDialogOpen, setSelectMediaDialogOpen] = useState(false);
const [uploadMediaDialogOpen, setUploadMediaDialogOpen] = useState(false); const [uploadMediaDialogOpen, setUploadMediaDialogOpen] = useState(false);
@ -94,6 +108,10 @@ export const MediaAreaForSight = observer(
<UploadMediaDialog <UploadMediaDialog
open={uploadMediaDialogOpen} open={uploadMediaDialogOpen}
onClose={() => setUploadMediaDialogOpen(false)} onClose={() => setUploadMediaDialogOpen(false)}
contextObjectName={contextObjectName}
contextType={contextType}
isArticle={isArticle}
articleName={articleName}
afterUploadSight={onFinishUpload} afterUploadSight={onFinishUpload}
/> />
<SelectMediaDialog <SelectMediaDialog

View File

@ -36,6 +36,11 @@ export function MediaViewer({
style={{ style={{
height: fullHeight ? "100%" : "auto", height: fullHeight ? "100%" : "auto",
width: fullWidth ? "100%" : "auto", width: fullWidth ? "100%" : "auto",
...(media?.filename?.toLowerCase().endsWith(".webp") && {
maxWidth: "300px",
maxHeight: "300px",
objectFit: "contain",
}),
}} }}
/> />
)} )}

View File

@ -5,7 +5,7 @@ import rehypeRaw from "rehype-raw";
export const ReactMarkdownComponent = ({ value }: { value: string }) => { export const ReactMarkdownComponent = ({ value }: { value: string }) => {
return ( return (
<Box <Box
className="prose prose-sm prose-invert" className="prose prose-sm prose-invert w-full"
sx={{ sx={{
"& img": { "& img": {
maxWidth: "100%", maxWidth: "100%",

View File

@ -38,6 +38,12 @@ const StyledMarkdownEditor = styled("div")(({ theme }) => ({
maxHeight: "500px", maxHeight: "500px",
overflowY: "auto", overflowY: "auto",
overflowX: "hidden", overflowX: "hidden",
"&::-webkit-scrollbar": {
display: "none",
},
"&": {
scrollbarWidth: "none",
},
wordBreak: "break-word", // ✅ добавлено wordBreak: "break-word", // ✅ добавлено
}, },
"& .CodeMirror-selected": { "& .CodeMirror-selected": {

View File

@ -31,7 +31,7 @@ import { toast } from "react-toastify";
export const CreateInformationTab = observer( export const CreateInformationTab = observer(
({ value, index }: { value: number; index: number }) => { ({ value, index }: { value: number; index: number }) => {
const { ruCities } = cityStore; const { cities } = cityStore;
const [mediaId, setMediaId] = useState<string>(""); const [mediaId, setMediaId] = useState<string>("");
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
@ -175,10 +175,11 @@ export const CreateInformationTab = observer(
/> />
<Autocomplete <Autocomplete
options={ruCities.data ?? []} options={cities["ru"]?.data ?? []}
value={ value={
ruCities.data.find((city) => city.id === sight.city_id) ?? cities["ru"]?.data?.find(
null (city) => city.id === sight.city_id
) ?? null
} }
getOptionLabel={(option) => option.name} getOptionLabel={(option) => option.name}
onChange={(_, value) => { onChange={(_, value) => {
@ -246,7 +247,7 @@ export const CreateInformationTab = observer(
}} }}
> >
<ImageUploadCard <ImageUploadCard
title="Логотип" title="Иконка"
imageKey="thumbnail" imageKey="thumbnail"
imageUrl={sight.thumbnail} imageUrl={sight.thumbnail}
onImageClick={() => { onImageClick={() => {
@ -266,11 +267,10 @@ export const CreateInformationTab = observer(
setUploadMediaOpen={() => { setUploadMediaOpen={() => {
setIsUploadMediaOpen(true); setIsUploadMediaOpen(true);
setActiveMenuType("thumbnail"); setActiveMenuType("thumbnail");
setHardcodeType("thumbnail");
}} }}
setHardcodeType={(type) => { setHardcodeType={() => {
setHardcodeType( setHardcodeType("thumbnail");
type as "thumbnail" | "watermark_lu" | "watermark_rd"
);
}} }}
/> />
@ -295,16 +295,15 @@ export const CreateInformationTab = observer(
setUploadMediaOpen={() => { setUploadMediaOpen={() => {
setIsUploadMediaOpen(true); setIsUploadMediaOpen(true);
setActiveMenuType("watermark_lu"); setActiveMenuType("watermark_lu");
setHardcodeType("watermark_lu");
}} }}
setHardcodeType={(type) => { setHardcodeType={() => {
setHardcodeType( setHardcodeType("watermark_lu");
type as "thumbnail" | "watermark_lu" | "watermark_rd"
);
}} }}
/> />
<ImageUploadCard <ImageUploadCard
title="Водяной знак (правый нижний)" title="Водяной знак (правый верхний)"
imageKey="watermark_rd" imageKey="watermark_rd"
imageUrl={sight.watermark_rd} imageUrl={sight.watermark_rd}
onImageClick={() => { onImageClick={() => {
@ -324,11 +323,10 @@ export const CreateInformationTab = observer(
setUploadMediaOpen={() => { setUploadMediaOpen={() => {
setIsUploadMediaOpen(true); setIsUploadMediaOpen(true);
setActiveMenuType("watermark_rd"); setActiveMenuType("watermark_rd");
setHardcodeType("watermark_rd");
}} }}
setHardcodeType={(type) => { setHardcodeType={() => {
setHardcodeType( setHardcodeType("watermark_rd");
type as "thumbnail" | "watermark_lu" | "watermark_rd"
);
}} }}
/> />
</Box> </Box>
@ -412,6 +410,8 @@ export const CreateInformationTab = observer(
<UploadMediaDialog <UploadMediaDialog
open={isUploadMediaOpen} open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)} onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={sight[language].name}
contextType="sight"
afterUpload={(media) => { afterUpload={(media) => {
handleChange({ handleChange({
[activeMenuType ?? "thumbnail"]: media.id, [activeMenuType ?? "thumbnail"]: media.id,

View File

@ -44,7 +44,7 @@ export const CreateLeftTab = observer(
} = editSightStore; } = editSightStore;
const { language } = languageStore; const { language } = languageStore;
const token = localStorage.getItem("token");
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] = const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
useState(false); useState(false);
const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] = const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] =
@ -326,6 +326,7 @@ export const CreateLeftTab = observer(
<Box <Box
sx={{ sx={{
overflow: "hidden", overflow: "hidden",
position: "relative",
width: "100%", width: "100%",
minHeight: 100, minHeight: 100,
padding: "3px", padding: "3px",
@ -343,6 +344,7 @@ export const CreateLeftTab = observer(
}} }}
> >
{sight[language].left.media.length > 0 ? ( {sight[language].left.media.length > 0 ? (
<>
<MediaViewer <MediaViewer
media={{ media={{
id: sight[language].left.media[0].id, id: sight[language].left.media[0].id,
@ -352,6 +354,35 @@ export const CreateLeftTab = observer(
}} }}
fullWidth fullWidth
/> />
{sight.watermark_lu && (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
sight.watermark_lu
}/download?token=${token}`}
alt="preview"
className="absolute top-4 left-4 z-10"
style={{
width: "30px",
height: "30px",
objectFit: "contain",
}}
/>
)}
{sight.watermark_rd && (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
sight.watermark_rd
}/download?token=${token}`}
alt="preview"
className="absolute bottom-4 right-4 z-10"
style={{
width: "30px",
height: "30px",
objectFit: "contain",
}}
/>
)}
</>
) : ( ) : (
<ImagePlus size={48} color="white" /> <ImagePlus size={48} color="white" />
)} )}
@ -400,7 +431,13 @@ export const CreateLeftTab = observer(
sx={{ sx={{
padding: 1, padding: 1,
maxHeight: "300px", maxHeight: "300px",
overflowY: "scroll", overflowY: "auto",
"&::-webkit-scrollbar": {
display: "none",
},
"&": {
scrollbarWidth: "none",
},
background: background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)", "#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
flexGrow: 1, flexGrow: 1,
@ -451,6 +488,10 @@ export const CreateLeftTab = observer(
<UploadMediaDialog <UploadMediaDialog
open={uploadMediaOpen} open={uploadMediaOpen}
onClose={() => setUploadMediaOpen(false)} onClose={() => setUploadMediaOpen(false)}
contextObjectName={sight[language].name}
contextType="sight"
isArticle={true}
articleName={sight[language].left.heading || "Левая статья"}
afterUpload={async (media) => { afterUpload={async (media) => {
setUploadMediaOpen(false); setUploadMediaOpen(false);
setFileToUpload(null); setFileToUpload(null);
@ -466,6 +507,7 @@ export const CreateLeftTab = observer(
open={isDeleteModalOpen} open={isDeleteModalOpen}
onDelete={() => { onDelete={() => {
deleteLeftArticle(sight.left_article); deleteLeftArticle(sight.left_article);
setIsDeleteModalOpen(false);
toast.success("Статья откреплена"); toast.success("Статья откреплена");
}} }}
onCancel={() => setIsDeleteModalOpen(false)} onCancel={() => setIsDeleteModalOpen(false)}

View File

@ -153,10 +153,12 @@ export const CreateRightTab = observer(
selectedArticleId: number selectedArticleId: number
) => { ) => {
try { try {
await linkExistingRightArticle(selectedArticleId); const linkedArticleId = await linkExistingRightArticle(
selectedArticleId
);
setSelectArticleDialogOpen(false); // Close dialog setSelectArticleDialogOpen(false); // Close dialog
const newIndex = sight[language].right.findIndex( const newIndex = sight[language].right.findIndex(
(a) => a.id === selectedArticleId (a) => a.id === linkedArticleId
); );
if (newIndex > -1) { if (newIndex > -1) {
setActiveArticleIndex(newIndex); setActiveArticleIndex(newIndex);
@ -483,6 +485,9 @@ export const CreateRightTab = observer(
linkPreviewMedia(mediaId); linkPreviewMedia(mediaId);
}} }}
onFilesDrop={() => {}} onFilesDrop={() => {}}
contextObjectName={sight[language].name}
contextType="sight"
isArticle={false}
/> />
)} )}
</Box> </Box>
@ -495,6 +500,9 @@ export const CreateRightTab = observer(
linkPreviewMedia(mediaId); linkPreviewMedia(mediaId);
}} }}
onFilesDrop={() => {}} onFilesDrop={() => {}}
contextObjectName={sight[language].name}
contextType="sight"
isArticle={false}
/> />
)} )}
</Box> </Box>
@ -597,7 +605,13 @@ export const CreateRightTab = observer(
padding: 1, padding: 1,
minHeight: "200px", minHeight: "200px",
maxHeight: "300px", maxHeight: "300px",
overflowY: "scroll", overflowY: "auto",
"&::-webkit-scrollbar": {
display: "none",
},
"&": {
scrollbarWidth: "none",
},
background: background:
"rgba(179, 165, 152, 0.4), linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 100%)", "rgba(179, 165, 152, 0.4), linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 100%)",
@ -698,6 +712,14 @@ export const CreateRightTab = observer(
setFileToUpload(null); // Clear file if dialog is closed without upload setFileToUpload(null); // Clear file if dialog is closed without upload
setMediaTarget(null); setMediaTarget(null);
}} }}
contextObjectName={sight[language].name}
contextType="sight"
isArticle={mediaTarget === "rightArticle"}
articleName={
mediaTarget === "rightArticle" && activeArticleIndex !== null
? sight[language].right[activeArticleIndex].heading
: undefined
}
afterUpload={handleMediaUploaded} // This will use the mediaTarget afterUpload={handleMediaUploaded} // This will use the mediaTarget
/> />
<SelectMediaDialog <SelectMediaDialog

View File

@ -32,8 +32,6 @@ import { toast } from "react-toastify";
export const InformationTab = observer( export const InformationTab = observer(
({ value, index }: { value: number; index: number }) => { ({ value, index }: { value: number; index: number }) => {
const { ruCities } = cityStore;
const [mediaId, setMediaId] = useState<string>(""); const [mediaId, setMediaId] = useState<string>("");
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
@ -53,6 +51,7 @@ export const InformationTab = observer(
const [hardcodeType, setHardcodeType] = useState< const [hardcodeType, setHardcodeType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | null "thumbnail" | "watermark_lu" | "watermark_rd" | null
>(null); >(null);
const { cities } = cityStore;
useEffect(() => { useEffect(() => {
// Показывать только при инициализации (не менять при ошибках пользователя) // Показывать только при инициализации (не менять при ошибках пользователя)
if (sight.common.latitude !== 0 || sight.common.longitude !== 0) { if (sight.common.latitude !== 0 || sight.common.longitude !== 0) {
@ -89,7 +88,7 @@ export const InformationTab = observer(
}, },
true true
); );
setActiveMenuType(null);
setIsUploadMediaOpen(false); setIsUploadMediaOpen(false);
}; };
@ -163,9 +162,9 @@ export const InformationTab = observer(
/> />
<Autocomplete <Autocomplete
options={ruCities?.data ?? []} options={cities["ru"]?.data ?? []}
value={ value={
ruCities?.data?.find( cities["ru"]?.data?.find(
(city) => city.id === sight.common.city_id (city) => city.id === sight.common.city_id
) ?? null ) ?? null
} }
@ -246,7 +245,7 @@ export const InformationTab = observer(
}} }}
> >
<ImageUploadCard <ImageUploadCard
title="Логотип" title="Иконка"
imageKey="thumbnail" imageKey="thumbnail"
imageUrl={sight.common.thumbnail} imageUrl={sight.common.thumbnail}
onImageClick={() => { onImageClick={() => {
@ -270,11 +269,10 @@ export const InformationTab = observer(
setUploadMediaOpen={() => { setUploadMediaOpen={() => {
setIsUploadMediaOpen(true); setIsUploadMediaOpen(true);
setActiveMenuType("thumbnail"); setActiveMenuType("thumbnail");
setHardcodeType("thumbnail");
}} }}
setHardcodeType={(type) => { setHardcodeType={() => {
setHardcodeType( setHardcodeType("thumbnail");
type as "thumbnail" | "watermark_lu" | "watermark_rd"
);
}} }}
/> />
<ImageUploadCard <ImageUploadCard
@ -302,15 +300,14 @@ export const InformationTab = observer(
setUploadMediaOpen={() => { setUploadMediaOpen={() => {
setIsUploadMediaOpen(true); setIsUploadMediaOpen(true);
setActiveMenuType("watermark_lu"); setActiveMenuType("watermark_lu");
setHardcodeType("watermark_lu");
}} }}
setHardcodeType={(type) => { setHardcodeType={() => {
setHardcodeType( setHardcodeType("watermark_lu");
type as "thumbnail" | "watermark_lu" | "watermark_rd"
);
}} }}
/> />
<ImageUploadCard <ImageUploadCard
title="Водяной знак (правый нижний)" title="Водяной знак (правый верхний)"
imageKey="watermark_rd" imageKey="watermark_rd"
imageUrl={sight.common.watermark_rd} imageUrl={sight.common.watermark_rd}
onImageClick={() => { onImageClick={() => {
@ -334,11 +331,10 @@ export const InformationTab = observer(
setUploadMediaOpen={() => { setUploadMediaOpen={() => {
setIsUploadMediaOpen(true); setIsUploadMediaOpen(true);
setActiveMenuType("watermark_rd"); setActiveMenuType("watermark_rd");
setHardcodeType("watermark_rd");
}} }}
setHardcodeType={(type) => { setHardcodeType={() => {
setHardcodeType( setHardcodeType("watermark_rd");
type as "thumbnail" | "watermark_lu" | "watermark_rd"
);
}} }}
/> />
</Box> </Box>
@ -399,7 +395,6 @@ export const InformationTab = observer(
open={isAddMediaOpen} open={isAddMediaOpen}
onClose={() => { onClose={() => {
setIsAddMediaOpen(false); setIsAddMediaOpen(false);
setActiveMenuType(null);
}} }}
onSelectMedia={handleMediaSelect} onSelectMedia={handleMediaSelect}
mediaType={ mediaType={
@ -414,6 +409,8 @@ export const InformationTab = observer(
<UploadMediaDialog <UploadMediaDialog
open={isUploadMediaOpen} open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)} onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={sight[language].name}
contextType="sight"
afterUpload={(media) => { afterUpload={(media) => {
handleChange( handleChange(
language as Language, language as Language,

View File

@ -9,6 +9,7 @@ import {
SelectArticleModal, SelectArticleModal,
UploadMediaDialog, UploadMediaDialog,
Language, Language,
articlesStore,
} from "@shared"; } from "@shared";
import { import {
LanguageSwitcher, LanguageSwitcher,
@ -43,7 +44,7 @@ export const LeftWidgetTab = observer(
const { language } = languageStore; const { language } = languageStore;
const data = sight[language]; const data = sight[language];
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const token = localStorage.getItem("token");
const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] = const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] =
useState(false); useState(false);
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] = const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
@ -76,20 +77,40 @@ export const LeftWidgetTab = observer(
}, []); }, []);
const handleSelectArticle = useCallback( const handleSelectArticle = useCallback(
( async (
articleId: number, articleId: number
heading: string, // heading: string,
body: string, // body: string,
media: { id: string; media_type: number; filename: string }[] // media: { id: string; media_type: number; filename: string }[]
) => { ) => {
setIsSelectArticleDialogOpen(false); setIsSelectArticleDialogOpen(false);
updateSightInfo(languageStore.language, {
const ruArticle = await articlesStore.getArticle(articleId, "ru");
const enArticle = await articlesStore.getArticle(articleId, "en");
const zhArticle = await articlesStore.getArticle(articleId, "zh");
updateSightInfo("ru", {
left: { left: {
heading, heading: ruArticle.data.heading,
body, body: ruArticle.data.body,
media, media: ruArticle.data.media || [],
}, },
}); });
updateSightInfo("en", {
left: {
heading: enArticle.data.heading,
body: enArticle.data.body,
media: enArticle.data.media || [],
},
});
updateSightInfo("zh", {
left: {
heading: zhArticle.data.heading,
body: zhArticle.data.body,
media: zhArticle.data.media || [],
},
});
updateSightInfo( updateSightInfo(
languageStore.language, languageStore.language,
{ {
@ -271,6 +292,7 @@ export const LeftWidgetTab = observer(
width: "100%", width: "100%",
minHeight: 100, minHeight: 100,
padding: "3px", padding: "3px",
position: "relative",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
@ -285,6 +307,7 @@ export const LeftWidgetTab = observer(
}} }}
> >
{data.left.media.length > 0 ? ( {data.left.media.length > 0 ? (
<>
<MediaViewer <MediaViewer
media={{ media={{
id: data.left.media[0].id, id: data.left.media[0].id,
@ -293,6 +316,32 @@ export const LeftWidgetTab = observer(
}} }}
fullWidth fullWidth
/> />
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
sight.common.watermark_lu
}/download?token=${token}`}
alt="preview"
className="absolute top-4 left-4 z-10"
style={{
width: "30px",
height: "30px",
objectFit: "contain",
}}
/>
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
sight.common.watermark_rd
}/download?token=${token}`}
alt="preview"
className="absolute bottom-4 right-4 z-10"
style={{
width: "30px",
height: "30px",
objectFit: "contain",
}}
/>
</>
) : ( ) : (
<ImagePlus size={48} color="white" /> <ImagePlus size={48} color="white" />
)} )}
@ -341,7 +390,14 @@ export const LeftWidgetTab = observer(
sx={{ sx={{
padding: 1, padding: 1,
maxHeight: "300px", maxHeight: "300px",
overflowY: "scroll", overflowY: "auto",
width: "100%",
"&::-webkit-scrollbar": {
display: "none",
},
"&": {
scrollbarWidth: "none",
},
background: background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)", "#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
flexGrow: 1, flexGrow: 1,
@ -373,6 +429,12 @@ export const LeftWidgetTab = observer(
<UploadMediaDialog <UploadMediaDialog
open={uploadMediaOpen} open={uploadMediaOpen}
onClose={() => setUploadMediaOpen(false)} onClose={() => setUploadMediaOpen(false)}
contextObjectName={sight[languageStore.language].name}
contextType="sight"
isArticle={true}
articleName={
sight[languageStore.language].left.heading || "Левая статья"
}
afterUpload={async (media) => { afterUpload={async (media) => {
setUploadMediaOpen(false); setUploadMediaOpen(false);
setFileToUpload(null); setFileToUpload(null);

View File

@ -115,9 +115,21 @@ export const RightWidgetTab = observer(
setActiveArticleIndex(index); setActiveArticleIndex(index);
}; };
const handleCreateNew = () => { const handleCreateNew = async () => {
createNewRightArticle(); try {
const newArticleId = await createNewRightArticle();
handleClose(); handleClose();
// Automatically select the newly created article
const newIndex = sight[language].right.findIndex(
(article) => article.id === newArticleId
);
if (newIndex > -1) {
setActiveArticleIndex(newIndex);
setType("article");
}
} catch (error) {
console.error("Error creating new article:", error);
}
}; };
const handleSelectExisting = () => { const handleSelectExisting = () => {
@ -129,9 +141,21 @@ export const RightWidgetTab = observer(
setIsSelectModalOpen(false); setIsSelectModalOpen(false);
}; };
const handleArticleSelect = (id: number) => { const handleArticleSelect = async (id: number) => {
linkArticle(id); try {
const linkedArticleId = await linkArticle(id);
handleCloseSelectModal(); handleCloseSelectModal();
// Automatically select the newly linked article
const newIndex = sight[language].right.findIndex(
(article) => article.id === linkedArticleId
);
if (newIndex > -1) {
setActiveArticleIndex(newIndex);
setType("article");
}
} catch (error) {
console.error("Error linking article:", error);
}
}; };
const handleMediaSelected = async (media: { const handleMediaSelected = async (media: {
@ -417,6 +441,9 @@ export const RightWidgetTab = observer(
linkPreviewMedia(mediaId); linkPreviewMedia(mediaId);
}} }}
onFilesDrop={() => {}} onFilesDrop={() => {}}
contextObjectName={sight[language].name}
contextType="sight"
isArticle={false}
/> />
)} )}
</Box> </Box>
@ -441,6 +468,9 @@ export const RightWidgetTab = observer(
linkPreviewMedia(mediaId); linkPreviewMedia(mediaId);
}} }}
onFilesDrop={() => {}} onFilesDrop={() => {}}
contextObjectName={sight[language].name}
contextType="sight"
isArticle={false}
/> />
</Box> </Box>
</Box> </Box>
@ -539,7 +569,15 @@ export const RightWidgetTab = observer(
padding: 1, padding: 1,
minHeight: "200px", minHeight: "200px",
maxHeight: "300px", maxHeight: "300px",
overflowY: "scroll", overflowY: "auto",
width: "100%",
"&::-webkit-scrollbar": {
display: "none",
},
"&": {
scrollbarWidth: "none",
},
background: background:
"rgba(179, 165, 152, 0.4), linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 100%)", "rgba(179, 165, 152, 0.4), linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 100%)",
@ -565,13 +603,13 @@ export const RightWidgetTab = observer(
sx={{ sx={{
p: 2, p: 2,
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "center",
fontSize: "24px", fontSize: "24px",
fontWeight: 700, fontWeight: 700,
lineHeight: "120%", lineHeight: "120%",
flexWrap: "wrap", flexWrap: "wrap",
gap: 1, gap: "34px",
backdropFilter: "blur(12px)", backdropFilter: "blur(12px)",
boxShadow: boxShadow:
"inset 4px 4px 12px 0 rgba(255,255,255,0.12)", "inset 4px 4px 12px 0 rgba(255,255,255,0.12)",
@ -627,6 +665,14 @@ export const RightWidgetTab = observer(
<UploadMediaDialog <UploadMediaDialog
open={uploadMediaOpen} open={uploadMediaOpen}
onClose={() => setUploadMediaOpen(false)} onClose={() => setUploadMediaOpen(false)}
contextObjectName={sight[language].name}
contextType="sight"
isArticle={true}
articleName={
activeArticleIndex !== null
? sight[language].right[activeArticleIndex].heading
: "Правая статья"
}
afterUpload={async (media) => { afterUpload={async (media) => {
setUploadMediaOpen(false); setUploadMediaOpen(false);
setFileToUpload(null); setFileToUpload(null);

View File

@ -16,3 +16,4 @@ export * from "./LeaveAgree";
export * from "./DeleteModal"; export * from "./DeleteModal";
export * from "./SnapshotRestore"; export * from "./SnapshotRestore";
export * from "./CreateButton"; export * from "./CreateButton";
export * from "./modals";

View File

@ -0,0 +1,140 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
Typography,
IconButton,
Box,
} from "@mui/material";
import { routeStore } from "@shared";
import { observer } from "mobx-react-lite";
import { ArrowLeft } from "lucide-react";
import { useParams } from "react-router-dom";
import { toast } from "react-toastify";
interface EditStationModalProps {
open: boolean;
onClose: () => void;
}
const transferFields = [
{ key: "bus", label: "Автобус" },
{ key: "metro_blue", label: "Метро (синяя)" },
{ key: "metro_green", label: "Метро (зеленая)" },
{ key: "metro_orange", label: "Метро (оранжевая)" },
{ key: "metro_purple", label: "Метро (фиолетовая)" },
{ key: "metro_red", label: "Метро (красная)" },
{ key: "train", label: "Электричка" },
{ key: "tram", label: "Трамвай" },
{ key: "trolleybus", label: "Троллейбус" },
];
export const EditStationModal = observer(
({ open, onClose }: EditStationModalProps) => {
const { id: routeId } = useParams<{ id: string }>();
const {
selectedStationId,
setRouteStations,
saveRouteStations,
routeStations,
} = routeStore;
const handleSave = async () => {
console.log(routeId, selectedStationId);
await saveRouteStations(Number(routeId), selectedStationId);
toast.success("Успешно сохранено");
onClose();
};
const station = routeStations[Number(routeId)]?.find(
(station: any) => station.id === selectedStationId
);
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>
<Box display="flex" alignItems="center" gap={2}>
<IconButton onClick={onClose}>
<ArrowLeft />
</IconButton>
<Box>
<Typography variant="caption" color="text.secondary">
Маршруты / Редактировать
</Typography>
<Typography variant="h6">Редактирование остановки</Typography>
</Box>
</Box>
</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2, display: "flex", gap: 2, flexDirection: "column" }}>
<TextField
label="Смещение (X)"
name="offset_x"
type="number"
fullWidth
onChange={(e) => {
setRouteStations(Number(routeId), selectedStationId, {
offset_x: Number(e.target.value),
});
}}
defaultValue={station?.offset_x}
/>
<TextField
label="Смещение (Y)"
name="offset_y"
type="number"
fullWidth
onChange={(e) => {
setRouteStations(Number(routeId), selectedStationId, {
offset_y: Number(e.target.value),
});
}}
defaultValue={station?.offset_y}
/>
</Box>
<Box sx={{ mt: 4 }}>
<Typography variant="h6" gutterBottom>
Пересадки
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 2,
}}
>
{transferFields.map(({ key, label }) => (
<TextField
key={key}
label={label}
name={key}
fullWidth
defaultValue={station?.transfers?.[key]}
onChange={(e) => {
setRouteStations(Number(routeId), selectedStationId, {
...station,
transfers: {
...station?.transfers,
[key]: e.target.value,
},
});
}}
/>
))}
</Box>
</Box>
</DialogContent>
<DialogActions sx={{ p: 3, justifyContent: "flex-end" }}>
<Button onClick={handleSave} variant="contained" color="primary">
Сохранить
</Button>
</DialogActions>
</Dialog>
);
}
);

View File

@ -1 +1,2 @@
export * from "./SelectArticleDialog"; export * from "./SelectArticleDialog";
export * from "./EditStationModal";