feat: Improving page loading

This commit is contained in:
2025-11-20 20:17:52 +03:00
parent 6f32c6e671
commit 85c71563c1
17 changed files with 545 additions and 273 deletions

View File

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

View File

@@ -6,13 +6,20 @@ import {
MenuItem, MenuItem,
FormControl, FormControl,
InputLabel, InputLabel,
Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { carrierStore, cityStore, mediaStore, languageStore } from "@shared"; import {
carrierStore,
cityStore,
mediaStore,
languageStore,
LoadingSpinner,
} from "@shared";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets"; import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets";
import { import {
@@ -28,6 +35,7 @@ export const CarrierEditPage = observer(() => {
const { language } = languageStore; const { language } = languageStore;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
@@ -39,39 +47,48 @@ export const CarrierEditPage = observer(() => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
await cityStore.getCities("ru"); if (!id) {
await cityStore.getCities("en"); setIsLoadingData(false);
await cityStore.getCities("zh"); return;
const carrierData = await getCarrier(Number(id));
if (carrierData) {
setEditCarrierData(
carrierData.ru?.full_name || "",
carrierData.ru?.short_name || "",
carrierData.ru?.city_id || 0,
carrierData.ru?.slogan || "",
carrierData.ru?.logo || "",
"ru"
);
setEditCarrierData(
carrierData.en?.full_name || "",
carrierData.en?.short_name || "",
carrierData.en?.city_id || 0,
carrierData.en?.slogan || "",
carrierData.en?.logo || "",
"en"
);
setEditCarrierData(
carrierData.zh?.full_name || "",
carrierData.zh?.short_name || "",
carrierData.zh?.city_id || 0,
carrierData.zh?.slogan || "",
carrierData.zh?.logo || "",
"zh"
);
} }
setIsLoadingData(true);
try {
await cityStore.getCities("ru");
await cityStore.getCities("en");
await cityStore.getCities("zh");
const carrierData = await getCarrier(Number(id));
mediaStore.getMedia(); if (carrierData) {
setEditCarrierData(
carrierData.ru?.full_name || "",
carrierData.ru?.short_name || "",
carrierData.ru?.city_id || 0,
carrierData.ru?.slogan || "",
carrierData.ru?.logo || "",
"ru"
);
setEditCarrierData(
carrierData.en?.full_name || "",
carrierData.en?.short_name || "",
carrierData.en?.city_id || 0,
carrierData.en?.slogan || "",
carrierData.en?.logo || "",
"en"
);
setEditCarrierData(
carrierData.zh?.full_name || "",
carrierData.zh?.short_name || "",
carrierData.zh?.city_id || 0,
carrierData.zh?.slogan || "",
carrierData.zh?.logo || "",
"zh"
);
}
await mediaStore.getMedia();
} finally {
setIsLoadingData(false);
}
})(); })();
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
@@ -110,6 +127,21 @@ export const CarrierEditPage = observer(() => {
? mediaStore.media.find((m) => m.id === editCarrierData.logo) ? mediaStore.media.find((m) => m.id === editCarrierData.logo)
: null; : null;
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных перевозчика..." />
</Box>
);
}
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher /> <LanguageSwitcher />

View File

@@ -6,6 +6,7 @@ import {
MenuItem, MenuItem,
FormControl, FormControl,
InputLabel, InputLabel,
Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
@@ -18,6 +19,7 @@ import {
languageStore, languageStore,
mediaStore, mediaStore,
CashedCities, CashedCities,
LoadingSpinner,
} from "@shared"; } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LanguageSwitcher, ImageUploadCard } from "@widgets"; import { LanguageSwitcher, ImageUploadCard } from "@widgets";
@@ -30,6 +32,7 @@ import {
export const CityEditPage = observer(() => { export const CityEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
@@ -62,19 +65,26 @@ export const CityEditPage = observer(() => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
await getCountries("ru"); setIsLoadingData(true);
try {
await getCountries("ru");
const ruData = await getCity(id as string, "ru"); const ruData = await getCity(id as string, "ru");
const enData = await getCity(id as string, "en"); const enData = await getCity(id as string, "en");
const zhData = await getCity(id as string, "zh"); const zhData = await getCity(id as string, "zh");
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru"); setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
setEditCityData(enData.name, enData.country_code, enData.arms, "en"); setEditCityData(enData.name, enData.country_code, enData.arms, "en");
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh"); setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
await getOneMedia(ruData.arms as string); await getOneMedia(ruData.arms as string);
await getMedia(); await getMedia();
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
} }
})(); })();
}, [id]); }, [id]);
@@ -97,6 +107,21 @@ export const CityEditPage = observer(() => {
? mediaStore.media.find((m) => m.id === editCityData.arms) ? mediaStore.media.find((m) => m.id === editCityData.arms)
: null; : null;
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных города..." />
</Box>
);
}
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher /> <LanguageSwitcher />

