fix: Update map with tables fixes

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

View File

@ -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 (
<div className="flex flex-col gap-10 w-full items-end">
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
<h1 className="text-3xl break-words">Создание статьи</h1>
<h1 className="text-3xl break-words">
{articleData?.ru?.heading || "Создание статьи"}
</h1>
</div>
<LanguageSwitcher />
<div className="flex items-center gap-4">

View File

@ -2,7 +2,7 @@ import React, { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { 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

View File

@ -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<string | null>(null);
const { language } = languageStore;
const [ids, setIds] = useState<number[]>([]);
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: () => (
<Box
sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}
>
{isLoading ? <CircularProgress size={20} /> : "Нет статей"}
</Box>
),
}}
/>
</div>
</div>

View File

@ -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(() => {
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={createCarrierData[language].full_name}
contextType="carrier"
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>

View File

@ -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 ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Обновить"
"Сохранить"
)}
</Button>
</div>
@ -259,6 +255,8 @@ export const CarrierEditPage = observer(() => {
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => 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}
/>
<DeleteModal
open={isDeleteLogoModalOpen}
onDelete={() => {
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
"",
language
);
setIsDeleteLogoModalOpen(false);
}}
onCancel={() => setIsDeleteLogoModalOpen(false)}
edit
/>
</Paper>
);
});

View File

