diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index 8b13789..0000000 --- a/src/App.tsx +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/app/router/index.tsx b/src/app/router/index.tsx index 1bf8fbe..6082418 100644 --- a/src/app/router/index.tsx +++ b/src/app/router/index.tsx @@ -26,7 +26,7 @@ import { CountryCreatePage, CityCreatePage, CarrierCreatePage, - // VehicleCreatePage, + VehicleCreatePage, CountryEditPage, CityEditPage, UserCreatePage, @@ -40,6 +40,7 @@ import { RoutePreview, RouteEditPage, ArticlePreviewPage, + CountryAddPage, } from "@pages"; import { authStore, createSightStore, editSightStore } from "@shared"; import { Layout } from "@widgets"; @@ -57,7 +58,7 @@ import { const PublicRoute = ({ children }: { children: React.ReactNode }) => { const { isAuthenticated } = authStore; if (isAuthenticated) { - return ; + return ; } return <>{children}; }; @@ -69,7 +70,7 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { return ; } if (location.pathname === "/") { - return ; + return ; } return <>{children}; }; @@ -134,6 +135,7 @@ const router = createBrowserRouter([ // Country { path: "country", element: }, { path: "country/create", element: }, + { path: "country/add", element: }, // { path: "country/:id", element: }, { path: "country/:id/edit", element: }, // City @@ -166,7 +168,7 @@ const router = createBrowserRouter([ { path: "station/:id/edit", element: }, // Vehicle // { path: "vehicle", element: }, - // { path: "vehicle/create", element: }, + { path: "vehicle/create", element: }, // { path: "vehicle/:id", element: }, // { path: "vehicle/:id/edit", element: }, // Article diff --git a/src/pages/Article/ArticleCreatePage/index.tsx b/src/pages/Article/ArticleCreatePage/index.tsx index 36c54b6..ac5cfaf 100644 --- a/src/pages/Article/ArticleCreatePage/index.tsx +++ b/src/pages/Article/ArticleCreatePage/index.tsx @@ -2,14 +2,19 @@ import React from "react"; import { useNavigate } from "react-router-dom"; import { ArrowLeft } from "lucide-react"; import { LanguageSwitcher } from "@widgets"; +import { articlesStore } from "@shared"; const ArticleCreatePage: React.FC = () => { const navigate = useNavigate(); + const { articleData } = articlesStore; + return (
-

Создание статьи

+

+ {articleData?.ru?.heading || "Создание статьи"} +

diff --git a/src/pages/Article/ArticleEditPage/index.tsx b/src/pages/Article/ArticleEditPage/index.tsx index b33980d..47cd0ff 100644 --- a/src/pages/Article/ArticleEditPage/index.tsx +++ b/src/pages/Article/ArticleEditPage/index.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { ArrowLeft } from "lucide-react"; import { LanguageSwitcher } from "@widgets"; -import { articlesStore } from "@shared"; +import { articlesStore, languageStore } from "@shared"; import { observer } from "mobx-react-lite"; const ArticleEditPage: React.FC = observer(() => { @@ -11,6 +11,11 @@ const ArticleEditPage: React.FC = observer(() => { const { articleData, getArticle } = articlesStore; + useEffect(() => { + // Устанавливаем русский язык при загрузке страницы + languageStore.setLanguage("ru"); + }, []); + useEffect(() => { if (id) { // Fetch data for all languages diff --git a/src/pages/Article/ArticleListPage/index.tsx b/src/pages/Article/ArticleListPage/index.tsx index fd410a1..b0b8aea 100644 --- a/src/pages/Article/ArticleListPage/index.tsx +++ b/src/pages/Article/ArticleListPage/index.tsx @@ -1,10 +1,12 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; +import { ruRU } from "@mui/x-data-grid/locales"; import { articlesStore, languageStore } from "@shared"; import { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; import { Trash2, Eye, Minus } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { DeleteModal, LanguageSwitcher } from "@widgets"; +import { Box, CircularProgress } from "@mui/material"; export const ArticleListPage = observer(() => { const { articleList, getArticleList, deleteArticles } = articlesStore; @@ -14,9 +16,15 @@ export const ArticleListPage = observer(() => { const [rowId, setRowId] = useState(null); const { language } = languageStore; const [ids, setIds] = useState([]); + const [isLoading, setIsLoading] = useState(false); useEffect(() => { - getArticleList(); + const fetchArticles = async () => { + setIsLoading(true); + await getArticleList(); + setIsLoading(false); + }; + fetchArticles(); }, [language]); const columns: GridColDef[] = [ @@ -93,10 +101,21 @@ export const ArticleListPage = observer(() => { columns={columns} hideFooterPagination checkboxSelection + loading={isLoading} + localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} onRowSelectionModelChange={(newSelection) => { setIds(Array.from(newSelection.ids) as number[]); }} hideFooter + slots={{ + noRowsOverlay: () => ( + + {isLoading ? : "Нет статей"} + + ), + }} />
diff --git a/src/pages/Carrier/CarrierCreatePage/index.tsx b/src/pages/Carrier/CarrierCreatePage/index.tsx index 06a8f92..f447921 100644 --- a/src/pages/Carrier/CarrierCreatePage/index.tsx +++ b/src/pages/Carrier/CarrierCreatePage/index.tsx @@ -38,12 +38,14 @@ export const CarrierCreatePage = observer(() => { useEffect(() => { cityStore.getCities("ru"); mediaStore.getMedia(); + languageStore.setLanguage("ru"); }, []); const handleCreate = async () => { try { setIsLoading(true); await carrierStore.createCarrier(); + toast.success("Перевозчик успешно создан"); navigate("/carrier"); } catch (error) { @@ -229,6 +231,8 @@ export const CarrierCreatePage = observer(() => { setIsUploadMediaOpen(false)} + contextObjectName={createCarrierData[language].full_name} + contextType="carrier" afterUpload={handleMediaSelect} hardcodeType={activeMenuType} /> diff --git a/src/pages/Carrier/CarrierEditPage/index.tsx b/src/pages/Carrier/CarrierEditPage/index.tsx index 20bea5f..e914ba2 100644 --- a/src/pages/Carrier/CarrierEditPage/index.tsx +++ b/src/pages/Carrier/CarrierEditPage/index.tsx @@ -14,11 +14,11 @@ import { useNavigate, useParams } from "react-router-dom"; import { toast } from "react-toastify"; import { carrierStore, cityStore, mediaStore, languageStore } from "@shared"; import { useState, useEffect } from "react"; -import { ImageUploadCard, LanguageSwitcher } from "@widgets"; +import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets"; import { SelectMediaDialog, - UploadMediaDialog, PreviewMediaDialog, + UploadMediaDialog, } from "@shared"; export const CarrierEditPage = observer(() => { @@ -32,6 +32,7 @@ export const CarrierEditPage = observer(() => { const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); const [mediaId, setMediaId] = useState(""); + const [isDeleteLogoModalOpen, setIsDeleteLogoModalOpen] = useState(false); const [activeMenuType, setActiveMenuType] = useState< "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null >(null); @@ -72,6 +73,9 @@ export const CarrierEditPage = observer(() => { mediaStore.getMedia(); })(); + + // Устанавливаем русский язык при загрузке страницы + languageStore.setLanguage("ru"); }, [id]); const handleEdit = async () => { @@ -209,15 +213,7 @@ export const CarrierEditPage = observer(() => { setMediaId(selectedMedia?.id ?? ""); }} onDeleteImageClick={() => { - setEditCarrierData( - editCarrierData[language].full_name, - editCarrierData[language].short_name, - editCarrierData.city_id, - editCarrierData[language].slogan, - "", - language - ); - setActiveMenuType(null); + setIsDeleteLogoModalOpen(true); }} onSelectFileClick={() => { setActiveMenuType("image"); @@ -244,7 +240,7 @@ export const CarrierEditPage = observer(() => { {isLoading ? ( ) : ( - "Обновить" + "Сохранить" )} @@ -259,6 +255,8 @@ export const CarrierEditPage = observer(() => { setIsUploadMediaOpen(false)} + contextObjectName={editCarrierData[language].full_name} + contextType="carrier" afterUpload={handleMediaSelect} hardcodeType={activeMenuType} /> @@ -268,6 +266,23 @@ export const CarrierEditPage = observer(() => { onClose={() => setIsPreviewMediaOpen(false)} mediaId={mediaId} /> + + { + setEditCarrierData( + editCarrierData[language].full_name, + editCarrierData[language].short_name, + editCarrierData.city_id, + editCarrierData[language].slogan, + "", + language + ); + setIsDeleteLogoModalOpen(false); + }} + onCancel={() => setIsDeleteLogoModalOpen(false)} + edit + /> ); }); diff --git a/src/pages/Carrier/CarrierListPage/index.tsx b/src/pages/Carrier/CarrierListPage/index.tsx index 24c035b..6802a35 100644 --- a/src/pages/Carrier/CarrierListPage/index.tsx +++ b/src/pages/Carrier/CarrierListPage/index.tsx @@ -1,10 +1,12 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; +import { ruRU } from "@mui/x-data-grid/locales"; import { carrierStore, cityStore, languageStore } from "@shared"; import { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; import { Pencil, Trash2, Minus } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; +import { Box, CircularProgress } from "@mui/material"; export const CarrierListPage = observer(() => { const { carriers, getCarriers, deleteCarrier } = carrierStore; @@ -14,15 +16,19 @@ export const CarrierListPage = observer(() => { const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); const [rowId, setRowId] = useState(null); const [ids, setIds] = useState([]); + const [isLoading, setIsLoading] = useState(false); const { language } = languageStore; useEffect(() => { - (async () => { + const fetchData = async () => { + setIsLoading(true); await getCities("ru"); await getCities("en"); await getCities("zh"); await getCarriers(language); - })(); + setIsLoading(false); + }; + fetchData(); }, [language]); const columns: GridColDef[] = [ @@ -63,11 +69,13 @@ export const CarrierListPage = observer(() => { headerName: "Город", flex: 1, renderCell: (params: GridRenderCellParams) => { + const city = cities[language]?.data.find( + (city) => city.id == params.value + ); return (
- {params.value ? ( - cities[language].data.find((city) => city.id == params.value) - ?.name + {city && city.name ? ( + city.name ) : ( )} @@ -136,12 +144,24 @@ export const CarrierListPage = observer(() => { { - setIds(Array.from(newSelection.ids) as number[]); - }} hideFooter + checkboxSelection + loading={isLoading} + localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} + onRowSelectionModelChange={(newSelection) => { + setIds(Array.from(newSelection.ids as unknown as number[])); + }} + slots={{ + noRowsOverlay: () => ( + + {isLoading ? ( + + ) : ( + "Нет перевозчиков" + )} + + ), + }} />
diff --git a/src/pages/City/CityCreatePage/index.tsx b/src/pages/City/CityCreatePage/index.tsx index ac5e908..0f26a89 100644 --- a/src/pages/City/CityCreatePage/index.tsx +++ b/src/pages/City/CityCreatePage/index.tsx @@ -157,15 +157,8 @@ export const CityCreatePage = observer(() => { setIsUploadMediaOpen(true); setActiveMenuType("image"); }} - setHardcodeType={(type) => { - setActiveMenuType( - type as - | "thumbnail" - | "watermark_lu" - | "watermark_rd" - | "image" - | null - ); + setHardcodeType={() => { + setActiveMenuType("image"); }} /> @@ -195,6 +188,8 @@ export const CityCreatePage = observer(() => { setIsUploadMediaOpen(false)} + contextObjectName={createCityData[language]?.name} + contextType="city" afterUpload={handleMediaSelect} hardcodeType={ activeMenuType as "thumbnail" | "watermark_lu" | "watermark_rd" | null diff --git a/src/pages/City/CityEditPage/index.tsx b/src/pages/City/CityEditPage/index.tsx index 4fa3199..4d7b0b4 100644 --- a/src/pages/City/CityEditPage/index.tsx +++ b/src/pages/City/CityEditPage/index.tsx @@ -43,6 +43,11 @@ export const CityEditPage = observer(() => { const { getCountries } = countryStore; const { getMedia, getOneMedia } = mediaStore; + useEffect(() => { + // Устанавливаем русский язык при загрузке страницы + languageStore.setLanguage("ru"); + }, []); + const handleEdit = async () => { try { setIsLoading(true); @@ -58,6 +63,7 @@ export const CityEditPage = observer(() => { useEffect(() => { (async () => { if (id) { + await getCountries("ru"); // Fetch data for all languages const ruData = await getCity(id as string, "ru"); const enData = await getCity(id as string, "en"); @@ -69,7 +75,7 @@ export const CityEditPage = observer(() => { setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh"); await getOneMedia(ruData.arms as string); - await getCountries("ru"); + await getMedia(); } })(); @@ -174,15 +180,8 @@ export const CityEditPage = observer(() => { setIsUploadMediaOpen(true); setActiveMenuType("image"); }} - setHardcodeType={(type) => { - setActiveMenuType( - type as - | "thumbnail" - | "watermark_lu" - | "watermark_rd" - | "image" - | null - ); + setHardcodeType={() => { + setActiveMenuType("image"); }} /> @@ -199,7 +198,7 @@ export const CityEditPage = observer(() => { {isLoading ? ( ) : ( - "Обновить" + "Сохранить" )} @@ -214,6 +213,8 @@ export const CityEditPage = observer(() => { setIsUploadMediaOpen(false)} + contextObjectName={editCityData[language].name} + contextType="city" afterUpload={handleMediaSelect} hardcodeType={ activeMenuType as diff --git a/src/pages/City/CityListPage/index.tsx b/src/pages/City/CityListPage/index.tsx index 528b179..cd3c288 100644 --- a/src/pages/City/CityListPage/index.tsx +++ b/src/pages/City/CityListPage/index.tsx @@ -1,11 +1,13 @@ 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 { observer } from "mobx-react-lite"; import { Pencil, Trash2, Minus } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; import { toast } from "react-toastify"; +import { Box, CircularProgress } from "@mui/material"; export const CityListPage = observer(() => { const { cities, getCities, deleteCity } = cityStore; @@ -14,12 +16,43 @@ export const CityListPage = observer(() => { const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); const [rowId, setRowId] = useState(null); const [ids, setIds] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [rows, setRows] = useState([]); const { language } = languageStore; 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]); + useEffect(() => { + let newRows = cities[language]?.data?.map((city) => ({ + id: city.id, + name: city.name, + country: city.country_code, + })); + + let newRows2: any[] = []; + for (const city of newRows) { + const name = countryStore.countries[language]?.data?.find( + (country) => country.code === city.country + )?.name; + if (name) { + newRows2.push(city); + } + } + + setRows(newRows2 || []); + console.log(newRows2); + }, [cities, countryStore.countries, language, isLoading]); + const columns: GridColDef[] = [ { field: "country", @@ -29,7 +62,9 @@ export const CityListPage = observer(() => { return (
{params.value ? ( - params.value + countryStore.countries[language]?.data?.find( + (country) => country.code === params.value + )?.name ) : ( )} @@ -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 ( <> @@ -115,12 +144,20 @@ export const CityListPage = observer(() => { { setIds(Array.from(newSelection.ids as unknown as number[])); }} + slots={{ + noRowsOverlay: () => ( + + {isLoading ? : "Нет городов"} + + ), + }} />
diff --git a/src/pages/Country/CountryAddPage/index.tsx b/src/pages/Country/CountryAddPage/index.tsx new file mode 100644 index 0000000..d6ef176 --- /dev/null +++ b/src/pages/Country/CountryAddPage/index.tsx @@ -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 ( + +
+ +
+ +
+ + c.code === createCountryData.code) || + null + } + onChange={(_, newValue) => { + if (newValue) { + handleCountryCodeChange(newValue.code); + } + }} + options={RU_COUNTRIES} + getOptionLabel={(option) => `${option.code} - ${option.name}`} + renderInput={(params) => ( + + )} + filterOptions={(options, { inputValue }) => { + const searchValue = inputValue.toUpperCase(); + return options.filter( + (option) => + option.code.includes(searchValue) || + option.name.toLowerCase().includes(inputValue.toLowerCase()) + ); + }} + /> + + + +
+
+ ); +}); diff --git a/src/pages/Country/CountryEditPage/index.tsx b/src/pages/Country/CountryEditPage/index.tsx index cc280c9..e763aaa 100644 --- a/src/pages/Country/CountryEditPage/index.tsx +++ b/src/pages/Country/CountryEditPage/index.tsx @@ -16,6 +16,11 @@ export const CountryEditPage = observer(() => { const { editCountryData, editCountry, getCountry, setEditCountryData } = countryStore; + useEffect(() => { + // Устанавливаем русский язык при загрузке страницы + languageStore.setLanguage("ru"); + }, []); + const handleEdit = async () => { try { setIsLoading(true); @@ -88,7 +93,7 @@ export const CountryEditPage = observer(() => { {isLoading ? ( ) : ( - "Обновить" + "Сохранить" )} diff --git a/src/pages/Country/CountryListPage/index.tsx b/src/pages/Country/CountryListPage/index.tsx index 0e06b30..4b3f0af 100644 --- a/src/pages/Country/CountryListPage/index.tsx +++ b/src/pages/Country/CountryListPage/index.tsx @@ -1,22 +1,30 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; +import { ruRU } from "@mui/x-data-grid/locales"; import { countryStore, languageStore } from "@shared"; import { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; -import { Pencil, Trash2, Minus } from "lucide-react"; -import { useNavigate } from "react-router-dom"; +import { Trash2, Minus } from "lucide-react"; + import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; +import { Box, CircularProgress } from "@mui/material"; export const CountryListPage = observer(() => { const { countries, getCountries, deleteCountry } = countryStore; - const navigate = useNavigate(); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); const [rowId, setRowId] = useState(null); const [ids, setIds] = useState([]); + const [isLoading, setIsLoading] = useState(false); const { language } = languageStore; useEffect(() => { - getCountries(language); + const fetchCountries = async () => { + setIsLoading(true); + await getCountries(language); + setIsLoading(false); + }; + fetchCountries(); }, [language]); const columns: GridColDef[] = [ @@ -45,11 +53,11 @@ export const CountryListPage = observer(() => { renderCell: (params: GridRenderCellParams) => { return (
- + */} {/* */} @@ -81,7 +89,7 @@ export const CountryListPage = observer(() => {

Страны

- +
{
{ - console.log(newSelection); setIds(Array.from(newSelection.ids as unknown as number[])); }} + slots={{ + noRowsOverlay: () => ( + + {isLoading ? : "Нет стран"} + + ), + }} />
diff --git a/src/pages/Country/index.ts b/src/pages/Country/index.ts index f80e3eb..18c6ad6 100644 --- a/src/pages/Country/index.ts +++ b/src/pages/Country/index.ts @@ -2,3 +2,4 @@ export * from "./CountryListPage"; export * from "./CountryPreviewPage"; export * from "./CountryCreatePage"; export * from "./CountryEditPage"; +export * from "./CountryAddPage"; diff --git a/src/pages/EditSightPage/index.tsx b/src/pages/EditSightPage/index.tsx index c38b68e..48c3f42 100644 --- a/src/pages/EditSightPage/index.tsx +++ b/src/pages/EditSightPage/index.tsx @@ -19,7 +19,7 @@ export const EditSightPage = observer(() => { const { getArticles } = articlesStore; const { id } = useParams(); - const { getRuCities } = cityStore; + const { getCities } = cityStore; let blocker = useBlocker( ({ currentLocation, nextLocation }) => @@ -33,13 +33,13 @@ export const EditSightPage = observer(() => { useEffect(() => { const fetchData = async () => { if (id) { + await getCities("ru"); await getSightInfo(+id, "ru"); await getSightInfo(+id, "en"); await getSightInfo(+id, "zh"); await getArticles("ru"); await getArticles("en"); await getArticles("zh"); - await getRuCities(); } }; fetchData(); diff --git a/src/pages/LoginPage/index.tsx b/src/pages/LoginPage/index.tsx index 77fa0eb..4318742 100644 --- a/src/pages/LoginPage/index.tsx +++ b/src/pages/LoginPage/index.tsx @@ -5,9 +5,12 @@ import { Typography, Alert, CircularProgress, + FormControlLabel, + Checkbox, + Paper, } from "@mui/material"; -import { authStore } from "@shared"; -import { useState } from "react"; +import { authStore, userStore } from "@shared"; +import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "react-toastify"; @@ -15,9 +18,21 @@ export const LoginPage = () => { const navigate = useNavigate(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + const [rememberMe, setRememberMe] = useState(false); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); const { login } = authStore; + const { getUsers } = userStore; + useEffect(() => { + // Load saved credentials if they exist + const savedEmail = localStorage.getItem("rememberedEmail"); + const savedPassword = localStorage.getItem("rememberedPassword"); + if (savedEmail && savedPassword) { + setEmail(savedEmail); + setPassword(savedPassword); + setRememberMe(true); + } + }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -26,7 +41,18 @@ export const LoginPage = () => { try { await login(email, password); - navigate("/sight"); + + // Save or clear credentials based on remember me checkbox + if (rememberMe) { + localStorage.setItem("rememberedEmail", email); + localStorage.setItem("rememberedPassword", password); + } else { + localStorage.removeItem("rememberedEmail"); + localStorage.removeItem("rememberedPassword"); + } + + navigate("/map"); + await getUsers(); toast.success("Вход в систему выполнен успешно"); } catch (err) { setError( @@ -47,73 +73,102 @@ export const LoginPage = () => { flexDirection: "column", alignItems: "center", justifyContent: "center", - minHeight: "100vh", + width: "100vw", + height: "100vh", gap: 3, p: 3, + backgroundImage: "url('/login-bg.png')", + backgroundSize: "cover", + backgroundPosition: "center", + backgroundRepeat: "no-repeat", }} > - - Вход в систему - - - {error && ( - - {error} - - )} - setEmail(e.target.value)} - disabled={isLoading} - error={!!error} - /> - setPassword(e.target.value)} - disabled={isLoading} - error={!!error} - /> - - + {error && ( + + {error} + + )} + setEmail(e.target.value)} + disabled={isLoading} + error={!!error} + /> + setPassword(e.target.value)} + disabled={isLoading} + error={!!error} + /> + setRememberMe(e.target.checked)} + disabled={isLoading} + /> + } + label="Запомнить пароль" + /> + + + ); }; diff --git a/src/pages/MapPage/index.tsx b/src/pages/MapPage/index.tsx index de06029..613d7f8 100644 --- a/src/pages/MapPage/index.tsx +++ b/src/pages/MapPage/index.tsx @@ -11,7 +11,12 @@ import TileLayer from "ol/layer/Tile"; import OSM from "ol/source/OSM"; import VectorLayer from "ol/layer/Vector"; import VectorSource, { VectorSourceEvent } from "ol/source/Vector"; -import { Draw, Modify, Select } from "ol/interaction"; +import { + Draw, + Modify, + Select, + defaults as defaultInteractions, +} from "ol/interaction"; import { DrawEvent } from "ol/interaction/Draw"; import { SelectEvent } from "ol/interaction/Select"; import { @@ -22,7 +27,7 @@ import { RegularShape, } from "ol/style"; import { Point, LineString, Geometry, Polygon } from "ol/geom"; -import { transform } from "ol/proj"; +import { transform, toLonLat } from "ol/proj"; import { GeoJSON } from "ol/format"; import { Bus, @@ -43,7 +48,26 @@ import Layer from "ol/layer/Layer"; import Source from "ol/source/Source"; import { FeatureLike } from "ol/Feature"; +// --- CUSTOM SCROLLBAR STYLES --- +const scrollbarHideStyles = ` + .scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; + } + .scrollbar-hide::-webkit-scrollbar { + display: none; + } +`; + +// Inject styles into document head +if (typeof document !== "undefined") { + const styleElement = document.createElement("style"); + styleElement.textContent = scrollbarHideStyles; + document.head.appendChild(styleElement); +} + // --- MAP STORE --- +// @ts-ignore import { languageInstance } from "@shared"; // Убедитесь, что этот импорт правильный import { makeAutoObservable } from "mobx"; @@ -79,14 +103,12 @@ class MapStore { getRoutes = async () => { const response = await languageInstance("ru").get("/route"); - console.log(response.data); const routesIds = response.data.map((route: any) => route.id); - for (const id of routesIds) { - const route = await languageInstance("ru").get(`/route/${id}`); - this.routes.push({ - ...route.data, - }); - } + const routePromises = routesIds.map((id: number) => + languageInstance("ru").get(`/route/${id}`) + ); + const routeResponses = await Promise.all(routePromises); + this.routes = routeResponses.map((res) => res.data); this.routes = this.routes.sort((a, b) => a.route_number.localeCompare(b.route_number) @@ -124,14 +146,14 @@ class MapStore { if (featureType === "station") { data = { - name: properties.name || "Новая станция", + name: properties.name || "Новая остановка", latitude: geometry.coordinates[1], longitude: geometry.coordinates[0], }; } else if (featureType === "route") { data = { - route_number: properties.name || "Новый маршрут", - path: geometry.coordinates, + route_number: properties.name || "Маршрут 1", + path: geometry.coordinates.map((c: any) => [c[1], c[0]]), center_latitude: geometry.coordinates[0][1], center_longitude: geometry.coordinates[0][0], }; @@ -172,7 +194,7 @@ class MapStore { } else if (featureType === "route") { data = { route_number: properties.name, - path: geometry.coordinates.map((coord: any) => [coord[1], coord[0]]), // Swap coordinates + path: geometry.coordinates.map((coord: any) => [coord[1], coord[0]]), }; } else if (featureType === "sight") { data = { @@ -185,47 +207,50 @@ class MapStore { throw new Error(`Unknown feature type for update: ${featureType}`); } + const findOldData = (store: any[], id: number) => + store.find((f: any) => f.id === id); let oldData; - if (featureType === "route") { - oldData = this.routes.find((f) => f.id === numericId); - } else if (featureType === "station") { - oldData = this.stations.find((f) => f.id === numericId); - } else if (featureType === "sight") { - oldData = this.sights.find((f) => f.id === numericId); + if (featureType === "route") oldData = findOldData(this.routes, numericId); + else if (featureType === "station") + oldData = findOldData(this.stations, numericId); + else if (featureType === "sight") + oldData = findOldData(this.sights, numericId); + + if (!oldData) { + throw new Error( + `Could not find old data for ${featureType} with id ${numericId}` + ); } - let response; - if (featureType !== "route") { - response = await languageInstance("ru").patch( - `/${featureType}/${numericId}`, - { - ...oldData, - latitude: data.latitude, - longitude: data.longitude, - } - ); + let requestBody: any; + if (featureType === "route") { + requestBody = { + ...oldData, + ...data, + center_latitude: + data.path.length > 0 ? data.path[0][0] : oldData.center_latitude, + center_longitude: + data.path.length > 0 ? data.path[0][1] : oldData.center_longitude, + }; } else { - response = await languageInstance("ru").patch( - `/${featureType}/${numericId}`, - { - ...oldData, - path: data.path, - center_latitude: data.path[0][0], // First coordinate is latitude - center_longitude: data.path[0][1], // Second coordinate is longitude - } - ); + requestBody = { ...oldData, ...data }; } - if (featureType === "route") { - const index = this.routes.findIndex((f) => f.id === numericId); - if (index !== -1) this.routes[index] = response.data; - } else if (featureType === "station") { - const index = this.stations.findIndex((f) => f.id === numericId); - if (index !== -1) this.stations[index] = response.data; - } else if (featureType === "sight") { - const index = this.sights.findIndex((f) => f.id === numericId); - if (index !== -1) this.sights[index] = response.data; - } + const response = await languageInstance("ru").patch( + `/${featureType}/${numericId}`, + requestBody + ); + + const updateStore = (store: any[], updatedItem: any) => { + const index = store.findIndex((f) => f.id === updatedItem.id); + if (index !== -1) store[index] = updatedItem; + else store.push(updatedItem); + }; + + if (featureType === "route") updateStore(this.routes, response.data); + else if (featureType === "station") + updateStore(this.stations, response.data); + else if (featureType === "sight") updateStore(this.sights, response.data); return response.data; }; @@ -239,6 +264,71 @@ export const mapConfig = { zoom: 13, }; +// --- MAP POSITION STORAGE --- +const MAP_POSITION_KEY = "mapPosition"; +const ACTIVE_SECTION_KEY = "mapActiveSection"; + +interface MapPosition { + center: [number, number]; + zoom: number; +} + +const getStoredMapPosition = (): MapPosition | null => { + try { + const stored = localStorage.getItem(MAP_POSITION_KEY); + if (stored) { + const position = JSON.parse(stored); + // Validate the stored data + if ( + position && + Array.isArray(position.center) && + position.center.length === 2 && + typeof position.zoom === "number" && + position.zoom >= 0 && + position.zoom <= 20 + ) { + return position; + } + } + } catch (error) { + console.warn("Failed to parse stored map position:", error); + } + return null; +}; + +const saveMapPosition = (position: MapPosition): void => { + try { + localStorage.setItem(MAP_POSITION_KEY, JSON.stringify(position)); + } catch (error) { + console.warn("Failed to save map position:", error); + } +}; + +// --- ACTIVE SECTION STORAGE --- +const getStoredActiveSection = (): string | null => { + try { + const stored = localStorage.getItem(ACTIVE_SECTION_KEY); + if (stored) { + return stored; + } + } catch (error) { + console.warn("Failed to get stored active section:", error); + } + return null; +}; + +const saveActiveSection = (section: string | null): void => { + try { + if (section) { + localStorage.setItem(ACTIVE_SECTION_KEY, section); + } else { + localStorage.removeItem(ACTIVE_SECTION_KEY); + } + } catch (error) { + console.warn("Failed to save active section:", error); + } +}; + // --- SVG ICONS --- const EditIcon = () => ( ) => void; @@ -356,7 +445,6 @@ class MapService { this.hoveredFeatureId = null; this.history = []; this.historyIndex = -1; - this.beforeModifyState = null; this.setLoading = setLoading; this.setError = setError; @@ -366,22 +454,19 @@ class MapService { this.defaultStyle = new Style({ fill: new Fill({ color: "rgba(66, 153, 225, 0.2)" }), - stroke: new Stroke({ color: "#3182ce", width: 3 }), + stroke: new Stroke({ color: "#3182ce", width: 8 }), }); - // ИСПРАВЛЕНИЕ: Удалено свойство image из этого стиля. - // Оно предназначалось для линий, но применялось и к точкам, - // создавая ненужный центральный круг. this.selectedStyle = new Style({ fill: new Fill({ color: "rgba(221, 107, 32, 0.3)" }), - stroke: new Stroke({ color: "#dd6b20", width: 4 }), + stroke: new Stroke({ color: "#dd6b20", width: 8 }), }); this.drawStyle = new Style({ fill: new Fill({ color: "rgba(74, 222, 128, 0.3)" }), stroke: new Stroke({ color: "rgba(34, 197, 94, 0.7)", - width: 2, + width: 8, lineDash: [5, 5], }), image: new CircleStyle({ @@ -463,7 +548,7 @@ class MapService { zIndex: Infinity, }); this.universalHoverStyleLine = new Style({ - stroke: new Stroke({ color: "rgba(255, 165, 0, 0.8)", width: 5 }), + stroke: new Stroke({ color: "rgba(255, 165, 0, 0.8)", width: 8 }), zIndex: Infinity, }); @@ -494,16 +579,7 @@ class MapService { return this.universalHoverStyleLine; } - if (isLassoSelected) { - if (geometryType === "Point") { - return featureType === "sight" - ? this.selectedSightIconStyle - : this.selectedBusIconStyle; - } - return this.selectedStyle; - } - - if (isEditSelected) { + if (isLassoSelected || isEditSelected) { if (geometryType === "Point") { return featureType === "sight" ? this.selectedSightIconStyle @@ -539,15 +615,47 @@ class MapService { let renderCompleteHandled = false; const MAP_LOAD_TIMEOUT = 15000; try { + // Get stored position or use default + const storedPosition = getStoredMapPosition(); + const initialCenter = storedPosition?.center || config.center; + const initialZoom = storedPosition?.zoom || config.zoom; + this.map = new Map({ target: config.target, layers: [new TileLayer({ source: new OSM() }), this.vectorLayer], view: new View({ - center: transform(config.center, "EPSG:4326", "EPSG:3857"), - zoom: config.zoom, + center: transform(initialCenter, "EPSG:4326", "EPSG:3857"), + zoom: initialZoom, }), + interactions: defaultInteractions({ doubleClickZoom: false }), controls: [], }); + + // Add view change listener to save position + this.map.getView().on("change:center", () => { + const center = this.map?.getView().getCenter(); + const zoom = this.map?.getView().getZoom(); + if (center && zoom !== undefined && this.map) { + const [lon, lat] = toLonLat( + center, + this.map.getView().getProjection() + ); + saveMapPosition({ center: [lon, lat], zoom }); + } + }); + + this.map.getView().on("change:resolution", () => { + const center = this.map?.getView().getCenter(); + const zoom = this.map?.getView().getZoom(); + if (center && zoom !== undefined && this.map) { + const [lon, lat] = toLonLat( + center, + this.map.getView().getProjection() + ); + saveMapPosition({ center: [lon, lat], zoom }); + } + }); + if (this.tooltipElement && this.map) { this.tooltipOverlay = new Overlay({ element: this.tooltipElement, @@ -576,23 +684,6 @@ class MapService { renderCompleteHandled = true; } - this.modifyInteraction = new Modify({ - source: this.vectorSource, - style: new Style({ - image: new CircleStyle({ - radius: 6, - fill: new Fill({ - color: "rgba(255, 255, 255, 0.8)", - }), - stroke: new Stroke({ - color: "#0099ff", - width: 2.5, - }), - }), - }), - deleteCondition: (e: MapBrowserEvent) => doubleClick(e), - }); - this.selectInteraction = new Select({ style: (featureLike: FeatureLike) => { if (!featureLike || !featureLike.getGeometry) return this.defaultStyle; @@ -607,35 +698,181 @@ class MapService { } return this.selectedStyle; }, - condition: singleClick, + condition: (event: MapBrowserEvent) => { + // Only allow single click selection when Ctrl is not pressed + return ( + singleClick(event) && + !event.originalEvent.ctrlKey && + !event.originalEvent.metaKey + ); + }, filter: (_: FeatureLike, l: Layer | null) => l === this.vectorLayer, + multi: false, }); + + this.modifyInteraction = new Modify({ + source: this.vectorSource, + style: new Style({ + image: new CircleStyle({ + radius: 6, + fill: new Fill({ + color: "rgba(255, 255, 255, 0.8)", + }), + stroke: new Stroke({ + color: "#0099ff", + width: 2.5, + }), + }), + }), + // --- НАЧАЛО ИЗМЕНЕНИЯ --- + // Кастомная логика для удаления вершин + deleteCondition: (e: MapBrowserEvent) => { + // Удаление по-прежнему происходит по двойному клику + if (!doubleClick(e)) { + return false; + } + + const selectedFeatures = this.selectInteraction.getFeatures(); + // Эта логика применима только когда редактируется один объект + if (selectedFeatures.getLength() !== 1) { + return true; // Разрешаем удаление по умолчанию для других случаев + } + + const feature = selectedFeatures.item(0) as Feature; + const geometry = feature.getGeometry(); + + // Проверяем, что это линия (маршрут) + if (!geometry || geometry.getType() !== "LineString") { + return true; // Если это не линия, разрешаем удаление (например, всего полигона) + } + + const lineString = geometry as LineString; + const coordinates = lineString.getCoordinates(); + + // Если в линии всего 2 точки, не даем удалить ни одну из них, + // так как линия перестанет быть линией. + if (coordinates.length <= 2) { + toast.info("В маршруте должно быть не менее 2 точек."); + return false; + } + + // Находим ближайшую к клику вершину + const clickCoordinate = e.coordinate; + let closestVertexIndex = -1; + let minDistanceSq = Infinity; + + coordinates.forEach((vertex, index) => { + const dx = vertex[0] - clickCoordinate[0]; + const dy = vertex[1] - clickCoordinate[1]; + const distanceSq = dx * dx + dy * dy; + if (distanceSq < minDistanceSq) { + minDistanceSq = distanceSq; + closestVertexIndex = index; + } + }); + + // Проверяем, является ли ближайшая вершина начальной или конечной + if ( + closestVertexIndex === 0 || + closestVertexIndex === coordinates.length - 1 + ) { + // Это конечная точка, запрещаем удаление + + return false; + } + + // Если это не начальная и не конечная точка, разрешаем удаление + return true; + }, + // --- КОНЕЦ ИЗМЕНЕНИЯ --- + features: this.selectInteraction.getFeatures(), + }); + // @ts-ignore this.modifyInteraction.on("modifystart", (event) => { - const geoJSONFormat = new GeoJSON(); - if (!this.map) return; - this.beforeModifyState = geoJSONFormat.writeFeatures( - this.vectorSource.getFeatures(), // Сохраняем все фичи для отката - { - dataProjection: "EPSG:4326", - featureProjection: this.map.getView().getProjection().getCode(), - } - ); + // Only save state if we don't already have a beforeActionState + if (!this.beforeActionState) { + this.beforeActionState = this.getCurrentStateAsGeoJSON(); + } }); this.modifyInteraction.on("modifyend", (event) => { - if (this.beforeModifyState) { - this.addStateToHistory("modify", this.beforeModifyState); - this.beforeModifyState = null; + if (this.beforeActionState) { + this.addStateToHistory(this.beforeActionState); } - this.updateFeaturesInReact(); event.features.getArray().forEach((feature) => { this.saveModifiedFeature(feature as Feature); }); + this.beforeActionState = null; }); + // Add double-click handler for route point deletion + if (this.map) { + this.map.on("dblclick", (event: MapBrowserEvent) => { + if (this.mode !== "edit") return; + + const feature = this.map?.forEachFeatureAtPixel( + event.pixel, + (f: FeatureLike) => f as Feature, + { layerFilter: (l) => l === this.vectorLayer, hitTolerance: 5 } + ); + + if (!feature) return; + + const featureType = feature.get("featureType"); + if (featureType !== "route") return; + + const geometry = feature.getGeometry(); + if (!geometry || geometry.getType() !== "LineString") return; + + const lineString = geometry as LineString; + const coordinates = lineString.getCoordinates(); + + // If the line has only 2 points, don't allow deletion + if (coordinates.length <= 2) { + toast.info("В маршруте должно быть не менее 2 точек."); + return; + } + + // Find the closest coordinate to the click point + const clickCoordinate = event.coordinate; + let closestIndex = -1; + let minDistanceSq = Infinity; + + coordinates.forEach((vertex, index) => { + const dx = vertex[0] - clickCoordinate[0]; + const dy = vertex[1] - clickCoordinate[1]; + const distanceSq = dx * dx + dy * dy; + if (distanceSq < minDistanceSq) { + minDistanceSq = distanceSq; + closestIndex = index; + } + }); + + // Check if the closest vertex is an endpoint + if (closestIndex === 0 || closestIndex === coordinates.length - 1) { + return; + } + + // Save state before modification + const beforeState = this.getCurrentStateAsGeoJSON(); + if (beforeState) { + this.addStateToHistory(beforeState); + } + + // Remove the point and update the route + const newCoordinates = coordinates.filter( + (_, index) => index !== closestIndex + ); + lineString.setCoordinates(newCoordinates); + + // Save the modified feature + this.saveModifiedFeature(feature); + }); + } + this.lassoInteraction = new Draw({ type: "Polygon", style: new Style({ @@ -735,10 +972,10 @@ class MapService { apiRoutes.forEach((route) => { if (!route.path || route.path.length === 0) return; const coordinates = route.path - .filter((c) => c[0] != null && c[1] != null) + .filter((c) => c && c[0] != null && c[1] != null) .map((c: [number, number]) => transform([c[1], c[0]], "EPSG:4326", projection) - ); // Swap coordinates + ); if (coordinates.length === 0) return; const line = new LineString(coordinates); const feature = new Feature({ geometry: line, name: route.route_number }); @@ -764,14 +1001,15 @@ class MapService { this.vectorSource.addFeatures(featuresToAdd); this.updateFeaturesInReact(); + const initialState = this.getCurrentStateAsGeoJSON(); + if (initialState) { + this.addStateToHistory(initialState); + } } - private addStateToHistory( - actionDescription: string, - stateToSave: string - ): void { + private addStateToHistory(stateToSave: string): void { this.history = this.history.slice(0, this.historyIndex + 1); - this.history.push({ action: actionDescription, state: stateToSave }); + this.history.push({ state: stateToSave }); this.historyIndex = this.history.length - 1; } @@ -784,11 +1022,104 @@ class MapService { }); } + private applyHistoryState(geoJSONState: string) { + if (!this.map) return; + const projection = this.map.getView().getProjection(); + const geoJSONFormat = new GeoJSON({ + dataProjection: "EPSG:4326", + featureProjection: projection.getCode(), + }); + const features = geoJSONFormat.readFeatures( + geoJSONState + ) as Feature[]; + + this.unselect(); + this.vectorSource.clear(); + this.vectorSource.addFeatures(features); + this.updateFeaturesInReact(); + + const newStations: ApiStation[] = []; + const newRoutes: ApiRoute[] = []; + const newSights: ApiSight[] = []; + + features.forEach((feature) => { + const id = feature.getId(); + if (!id) return; + const [featureType, numericIdStr] = String(id).split("-"); + const numericId = parseInt(numericIdStr, 10); + if (isNaN(numericId)) return; + const geometry = feature.getGeometry(); + if (!geometry) return; + const properties = feature.getProperties(); + + if (featureType === "station") { + const coords = (geometry as Point).getCoordinates(); + const [lon, lat] = toLonLat(coords, projection); + newStations.push({ + id: numericId, + name: properties.name, + latitude: lat, + longitude: lon, + }); + } else if (featureType === "sight") { + const coords = (geometry as Point).getCoordinates(); + const [lon, lat] = toLonLat(coords, projection); + newSights.push({ + id: numericId, + name: properties.name, + description: properties.description, + latitude: lat, + longitude: lon, + }); + } else if (featureType === "route") { + const coords = (geometry as LineString).getCoordinates(); + const path = coords.map((c) => { + const [lon, lat] = toLonLat(c, projection); + return [lat, lon]; + }); + newRoutes.push({ + id: numericId, + route_number: properties.name, + path: path as [number, number][], + }); + } + }); + + mapStore.stations = newStations; + mapStore.routes = newRoutes.sort((a, b) => + a.route_number.localeCompare(b.route_number) + ); + mapStore.sights = newSights; + } + public undo(): void { - if (this.historyIndex >= 0) { + if (this.historyIndex > 0) { + this.historyIndex--; const stateToRestore = this.history[this.historyIndex].state; this.applyHistoryState(stateToRestore); - this.historyIndex--; + + // Update each feature in the backend + const features = this.vectorSource.getFeatures(); + const updatePromises = features.map((feature) => { + const featureType = feature.get("featureType"); + const geoJSONFormat = new GeoJSON({ + dataProjection: "EPSG:4326", + featureProjection: this.map?.getView().getProjection().getCode(), + }); + const featureGeoJSON = geoJSONFormat.writeFeatureObject(feature); + return mapStore.updateFeature(featureType, featureGeoJSON); + }); + + Promise.all(updatePromises) + .then(() => {}) + .catch((error) => { + console.error("Failed to update backend after undo:", error); + + // Revert to the previous state if backend update fails + this.historyIndex++; + const previousState = this.history[this.historyIndex].state; + this.applyHistoryState(previousState); + }); } else { toast.info("Больше отменять нечего"); } @@ -797,25 +1128,38 @@ class MapService { public redo(): void { if (this.historyIndex < this.history.length - 1) { this.historyIndex++; - this.applyHistoryState(this.history[this.historyIndex].state); + const stateToRestore = this.history[this.historyIndex].state; + this.applyHistoryState(stateToRestore); + + // Update each feature in the backend + const features = this.vectorSource.getFeatures(); + const updatePromises = features.map((feature) => { + const featureType = feature.get("featureType"); + const geoJSONFormat = new GeoJSON({ + dataProjection: "EPSG:4326", + featureProjection: this.map?.getView().getProjection().getCode(), + }); + const featureGeoJSON = geoJSONFormat.writeFeatureObject(feature); + return mapStore.updateFeature(featureType, featureGeoJSON); + }); + + Promise.all(updatePromises) + .then(() => { + toast.info("Действие повторено"); + }) + .catch((error) => { + console.error("Failed to update backend after redo:", error); + toast.error("Не удалось обновить данные на сервере"); + // Revert to the previous state if backend update fails + this.historyIndex--; + const previousState = this.history[this.historyIndex].state; + this.applyHistoryState(previousState); + }); } else { toast.info("Больше повторять нечего"); } } - private applyHistoryState(geoJSONState: string): void { - if (!this.map) return; - const geoJSONFormat = new GeoJSON(); - const features = geoJSONFormat.readFeatures(geoJSONState, { - dataProjection: "EPSG:4326", - featureProjection: this.map.getView().getProjection().getCode(), - }) as Feature[]; - this.unselect(); - this.vectorSource.clear(); - if (features.length > 0) this.vectorSource.addFeatures(features); - this.updateFeaturesInReact(); - } - private updateFeaturesInReact(): void { if (this.onFeaturesChange) { this.onFeaturesChange(this.vectorSource.getFeatures()); @@ -841,11 +1185,6 @@ class MapService { this.redo(); return; } - if ((event.ctrlKey || event.metaKey) && event.key === "r") { - event.preventDefault(); - this.unselect(); - return; - } if (event.key === "Escape") { this.unselect(); } @@ -908,57 +1247,61 @@ class MapService { style: styleForDrawing, }); - let stateBeforeDraw: string | null = null; this.currentInteraction.on("drawstart", () => { - stateBeforeDraw = this.getCurrentStateAsGeoJSON(); + this.beforeActionState = this.getCurrentStateAsGeoJSON(); }); this.currentInteraction.on("drawend", async (event: DrawEvent) => { - if (stateBeforeDraw) { - this.addStateToHistory("draw-before", stateBeforeDraw); + if (this.beforeActionState) { + this.addStateToHistory(this.beforeActionState); } + this.beforeActionState = null; + const feature = event.feature as Feature; const fType = this.currentDrawingFeatureType; if (!fType) return; feature.set("featureType", fType); - let baseName = "", - namePrefix = ""; - if (fType === "station") { - baseName = "Станция"; - namePrefix = "Станция "; - } else if (fType === "sight") { - baseName = "Достопримечательность"; - namePrefix = "Достопримечательность "; - } else if (fType === "route") { - baseName = "Маршрут"; - namePrefix = "Маршрут "; + // --- ИЗМЕНЕНИЕ: Именование с порядковым номером для маршрутов --- + let resourceName: string; + switch (fType) { + case "station": + resourceName = "Новая остановка"; + break; + case "sight": + resourceName = "Новая достопримечательность"; + break; + case "route": + // Находим следующий доступный номер маршрута + const existingRoutes = this.vectorSource + .getFeatures() + .filter((f) => f.get("featureType") === "route"); + const routeNumbers = existingRoutes + .map((f) => { + const name = f.get("name") as string; + const match = name.match(/^Маршрут (\d+)$/); + return match ? parseInt(match[1], 10) : 0; + }) + .filter((num) => num > 0); + + const nextNumber = + routeNumbers.length > 0 ? Math.max(...routeNumbers) + 1 : 1; + resourceName = `Маршрут ${nextNumber}`; + break; + default: + resourceName = "Объект"; + } + feature.set("name", resourceName); + // --- КОНЕЦ ИЗМЕНЕНИЯ --- + + if (fType === "route") { + this.activateEditMode(); } - const existingNamedFeatures = this.vectorSource - .getFeatures() - .filter( - (f) => - f !== feature && - f.get("featureType") === fType && - (f.get("name") as string)?.startsWith(namePrefix) - ); - - let maxNumber = 0; - existingNamedFeatures.forEach((f) => { - const name = f.get("name") as string; - if (name) { - const num = parseInt(name.substring(namePrefix.length), 10); - if (!isNaN(num) && num > maxNumber) maxNumber = num; - } - }); - - feature.set("name", `${baseName} ${maxNumber + 1}`); - await this.saveNewFeature(feature); - // Убираем вызов stopDrawing, чтобы режим рисования оставался активным - // this.stopDrawing(); + + // --- ИЗМЕНЕНИЕ: Автоматический переход в режим редактирования для маршрутов --- }); this.map.addInteraction(this.currentInteraction); @@ -987,8 +1330,6 @@ class MapService { this.currentInteraction = null; this.currentDrawingType = null; this.currentDrawingFeatureType = null; - // Убираем автоматическое переключение в режим редактирования - // this.activateEditMode(); } public finishDrawing(): void { @@ -1007,7 +1348,6 @@ class MapService { this.currentInteraction instanceof Draw ) { this.finishDrawing(); - // После завершения рисования маршрута, останавливаем режим рисования if (this.currentDrawingType === "LineString") { this.stopDrawing(); } @@ -1052,8 +1392,7 @@ class MapService { } } - // Only update hoveredFeatureId if not in edit mode - if (this.mode !== "edit" && this.hoveredFeatureId !== newHoveredFeatureId) { + if (this.hoveredFeatureId !== newHoveredFeatureId) { this.hoveredFeatureId = newHoveredFeatureId as string | number | null; this.vectorLayer.changed(); } @@ -1070,7 +1409,7 @@ class MapService { ); if (!featureAtPixel) { - if (ctrlKey) this.unselect(); + if (!ctrlKey) this.unselect(); return; } @@ -1080,16 +1419,18 @@ class MapService { const newSet = new Set(this.selectedIds); if (ctrlKey) { - // Toggle selection for the clicked feature if (newSet.has(featureId)) { newSet.delete(featureId); } else { newSet.add(featureId); } } else { - // Single selection - newSet.clear(); - newSet.add(featureId); + if (newSet.size === 1 && newSet.has(featureId)) { + // Already selected, do nothing to allow dragging + } else { + newSet.clear(); + newSet.add(featureId); + } } this.setSelectedIds(newSet); @@ -1116,13 +1457,13 @@ class MapService { view.animate({ center: geometry.getCoordinates(), duration: 500, - zoom: Math.max(view.getZoom() || 14, 14), + zoom: Math.max(view.getZoom() || 14, 15), }); } else { view.fit(geometry.getExtent(), { duration: 500, padding: [50, 50, 50, 50], - maxZoom: 15, + maxZoom: 16, }); } } @@ -1134,7 +1475,7 @@ class MapService { ): void { if (featureId === undefined) return; - const stateBeforeDelete = this.getCurrentStateAsGeoJSON(); + this.beforeActionState = this.getCurrentStateAsGeoJSON(); const numericId = parseInt(String(featureId).split("-")[1], 10); if (!recourse || isNaN(numericId)) return; @@ -1145,8 +1486,9 @@ class MapService { mapStore .deleteFeature(recourse, numericId) .then(() => { - if (stateBeforeDelete) - this.addStateToHistory("delete", stateBeforeDelete); + if (this.beforeActionState) + this.addStateToHistory(this.beforeActionState); + this.beforeActionState = null; this.vectorSource.removeFeature(feature); this.unselect(); }) @@ -1158,7 +1500,7 @@ class MapService { public deleteMultipleFeatures(featureIds: (string | number)[]): void { if (!featureIds || featureIds.length === 0) return; - const stateBeforeDelete = this.getCurrentStateAsGeoJSON(); + this.beforeActionState = this.getCurrentStateAsGeoJSON(); const deletePromises = Array.from(featureIds).map((id) => { const feature = this.vectorSource.getFeatureById(id); @@ -1167,7 +1509,7 @@ class MapService { const recourse = String(id).split("-")[0]; const numericId = parseInt(String(id).split("-")[1], 10); if (recourse && !isNaN(numericId)) { - return mapStore.deleteFeature(recourse, numericId).then(() => feature); // Возвращаем фичу в случае успеха + return mapStore.deleteFeature(recourse, numericId).then(() => feature); } return Promise.resolve(); }); @@ -1176,8 +1518,9 @@ class MapService { .then((deletedFeatures) => { const successfulDeletes = deletedFeatures.filter((f) => f); if (successfulDeletes.length > 0) { - if (stateBeforeDelete) - this.addStateToHistory("multiple-delete", stateBeforeDelete); + if (this.beforeActionState) + this.addStateToHistory(this.beforeActionState); + this.beforeActionState = null; successfulDeletes.forEach((f) => this.vectorSource.removeFeature(f as Feature) ); @@ -1190,17 +1533,6 @@ class MapService { }); } - public getAllFeaturesAsGeoJSON(): string | null { - if (!this.vectorSource || !this.map) return null; - const feats = this.vectorSource.getFeatures(); - if (feats.length === 0) return null; - const geoJSONFmt = new GeoJSON(); - return geoJSONFmt.writeFeatures(feats, { - dataProjection: "EPSG:4326", - featureProjection: this.map.getView().getProjection(), - }); - } - public destroy(): void { if (this.map) { document.removeEventListener("keydown", this.boundHandleKeyDown); @@ -1259,7 +1591,6 @@ class MapService { this.selectedIds = new Set(ids); if (this.onSelectionChange) this.onSelectionChange(this.selectedIds); - // Update selectInteraction to match selectedIds if (this.selectInteraction) { this.selectInteraction.getFeatures().clear(); ids.forEach((id) => { @@ -1270,10 +1601,8 @@ class MapService { }); } - // Update modifyInteraction this.modifyInteraction.setActive(ids.size > 0); - // Update feature selection in sidebar if (ids.size === 1) { const feature = this.vectorSource.getFeatureById(Array.from(ids)[0]); if (feature) { @@ -1298,6 +1627,16 @@ class MapService { return this.map; } + public saveCurrentPosition(): void { + if (!this.map) return; + const center = this.map.getView().getCenter(); + const zoom = this.map.getView().getZoom(); + if (center && zoom !== undefined) { + const [lon, lat] = toLonLat(center, this.map.getView().getProjection()); + saveMapPosition({ center: [lon, lat], zoom }); + } + } + private async saveModifiedFeature(feature: Feature) { const featureType = feature.get("featureType") as FeatureType; const featureId = feature.getId(); @@ -1313,7 +1652,7 @@ class MapService { const geoJSONFormat = new GeoJSON({ dataProjection: "EPSG:4326", - featureProjection: this.map.getView().getProjection(), + featureProjection: this.map.getView().getProjection().getCode(), }); const featureGeoJSON = geoJSONFormat.writeFeatureObject(feature); @@ -1321,8 +1660,11 @@ class MapService { await mapStore.updateFeature(featureType, featureGeoJSON); } catch (error) { console.error("Failed to update feature:", error); - - this.undo(); + toast.error(`Не удалось обновить: ${error}`); + // Revert to the state before modification on failure + if (this.beforeActionState) { + this.applyHistoryState(this.beforeActionState); + } } } @@ -1332,37 +1674,38 @@ class MapService { const geoJSONFormat = new GeoJSON({ dataProjection: "EPSG:4326", - featureProjection: this.map.getView().getProjection(), + featureProjection: this.map.getView().getProjection().getCode(), }); const featureGeoJSON = geoJSONFormat.writeFeatureObject(feature); - const tempId = feature.getId(); try { const createdFeatureData = await mapStore.createFeature( featureType, featureGeoJSON ); - const newName = - featureType === "route" - ? createdFeatureData.route_number - : createdFeatureData.name; + + this.vectorSource.removeFeature(feature); const newFeatureId = `${featureType}-${createdFeatureData.id}`; feature.setId(newFeatureId); - feature.set("name", newName); + // Используем route_number для маршрутов, name для остальных + const displayName = + featureType === "route" + ? createdFeatureData.route_number + : createdFeatureData.name; + feature.set("name", displayName); + this.vectorSource.addFeature(feature); this.updateFeaturesInReact(); - // Убираем автоматический выбор созданного объекта - // this.selectFeature(newFeatureId); } catch (error) { console.error("Failed to save new feature:", error); toast.error("Не удалось сохранить объект."); - if (tempId) { - const tempFeature = this.vectorSource.getFeatureById(tempId); - if (tempFeature) this.vectorSource.removeFeature(tempFeature); + this.vectorSource.removeFeature(feature); // Ensure temporary feature is removed on error + if (this.beforeActionState) { + this.applyHistoryState(this.beforeActionState); // Revert to the state before drawing } - this.undo(); // Откатываем состояние до момента начала рисования + this.beforeActionState = null; } } } @@ -1404,8 +1747,8 @@ const MapControls: React.FC = ({ }, { mode: "drawing-station", - title: "Станция", - longTitle: "Добавить станцию", + title: "Остановка", + longTitle: "Добавить остановку", icon: , action: () => mapService.startDrawingMarker(), }, @@ -1544,7 +1887,6 @@ const MapSightbar: React.FC = ({ }, [mapService, selectedIds, setSelectedIds]); // @ts-ignore - const handleEditFeature = useCallback( // @ts-ignore (featureType, fullId) => { @@ -1555,30 +1897,47 @@ const MapSightbar: React.FC = ({ [navigate] ); + // --- ИЗМЕНЕНИЕ: Логика сортировки с приоритетом для новых объектов --- const sortFeatures = ( - // @ts-ignore - features, - // @ts-ignore - currentSelectedIds, - // @ts-ignore - currentSelectedFeature + features: Feature[], + currentSelectedIds: Set, + currentSelectedFeature: Feature | null ) => { const selectedId = currentSelectedFeature?.getId(); return [...features].sort((a, b) => { - const aId = a.getId(), - bId = b.getId(); - if (selectedId && aId === selectedId) return -1; - if (selectedId && bId === selectedId) return 1; - const aSelected = aId !== undefined && currentSelectedIds.has(aId); - const bSelected = bId !== undefined && currentSelectedIds.has(bId); - if (aSelected && !bSelected) return -1; - if (!aSelected && bSelected) return 1; - return ((a.get("name") as string) || "").localeCompare( - (b.get("name") as string) || "", - "ru" - ); + const aId = a.getId(); + const bId = b.getId(); + + // 1. Приоритет для явно выделенного объекта + if (selectedId) { + if (aId === selectedId) return -1; + if (bId === selectedId) return 1; + } + + // 2. Приоритет для остальных выделенных (чекбоксами) объектов + const aIsChecked = aId !== undefined && currentSelectedIds.has(aId); + const bIsChecked = bId !== undefined && currentSelectedIds.has(bId); + if (aIsChecked && !bIsChecked) return -1; + if (!aIsChecked && bIsChecked) return 1; + + // 3. Сортировка по ID (новые объекты с большими ID в начале) + const aNumericId = aId ? parseInt(String(aId).split("-")[1], 10) : 0; + const bNumericId = bId ? parseInt(String(bId).split("-")[1], 10) : 0; + if ( + !isNaN(aNumericId) && + !isNaN(bNumericId) && + aNumericId !== bNumericId + ) { + return bNumericId - aNumericId; // По убыванию - новые сверху + } + + // 4. Запасная сортировка по имени + const aName = (a.get("name") as string) || ""; + const bName = (b.get("name") as string) || ""; + return aName.localeCompare(bName, "ru"); }); }; + // --- КОНЕЦ ИЗМЕНЕНИЯ --- const toggleSection = (id: string) => setActiveSection(activeSection === id ? null : id); @@ -1597,271 +1956,121 @@ const MapSightbar: React.FC = ({ const sortedLines = sortFeatures(lines, selectedIds, selectedFeature); const sortedSights = sortFeatures(sights, selectedIds, selectedFeature); + const renderFeatureList = ( + features: Feature[], + featureType: "station" | "route" | "sight", + IconComponent: React.ElementType + ) => ( +
+ {features.length > 0 ? ( + features.map((feature) => { + const fId = feature.getId(); + const fName = (feature.get("name") as string) || "Без названия"; + const isSelected = selectedFeature?.getId() === fId; + const isChecked = fId !== undefined && selectedIds.has(fId); + return ( +
+
+ handleCheckboxChange(fId)} + onClick={(e) => e.stopPropagation()} + aria-label={`Выбрать ${fName}`} + /> +
+
handleFeatureClick(fId)} + > +
+ )} + // @ts-ignore + size={16} + /> + + {fName} + +
+
+
+ + +
+
+ ); + }) + ) : ( +

Нет объектов этого типа.

+ )} +
+ ); + const sections = [ { id: "layers", title: `Остановки (${sortedStations.length})`, icon: , count: sortedStations.length, - content: ( -
- {sortedStations.length > 0 ? ( - sortedStations.map((s) => { - const sId = s.getId(), - sName = (s.get("name") as string) || "Без названия"; - const isSelected = selectedFeature?.getId() === sId, - isChecked = sId !== undefined && selectedIds.has(sId); - return ( -
-
- handleCheckboxChange(sId)} - onClick={(e) => e.stopPropagation()} - aria-label={`Выбрать ${sName}`} - /> -
-
handleFeatureClick(sId)} - > -
- - - {sName} - -
-
-
- - -
-
- ); - }) - ) : ( -

Нет остановок.

- )} -
- ), + content: renderFeatureList(sortedStations, "station", MapPin), }, { id: "lines", title: `Маршруты (${sortedLines.length})`, icon: , count: sortedLines.length, - content: ( -
- {sortedLines.length > 0 ? ( - sortedLines.map((l) => { - const lId = l.getId(), - lName = (l.get("name") as string) || "Без названия"; - const isSelected = selectedFeature?.getId() === lId, - isChecked = lId !== undefined && selectedIds.has(lId); - return ( -
-
- handleCheckboxChange(lId)} - onClick={(e) => e.stopPropagation()} - aria-label={`Выбрать ${lName}`} - /> -
-
handleFeatureClick(lId)} - > -
- - - {lName} - -
-
-
- - -
-
- ); - }) - ) : ( -

Нет маршрутов.

- )} -
- ), + content: renderFeatureList(sortedLines, "route", ArrowRightLeft), }, { id: "sights", title: `Достопримечательности (${sortedSights.length})`, icon: , count: sortedSights.length, - content: ( -
- {sortedSights.length > 0 ? ( - sortedSights.map((s) => { - const sId = s.getId(), - sName = (s.get("name") as string) || "Без названия"; - const isSelected = selectedFeature?.getId() === sId, - isChecked = sId !== undefined && selectedIds.has(sId); - return ( -
-
- handleCheckboxChange(sId)} - onClick={(e) => e.stopPropagation()} - aria-label={`Выбрать ${sName}`} - /> -
-
handleFeatureClick(sId)} - > - - - {sName} - -
-
- - -
-
- ); - }) - ) : ( -

Нет достопримечательностей.

- )} -
- ), + content: renderFeatureList(sortedSights, "sight", Landmark), }, ]; @@ -1879,67 +2088,65 @@ const MapSightbar: React.FC = ({ className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
+
-
- {filteredFeatures.length === 0 && searchQuery ? ( -
- Ничего не найдено. -
- ) : ( - sections.map( - (s) => - (s.count > 0 || !searchQuery) && ( -
+ Ничего не найдено. +
+ ) : ( + sections.map( + (s) => + (s.count > 0 || !searchQuery) && ( +
+ -
{s.title} +
+ -
- {s.content} -
-
+ ▼ + + +
+
{s.content}
- ) - ) - )} -
+
+ ) + ) + )} -
- {selectedIds.size > 0 && ( + + {selectedIds.size > 0 && ( +
- )} -
+
+ )} ); }; - // --- MAP PAGE COMPONENT --- export const MapPage: React.FC = () => { const mapRef = useRef(null); @@ -1973,7 +2179,7 @@ export const MapPage: React.FC = () => { const [showHelp, setShowHelp] = useState(false); const [activeSectionFromParent, setActiveSectionFromParent] = useState< string | null - >("layers"); + >(() => getStoredActiveSection() || "layers"); const handleFeaturesChange = useCallback( (feats: Feature[]) => setMapFeatures([...feats]), @@ -2064,6 +2270,7 @@ export const MapPage: React.FC = () => { service?.destroy(); setMapServiceInstance(null); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { @@ -2082,7 +2289,7 @@ export const MapPage: React.FC = () => { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Shift" && mapServiceInstance) { + if (e.key === "Shift" && mapServiceInstance && !isLassoActive) { mapServiceInstance.activateLasso(); setIsLassoActive(true); } @@ -2099,7 +2306,7 @@ export const MapPage: React.FC = () => { window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); }; - }, [mapServiceInstance]); + }, [mapServiceInstance, isLassoActive]); useEffect(() => { if (mapServiceInstance) { @@ -2115,6 +2322,11 @@ export const MapPage: React.FC = () => { } }, [mapServiceInstance, currentMapMode]); + // Сохраняем активную секцию в localStorage при её изменении + useEffect(() => { + saveActiveSection(activeSectionFromParent); + }, [activeSectionFromParent]); + const showLoader = isMapLoading || isDataLoading; const showContent = mapServiceInstance && !showLoader && !error; const isAnythingSelected = @@ -2208,12 +2420,6 @@ export const MapPage: React.FC = () => { {" "} - Повторить действие -
  • - - Ctrl+R - {" "} - - Отменить выделение -
  • +
    +
    +
    +
    - )} + + {oneMedia && ( +
    +

    + Чтобы скачать файл, нажмите на кнопку ниже +

    + +
    + )} +
    ); }); diff --git a/src/pages/Route/LinekedStations.tsx b/src/pages/Route/LinekedStations.tsx index 604d61a..6d2e537 100644 --- a/src/pages/Route/LinekedStations.tsx +++ b/src/pages/Route/LinekedStations.tsx @@ -18,6 +18,11 @@ import { Paper, TableBody, IconButton, + Checkbox, + FormControlLabel, + Tabs, + Tab, + Box, } from "@mui/material"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; @@ -28,7 +33,8 @@ import { DropResult, } 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) function insertAtPosition(arr: T[], pos: number, value: T): T[] { @@ -68,6 +74,7 @@ type LinkedItemsProps = { updatedLinkedItems?: T[]; refresh?: number; cityId?: number; + routeDirection?: boolean; }; export const LinkedItems = < @@ -118,6 +125,7 @@ export const LinkedItemsContents = < updatedLinkedItems, refresh, cityId, + routeDirection, }: LinkedItemsProps) => { const { language } = languageStore; @@ -127,6 +135,10 @@ export const LinkedItemsContents = < const [selectedItemId, setSelectedItemId] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [selectedItems, setSelectedItems] = useState>(new Set()); + const [activeTab, setActiveTab] = useState(0); + const [searchQuery, setSearchQuery] = useState(""); useEffect(() => { console.log(error); @@ -137,8 +149,25 @@ export const LinkedItemsContents = < const availableItems = allItems .filter((item) => !linkedItems.some((linked) => linked.id === item.id)) + .filter((item) => { + // Если направление маршрута не указано, показываем все станции + if (routeDirection === undefined) return true; + // Фильтруем станции по направлению маршрута + return item.direction === routeDirection; + }) .sort((a, b) => a.name.localeCompare(b.name)); + // Фильтрация по поиску для массового режима + const filteredAvailableItems = availableItems.filter((item) => { + if (!cityId || item.city_id == cityId) { + if (!searchQuery.trim()) return true; + return String(item.name) + .toLowerCase() + .includes(searchQuery.toLowerCase()); + } + return false; + }); + useEffect(() => { if (updatedLinkedItems) { setLinkedItems(updatedLinkedItems); @@ -250,12 +279,57 @@ export const LinkedItemsContents = < data: { [`${childResource}_id`]: itemId }, }) .then(() => { - setLinkedItems((prev) => prev.filter((item) => item.id !== itemId)); + setLinkedItems(linkedItems.filter((item) => item.id !== itemId)); onUpdate?.(); }) .catch((error) => { - console.error("Error unlinking item:", error); - setError("Failed to unlink station"); + console.error("Error deleting item:", error); + setError("Failed to delete station"); + }); + }; + + const handleStationClick = (item: T) => { + routeStore.setSelectedStationId(item.id); + setIsModalOpen(true); + }; + + const handleCloseModal = () => { + setIsModalOpen(false); + }; + + const handleCheckboxChange = (itemId: number) => { + const newSelected = new Set(selectedItems); + if (newSelected.has(itemId)) { + newSelected.delete(itemId); + } else { + newSelected.add(itemId); + } + setSelectedItems(newSelected); + }; + + const handleBulkLink = () => { + if (selectedItems.size === 0) return; + + setError(null); + const selectedStations = Array.from(selectedItems).map((id) => ({ id })); + const requestData = { + stations: [ + ...linkedItems.map((item) => ({ id: item.id })), + ...selectedStations, + ], + }; + + authInstance + .post(`/${parentResource}/${parentId}/${childResource}`, requestData) + .then(() => { + const newItems = allItems.filter((item) => selectedItems.has(item.id)); + setLinkedItems([...linkedItems, ...newItems]); + setSelectedItems(new Set()); + onUpdate?.(); + }) + .catch((error) => { + console.error("Error linking items:", error); + setError("Failed to link stations"); }); }; @@ -306,6 +380,7 @@ export const LinkedItemsContents = < ref={provided.innerRef} {...provided.draggableProps} hover + onClick={() => handleStationClick(item)} > {type === "edit" && dragAllowed && ( @@ -358,72 +433,169 @@ export const LinkedItemsContents = < {type === "edit" && !disableCreation && ( - Добавить станцию - item.id === selectedItemId) || null - } - onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)} - options={availableItems.filter( - (item) => !cityId || item.city_id == cityId - )} - getOptionLabel={(item) => String(item.name)} - renderInput={(params) => ( - - )} - 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) => ( -
  • - {String(option.name)} -
  • - )} - /> + Добавить остановки + {routeDirection !== undefined && ( + + Показываются только остановки для{" "} + {routeDirection ? "прямого" : "обратного"} направления + + )} - - { - const newValue = Math.max(1, Number(e.target.value)); - setPosition( - newValue > linkedItems.length + 1 - ? linkedItems.length + 1 - : newValue - ); - }} - InputProps={{ - inputProps: { min: 1, max: linkedItems.length + 1 }, - }} - fullWidth - /> - - - + + + + + + {activeTab === 0 && ( + + item.id === selectedItemId + ) || null + } + onChange={(_, newValue) => + setSelectedItemId(newValue?.id || null) + } + options={availableItems.filter( + (item) => !cityId || item.city_id == cityId + )} + getOptionLabel={(item) => String(item.name)} + renderInput={(params) => ( + + )} + 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) => ( +
  • +
    +

    {String(option.name)}

    +

    + {String(option.description)} +

    +
    +
  • + )} + /> + + + { + const newValue = Math.max(1, Number(e.target.value)); + setPosition( + newValue > linkedItems.length + 1 + ? linkedItems.length + 1 + : newValue + ); + }} + InputProps={{ + inputProps: { min: 1, max: linkedItems.length + 1 }, + }} + fullWidth + /> + + + +
    + )} + + {activeTab === 1 && ( + + {/* Поле поиска */} + setSearchQuery(e.target.value)} + placeholder="Введите название остановки..." + size="small" + sx={{ mb: 1 }} + /> + + {/* Список доступных остановок с чекбоксами */} + + + {filteredAvailableItems.map((item) => ( + handleCheckboxChange(item.id)} + size="small" + /> + } + label={String(item.name)} + sx={{ + margin: 0, + "& .MuiFormControlLabel-label": { + fontSize: "0.875rem", + }, + }} + /> + ))} + {filteredAvailableItems.length === 0 && ( + + {searchQuery.trim() + ? "Остановки не найдены" + : "Нет доступных остановок"} + + )} + + + + + + )} +
    )} + ); }; diff --git a/src/pages/Route/RouteCreatePage/index.tsx b/src/pages/Route/RouteCreatePage/index.tsx index e17a76c..96cde4a 100644 --- a/src/pages/Route/RouteCreatePage/index.tsx +++ b/src/pages/Route/RouteCreatePage/index.tsx @@ -6,19 +6,23 @@ import { MenuItem, FormControl, InputLabel, - // Typography, + Typography, Box, + Dialog, + DialogTitle, + DialogContent, + DialogActions, } from "@mui/material"; -import { LanguageSwitcher } from "@widgets"; +import { MediaViewer } from "@widgets"; 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 { useNavigate } from "react-router-dom"; import { toast } from "react-toastify"; import { carrierStore } from "../../../shared/store/CarrierStore"; import { articlesStore } from "../../../shared/store/ArticlesStore"; import { Route, routeStore } from "../../../shared/store/RouteStore"; -import { languageStore } from "@shared"; +import { languageStore, SelectArticleModal, SelectMediaDialog } from "@shared"; export const RouteCreatePage = observer(() => { const navigate = useNavigate(); @@ -33,7 +37,12 @@ export const RouteCreatePage = observer(() => { const [turn, setTurn] = useState(""); const [centerLat, setCenterLat] = useState(""); const [centerLng, setCenterLng] = useState(""); + const [videoPreview, setVideoPreview] = useState(""); const [isLoading, setIsLoading] = useState(false); + const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] = + useState(false); + const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false); + const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false); const { language } = languageStore; 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 () => { try { setIsLoading(true); @@ -126,6 +154,8 @@ export const RouteCreatePage = observer(() => { center_latitude, center_longitude, path, + video_preview: + videoPreview && videoPreview !== "" ? videoPreview : undefined, }; 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 ( -
    + + + + {/* Селектор видео превью */} + + + + + {videoPreview && videoPreview !== "" + ? "Видео выбрано" + : "Видео не выбрано"} + + + + + Прямой/обратный маршрут - routeStore.setEditRouteData({ - governor_appeal: Number(e.target.value), - }) - } - disabled={articlesStore.articleList.ru.data.length === 0} - > - Не выбрано - {articlesStore.articleList.ru.data.map( - (a: (typeof articlesStore.articleList.ru.data)[number]) => ( - - {a.heading} - - ) - )} - - + + {/* Заменяем Select на кнопку для выбора статьи */} + + + + + + + + + {/* Селектор видео превью */} + + + + + {editRouteData.video_preview && + editRouteData.video_preview !== "" + ? "Видео выбрано" + : "Видео не выбрано"} + + + + + Прямой/обратный маршрут + {id && ( + + )} +
    diff --git a/src/pages/Station/StationListPage/index.tsx b/src/pages/Station/StationListPage/index.tsx index 149209f..953e11e 100644 --- a/src/pages/Station/StationListPage/index.tsx +++ b/src/pages/Station/StationListPage/index.tsx @@ -1,10 +1,12 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; +import { ruRU } from "@mui/x-data-grid/locales"; import { languageStore, stationsStore } from "@shared"; import { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; import { Eye, Pencil, Trash2, Minus } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; +import { Box, CircularProgress } from "@mui/material"; export const StationListPage = observer(() => { const { stationLists, getStationList, deleteStation } = stationsStore; @@ -13,10 +15,16 @@ export const StationListPage = observer(() => { const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); const [rowId, setRowId] = useState(null); const [ids, setIds] = useState([]); + const [isLoading, setIsLoading] = useState(false); const { language } = languageStore; useEffect(() => { - getStationList(); + const fetchStations = async () => { + setIsLoading(true); + await getStationList(); + setIsLoading(false); + }; + fetchStations(); }, [language]); const columns: GridColDef[] = [ @@ -115,7 +123,7 @@ export const StationListPage = observer(() => {

    Станции

    - +
    { columns={columns} hideFooterPagination checkboxSelection + loading={isLoading} onRowSelectionModelChange={(newSelection) => { setIds(Array.from(newSelection.ids) as number[]); }} hideFooter + localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} + slots={{ + noRowsOverlay: () => ( + + {isLoading ? : "Нет станций"} + + ), + }} />
    diff --git a/src/pages/Station/StationPreviewPage/index.tsx b/src/pages/Station/StationPreviewPage/index.tsx index cdedf7e..d45dc72 100644 --- a/src/pages/Station/StationPreviewPage/index.tsx +++ b/src/pages/Station/StationPreviewPage/index.tsx @@ -5,6 +5,7 @@ import { observer } from "mobx-react-lite"; import { ArrowLeft } from "lucide-react"; import { useEffect } from "react"; import { useNavigate, useParams } from "react-router-dom"; +import { LinkedSights } from "../LinkedSights"; export const StationPreviewPage = observer(() => { const { id } = useParams(); @@ -71,6 +72,17 @@ export const StationPreviewPage = observer(() => {

    {stationPreview[id!]?.[language]?.data.description}

    )} + + {id && ( + + )}
    ); diff --git a/src/pages/Station/index.ts b/src/pages/Station/index.ts index 610bcc7..98394af 100644 --- a/src/pages/Station/index.ts +++ b/src/pages/Station/index.ts @@ -2,3 +2,4 @@ export * from "./StationListPage"; export * from "./StationCreatePage"; export * from "./StationPreviewPage"; export * from "./StationEditPage"; +export * from "./LinkedSights"; diff --git a/src/pages/User/UserEditPage/index.tsx b/src/pages/User/UserEditPage/index.tsx index a7c8f3c..b6def48 100644 --- a/src/pages/User/UserEditPage/index.tsx +++ b/src/pages/User/UserEditPage/index.tsx @@ -10,15 +10,21 @@ import { ArrowLeft, Save } from "lucide-react"; import { Loader2 } from "lucide-react"; import { useNavigate, useParams } from "react-router-dom"; import { toast } from "react-toastify"; -import { userStore } from "@shared"; +import { userStore, languageStore } from "@shared"; import { useEffect, useState } from "react"; export const UserEditPage = observer(() => { const navigate = useNavigate(); const [isLoading, setIsLoading] = useState(false); + const { id } = useParams(); const { editUserData, editUser, getUser, setEditUserData } = userStore; + useEffect(() => { + // Устанавливаем русский язык при загрузке страницы + languageStore.setLanguage("ru"); + }, []); + const handleEdit = async () => { try { setIsLoading(true); @@ -130,7 +136,7 @@ export const UserEditPage = observer(() => { {isLoading ? ( ) : ( - "Обновить" + "Сохранить" )} diff --git a/src/pages/User/UserListPage/index.tsx b/src/pages/User/UserListPage/index.tsx index 307cc48..a4ef2e8 100644 --- a/src/pages/User/UserListPage/index.tsx +++ b/src/pages/User/UserListPage/index.tsx @@ -1,11 +1,12 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; +import { ruRU } from "@mui/x-data-grid/locales"; import { userStore } from "@shared"; import { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; import { Pencil, Trash2, Minus } from "lucide-react"; import { useNavigate } from "react-router-dom"; - import { CreateButton, DeleteModal } from "@widgets"; +import { Box, CircularProgress } from "@mui/material"; export const UserListPage = observer(() => { const { users, getUsers, deleteUser } = userStore; @@ -14,9 +15,15 @@ export const UserListPage = observer(() => { const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); const [rowId, setRowId] = useState(null); const [ids, setIds] = useState([]); + const [isLoading, setIsLoading] = useState(false); useEffect(() => { - getUsers(); + const fetchUsers = async () => { + setIsLoading(true); + await getUsers(); + setIsLoading(false); + }; + fetchUsers(); }, []); const columns: GridColDef[] = [ @@ -136,10 +143,23 @@ export const UserListPage = observer(() => { columns={columns} hideFooterPagination checkboxSelection + loading={isLoading} onRowSelectionModelChange={(newSelection) => { setIds(Array.from(newSelection.ids) as number[]); }} hideFooter + localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} + slots={{ + noRowsOverlay: () => ( + + {isLoading ? ( + + ) : ( + "Нет пользователей" + )} + + ), + }} /> diff --git a/src/pages/Vehicle/VehicleEditPage/index.tsx b/src/pages/Vehicle/VehicleEditPage/index.tsx index 5e3858f..55a5bd4 100644 --- a/src/pages/Vehicle/VehicleEditPage/index.tsx +++ b/src/pages/Vehicle/VehicleEditPage/index.tsx @@ -31,6 +31,12 @@ export const VehicleEditPage = observer(() => { } = vehicleStore; const { getCarriers } = carrierStore; const { language } = languageStore; + + useEffect(() => { + // Устанавливаем русский язык при загрузке страницы + languageStore.setLanguage("ru"); + }, []); + useEffect(() => { (async () => { await getVehicle(Number(id)); diff --git a/src/pages/Vehicle/VehicleListPage/index.tsx b/src/pages/Vehicle/VehicleListPage/index.tsx index ecfd37c..d6333bf 100644 --- a/src/pages/Vehicle/VehicleListPage/index.tsx +++ b/src/pages/Vehicle/VehicleListPage/index.tsx @@ -1,4 +1,5 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; +import { ruRU } from "@mui/x-data-grid/locales"; import { carrierStore, languageStore, vehicleStore } from "@shared"; import { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; @@ -6,6 +7,7 @@ import { Eye, Pencil, Trash2, Minus } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { CreateButton, DeleteModal } from "@widgets"; import { VEHICLE_TYPES } from "@shared"; +import { Box, CircularProgress } from "@mui/material"; export const VehicleListPage = observer(() => { const { vehicles, getVehicles, deleteVehicle } = vehicleStore; @@ -15,11 +17,17 @@ export const VehicleListPage = observer(() => { const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); const [rowId, setRowId] = useState(null); const [ids, setIds] = useState([]); + const [isLoading, setIsLoading] = useState(false); const { language } = languageStore; useEffect(() => { - getVehicles(); - getCarriers(language); + const fetchData = async () => { + setIsLoading(true); + await getVehicles(); + await getCarriers(language); + setIsLoading(false); + }; + fetchData(); }, [language]); const columns: GridColDef[] = [ @@ -157,10 +165,23 @@ export const VehicleListPage = observer(() => { columns={columns} hideFooterPagination checkboxSelection + loading={isLoading} onRowSelectionModelChange={(newSelection) => { setIds(Array.from(newSelection.ids) as number[]); }} hideFooter + localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} + slots={{ + noRowsOverlay: () => ( + + {isLoading ? ( + + ) : ( + "Нет транспортных средств" + )} + + ), + }} /> diff --git a/src/shared/config/constants.tsx b/src/shared/config/constants.tsx index 246ba59..218d32d 100644 --- a/src/shared/config/constants.tsx +++ b/src/shared/config/constants.tsx @@ -11,10 +11,10 @@ import { // Car, Table, Split, - Newspaper, + // Newspaper, PersonStanding, Cpu, - BookImage, + // BookImage, } from "lucide-react"; import { CarrierSvg } from "./CarrierSvg"; @@ -70,18 +70,18 @@ export const NAVIGATION_ITEMS: { label: "Справочник", icon: Table, nestedItems: [ - { - id: "media", - label: "Медиа", - icon: BookImage, - path: "/media", - }, - { - id: "articles", - label: "Статьи", - icon: Newspaper, - path: "/article", - }, + // { + // id: "media", + // label: "Медиа", + // icon: BookImage, + // path: "/media", + // }, + // { + // id: "articles", + // label: "Статьи", + // icon: Newspaper, + // path: "/article", + // }, { id: "attractions", label: "Достопримечательности", diff --git a/src/shared/const/index.ts b/src/shared/const/index.ts index f1c1672..be30fa8 100644 --- a/src/shared/const/index.ts +++ b/src/shared/const/index.ts @@ -18,3 +18,752 @@ export const MEDIA_TYPE_VALUES = { panorama: 5, model: 6, }; + +export const RU_COUNTRIES = [ + { code: "AF", name: "Афганистан" }, + { code: "AX", name: "Аландские острова" }, + { code: "AL", name: "Албания" }, + { code: "DZ", name: "Алжир" }, + { code: "AS", name: "Американское Самоа" }, + { code: "AD", name: "Андорра" }, + { code: "AO", name: "Ангола" }, + { code: "AI", name: "Ангилья" }, + { code: "AQ", name: "Антарктида" }, + { code: "AG", name: "Антигуа и Барбуда" }, + { code: "AR", name: "Аргентина" }, + { code: "AM", name: "Армения" }, + { code: "AW", name: "Аруба" }, + { code: "AU", name: "Австралия" }, + { code: "AT", name: "Австрия" }, + { code: "AZ", name: "Азербайджан" }, + { code: "BS", name: "Багамы" }, + { code: "BH", name: "Бахрейн" }, + { code: "BD", name: "Бангладеш" }, + { code: "BB", name: "Барбадос" }, + { code: "BY", name: "Беларусь" }, + { code: "BE", name: "Бельгия" }, + { code: "BZ", name: "Белиз" }, + { code: "BJ", name: "Бенин" }, + { code: "BM", name: "Бермуды" }, + { code: "BT", name: "Бутан" }, + { code: "BO", name: "Боливия" }, + { code: "BA", name: "Босния и Герцеговина" }, + { code: "BW", name: "Ботсвана" }, + { code: "BV", name: "Остров Буве" }, + { code: "BR", name: "Бразилия" }, + { code: "IO", name: "Британская территория в Индийском океане" }, + { code: "BN", name: "Бруней-Даруссалам" }, + { code: "BG", name: "Болгария" }, + { code: "BF", name: "Буркина-Фасо" }, + { code: "BI", name: "Бурунди" }, + { code: "KH", name: "Камбоджа" }, + { code: "CM", name: "Камерун" }, + { code: "CA", name: "Канада" }, + { code: "CV", name: "Кабо-Верде" }, + { code: "KY", name: "Каймановы острова" }, + { code: "CF", name: "Центральноафриканская Республика" }, + { code: "TD", name: "Чад" }, + { code: "CL", name: "Чили" }, + { code: "CN", name: "Китай" }, + { code: "CX", name: "Остров Рождества" }, + { code: "CC", name: "Кокосовые (Килинг) острова" }, + { code: "CO", name: "Колумбия" }, + { code: "KM", name: "Коморы" }, + { code: "CG", name: "Конго" }, + { code: "CD", name: "Демократическая Республика Конго" }, + { code: "CK", name: "Острова Кука" }, + { code: "CR", name: "Коста-Рика" }, + { code: "CI", name: "Кот-д'Ивуар" }, + { code: "HR", name: "Хорватия" }, + { code: "CU", name: "Куба" }, + { code: "CY", name: "Кипр" }, + { code: "CZ", name: "Чехия" }, + { code: "DK", name: "Дания" }, + { code: "DJ", name: "Джибути" }, + { code: "DM", name: "Доминика" }, + { code: "DO", name: "Доминиканская Республика" }, + { code: "EC", name: "Эквадор" }, + { code: "EG", name: "Египет" }, + { code: "SV", name: "Сальвадор" }, + { code: "GQ", name: "Экваториальная Гвинея" }, + { code: "ER", name: "Эритрея" }, + { code: "EE", name: "Эстония" }, + { code: "ET", name: "Эфиопия" }, + { code: "FK", name: "Фолклендские острова (Мальвинские)" }, + { code: "FO", name: "Фарерские острова" }, + { code: "FJ", name: "Фиджи" }, + { code: "FI", name: "Финляндия" }, + { code: "FR", name: "Франция" }, + { code: "GF", name: "Французская Гвиана" }, + { code: "PF", name: "Французская Полинезия" }, + { code: "TF", name: "Французские Южные территории" }, + { code: "GA", name: "Габон" }, + { code: "GM", name: "Гамбия" }, + { code: "GE", name: "Грузия" }, + { code: "DE", name: "Германия" }, + { code: "GH", name: "Гана" }, + { code: "GI", name: "Гибралтар" }, + { code: "GR", name: "Греция" }, + { code: "GL", name: "Гренландия" }, + { code: "GD", name: "Гренада" }, + { code: "GP", name: "Гваделупа" }, + { code: "GU", name: "Гуам" }, + { code: "GT", name: "Гватемала" }, + { code: "GG", name: "Гернси" }, + { code: "GN", name: "Гвинея" }, + { code: "GW", name: "Гвинея-Бисау" }, + { code: "GY", name: "Гайана" }, + { code: "HT", name: "Гаити" }, + { code: "HM", name: "Остров Херд и острова Макдональд" }, + { code: "VA", name: "Ватикан" }, + { code: "HN", name: "Гондурас" }, + { code: "HK", name: "Гонконг" }, + { code: "HU", name: "Венгрия" }, + { code: "IS", name: "Исландия" }, + { code: "IN", name: "Индия" }, + { code: "ID", name: "Индонезия" }, + { code: "IR", name: "Иран" }, + { code: "IQ", name: "Ирак" }, + { code: "IE", name: "Ирландия" }, + { code: "IM", name: "Остров Мэн" }, + { code: "IL", name: "Израиль" }, + { code: "IT", name: "Италия" }, + { code: "JM", name: "Ямайка" }, + { code: "JP", name: "Япония" }, + { code: "JE", name: "Джерси" }, + { code: "JO", name: "Иордания" }, + { code: "KZ", name: "Казахстан" }, + { code: "KE", name: "Кения" }, + { code: "KI", name: "Кирибати" }, + { code: "KR", name: "Корея" }, + { code: "KP", name: "Северная Корея" }, + { code: "KW", name: "Кувейт" }, + { code: "KG", name: "Киргизия" }, + { code: "LA", name: "Лаос" }, + { code: "LV", name: "Латвия" }, + { code: "LB", name: "Ливан" }, + { code: "LS", name: "Лесото" }, + { code: "LR", name: "Либерия" }, + { code: "LY", name: "Ливия" }, + { code: "LI", name: "Лихтенштейн" }, + { code: "LT", name: "Литва" }, + { code: "LU", name: "Люксембург" }, + { code: "MO", name: "Макао" }, + { code: "MK", name: "Северная Македония" }, + { code: "MG", name: "Мадагаскар" }, + { code: "MW", name: "Малави" }, + { code: "MY", name: "Малайзия" }, + { code: "MV", name: "Мальдивы" }, + { code: "ML", name: "Мали" }, + { code: "MT", name: "Мальта" }, + { code: "MH", name: "Маршалловы Острова" }, + { code: "MQ", name: "Мартиника" }, + { code: "MR", name: "Мавритания" }, + { code: "MU", name: "Маврикий" }, + { code: "YT", name: "Майотта" }, + { code: "MX", name: "Мексика" }, + { code: "FM", name: "Микронезия" }, + { code: "MD", name: "Молдова" }, + { code: "MC", name: "Монако" }, + { code: "MN", name: "Монголия" }, + { code: "ME", name: "Черногория" }, + { code: "MS", name: "Монтсеррат" }, + { code: "MA", name: "Марокко" }, + { code: "MZ", name: "Мозамбик" }, + { code: "MM", name: "Мьянма" }, + { code: "NA", name: "Намибия" }, + { code: "NR", name: "Науру" }, + { code: "NP", name: "Непал" }, + { code: "NL", name: "Нидерланды" }, + { code: "AN", name: "Нидерландские Антильские острова" }, + { code: "NC", name: "Новая Каледония" }, + { code: "NZ", name: "Новая Зеландия" }, + { code: "NI", name: "Никарагуа" }, + { code: "NE", name: "Нигер" }, + { code: "NG", name: "Нигерия" }, + { code: "NU", name: "Ниуэ" }, + { code: "NF", name: "Остров Норфолк" }, + { code: "MP", name: "Северные Марианские острова" }, + { code: "NO", name: "Норвегия" }, + { code: "OM", name: "Оман" }, + { code: "PK", name: "Пакистан" }, + { code: "PW", name: "Палау" }, + { code: "PS", name: "Палестинская территория" }, + { code: "PA", name: "Панама" }, + { code: "PG", name: "Папуа — Новая Гвинея" }, + { code: "PY", name: "Парагвай" }, + { code: "PE", name: "Перу" }, + { code: "PH", name: "Филиппины" }, + { code: "PN", name: "Питкэрн" }, + { code: "PL", name: "Польша" }, + { code: "PT", name: "Португалия" }, + { code: "PR", name: "Пуэрто-Рико" }, + { code: "QA", name: "Катар" }, + { code: "RE", name: "Реюньон" }, + { code: "RO", name: "Румыния" }, + { code: "RU", name: "Россия" }, + { code: "RW", name: "Руанда" }, + { code: "BL", name: "Сен-Бартелеми" }, + { code: "SH", name: "Остров Святой Елены" }, + { code: "KN", name: "Сент-Китс и Невис" }, + { code: "LC", name: "Сент-Люсия" }, + { code: "MF", name: "Сен-Мартен" }, + { code: "PM", name: "Сен-Пьер и Микелон" }, + { code: "VC", name: "Сент-Винсент и Гренадины" }, + { code: "WS", name: "Самоа" }, + { code: "SM", name: "Сан-Марино" }, + { code: "ST", name: "Сан-Томе и Принсипи" }, + { code: "SA", name: "Саудовская Аравия" }, + { code: "SN", name: "Сенегал" }, + { code: "RS", name: "Сербия" }, + { code: "SC", name: "Сейшельские Острова" }, + { code: "SL", name: "Сьерра-Леоне" }, + { code: "SG", name: "Сингапур" }, + { code: "SK", name: "Словакия" }, + { code: "SI", name: "Словения" }, + { code: "SB", name: "Соломоновы Острова" }, + { code: "SO", name: "Сомали" }, + { code: "ZA", name: "Южная Африка" }, + { code: "GS", name: "Южная Георгия и Южные Сандвичевы острова" }, + { code: "ES", name: "Испания" }, + { code: "LK", name: "Шри-Ланка" }, + { code: "SD", name: "Судан" }, + { code: "SR", name: "Суринам" }, + { code: "SJ", name: "Шпицберген и Ян-Майен" }, + { code: "SZ", name: "Свазиленд" }, + { code: "SE", name: "Швеция" }, + { code: "CH", name: "Швейцария" }, + { code: "SY", name: "Сирия" }, + { code: "TW", name: "Тайвань" }, + { code: "TJ", name: "Таджикистан" }, + { code: "TZ", name: "Танзания" }, + { code: "TH", name: "Таиланд" }, + { code: "TL", name: "Восточный Тимор" }, + { code: "TG", name: "Того" }, + { code: "TK", name: "Токелау" }, + { code: "TO", name: "Тонга" }, + { code: "TT", name: "Тринидад и Тобаго" }, + { code: "TN", name: "Тунис" }, + { code: "TR", name: "Турция" }, + { code: "TM", name: "Туркмения" }, + { code: "TC", name: "Теркс и Кайкос" }, + { code: "TV", name: "Тувалу" }, + { code: "UG", name: "Уганда" }, + { code: "UA", name: "Украина" }, + { code: "AE", name: "Объединённые Арабские Эмираты" }, + { code: "GB", name: "Великобритания" }, + { code: "US", name: "США" }, + { code: "UM", name: "Внешние малые острова США" }, + { code: "UY", name: "Уругвай" }, + { code: "UZ", name: "Узбекистан" }, + { code: "VU", name: "Вануату" }, + { code: "VE", name: "Венесуэла" }, + { code: "VN", name: "Вьетнам" }, + { code: "VG", name: "Британские Виргинские острова" }, + { code: "VI", name: "Виргинские острова (США)" }, + { code: "WF", name: "Уоллис и Футуна" }, + { code: "EH", name: "Западная Сахара" }, + { code: "YE", name: "Йемен" }, + { code: "ZM", name: "Замбия" }, + { code: "ZW", name: "Зимбабве" }, +]; + +// countries-en.js +export const EN_COUNTRIES = [ + { code: "AF", name: "Afghanistan" }, + { code: "AX", name: "Aland Islands" }, + { code: "AL", name: "Albania" }, + { code: "DZ", name: "Algeria" }, + { code: "AS", name: "American Samoa" }, + { code: "AD", name: "Andorra" }, + { code: "AO", name: "Angola" }, + { code: "AI", name: "Anguilla" }, + { code: "AQ", name: "Antarctica" }, + { code: "AG", name: "Antigua And Barbuda" }, + { code: "AR", name: "Argentina" }, + { code: "AM", name: "Armenia" }, + { code: "AW", name: "Aruba" }, + { code: "AU", name: "Australia" }, + { code: "AT", name: "Austria" }, + { code: "AZ", name: "Azerbaijan" }, + { code: "BS", name: "Bahamas" }, + { code: "BH", name: "Bahrain" }, + { code: "BD", name: "Bangladesh" }, + { code: "BB", name: "Barbados" }, + { code: "BY", name: "Belarus" }, + { code: "BE", name: "Belgium" }, + { code: "BZ", name: "Belize" }, + { code: "BJ", name: "Benin" }, + { code: "BM", name: "Bermuda" }, + { code: "BT", name: "Bhutan" }, + { code: "BO", name: "Bolivia" }, + { code: "BA", name: "Bosnia And Herzegovina" }, + { code: "BW", name: "Botswana" }, + { code: "BV", name: "Bouvet Island" }, + { code: "BR", name: "Brazil" }, + { code: "IO", name: "British Indian Ocean Territory" }, + { code: "BN", name: "Brunei Darussalam" }, + { code: "BG", name: "Bulgaria" }, + { code: "BF", name: "Burkina Faso" }, + { code: "BI", name: "Burundi" }, + { code: "KH", name: "Cambodia" }, + { code: "CM", name: "Cameroon" }, + { code: "CA", name: "Canada" }, + { code: "CV", name: "Cape Verde" }, + { code: "KY", name: "Cayman Islands" }, + { code: "CF", name: "Central African Republic" }, + { code: "TD", name: "Chad" }, + { code: "CL", name: "Chile" }, + { code: "CN", name: "China" }, + { code: "CX", name: "Christmas Island" }, + { code: "CC", name: "Cocos (Keeling) Islands" }, + { code: "CO", name: "Colombia" }, + { code: "KM", name: "Comoros" }, + { code: "CG", name: "Congo" }, + { code: "CD", name: "Congo, Democratic Republic" }, + { code: "CK", name: "Cook Islands" }, + { code: "CR", name: "Costa Rica" }, + { code: "CI", name: "Cote D'Ivoire" }, + { code: "HR", name: "Croatia" }, + { code: "CU", name: "Cuba" }, + { code: "CY", name: "Cyprus" }, + { code: "CZ", name: "Czech Republic" }, + { code: "DK", name: "Denmark" }, + { code: "DJ", name: "Djibouti" }, + { code: "DM", name: "Dominica" }, + { code: "DO", name: "Dominican Republic" }, + { code: "EC", name: "Ecuador" }, + { code: "EG", name: "Egypt" }, + { code: "SV", name: "El Salvador" }, + { code: "GQ", name: "Equatorial Guinea" }, + { code: "ER", name: "Eritrea" }, + { code: "EE", name: "Estonia" }, + { code: "ET", name: "Ethiopia" }, + { code: "FK", name: "Falkland Islands (Malvinas)" }, + { code: "FO", name: "Faroe Islands" }, + { code: "FJ", name: "Fiji" }, + { code: "FI", name: "Finland" }, + { code: "FR", name: "France" }, + { code: "GF", name: "French Guiana" }, + { code: "PF", name: "French Polynesia" }, + { code: "TF", name: "French Southern Territories" }, + { code: "GA", name: "Gabon" }, + { code: "GM", name: "Gambia" }, + { code: "GE", name: "Georgia" }, + { code: "DE", name: "Germany" }, + { code: "GH", name: "Ghana" }, + { code: "GI", name: "Gibraltar" }, + { code: "GR", name: "Greece" }, + { code: "GL", name: "Greenland" }, + { code: "GD", name: "Grenada" }, + { code: "GP", name: "Guadeloupe" }, + { code: "GU", name: "Guam" }, + { code: "GT", name: "Guatemala" }, + { code: "GG", name: "Guernsey" }, + { code: "GN", name: "Guinea" }, + { code: "GW", name: "Guinea-Bissau" }, + { code: "GY", name: "Guyana" }, + { code: "HT", name: "Haiti" }, + { code: "HM", name: "Heard Island & Mcdonald Islands" }, + { code: "VA", name: "Holy See (Vatican City State)" }, + { code: "HN", name: "Honduras" }, + { code: "HK", name: "Hong Kong" }, + { code: "HU", name: "Hungary" }, + { code: "IS", name: "Iceland" }, + { code: "IN", name: "India" }, + { code: "ID", name: "Indonesia" }, + { code: "IR", name: "Iran, Islamic Republic Of" }, + { code: "IQ", name: "Iraq" }, + { code: "IE", name: "Ireland" }, + { code: "IM", name: "Isle Of Man" }, + { code: "IL", name: "Israel" }, + { code: "IT", name: "Italy" }, + { code: "JM", name: "Jamaica" }, + { code: "JP", name: "Japan" }, + { code: "JE", name: "Jersey" }, + { code: "JO", name: "Jordan" }, + { code: "KZ", name: "Kazakhstan" }, + { code: "KE", name: "Kenya" }, + { code: "KI", name: "Kiribati" }, + { code: "KR", name: "Korea" }, + { code: "KP", name: "North Korea" }, + { code: "KW", name: "Kuwait" }, + { code: "KG", name: "Kyrgyzstan" }, + { code: "LA", name: "Lao People's Democratic Republic" }, + { code: "LV", name: "Latvia" }, + { code: "LB", name: "Lebanon" }, + { code: "LS", name: "Lesotho" }, + { code: "LR", name: "Liberia" }, + { code: "LY", name: "Libyan Arab Jamahiriya" }, + { code: "LI", name: "Liechtenstein" }, + { code: "LT", name: "Lithuania" }, + { code: "LU", name: "Luxembourg" }, + { code: "MO", name: "Macao" }, + { code: "MK", name: "Macedonia" }, + { code: "MG", name: "Madagascar" }, + { code: "MW", name: "Malawi" }, + { code: "MY", name: "Malaysia" }, + { code: "MV", name: "Maldives" }, + { code: "ML", name: "Mali" }, + { code: "MT", name: "Malta" }, + { code: "MH", name: "Marshall Islands" }, + { code: "MQ", name: "Martinique" }, + { code: "MR", name: "Mauritania" }, + { code: "MU", name: "Mauritius" }, + { code: "YT", name: "Mayotte" }, + { code: "MX", name: "Mexico" }, + { code: "FM", name: "Micronesia, Federated States Of" }, + { code: "MD", name: "Moldova" }, + { code: "MC", name: "Monaco" }, + { code: "MN", name: "Mongolia" }, + { code: "ME", name: "Montenegro" }, + { code: "MS", name: "Montserrat" }, + { code: "MA", name: "Morocco" }, + { code: "MZ", name: "Mozambique" }, + { code: "MM", name: "Myanmar" }, + { code: "NA", name: "Namibia" }, + { code: "NR", name: "Nauru" }, + { code: "NP", name: "Nepal" }, + { code: "NL", name: "Netherlands" }, + { code: "AN", name: "Netherlands Antilles" }, + { code: "NC", name: "New Caledonia" }, + { code: "NZ", name: "New Zealand" }, + { code: "NI", name: "Nicaragua" }, + { code: "NE", name: "Niger" }, + { code: "NG", name: "Nigeria" }, + { code: "NU", name: "Niue" }, + { code: "NF", name: "Norfolk Island" }, + { code: "MP", name: "Northern Mariana Islands" }, + { code: "NO", name: "Norway" }, + { code: "OM", name: "Oman" }, + { code: "PK", name: "Pakistan" }, + { code: "PW", name: "Palau" }, + { code: "PS", name: "Palestinian Territory, Occupied" }, + { code: "PA", name: "Panama" }, + { code: "PG", name: "Papua New Guinea" }, + { code: "PY", name: "Paraguay" }, + { code: "PE", name: "Peru" }, + { code: "PH", name: "Philippines" }, + { code: "PN", name: "Pitcairn" }, + { code: "PL", name: "Poland" }, + { code: "PT", name: "Portugal" }, + { code: "PR", name: "Puerto Rico" }, + { code: "QA", name: "Qatar" }, + { code: "RE", name: "Reunion" }, + { code: "RO", name: "Romania" }, + { code: "RU", name: "Russian Federation" }, + { code: "RW", name: "Rwanda" }, + { code: "BL", name: "Saint Barthelemy" }, + { code: "SH", name: "Saint Helena" }, + { code: "KN", name: "Saint Kitts And Nevis" }, + { code: "LC", name: "Saint Lucia" }, + { code: "MF", name: "Saint Martin" }, + { code: "PM", name: "Saint Pierre And Miquelon" }, + { code: "VC", name: "Saint Vincent And Grenadines" }, + { code: "WS", name: "Samoa" }, + { code: "SM", name: "San Marino" }, + { code: "ST", name: "Sao Tome And Principe" }, + { code: "SA", name: "Saudi Arabia" }, + { code: "SN", name: "Senegal" }, + { code: "RS", name: "Serbia" }, + { code: "SC", name: "Seychelles" }, + { code: "SL", name: "Sierra Leone" }, + { code: "SG", name: "Singapore" }, + { code: "SK", name: "Slovakia" }, + { code: "SI", name: "Slovenia" }, + { code: "SB", name: "Solomon Islands" }, + { code: "SO", name: "Somalia" }, + { code: "ZA", name: "South Africa" }, + { code: "GS", name: "South Georgia And Sandwich Isl." }, + { code: "ES", name: "Spain" }, + { code: "LK", name: "Sri Lanka" }, + { code: "SD", name: "Sudan" }, + { code: "SR", name: "Suriname" }, + { code: "SJ", name: "Svalbard And Jan Mayen" }, + { code: "SZ", name: "Swaziland" }, + { code: "SE", name: "Sweden" }, + { code: "CH", name: "Switzerland" }, + { code: "SY", name: "Syrian Arab Republic" }, + { code: "TW", name: "Taiwan" }, + { code: "TJ", name: "Tajikistan" }, + { code: "TZ", name: "Tanzania" }, + { code: "TH", name: "Thailand" }, + { code: "TL", name: "Timor-Leste" }, + { code: "TG", name: "Togo" }, + { code: "TK", name: "Tokelau" }, + { code: "TO", name: "Tonga" }, + { code: "TT", name: "Trinidad And Tobago" }, + { code: "TN", name: "Tunisia" }, + { code: "TR", name: "Turkey" }, + { code: "TM", name: "Turkmenistan" }, + { code: "TC", name: "Turks And Caicos Islands" }, + { code: "TV", name: "Tuvalu" }, + { code: "UG", name: "Uganda" }, + { code: "UA", name: "Ukraine" }, + { code: "AE", name: "United Arab Emirates" }, + { code: "GB", name: "United Kingdom" }, + { code: "US", name: "United States" }, + { code: "UM", name: "United States Outlying Islands" }, + { code: "UY", name: "Uruguay" }, + { code: "UZ", name: "Uzbekistan" }, + { code: "VU", name: "Vanuatu" }, + { code: "VE", name: "Venezuela" }, + { code: "VN", name: "Vietnam" }, + { code: "VG", name: "Virgin Islands, British" }, + { code: "VI", name: "Virgin Islands, U.S." }, + { code: "WF", name: "Wallis And Futuna" }, + { code: "EH", name: "Western Sahara" }, + { code: "YE", name: "Yemen" }, + { code: "ZM", name: "Zambia" }, + { code: "ZW", name: "Zimbabwe" }, +]; + +// countries-zh.js +export const ZH_COUNTRIES = [ + { code: "AF", name: "阿富汗" }, + { code: "AX", name: "奥兰群岛" }, + { code: "AL", name: "阿尔巴尼亚" }, + { code: "DZ", name: "阿尔及利亚" }, + { code: "AS", name: "美属萨摩亚" }, + { code: "AD", name: "安道尔" }, + { code: "AO", name: "安哥拉" }, + { code: "AI", name: "安圭拉" }, + { code: "AQ", name: "南极洲" }, + { code: "AG", name: "安提瓜和巴布达" }, + { code: "AR", name: "阿根廷" }, + { code: "AM", name: "亚美尼亚" }, + { code: "AW", name: "阿鲁巴" }, + { code: "AU", name: "澳大利亚" }, + { code: "AT", name: "奥地利" }, + { code: "AZ", name: "阿塞拜疆" }, + { code: "BS", name: "巴哈马" }, + { code: "BH", name: "巴林" }, + { code: "BD", name: "孟加拉国" }, + { code: "BB", name: "巴巴多斯" }, + { code: "BY", name: "白俄罗斯" }, + { code: "BE", name: "比利时" }, + { code: "BZ", name: "伯利兹" }, + { code: "BJ", name: "贝宁" }, + { code: "BM", name: "百慕大" }, + { code: "BT", name: "不丹" }, + { code: "BO", name: "玻利维亚" }, + { code: "BA", name: "波斯尼亚和黑塞哥维那" }, + { code: "BW", name: "博茨瓦纳" }, + { code: "BV", name: "布韦岛" }, + { code: "BR", name: "巴西" }, + { code: "IO", name: "英属印度洋领地" }, + { code: "BN", name: "文莱" }, + { code: "BG", name: "保加利亚" }, + { code: "BF", name: "布基纳法索" }, + { code: "BI", name: "布隆迪" }, + { code: "KH", name: "柬埔寨" }, + { code: "CM", name: "喀麦隆" }, + { code: "CA", name: "加拿大" }, + { code: "CV", name: "佛得角" }, + { code: "KY", name: "开曼群岛" }, + { code: "CF", name: "中非共和国" }, + { code: "TD", name: "乍得" }, + { code: "CL", name: "智利" }, + { code: "CN", name: "中国" }, + { code: "CX", name: "圣诞岛" }, + { code: "CC", name: "科科斯(基林)群岛" }, + { code: "CO", name: "哥伦比亚" }, + { code: "KM", name: "科摩罗" }, + { code: "CG", name: "刚果" }, + { code: "CD", name: "刚果(金)" }, + { code: "CK", name: "库克群岛" }, + { code: "CR", name: "哥斯达黎加" }, + { code: "CI", name: "科特迪瓦" }, + { code: "HR", name: "克罗地亚" }, + { code: "CU", name: "古巴" }, + { code: "CY", name: "塞浦路斯" }, + { code: "CZ", name: "捷克" }, + { code: "DK", name: "丹麦" }, + { code: "DJ", name: "吉布提" }, + { code: "DM", name: "多米尼克" }, + { code: "DO", name: "多米尼加共和国" }, + { code: "EC", name: "厄瓜多尔" }, + { code: "EG", name: "埃及" }, + { code: "SV", name: "萨尔瓦多" }, + { code: "GQ", name: "赤道几内亚" }, + { code: "ER", name: "厄立特里亚" }, + { code: "EE", name: "爱沙尼亚" }, + { code: "ET", name: "埃塞俄比亚" }, + { code: "FK", name: "福克兰群岛" }, + { code: "FO", name: "法罗群岛" }, + { code: "FJ", name: "斐济" }, + { code: "FI", name: "芬兰" }, + { code: "FR", name: "法国" }, + { code: "GF", name: "法属圭亚那" }, + { code: "PF", name: "法属波利尼西亚" }, + { code: "TF", name: "法属南部领地" }, + { code: "GA", name: "加蓬" }, + { code: "GM", name: "冈比亚" }, + { code: "GE", name: "格鲁吉亚" }, + { code: "DE", name: "德国" }, + { code: "GH", name: "加纳" }, + { code: "GI", name: "直布罗陀" }, + { code: "GR", name: "希腊" }, + { code: "GL", name: "格陵兰" }, + { code: "GD", name: "格林纳达" }, + { code: "GP", name: "瓜德罗普" }, + { code: "GU", name: "关岛" }, + { code: "GT", name: "危地马拉" }, + { code: "GG", name: "根西岛" }, + { code: "GN", name: "几内亚" }, + { code: "GW", name: "几内亚比绍" }, + { code: "GY", name: "圭亚那" }, + { code: "HT", name: "海地" }, + { code: "HM", name: "赫德岛和麦克唐纳群岛" }, + { code: "VA", name: "梵蒂冈" }, + { code: "HN", name: "洪都拉斯" }, + { code: "HK", name: "中国香港" }, + { code: "HU", name: "匈牙利" }, + { code: "IS", name: "冰岛" }, + { code: "IN", name: "印度" }, + { code: "ID", name: "印度尼西亚" }, + { code: "IR", name: "伊朗" }, + { code: "IQ", name: "伊拉克" }, + { code: "IE", name: "爱尔兰" }, + { code: "IM", name: "马恩岛" }, + { code: "IL", name: "以色列" }, + { code: "IT", name: "意大利" }, + { code: "JM", name: "牙买加" }, + { code: "JP", name: "日本" }, + { code: "JE", name: "泽西岛" }, + { code: "JO", name: "约旦" }, + { code: "KZ", name: "哈萨克斯坦" }, + { code: "KE", name: "肯尼亚" }, + { code: "KI", name: "基里巴斯" }, + { code: "KR", name: "韩国" }, + { code: "KP", name: "朝鲜" }, + { code: "KW", name: "科威特" }, + { code: "KG", name: "吉尔吉斯斯坦" }, + { code: "LA", name: "老挝" }, + { code: "LV", name: "拉脱维亚" }, + { code: "LB", name: "黎巴嫩" }, + { code: "LS", name: "莱索托" }, + { code: "LR", name: "利比里亚" }, + { code: "LY", name: "利比亚" }, + { code: "LI", name: "列支敦士登" }, + { code: "LT", name: "立陶宛" }, + { code: "LU", name: "卢森堡" }, + { code: "MO", name: "中国澳门" }, + { code: "MK", name: "北马其顿" }, + { code: "MG", name: "马达加斯加" }, + { code: "MW", name: "马拉维" }, + { code: "MY", name: "马来西亚" }, + { code: "MV", name: "马尔代夫" }, + { code: "ML", name: "马里" }, + { code: "MT", name: "马耳他" }, + { code: "MH", name: "马绍尔群岛" }, + { code: "MQ", name: "马提尼克" }, + { code: "MR", name: "毛里塔尼亚" }, + { code: "MU", name: "毛里求斯" }, + { code: "YT", name: "马约特" }, + { code: "MX", name: "墨西哥" }, + { code: "FM", name: "密克罗尼西亚" }, + { code: "MD", name: "摩尔多瓦" }, + { code: "MC", name: "摩纳哥" }, + { code: "MN", name: "蒙古" }, + { code: "ME", name: "黑山" }, + { code: "MS", name: "蒙特塞拉特" }, + { code: "MA", name: "摩洛哥" }, + { code: "MZ", name: "莫桑比克" }, + { code: "MM", name: "缅甸" }, + { code: "NA", name: "纳米比亚" }, + { code: "NR", name: "瑙鲁" }, + { code: "NP", name: "尼泊尔" }, + { code: "NL", name: "荷兰" }, + { code: "AN", name: "荷属安的列斯" }, + { code: "NC", name: "新喀里多尼亚" }, + { code: "NZ", name: "新西兰" }, + { code: "NI", name: "尼加拉瓜" }, + { code: "NE", name: "尼日尔" }, + { code: "NG", name: "尼日利亚" }, + { code: "NU", name: "纽埃" }, + { code: "NF", name: "诺福克岛" }, + { code: "MP", name: "北马里亚纳群岛" }, + { code: "NO", name: "挪威" }, + { code: "OM", name: "阿曼" }, + { code: "PK", name: "巴基斯坦" }, + { code: "PW", name: "帕劳" }, + { code: "PS", name: "巴勒斯坦" }, + { code: "PA", name: "巴拿马" }, + { code: "PG", name: "巴布亚新几内亚" }, + { code: "PY", name: "巴拉圭" }, + { code: "PE", name: "秘鲁" }, + { code: "PH", name: "菲律宾" }, + { code: "PN", name: "皮特凯恩群岛" }, + { code: "PL", name: "波兰" }, + { code: "PT", name: "葡萄牙" }, + { code: "PR", name: "波多黎各" }, + { code: "QA", name: "卡塔尔" }, + { code: "RE", name: "留尼汪" }, + { code: "RO", name: "罗马尼亚" }, + { code: "RU", name: "俄罗斯" }, + { code: "RW", name: "卢旺达" }, + { code: "BL", name: "圣巴泰勒米" }, + { code: "SH", name: "圣赫勒拿" }, + { code: "KN", name: "圣基茨和尼维斯" }, + { code: "LC", name: "圣卢西亚" }, + { code: "MF", name: "法属圣马丁" }, + { code: "PM", name: "圣皮埃尔和密克隆" }, + { code: "VC", name: "圣文森特和格林纳丁斯" }, + { code: "WS", name: "萨摩亚" }, + { code: "SM", name: "圣马力诺" }, + { code: "ST", name: "圣多美和普林西比" }, + { code: "SA", name: "沙特阿拉伯" }, + { code: "SN", name: "塞内加尔" }, + { code: "RS", name: "塞尔维亚" }, + { code: "SC", name: "塞舌尔" }, + { code: "SL", name: "塞拉利昂" }, + { code: "SG", name: "新加坡" }, + { code: "SK", name: "斯洛伐克" }, + { code: "SI", name: "斯洛文尼亚" }, + { code: "SB", name: "所罗门群岛" }, + { code: "SO", name: "索马里" }, + { code: "ZA", name: "南非" }, + { code: "GS", name: "南乔治亚和南桑威奇群岛" }, + { code: "ES", name: "西班牙" }, + { code: "LK", name: "斯里兰卡" }, + { code: "SD", name: "苏丹" }, + { code: "SR", name: "苏里南" }, + { code: "SJ", name: "斯瓦尔巴和扬马延" }, + { code: "SZ", name: "斯威士兰" }, + { code: "SE", name: "瑞典" }, + { code: "CH", name: "瑞士" }, + { code: "SY", name: "叙利亚" }, + { code: "TW", name: "中国台湾" }, + { code: "TJ", name: "塔吉克斯坦" }, + { code: "TZ", name: "坦桑尼亚" }, + { code: "TH", name: "泰国" }, + { code: "TL", name: "东帝汶" }, + { code: "TG", name: "多哥" }, + { code: "TK", name: "托克劳" }, + { code: "TO", name: "汤加" }, + { code: "TT", name: "特立尼达和多巴哥" }, + { code: "TN", name: "突尼斯" }, + { code: "TR", name: "土耳其" }, + { code: "TM", name: "土库曼斯坦" }, + { code: "TC", name: "特克斯和凯科斯群岛" }, + { code: "TV", name: "图瓦卢" }, + { code: "UG", name: "乌干达" }, + { code: "UA", name: "乌克兰" }, + { code: "AE", name: "阿联酋" }, + { code: "GB", name: "英国" }, + { code: "US", name: "美国" }, + { code: "UM", name: "美国本土外小岛屿" }, + { code: "UY", name: "乌拉圭" }, + { code: "UZ", name: "乌兹别克斯坦" }, + { code: "VU", name: "瓦努阿图" }, + { code: "VE", name: "委内瑞拉" }, + { code: "VN", name: "越南" }, + { code: "VG", name: "英属维尔京群岛" }, + { code: "VI", name: "美属维尔京群岛" }, + { code: "WF", name: "瓦利斯和富图纳" }, + { code: "EH", name: "西撒哈拉" }, + { code: "YE", name: "也门" }, + { code: "ZM", name: "赞比亚" }, + { code: "ZW", name: "津巴布韦" }, +]; diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index 6219148..b633647 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -1,2 +1,54 @@ export * from "./mui/theme"; export * from "./DecodeJWT"; + +/** + * Генерирует название медиа по умолчанию в разных форматах + * + * Примеры использования: + * - Для достопримечательности с названием: "Михайловский замок_mikhail-zamok_Фото" + * - Для достопримечательности без названия: "Название_mikhail-zamok_Фото" + * - Для статьи: "Михайловский замок_mikhail-zamok_Факты" (где "Факты" - название статьи) + * + * @param objectName - Название объекта (достопримечательности, города и т.д.) + * @param fileName - Название файла + * @param mediaType - Тип медиа (число) или название статьи + * @param isArticle - Флаг, указывающий что медиа добавляется к статье + * @returns Строка в нужном формате + */ +export const generateDefaultMediaName = ( + objectName: string, + fileName: string, + mediaType: number | string, + isArticle: boolean = false +): string => { + // Убираем расширение из названия файла + const fileNameWithoutExtension = fileName.split(".").slice(0, -1).join("."); + + if (isArticle && typeof mediaType === "string") { + // Для статей: "Название достопримечательности_название файла_название статьи" + return `${objectName}_${fileNameWithoutExtension}_${mediaType}`; + } else if (typeof mediaType === "number") { + // Получаем название типа медиа + const mediaTypeLabels: Record = { + 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}_Медиа`; +}; diff --git a/src/shared/modals/PreviewMediaDialog/index.tsx b/src/shared/modals/PreviewMediaDialog/index.tsx index 2d4c80a..82fe7c2 100644 --- a/src/shared/modals/PreviewMediaDialog/index.tsx +++ b/src/shared/modals/PreviewMediaDialog/index.tsx @@ -75,6 +75,7 @@ export const PreviewMediaDialog = observer( setError(err instanceof Error ? err.message : "Failed to save media"); } finally { setIsLoading(false); + onClose(); } }; @@ -96,7 +97,6 @@ export const PreviewMediaDialog = observer( className="flex gap-4" dividers sx={{ - height: "600px", display: "flex", flexDirection: "column", gap: 2, @@ -149,6 +149,8 @@ export const PreviewMediaDialog = observer( media_type: media.media_type, filename: media.filename, }} + className="h-full w-full object-contain" + fullHeight /> diff --git a/src/shared/modals/SelectArticleDialog/index.tsx b/src/shared/modals/SelectArticleDialog/index.tsx index ba70cff..c4d7f5c 100644 --- a/src/shared/modals/SelectArticleDialog/index.tsx +++ b/src/shared/modals/SelectArticleDialog/index.tsx @@ -38,7 +38,9 @@ export const SelectArticleModal = observer( onSelectArticle, linkedArticleIds = [], }: SelectArticleModalProps) => { - const { articles, getArticle, getArticleMedia } = articlesStore; + const { language } = languageStore; + const { articles, getArticle, getArticleMedia, getArticles } = + articlesStore; const [searchQuery, setSearchQuery] = useState(""); const [selectedArticleId, setSelectedArticleId] = useState( null @@ -54,6 +56,21 @@ export const SelectArticleModal = observer( } }, [open]); + useEffect(() => { + const fetchData = async () => { + await getArticles("ru"); + await getArticles("en"); + await getArticles("zh"); + }; + fetchData(); + }, []); + + useEffect(() => { + if (selectedArticleId) { + handleArticleClick(selectedArticleId); + } + }, [language]); + useEffect(() => { const handleKeyPress = async (event: KeyboardEvent) => { if (event.key.toLowerCase() === "enter") { @@ -273,6 +290,25 @@ export const SelectArticleModal = observer( fontSize: "24px", fontWeight: 700, lineHeight: "120%", + cursor: "pointer", + "&:hover": { + textDecoration: "underline", + }, + }} + onDoubleClick={async () => { + if (selectedArticleId) { + const media = await authInstance.get( + `/article/${selectedArticleId}/media` + ); + onSelectArticle( + selectedArticleId, + articlesStore.articleData?.heading || "", + articlesStore.articleData?.body || "", + media.data || [] + ); + onClose(); + setSelectedArticleId(null); + } }} > {articlesStore.articleData?.heading || "Название cтатьи"} diff --git a/src/shared/modals/UploadMediaDialog/index.tsx b/src/shared/modals/UploadMediaDialog/index.tsx index 5d598ae..4dc4d47 100644 --- a/src/shared/modals/UploadMediaDialog/index.tsx +++ b/src/shared/modals/UploadMediaDialog/index.tsx @@ -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 { useEffect, useState } from "react"; import { @@ -32,6 +37,16 @@ interface UploadMediaDialogProps { }) => void; afterUploadSight?: (id: string) => void; hardcodeType?: "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null; + contextObjectName?: string; + contextType?: + | "sight" + | "city" + | "carrier" + | "country" + | "vehicle" + | "station"; + isArticle?: boolean; + articleName?: string; } export const UploadMediaDialog = observer( @@ -41,6 +56,10 @@ export const UploadMediaDialog = observer( afterUpload, afterUploadSight, hardcodeType, + contextObjectName, + + isArticle, + articleName, }: UploadMediaDialogProps) => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -66,7 +85,7 @@ export const UploadMediaDialog = observer( setAvailableMediaTypes([6]); setMediaType(6); } - if (["jpg", "jpeg", "png", "gif"].includes(extension)) { + if (["jpg", "jpeg", "png", "gif", "svg"].includes(extension)) { // Для изображений доступны все типы кроме видео setAvailableMediaTypes([1, 3, 4, 5]); // Фото, Иконка, Водяной знак, Панорама, 3Д-модель setMediaType(1); // По умолчанию Фото @@ -76,8 +95,95 @@ export const UploadMediaDialog = observer( setMediaType(2); } } + + // Генерируем название по умолчанию если есть контекст + if (fileToUpload.name) { + let defaultName = ""; + + if (isArticle && articleName && contextObjectName) { + // Для статей: "Название достопримечательности_название файла_название статьи" + defaultName = generateDefaultMediaName( + contextObjectName, + fileToUpload.name, + articleName, + true + ); + } else if (contextObjectName && contextObjectName.trim() !== "") { + // Для обычных медиа с названием объекта + const currentMediaType = hardcodeType + ? MEDIA_TYPE_VALUES[hardcodeType] + : 1; // По умолчанию фото + defaultName = generateDefaultMediaName( + contextObjectName, + fileToUpload.name, + currentMediaType, + false + ); + } else { + // Для медиа без названия объекта + const currentMediaType = hardcodeType + ? MEDIA_TYPE_VALUES[hardcodeType] + : 1; // По умолчанию фото + defaultName = generateDefaultMediaName( + "", + fileToUpload.name, + currentMediaType, + false + ); + } + + setMediaName(defaultName); + } } - }, [fileToUpload]); + }, [fileToUpload, contextObjectName, hardcodeType, isArticle, articleName]); + + // Обновляем название при изменении типа медиа + useEffect(() => { + if (mediaFilename && mediaType > 0) { + let defaultName = ""; + + if (isArticle && articleName && contextObjectName) { + // Для статей: "Название достопримечательности_название файла_название статьи" + defaultName = generateDefaultMediaName( + contextObjectName, + mediaFilename, + articleName, + true + ); + } else if (contextObjectName && contextObjectName.trim() !== "") { + // Для обычных медиа с названием объекта + const currentMediaType = hardcodeType + ? MEDIA_TYPE_VALUES[hardcodeType] + : mediaType; + defaultName = generateDefaultMediaName( + contextObjectName, + mediaFilename, + currentMediaType, + false + ); + } else { + // Для медиа без названия объекта + const currentMediaType = hardcodeType + ? MEDIA_TYPE_VALUES[hardcodeType] + : mediaType; + defaultName = generateDefaultMediaName( + "", + mediaFilename, + currentMediaType, + false + ); + } + + setMediaName(defaultName); + } + }, [ + mediaType, + contextObjectName, + mediaFilename, + hardcodeType, + isArticle, + articleName, + ]); useEffect(() => { if (mediaFile) { @@ -141,7 +247,6 @@ export const UploadMediaDialog = observer( className="flex gap-4" dividers sx={{ - height: "600px", display: "flex", flexDirection: "column", gap: 2, @@ -200,13 +305,16 @@ export const UploadMediaDialog = observer( height: "100%", }} > - {/* */} + {mediaType == 2 && mediaUrl && ( +