View File

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

View File

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

View File

@@ -21,6 +21,7 @@ import {
mediaStore, mediaStore,
MEDIA_TYPE_LABELS, MEDIA_TYPE_LABELS,
languageStore, languageStore,
LoadingSpinner,
} from "@shared"; } from "@shared";
import { MediaViewer } from "@widgets"; import { MediaViewer } from "@widgets";
@@ -138,8 +139,15 @@ export const MediaEditPage = observer(() => {
if (!media && id) { if (!media && id) {
return ( return (
<Box className="flex justify-center items-center h-screen"> <Box
<CircularProgress /> sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных медиа..." />
</Box> </Box>
); );
} }

View File

@@ -27,6 +27,7 @@ import {
ArticleSelectOrCreateDialog, ArticleSelectOrCreateDialog,
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
LoadingSpinner,
} from "@shared"; } from "@shared";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { stationsStore } from "@shared"; import { stationsStore } from "@shared";
@@ -37,6 +38,7 @@ export const RouteEditPage = observer(() => {
const { id } = useParams(); const { id } = useParams();
const { editRouteData, copyRouteAction } = routeStore; const { editRouteData, copyRouteAction } = routeStore;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] = const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
useState(false); useState(false);
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false); const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
@@ -48,18 +50,27 @@ export const RouteEditPage = observer(() => {
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
const response = await routeStore.getRoute(Number(id)); if (!id) {
routeStore.setEditRouteData(response); setIsLoadingData(false);
languageStore.setLanguage("ru"); return;
}
setIsLoadingData(true);
try {
const response = await routeStore.getRoute(Number(id));
routeStore.setEditRouteData(response);
languageStore.setLanguage("ru");
} finally {
setIsLoadingData(false);
}
}; };
fetchData(); fetchData();
}, []); }, [id]);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
carrierStore.getCarriers(language); await carrierStore.getCarriers(language);
stationsStore.getStations(); await stationsStore.getStations();
articlesStore.getArticleList(); await articlesStore.getArticleList();
}; };
fetchData(); fetchData();
}, [id, language]); }, [id, language]);
@@ -233,6 +244,21 @@ export const RouteEditPage = observer(() => {
(article) => article.id === editRouteData.governor_appeal (article) => article.id === editRouteData.governor_appeal
); );
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных маршрута..." />
</Box>
);
}
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">

View File