@ -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<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
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 (
<div className="w-full h-full flex items-center">
{params.value ? (
cities[language].data.find((city) => city.id == params.value)
?.name
{city && city.name ? (
city.name
) : (
<Minus size={20} className="text-red-500" />
)}
@ -136,12 +144,24 @@ export const CarrierListPage = observer(() => {
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
checkboxSelection
onRowSelectionModelChange={(newSelection) => {
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: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? (
<CircularProgress size={20} />
) : (
"Нет перевозчиков"
)}
</Box>
),
}}
/>
</div>

View File

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

View File

@ -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");
}}
/>
</div>
@ -199,7 +198,7 @@ export const CityEditPage = observer(() => {
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Обновить"
"Сохранить"
)}
</Button>
</div>
@ -214,6 +213,8 @@ export const CityEditPage = observer(() => {
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={editCityData[language].name}
contextType="city"
afterUpload={handleMediaSelect}
hardcodeType={
activeMenuType as

View File

@ -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<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [rows, setRows] = useState<any[]>([]);
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 (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
countryStore.countries[language]?.data?.find(
(country) => country.code === params.value
)?.name
) : (
<Minus size={20} className="text-red-500" />
)}
@ -83,12 +118,6 @@ export const CityListPage = observer(() => {
},
];
const rows = cities[language]?.data?.map((city) => ({
id: city.id,
name: city.name,
country: city.country,
}));
return (
<>
<LanguageSwitcher />
@ -115,12 +144,20 @@ export const CityListPage = observer(() => {
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
checkboxSelection
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => {
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет городов"}
</Box>
),
}}
/>
</div>

View File

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

View File

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

View File

@ -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<string | null>(null);
const [ids, setIds] = useState<number[]>([]);
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 (
<div className="flex h-full gap-7 justify-center items-center">
<button
{/* <button
onClick={() => navigate(`/country/${params.row.code}/edit`)}
>
<Pencil size={20} className="text-blue-500" />
</button>
</button> */}
{/* <button onClick={() => navigate(`/country/${params.row.code}`)}>
<Eye size={20} className="text-green-500" />
</button> */}
@ -81,7 +89,7 @@ export const CountryListPage = observer(() => {
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Страны</h1>
<CreateButton label="Создать страну" path="/country/create" />
<CreateButton label="Добавить страну" path="/country/add" />
</div>
<div
@ -98,14 +106,22 @@ export const CountryListPage = observer(() => {
</div>
<DataGrid
rows={rows}
rows={rows || []}
columns={columns}
hideFooter
checkboxSelection
loading={isLoading}
localeText={ruRU.components.MuiDataGrid.defaultProps.localeText}
onRowSelectionModelChange={(newSelection) => {
console.log(newSelection);
setIds(Array.from(newSelection.ids as unknown as number[]));
}}
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет стран"}
</Box>
),
}}
/>
</div>

View File

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

View File

@ -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();

View File

@ -5,9 +5,12 @@ import {
Typography,
Alert,
CircularProgress,
FormControlLabel,
Checkbox,
Paper,
} from "@mui/material";
import { authStore } from "@shared";
import { useState } from "react";
import { authStore, userStore } from "@shared";
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
@ -15,9 +18,21 @@ export const LoginPage = () => {
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const { login } = authStore;
const { getUsers } = userStore;
useEffect(() => {
// Load saved credentials if they exist
const savedEmail = localStorage.getItem("rememberedEmail");
const savedPassword = localStorage.getItem("rememberedPassword");
if (savedEmail && savedPassword) {
setEmail(savedEmail);
setPassword(savedPassword);
setRememberMe(true);
}
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@ -26,7 +41,18 @@ export const LoginPage = () => {
try {
await login(email, password);
navigate("/sight");
// Save or clear credentials based on remember me checkbox
if (rememberMe) {
localStorage.setItem("rememberedEmail", email);
localStorage.setItem("rememberedPassword", password);
} else {
localStorage.removeItem("rememberedEmail");
localStorage.removeItem("rememberedPassword");
}
navigate("/map");
await getUsers();
toast.success("Вход в систему выполнен успешно");
} catch (err) {
setError(
@ -47,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",
}}
>
<Typography variant="h4" component="h1" gutterBottom>
Вход в систему
</Typography>
<Box
component="form"
onSubmit={handleSubmit}
<Paper
elevation={3}
sx={{
display: "flex",
flexDirection: "column",
gap: 2,
p: 4,
borderRadius: 2,
backgroundColor: "white",
width: "100%",
maxWidth: "400px",
}}
>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<TextField
label="Email"
type="email"
variant="outlined"
fullWidth
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
error={!!error}
/>
<TextField
label="Пароль"
type="password"
variant="outlined"
fullWidth
required
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
error={!!error}
/>
<Button
variant="contained"
color="primary"
size="large"
type="submit"
disabled={isLoading}
<Typography
variant="h4"
component="h1"
className="text-center pb-[50px]"
>
Вход в систему
</Typography>
<Box
component="form"
onSubmit={handleSubmit}
sx={{
width: "100%",
height: "50px",
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "10px",
flexDirection: "column",
gap: 2,
width: "100%",
}}
>
{isLoading ? <CircularProgress size={24} sx={{}} /> : "Войти"}
</Button>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<TextField
label="Email"
type="email"
variant="outlined"
fullWidth
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
error={!!error}
/>
<TextField
label="Пароль"
type="password"
variant="outlined"
fullWidth
required
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
error={!!error}
/>
<FormControlLabel
control={
<Checkbox
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
disabled={isLoading}
/>
}
label="Запомнить пароль"
/>
<Button
variant="contained"
color="primary"
size="large"
type="submit"
disabled={isLoading}
sx={{
width: "100%",
height: "50px",
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "10px",
}}
>
{isLoading ? <CircularProgress size={24} sx={{}} /> : "Войти"}
</Button>
</Box>
</Paper>
</Box>
);
};

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@ -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<T>(arr: T[], pos: number, value: T): T[] {
@ -68,6 +74,7 @@ type LinkedItemsProps<T> = {
updatedLinkedItems?: T[];
refresh?: number;
cityId?: number;
routeDirection?: boolean;
};
export const LinkedItems = <
@ -118,6 +125,7 @@ export const LinkedItemsContents = <
updatedLinkedItems,
refresh,
cityId,
routeDirection,
}: LinkedItemsProps<T>) => {
const { language } = languageStore;
@ -127,6 +135,10 @@ export const LinkedItemsContents = <
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [activeTab, setActiveTab] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
console.log(error);
@ -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 && (
<TableCell {...provided.dragHandleProps}>
@ -358,72 +433,169 @@ export const LinkedItemsContents = <
{type === "edit" && !disableCreation && (
<Stack gap={2} mt={2}>
<Typography variant="subtitle1">Добавить станцию</Typography>
<Autocomplete
fullWidth
value={
availableItems?.find((item) => item.id === selectedItemId) || null
}
onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)}
options={availableItems.filter(
(item) => !cityId || item.city_id == cityId
)}
getOptionLabel={(item) => String(item.name)}
renderInput={(params) => (
<TextField {...params} label="Выберите станцию" fullWidth />
)}
isOptionEqualToValue={(option, value) => option.id === value?.id}
filterOptions={(options, { inputValue }) => {
const searchWords = inputValue
.toLowerCase()
.split(" ")
.filter(Boolean);
return options.filter((option) => {
const optionWords = String(option.name)
.toLowerCase()
.split(" ");
return searchWords.every((searchWord) =>
optionWords.some((word) => word.startsWith(searchWord))
);
});
}}
renderOption={(props, option) => (
<li {...props} key={option.id}>
{String(option.name)}
</li>
)}
/>
<Typography variant="subtitle1">Добавить остановки</Typography>
{routeDirection !== undefined && (
<Typography variant="body2" color="textSecondary">
Показываются только остановки для{" "}
{routeDirection ? "прямого" : "обратного"} направления
</Typography>
)}
<FormControl fullWidth>
<TextField
type="number"
label="Позиция добавляемой остановки"
value={position}
onChange={(e) => {
const newValue = Math.max(1, Number(e.target.value));
setPosition(
newValue > linkedItems.length + 1
? linkedItems.length + 1
: newValue
);
}}
InputProps={{
inputProps: { min: 1, max: linkedItems.length + 1 },
}}
fullWidth
/>
</FormControl>
<Button
variant="contained"
onClick={linkItem}
disabled={!selectedItemId}
sx={{ alignSelf: "flex-start" }}
<Tabs
value={activeTab}
onChange={(_, newValue) => setActiveTab(newValue)}
>
Добавить
</Button>
<Tab label="По одной" />
<Tab label="Массово" />
</Tabs>
<Box sx={{ mt: 2 }}>
{activeTab === 0 && (
<Stack gap={2}>
<Autocomplete
fullWidth
value={
availableItems?.find(
(item) => item.id === selectedItemId
) || null
}
onChange={(_, newValue) =>
setSelectedItemId(newValue?.id || null)
}
options={availableItems.filter(
(item) => !cityId || item.city_id == cityId
)}
getOptionLabel={(item) => String(item.name)}
renderInput={(params) => (
<TextField
{...params}
label="Выберите остановку"
fullWidth
/>
)}
isOptionEqualToValue={(option, value) =>
option.id === value?.id
}
filterOptions={(options, { inputValue }) => {
const searchWords = inputValue
.toLowerCase()
.split(" ")
.filter(Boolean);
return options.filter((option) => {
const optionWords = String(option.name)
.toLowerCase()
.split(" ");
return searchWords.every((searchWord) =>
optionWords.some((word) => word.startsWith(searchWord))
);
});
}}
renderOption={(props, option) => (
<li {...props} key={option.id}>
<div className="flex justify-between items-center w-full">
<p>{String(option.name)}</p>
<p className="text-xs text-gray-500 max-w-[300px] mr-4 truncate">
{String(option.description)}
</p>
</div>
</li>
)}
/>
<FormControl fullWidth>
<TextField
type="number"
label="Позиция добавляемой остановки"
value={position}
onChange={(e) => {
const newValue = Math.max(1, Number(e.target.value));
setPosition(
newValue > linkedItems.length + 1
? linkedItems.length + 1
: newValue
);
}}
InputProps={{
inputProps: { min: 1, max: linkedItems.length + 1 },
}}
fullWidth
/>
</FormControl>
<Button
variant="contained"
onClick={linkItem}
disabled={!selectedItemId}
sx={{ alignSelf: "flex-start" }}
>
Добавить
</Button>
</Stack>
)}
{activeTab === 1 && (
<Stack gap={2}>
{/* Поле поиска */}
<TextField
fullWidth
label="Поиск остановок"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Введите название остановки..."
size="small"
sx={{ mb: 1 }}
/>
{/* Список доступных остановок с чекбоксами */}
<Paper sx={{ maxHeight: 300, overflow: "auto", p: 2 }}>
<Stack gap={1}>
{filteredAvailableItems.map((item) => (
<FormControlLabel
key={item.id}
control={
<Checkbox
checked={selectedItems.has(item.id)}
onChange={() => handleCheckboxChange(item.id)}
size="small"
/>
}
label={String(item.name)}
sx={{
margin: 0,
"& .MuiFormControlLabel-label": {
fontSize: "0.875rem",
},
}}
/>
))}
{filteredAvailableItems.length === 0 && (
<Typography
color="textSecondary"
textAlign="center"
py={1}
>
{searchQuery.trim()
? "Остановки не найдены"
: "Нет доступных остановок"}
</Typography>
)}
</Stack>
</Paper>
<Button
variant="contained"
onClick={handleBulkLink}
disabled={selectedItems.size === 0}
sx={{ alignSelf: "flex-start" }}
>
Добавить выбранные ({selectedItems.size})
</Button>
</Stack>
)}
</Box>
</Stack>
)}
<EditStationModal open={isModalOpen} onClose={handleCloseModal} />
</>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
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(() => {
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Станции</h1>
<CreateButton label="Создать станцию" path="/station/create" />
<CreateButton label="Создать остановки" path="/station/create" />
</div>
<div
@ -136,10 +144,19 @@ 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: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет станций"}
</Box>
),
}}
/>
</div>

View File

@ -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(() => {
<p>{stationPreview[id!]?.[language]?.data.description}</p>
</div>
)}
{id && (
<LinkedSights
parentId={Number(id)}
fields={[
{ label: "Название", data: "name" },
{ label: "Описание", data: "description" },
]}
type="show"
/>
)}
</div>
</Paper>
);

View File

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

View File

@ -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 ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Обновить"
"Сохранить"
)}
</Button>
</div>

View File

@ -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<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
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: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? (
<CircularProgress size={20} />
) : (
"Нет пользователей"
)}
</Box>
),
}}
/>
</div>

View File

@ -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));

View File

@ -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<number | null>(null);
const [ids, setIds] = useState<number[]>([]);
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: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? (
<CircularProgress size={20} />
) : (
"Нет транспортных средств"
)}
</Box>
),
}}
/>
</div>