@@ -6,13 +6,19 @@ import {
MenuItem, MenuItem,
FormControl, FormControl,
InputLabel, InputLabel,
Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { stationsStore, languageStore, cityStore } from "@shared"; import {
stationsStore,
languageStore,
cityStore,
LoadingSpinner,
} from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets"; import { LanguageSwitcher } from "@widgets";
import { LinkedSights } from "../LinkedSights"; import { LinkedSights } from "../LinkedSights";
@@ -21,6 +27,7 @@ import { SaveWithoutCityAgree } from "@widgets";
export const StationEditPage = observer(() => { export const StationEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const { language } = languageStore; const { language } = languageStore;
const { id } = useParams(); const { id } = useParams();
const { const {
@@ -90,18 +97,41 @@ export const StationEditPage = observer(() => {
useEffect(() => { useEffect(() => {
const fetchAndSetStationData = async () => { const fetchAndSetStationData = async () => {
if (!id) return; if (!id) {
setIsLoadingData(false);
return;
}
const stationId = Number(id); setIsLoadingData(true);
await getEditStation(stationId); try {
await getCities("ru"); const stationId = Number(id);
await getCities("en"); await getEditStation(stationId);
await getCities("zh"); await getCities("ru");
await getCities("en");
await getCities("zh");
} finally {
setIsLoadingData(false);
}
}; };
fetchAndSetStationData(); fetchAndSetStationData();
}, [id]); }, [id]);
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных станции..." />
</Box>
);
}
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher /> <LanguageSwitcher />

View File

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

View File

@@ -4,18 +4,20 @@ import {
Checkbox, Checkbox,
Paper, Paper,
TextField, TextField,
Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { userStore, languageStore } from "@shared"; import { userStore, languageStore, LoadingSpinner } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export const UserEditPage = observer(() => { export const UserEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const { id } = useParams(); const { id } = useParams();
const { editUserData, editUser, getUser, setEditUserData } = userStore; const { editUserData, editUser, getUser, setEditUserData } = userStore;
@@ -41,18 +43,40 @@ export const UserEditPage = observer(() => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
const data = await getUser(Number(id)); setIsLoadingData(true);
try {
const data = await getUser(Number(id));
setEditUserData( setEditUserData(
data?.name || "", data?.name || "",
data?.email || "", data?.email || "",
data?.password || "", data?.password || "",
data?.is_admin || false data?.is_admin || false
); );
} finally {
setIsLoadingData(false);
}
} else {
setIsLoadingData(false);
} }
})(); })();
}, [id]); }, [id]);
if (isLoadingData) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных пользователя..." />
</Box>
);
}
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">

View File

@@ -86,28 +86,35 @@ class EditSightStore {
} }
hasLoadedCommon = false; hasLoadedCommon = false;
isLoading = false;
getSightInfo = async (id: number, language: Language) => { getSightInfo = async (id: number, language: Language) => {
const response = await languageInstance(language).get(`/sight/${id}`); this.isLoading = true;
const data = response.data; try {
const response = await languageInstance(language).get(`/sight/${id}`);
const data = response.data;
if (data.left_article != 0 && data.left_article != null) { if (data.left_article != 0 && data.left_article != null) {
await this.getLeftArticle(data.left_article); await this.getLeftArticle(data.left_article);
} }
runInAction(() => { runInAction(() => {
this.sight[language] = { this.sight[language] = {
...this.sight[language], ...this.sight[language],
...data,
};
if (!this.hasLoadedCommon) {
this.sight.common = {
...this.sight.common,
...data, ...data,
}; };
this.hasLoadedCommon = true;
} if (!this.hasLoadedCommon) {
}); this.sight.common = {
...this.sight.common,
...data,
};
this.hasLoadedCommon = true;
}
});
} finally {
this.isLoading = false;
}
}; };
updateLeftInfo = (language: Language, heading: string, body: string) => { updateLeftInfo = (language: Language, heading: string, body: string) => {
@@ -168,6 +175,8 @@ class EditSightStore {
clearSightInfo = () => { clearSightInfo = () => {
this.needLeaveAgree = false; this.needLeaveAgree = false;
this.hasLoadedCommon = false;
this.isLoading = false;
this.sight = { this.sight = {
common: { common: {
id: 0, id: 0,

View File

@@ -3,3 +3,4 @@ export * from "./BackButton";
export * from "./Modal"; export * from "./Modal";
export * from "./CoordinatesInput"; export * from "./CoordinatesInput";
export * from "./AnimatedCircleButton"; export * from "./AnimatedCircleButton";
export * from "./LoadingSpinner";

View File

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

View File

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

View File

@@ -127,8 +127,8 @@ export function MediaViewer({
src={`${import.meta.env.VITE_KRBL_MEDIA}${ src={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id media?.id
}/download?token=${token}`} }/download?token=${token}`}
width={width ? width : "500px"} width={fullWidth ? "100%" : width ? width : "500px"}
height={height ? height : "300px"} height={fullHeight ? "100%" : height ? height : "300px"}
/> />
)} )}

View File

@@ -434,49 +434,61 @@ export const CreateRightTab = observer(
</Box> </Box>
) : type === "media" ? ( ) : type === "media" ? (
<Box className="w-[80%] border border-gray-300 rounded-2xl relative flex items-center justify-center"> <Box className="w-[80%] border border-gray-300 rounded-2xl relative flex items-center justify-center">
{sight.preview_media && ( <>
<> {type === "media" && (
{type === "media" && ( <Box className="w-[80%] h-full rounded-2xl relative flex items-center justify-center">
<Box className="w-[80%] h-full rounded-2xl relative flex items-center justify-center"> {previewMedia && (
{previewMedia && ( <>
<> <Box className="absolute top-4 right-4 z-10">
<Box className="absolute top-4 right-4 z-10"> <button
<button className="w-10 h-10 flex items-center justify-center z-10"
className="w-10 h-10 flex items-center justify-center z-10" onClick={handleUnlinkPreviewMedia}
onClick={handleUnlinkPreviewMedia} >
> <X size={20} color="red" />
<X size={20} color="red" /> </button>
</button> </Box>
</Box>
<Box className="w-1/2 h-1/2"> <Box className="w-1/2 h-1/2">
<MediaViewer <MediaViewer
media={{ media={{
id: previewMedia.id || "", id: previewMedia.id || "",
media_type: previewMedia.media_type, media_type: previewMedia.media_type,
filename: previewMedia.filename || "", filename: previewMedia.filename || "",
}} }}
fullWidth fullWidth
fullHeight fullHeight
/> />
</Box> </Box>
</> </>
)} )}
</Box>
)} {!previewMedia && (
</> <Box className="w-full h-full flex justify-center items-center">
)} <Box
{!previewMedia && ( sx={{
<MediaAreaForSight maxWidth: "500px",
onFinishUpload={(mediaId) => { maxHeight: "100%",
linkPreviewMedia(mediaId); display: "flex",
}} flexGrow: 1,
onFilesDrop={() => {}} margin: "0 auto",
contextObjectName={sight[language].name} justifyContent: "center",
contextType="sight" }}
isArticle={false} >
/> <MediaAreaForSight
)} onFinishUpload={(mediaId) => {
linkPreviewMedia(mediaId);
}}
onFilesDrop={() => {}}
contextObjectName={sight[language].name}
contextType="sight"
isArticle={false}
/>
</Box>
</Box>
)}
</Box>
)}
</>
</Box> </Box>
) : ( ) : (
<Box className="w-[80%] h-[70vh] border border-gray-300 rounded-2xl p-3 flex justify-center items-center"> <Box className="w-[80%] h-[70vh] border border-gray-300 rounded-2xl p-3 flex justify-center items-center">

View File

@@ -415,21 +415,12 @@ export const RightWidgetTab = observer(
media_type: previewMedia.media_type, media_type: previewMedia.media_type,
filename: previewMedia.filename || "", filename: previewMedia.filename || "",
}} }}
fullWidth
fullHeight
/> />
</Box> </Box>
</> </>
)} )}
{!previewMedia && (
<MediaAreaForSight
onFinishUpload={(mediaId) => {
linkPreviewMedia(mediaId);
}}
onFilesDrop={() => {}}
contextObjectName={sight[language].name}
contextType="sight"
isArticle={false}
/>
)}
</Box> </Box>
)} )}
</> </>