From 60c6840db44ded39a99a464a28d32763e7a0ca1f Mon Sep 17 00:00:00 2001 From: itoshi Date: Tue, 28 Apr 2026 03:50:29 +0300 Subject: [PATCH] feat: cache delete + empty snapshot + route page --- src/client/src/api/ApiStore/store.ts | 22 +- .../components/ListOfSights/SightFrame.jsx | 10 + .../src/components/SimulationSettings.tsx | 16 +- src/client/src/utils/animationUtils.ts | 52 ++ src/pages/City/CityCreatePage/index.tsx | 11 +- src/pages/City/CityEditPage/index.tsx | 12 +- src/pages/CreateSightPage/index.tsx | 12 +- src/pages/Route/RouteListPage/index.tsx | 47 +- .../WebGLRouteMapPrototype.tsx | 24 +- src/pages/Snapshot/SnapshotListPage/index.tsx | 74 ++- src/shared/config/constants.tsx | 13 +- src/shared/store/CityStore/index.ts | 19 +- src/shared/store/CreateSightStore/index.tsx | 55 +- src/shared/store/RouteStore/index.ts | 43 ++ src/shared/store/SnapshotStore/index.ts | 50 ++ .../SightTabs/CreateInformationTab/index.tsx | 8 +- src/widgets/SightTabs/CreateLeftTab/index.tsx | 5 +- .../SightTabs/CreateRightTab/index.tsx | 479 ++++++++---------- .../RightWidgetTab/SightFramePreview.tsx | 27 +- .../SightTabs/RightWidgetTab/index.tsx | 149 ++++-- src/widgets/TestingModeBanner/index.tsx | 3 +- 21 files changed, 770 insertions(+), 361 deletions(-) diff --git a/src/client/src/api/ApiStore/store.ts b/src/client/src/api/ApiStore/store.ts index 91129bd..c04bcae 100644 --- a/src/client/src/api/ApiStore/store.ts +++ b/src/client/src/api/ApiStore/store.ts @@ -23,6 +23,7 @@ import { } from "./types"; // @ts-ignore import { orderStationsByRoute } from "../../utils/routeStationsUtils"; +import { resamplePath } from "../../utils/animationUtils"; class ApiStore { isLoading = true; @@ -88,7 +89,26 @@ class ApiStore { }; getRoute = async () => { - this.route = await getRoute(this.routeId!); + const route = await getRoute(this.routeId!); + if (route.path && route.path.length > 1) { + // Рассчитываем общую дистанцию для выбора адекватного шага ресемплинга + let totalDist = 0; + for (let i = 0; i < route.path.length - 1; i++) { + const p1 = route.path[i]; + const p2 = route.path[i + 1]; + totalDist += Math.sqrt( + Math.pow(p2[0] - p1[0], 2) + Math.pow(p2[1] - p1[1], 2) + ); + } + // Хотим иметь примерно 2000 точек для равномерности и плавности + const segmentLength = totalDist / 2000; + if (segmentLength > 0) { + route.path = resamplePath(route.path as [number, number][], segmentLength); + } + } + runInAction(() => { + this.route = route; + }); this.updateOrderedRouteStations(); }; diff --git a/src/client/src/components/ListOfSights/SightFrame.jsx b/src/client/src/components/ListOfSights/SightFrame.jsx index c65b0f5..7a42aa2 100644 --- a/src/client/src/components/ListOfSights/SightFrame.jsx +++ b/src/client/src/components/ListOfSights/SightFrame.jsx @@ -428,6 +428,16 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => { const processedSightName = useMemo(() => { if (!sight_name) return sight_name; + // Handle \n line breaks (только в правом виджете) + if (sight_name.includes("\n")) { + return sight_name.split("\n").map((line, i) => ( + + {i > 0 &&
} + {line} +
+ )); + } + const namePattern = /([А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]+)/g; diff --git a/src/client/src/components/SimulationSettings.tsx b/src/client/src/components/SimulationSettings.tsx index 524c399..ca59aac 100644 --- a/src/client/src/components/SimulationSettings.tsx +++ b/src/client/src/components/SimulationSettings.tsx @@ -6,22 +6,30 @@ export const SimulationSettings = observer(() => { const [open, setOpen] = useState(false); return ( -
+
{open && (
diff --git a/src/client/src/utils/animationUtils.ts b/src/client/src/utils/animationUtils.ts index 366a079..0b01130 100644 --- a/src/client/src/utils/animationUtils.ts +++ b/src/client/src/utils/animationUtils.ts @@ -134,6 +134,58 @@ export class PositionAnimator { }; } +/** + * Передискретизация пути для обеспечения равномерного расстояния между точками + * @param path - массив [lat, lon] или [x, y] + * @param segmentLength - желаемое расстояние между точками (в единицах координат) + * @returns новый массив точек + */ +export const resamplePath = (path: T[], segmentLength: number): T[] => { + if (path.length < 2) return path; + + const newPath: T[] = [path[0]]; + let leftover = 0; + + for (let i = 0; i < path.length - 1; i++) { + const p1 = path[i]; + const p2 = path[i + 1]; + const dx = p2[0] - p1[0]; + const dy = p2[1] - p1[1]; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist === 0) continue; + + let currentDist = segmentLength - leftover; + while (currentDist <= dist) { + const t = currentDist / dist; + const point = new Array(p1.length) as T; + for (let j = 0; j < p1.length; j++) { + point[j] = p1[j] + (p2[j] - p1[j]) * t; + } + newPath.push(point); + currentDist += segmentLength; + } + leftover = dist - (currentDist - segmentLength); + } + + // Добавляем последнюю точку, если она существенно отличается от последней добавленной + const lastP = path[path.length - 1]; + const lastNewP = newPath[newPath.length - 1]; + let isDifferent = false; + for (let j = 0; j < lastP.length; j++) { + if (Math.abs(lastP[j] - lastNewP[j]) > 0.0000001) { + isDifferent = true; + break; + } + } + + if (isDifferent) { + newPath.push(lastP); + } + + return newPath; +}; + /** * Класс для анимации по полярным координатам * Основано на логике анимации из HTML файла (a.html) diff --git a/src/pages/City/CityCreatePage/index.tsx b/src/pages/City/CityCreatePage/index.tsx index 76f5548..7040f3e 100644 --- a/src/pages/City/CityCreatePage/index.tsx +++ b/src/pages/City/CityCreatePage/index.tsx @@ -28,7 +28,7 @@ import { LanguageSwitcher, ImageUploadCard } from "@widgets"; export const CityCreatePage = observer(() => { const navigate = useNavigate(); const { language } = languageStore; - const { createCityData, setCreateCityData } = cityStore; + const { createCityData, setCreateCityData, setCreateCityWeatherCode } = cityStore; const [isLoading, setIsLoading] = useState(false); const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); @@ -139,6 +139,15 @@ export const CityCreatePage = observer(() => { + setCreateCityWeatherCode(Number(e.target.value))} + /> +
{ >(null); const { language } = languageStore; const { id } = useParams(); - const { editCityData, editCity, getCity, setEditCityData } = cityStore; + const { editCityData, editCity, getCity, setEditCityData, setEditCityWeatherCode } = cityStore; const { getCountries } = countryStore; const { getMedia, getOneMedia } = mediaStore; @@ -74,6 +74,7 @@ export const CityEditPage = observer(() => { setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru"); setEditCityData(enData.name, enData.country_code, enData.arms, "en"); setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh"); + setEditCityWeatherCode(ruData.weather_city_code ?? 0); await getOneMedia(ruData.arms as string); @@ -179,6 +180,15 @@ export const CityEditPage = observer(() => { + setEditCityWeatherCode(Number(e.target.value))} + /> +
{ const [value, setValue] = useState(0); const { getCities } = cityStore; const { getArticles } = articlesStore; - const { needLeaveAgree } = createSightStore; + const needLeave = createSightStore.needLeaveAgree; const handleChange = (_: React.SyntheticEvent, newValue: number) => { setValue(newValue); @@ -36,9 +36,15 @@ export const CreateSightPage = observer(() => { let blocker = useBlocker( ({ currentLocation, nextLocation }) => - needLeaveAgree && currentLocation.pathname !== nextLocation.pathname + needLeave && currentLocation.pathname !== nextLocation.pathname, ); + useEffect(() => { + if (blocker.state === "blocked" && !needLeave) { + blocker.proceed(); + } + }, [blocker.state, needLeave]); + useEffect(() => { const fetchData = async () => { if (!authStore.me) { diff --git a/src/pages/Route/RouteListPage/index.tsx b/src/pages/Route/RouteListPage/index.tsx index c18634c..d2390d4 100644 --- a/src/pages/Route/RouteListPage/index.tsx +++ b/src/pages/Route/RouteListPage/index.tsx @@ -6,10 +6,10 @@ import { observer } from "mobx-react-lite"; import { Map, Pencil, Trash2, Minus, Monitor } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; -import { Box, CircularProgress } from "@mui/material"; +import { Box, CircularProgress, Tooltip } from "@mui/material"; export const RouteListPage = observer(() => { - const { routes, getRoutes, deleteRoute } = routeStore; + const { routes, getRoutes, deleteRoute, sightCounts, stationCounts, countsLoading, loadCounts } = routeStore; const { carriers, getCarriers } = carrierStore; const navigate = useNavigate(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -38,6 +38,9 @@ export const RouteListPage = observer(() => { await getCarriers("zh"); await getRoutes(); setIsLoading(false); + + const routeIds = routeStore.routes.data.map((r) => r.id); + loadCounts(routeIds); }; fetchData(); }, [language]); @@ -128,6 +131,42 @@ export const RouteListPage = observer(() => { ); }, }, + { + field: "sightCount", + headerName: "Достопримечательности", + width: 180, + align: "center" as const, + headerAlign: "center" as const, + sortable: true, + renderHeader: (params: any) => ( + + {params.colDef.headerName} + + ), + renderCell: (params: GridRenderCellParams) => ( +
+ {params.value === null ? : params.value} +
+ ), + }, + { + field: "stationCount", + headerName: "Остановки", + width: 120, + align: "center" as const, + headerAlign: "center" as const, + sortable: true, + renderHeader: (params: any) => ( + + {params.colDef.headerName} + + ), + renderCell: (params: GridRenderCellParams) => ( +
+ {params.value === null ? : params.value} +
+ ), + }, ...(canShowActionsColumn ? [{ field: "actions", headerName: "Действия", @@ -195,8 +234,10 @@ export const RouteListPage = observer(() => { route_sys_number: route.route_sys_number, route_direction: route.route_direction ? "Прямой" : "Обратный", route_name: route.route_name, + sightCount: sightCounts.has(route.id) ? sightCounts.get(route.id) : null, + stationCount: stationCounts.has(route.id) ? stationCounts.get(route.id) : null, })); - }, [routes.data, carriers["ru"].data, selectedCityStore.selectedCityId, searchQuery]); + }, [routes.data, carriers["ru"].data, selectedCityStore.selectedCityId, searchQuery, sightCounts.size, stationCounts.size, countsLoading]); return ( <> diff --git a/src/pages/Route/route-preview/webgl-prototype/WebGLRouteMapPrototype.tsx b/src/pages/Route/route-preview/webgl-prototype/WebGLRouteMapPrototype.tsx index b17c484..b473eb9 100644 --- a/src/pages/Route/route-preview/webgl-prototype/WebGLRouteMapPrototype.tsx +++ b/src/pages/Route/route-preview/webgl-prototype/WebGLRouteMapPrototype.tsx @@ -1910,7 +1910,8 @@ export const WebGLRouteMapPrototype = observer(() => { const stationIconSizePercent = liveStationIconSizes.get(station.id) ?? - (typeof station.icon_size === "number" && Number.isFinite(station.icon_size) + (typeof station.icon_size === "number" && + Number.isFinite(station.icon_size) ? station.icon_size : 100); const iconSizePx = Math.max( @@ -2277,6 +2278,7 @@ export const WebGLRouteMapPrototype = observer(() => { position: "absolute", inset: 0, pointerEvents: "none", + zIndex: 1, }} > {stationData.ru.map((station, index) => { @@ -2706,13 +2708,14 @@ export const WebGLRouteMapPrototype = observer(() => { ? camera.scale / Math.max(customSightIconBaseScaleRef.current ?? 1, 1e-6) : 1; - const sightIconSizePercent = sight.is_default_icon === false - ? (liveSightIconSizes.get(sight.id) ?? - (typeof sight.icon_size === "number" && - Number.isFinite(sight.icon_size) - ? sight.icon_size - : 100)) - : (routeData?.icon_size ?? originalRouteData?.icon_size ?? 100); + const sightIconSizePercent = + sight.is_default_icon === false + ? (liveSightIconSizes.get(sight.id) ?? + (typeof sight.icon_size === "number" && + Number.isFinite(sight.icon_size) + ? sight.icon_size + : 100)) + : (routeData?.icon_size ?? originalRouteData?.icon_size ?? 100); const iconSize = 30 * clamp(sightIconSizePercent / 100, 0.1, 10) * @@ -2723,7 +2726,10 @@ export const WebGLRouteMapPrototype = observer(() => { resizingSightIconId === sight.id); const iconLeft = cssX - iconSize; const iconTop = cssY - iconSize; - const sightZoomClampedScale = Math.min(Math.max(camera.scale, 1), 3); + const sightZoomClampedScale = Math.min( + Math.max(camera.scale, 1), + 3, + ); const sightScaleFactor = 1 + (sightZoomClampedScale - 1) * 0.4; const labelHeight = 24 * sightScaleFactor; const labelPadding = 6 * sightScaleFactor; diff --git a/src/pages/Snapshot/SnapshotListPage/index.tsx b/src/pages/Snapshot/SnapshotListPage/index.tsx index 6d8068c..d0d5871 100644 --- a/src/pages/Snapshot/SnapshotListPage/index.tsx +++ b/src/pages/Snapshot/SnapshotListPage/index.tsx @@ -5,7 +5,7 @@ import { useEffect, useState, useMemo } from "react"; import { observer } from "mobx-react-lite"; import { DatabaseBackup, Trash2 } from "lucide-react"; import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets"; -import { Alert, Box, CircularProgress } from "@mui/material"; +import { Alert, Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from "@mui/material"; const LOW_STORAGE_THRESHOLD_GB = 10; @@ -30,6 +30,7 @@ export const SnapshotListPage = observer(() => { restoreSnapshot, storageInfo, getStorageInfo, + createEmptySnapshot, } = snapshotStore; const canWriteDevices = authStore.canWrite("devices"); const canCreateSnapshot = @@ -42,6 +43,9 @@ export const SnapshotListPage = observer(() => { const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const [isEmptySnapshotModalOpen, setIsEmptySnapshotModalOpen] = useState(false); + const [emptySnapshotName, setEmptySnapshotName] = useState(""); + const [isCreatingEmpty, setIsCreatingEmpty] = useState(false); const [paginationModel, setPaginationModel] = useState({ page: 0, pageSize: 50, @@ -167,13 +171,27 @@ export const SnapshotListPage = observer(() => {

Экспорт Медиа

- {canCreateSnapshot && ( - - )} +
+ {canCreateSnapshot && ( + + )} + {canCreateSnapshot && ( + + )} +
{usedGB != null && totalGB != null && (
@@ -301,6 +319,46 @@ export const SnapshotListPage = observer(() => { }} /> + setIsEmptySnapshotModalOpen(false)} + fullWidth + maxWidth="xs" + > + Создать пустой снапшот + + setEmptySnapshotName(e.target.value)} + margin="normal" + /> + + + + + + + { + snapshotStore.clearStoreCache(); + toast.success("Кэш очищен"); + }, + }, { id: "logout", label: "Выйти", diff --git a/src/shared/store/CityStore/index.ts b/src/shared/store/CityStore/index.ts index b806ed2..d5d2aa2 100644 --- a/src/shared/store/CityStore/index.ts +++ b/src/shared/store/CityStore/index.ts @@ -14,6 +14,7 @@ export type City = { country: string; country_code: string; arms: string; + weather_city_code?: number; }; export type CashedCities = { @@ -132,6 +133,7 @@ class CityStore { createCityData = { country_code: "", arms: "", + weather_city_code: 0, ru: { name: "", }, @@ -159,9 +161,13 @@ class CityStore { }; }; + setCreateCityWeatherCode = (weather_city_code: number) => { + this.createCityData = { ...this.createCityData, weather_city_code }; + }; + async createCity() { const language = languageStore.language as Language; - const { country_code, arms } = this.createCityData; + const { country_code, arms, weather_city_code } = this.createCityData; const { name } = this.createCityData[language]; if (!name || !country_code) { @@ -178,6 +184,7 @@ class CityStore { )?.name || "", country_code, ...(arms ? { arms } : {}), + weather_city_code: weather_city_code ?? 0, }; const cityResponse = await languageInstance(language).post( @@ -232,6 +239,7 @@ class CityStore { this.createCityData = { country_code: "", arms: "", + weather_city_code: 0, ru: { name: "" }, en: { name: "" }, zh: { name: "" }, @@ -246,6 +254,7 @@ class CityStore { editCityData = { country_code: "", arms: "", + weather_city_code: 0, ru: { name: "", }, @@ -267,16 +276,19 @@ class CityStore { ...this.editCityData, country_code: country_code, arms: arms, - [language]: { name: name, }, }; }; + setEditCityWeatherCode = (weather_city_code: number) => { + this.editCityData = { ...this.editCityData, weather_city_code }; + }; + editCity = async (code: string) => { for (const language of ["ru", "en", "zh"]) { - const { country_code, arms } = this.editCityData; + const { country_code, arms, weather_city_code } = this.editCityData; const { name } = this.editCityData[language as keyof CashedCities]; const { countries } = countryStore; @@ -289,6 +301,7 @@ class CityStore { country: country?.name || "", country_code: country_code, arms, + weather_city_code: weather_city_code ?? 0, }); runInAction(() => { diff --git a/src/shared/store/CreateSightStore/index.tsx b/src/shared/store/CreateSightStore/index.tsx index cd5b516..a2a7200 100644 --- a/src/shared/store/CreateSightStore/index.tsx +++ b/src/shared/store/CreateSightStore/index.tsx @@ -40,6 +40,7 @@ type SightCommonInfo = { left_article: number; preview_media: string | null; video_preview: string | null; + preview_font_size?: number; }; type SightBaseInfo = SightCommonInfo & { @@ -184,7 +185,7 @@ class CreateSightStore { index: number, language: Language, heading: string, - body: string + body: string, ) => { if (this.sight[language].right[index]) { this.sight[language].right[index].heading = heading; @@ -195,13 +196,13 @@ class CreateSightStore { unlinkRightAritcle = (articleId: number) => { runInAction(() => { this.sight.ru.right = this.sight.ru.right.filter( - (article) => article.id !== articleId + (article) => article.id !== articleId, ); this.sight.en.right = this.sight.en.right.filter( - (article) => article.id !== articleId + (article) => article.id !== articleId, ); this.sight.zh.right = this.sight.zh.right.filter( - (article) => article.id !== articleId + (article) => article.id !== articleId, ); }); }; @@ -211,13 +212,13 @@ class CreateSightStore { await authInstance.delete(`/article/${articleId}`); runInAction(() => { this.sight.ru.right = this.sight.ru.right.filter( - (article) => article.id !== articleId + (article) => article.id !== articleId, ); this.sight.en.right = this.sight.en.right.filter( - (article) => article.id !== articleId + (article) => article.id !== articleId, ); this.sight.zh.right = this.sight.zh.right.filter( - (article) => article.id !== articleId + (article) => article.id !== articleId, ); }); } catch (error) { @@ -235,7 +236,7 @@ class CreateSightStore { runInAction(() => { (["ru", "en", "zh"] as Language[]).forEach((lang) => { const article = this.sight[lang].right.find( - (a) => a.id === articleId + (a) => a.id === articleId, ); if (article) { if (!article.media) article.media = []; @@ -257,7 +258,7 @@ class CreateSightStore { runInAction(() => { (["ru", "en", "zh"] as Language[]).forEach((lang) => { const article = this.sight[lang].right.find( - (a) => a.id === articleId + (a) => a.id === articleId, ); if (article && article.media) { article.media = article.media.filter((m) => m.id !== mediaId); @@ -322,13 +323,13 @@ class CreateSightStore { runInAction(() => { articlesStore.articles.ru = articlesStore.articles.ru.filter( - (article) => article.id !== articleId + (article) => article.id !== articleId, ); articlesStore.articles.en = articlesStore.articles.en.filter( - (article) => article.id !== articleId + (article) => article.id !== articleId, ); articlesStore.articles.zh = articlesStore.articles.zh.filter( - (article) => article.id !== articleId + (article) => article.id !== articleId, ); }); this.unlinkLeftArticle(); @@ -431,7 +432,7 @@ class CreateSightStore { updateSightInfo = ( content: Partial, - language?: Language + language?: Language, ) => { this.needLeaveAgree = true; if (language) { @@ -464,15 +465,15 @@ class CreateSightStore { ) { await languageInstance("ru").patch( `/article/${this.sight.left_article}`, - { heading: this.sight.ru.left.heading, body: this.sight.ru.left.body } + { heading: this.sight.ru.left.heading, body: this.sight.ru.left.body }, ); await languageInstance("en").patch( `/article/${this.sight.left_article}`, - { heading: this.sight.en.left.heading, body: this.sight.en.left.body } + { heading: this.sight.en.left.heading, body: this.sight.en.left.body }, ); await languageInstance("zh").patch( `/article/${this.sight.left_article}`, - { heading: this.sight.zh.left.heading, body: this.sight.zh.left.body } + { heading: this.sight.zh.left.heading, body: this.sight.zh.left.body }, ); } @@ -488,7 +489,7 @@ class CreateSightStore { } } const rightArticleIdsForLink = this.sight[primaryLanguage].right.map( - (a) => a.id + (a) => a.id, ); const sightPayload = { @@ -508,16 +509,17 @@ class CreateSightStore { left_article: finalLeftArticleId === 0 ? null : finalLeftArticleId, preview_media: this.sight.preview_media, video_preview: this.sight.video_preview, + preview_font_size: this.sight.preview_font_size, }; const response = await languageInstance(primaryLanguage).post( "/sight", - sightPayload + sightPayload, ); const newSightId = response.data.id; const otherLanguages = (["ru", "en", "zh"] as Language[]).filter( - (l) => l !== primaryLanguage + (l) => l !== primaryLanguage, ); for (const lang of otherLanguages) { await languageInstance(lang).patch(`/sight/${newSightId}`, { @@ -547,7 +549,10 @@ class CreateSightStore { }); } - this.needLeaveAgree = false; + runInAction(() => { + this.needLeaveAgree = false; + }); + return newSightId; }; @@ -555,7 +560,7 @@ class CreateSightStore { filename: string, type: number, file: File, - media_name?: string + media_name?: string, ): Promise => { const formData = new FormData(); formData.append("file", file); @@ -585,7 +590,7 @@ class CreateSightStore { createLinkWithLeftArticle = async (media: MediaItem) => { if (!this.sight.left_article || this.sight.left_article === 10000000) { console.warn( - "Left article not selected or is a placeholder. Cannot link media yet." + "Left article not selected or is a placeholder. Cannot link media yet.", ); return; @@ -618,7 +623,7 @@ class CreateSightStore { (["ru", "en", "zh"] as Language[]).forEach((lang) => { if (this.sight[lang].left.media) { this.sight[lang].left.media = this.sight[lang].left.media.filter( - (m) => m.id !== mediaId + (m) => m.id !== mediaId, ); } }); @@ -634,13 +639,13 @@ class CreateSightStore { const sortArticles = (existing: any[]) => { const articleMap = new Map( - existing.map((article) => [article.id, article]) + existing.map((article) => [article.id, article]), ); return articlesIds .map((id) => articleMap.get(id)) .filter( (article): article is (typeof existing)[number] => - article !== undefined + article !== undefined, ); }; diff --git a/src/shared/store/RouteStore/index.ts b/src/shared/store/RouteStore/index.ts index f68c2eb..165a505 100644 --- a/src/shared/store/RouteStore/index.ts +++ b/src/shared/store/RouteStore/index.ts @@ -200,6 +200,49 @@ class RouteStore { }); }; + sightCounts: Map = new Map(); + stationCounts: Map = new Map(); + countsLoading = false; + + loadCounts = async (routeIds: number[]) => { + if (routeIds.length === 0) return; + + runInAction(() => { + this.countsLoading = true; + }); + + const batchSize = 20; + for (let i = 0; i < routeIds.length; i += batchSize) { + const batch = routeIds.slice(i, i + batchSize); + const results = await Promise.allSettled( + batch.flatMap((id) => [ + authInstance.get(`/route/${id}/sight/count`).then((res) => ({ id, type: "sight", data: res.data })), + authInstance.get(`/route/${id}/station/count`).then((res) => ({ id, type: "station", data: res.data })), + ]) + ); + + runInAction(() => { + for (const result of results) { + if (result.status === "fulfilled") { + const { id, type, data } = result.value; + let count = 0; + if (typeof data === "number") { + count = data; + } else if (data && typeof data === "object") { + count = Object.values(data).reduce((sum: number, v: any) => sum + (Number(v) || 0), 0); + } + if (type === "sight") this.sightCounts.set(id, count); + else this.stationCounts.set(id, count); + } + } + }); + } + + runInAction(() => { + this.countsLoading = false; + }); + }; + selectedStationId = 0; setSelectedStationId = (id: number) => { diff --git a/src/shared/store/SnapshotStore/index.ts b/src/shared/store/SnapshotStore/index.ts index 50b641f..94ba3d5 100644 --- a/src/shared/store/SnapshotStore/index.ts +++ b/src/shared/store/SnapshotStore/index.ts @@ -49,6 +49,50 @@ class SnapshotStore { makeAutoObservable(this); } + clearStoreCache = () => { + runInAction(() => { + articlesStore.articleList = { + ru: { data: [], loaded: false }, + en: { data: [], loaded: false }, + zh: { data: [], loaded: false }, + }; + articlesStore.articlePreview = {}; + articlesStore.articleData = null; + articlesStore.articleMedia = null; + + countryStore.countries = { + ru: { data: [], loaded: false }, + en: { data: [], loaded: false }, + zh: { data: [], loaded: false }, + }; + + carrierStore.carriers = { + ru: { data: [], loaded: false }, + en: { data: [], loaded: false }, + zh: { data: [], loaded: false }, + }; + + stationsStore.stationLists = { + ru: { data: [], loaded: false }, + en: { data: [], loaded: false }, + zh: { data: [], loaded: false }, + }; + stationsStore.stationPreview = {}; + + sightsStore.sights = []; + sightsStore.sight = null; + + routeStore.routes = { data: [], loaded: false }; + + vehicleStore.vehicles = { data: [], loaded: false }; + + userStore.users = { data: [], loaded: false }; + + mediaStore.media = []; + mediaStore.oneMedia = null; + }); + }; + private clearAllCaches = () => { articlesStore.articleList = { ru: { data: [], loaded: false }, @@ -297,6 +341,12 @@ class SnapshotStore { await authInstance.post(`/snapshots/${id}/restore`); }; + createEmptySnapshot = async (name: string) => { + await authInstance.post(`/snapshots/empty`, { + name: name.trim(), + }); + }; + createSnapshot = async (name: string) => { this.lastRequestId = uuidv4(); diff --git a/src/widgets/SightTabs/CreateInformationTab/index.tsx b/src/widgets/SightTabs/CreateInformationTab/index.tsx index 50f0081..6378dfc 100644 --- a/src/widgets/SightTabs/CreateInformationTab/index.tsx +++ b/src/widgets/SightTabs/CreateInformationTab/index.tsx @@ -38,6 +38,7 @@ import { Save } from "lucide-react"; import { observer } from "mobx-react-lite"; import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { toast } from "react-toastify"; import { SaveWithoutCityAgree } from "@widgets"; @@ -50,6 +51,7 @@ export const CreateInformationTab = observer( const { language } = languageStore; const { sight, updateSightInfo, createSight } = createSightStore; const data = sight[language]; + const navigate = useNavigate(); const [, setCity] = useState(sight.city_id ?? 0); const [coordinates, setCoordinates] = useState(`0, 0`); @@ -173,14 +175,16 @@ export const CreateInformationTab = observer( return; } - await createSight(language); + const newSightId = await createSight(language); toast.success("Достопримечательность создана"); + navigate(`/sight/${newSightId}/edit`); }; const handleConfirmSave = async () => { setIsSaveWarningOpen(false); - await createSight(language); + const newSightId = await createSight(language); toast.success("Достопримечательность создана"); + navigate(`/sight/${newSightId}/edit`); }; const handleCancelSave = () => { diff --git a/src/widgets/SightTabs/CreateLeftTab/index.tsx b/src/widgets/SightTabs/CreateLeftTab/index.tsx index 1a1f1bc..dc77a5e 100644 --- a/src/widgets/SightTabs/CreateLeftTab/index.tsx +++ b/src/widgets/SightTabs/CreateLeftTab/index.tsx @@ -20,6 +20,7 @@ import { } from "@widgets"; import { Trash2, ImagePlus, Unlink, Plus, Save, Search } from "lucide-react"; import { useState, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; import { observer } from "mobx-react-lite"; import { toast } from "react-toastify"; @@ -41,6 +42,7 @@ export const CreateLeftTab = observer( uploadMediaOpen, setUploadMediaOpen, } = editSightStore; + const navigate = useNavigate(); const { language } = languageStore; const token = localStorage.getItem("token"); @@ -449,8 +451,9 @@ export const CreateLeftTab = observer( startIcon={} onClick={async () => { try { - await createSight(language); + const newSightId = await createSight(language); toast.success("Страница создана"); + navigate(`/sight/${newSightId}/edit`); } catch (error) { console.error(error); } diff --git a/src/widgets/SightTabs/CreateRightTab/index.tsx b/src/widgets/SightTabs/CreateRightTab/index.tsx index af81f17..0130f15 100644 --- a/src/widgets/SightTabs/CreateRightTab/index.tsx +++ b/src/widgets/SightTabs/CreateRightTab/index.tsx @@ -1,9 +1,10 @@ import { Box, Button, - Paper, Typography, TextField, + Slider, + Stack, } from "@mui/material"; import { BackButton, @@ -19,17 +20,18 @@ import { LanguageSwitcher, MediaArea, MediaAreaForSight, - ReactMarkdownComponent, ReactMarkdownEditor, DeleteModal, } from "@widgets"; -import { ImagePlus, Plus, Save, Trash2, Unlink, X } from "lucide-react"; +import { Plus, Save, Trash2, Unlink, X } from "lucide-react"; import { observer } from "mobx-react-lite"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; +import { useNavigate } from "react-router-dom"; import { MediaViewer } from "../../MediaViewer/index"; import { toast } from "react-toastify"; import { authInstance } from "@shared"; import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; +import { SightFramePreview } from "../RightWidgetTab/SightFramePreview"; type MediaItemShared = { id: string; @@ -51,17 +53,19 @@ export const CreateRightTab = observer( unlinkRightAritcle, deleteRightArticle, createSight, - clearCreateSight, updateRightArticles, + updateSightInfo, } = createSightStore; const { language } = languageStore; + const navigate = useNavigate(); const { setFileToUpload, setUploadMediaOpen, uploadMediaOpen } = editSightStore; const [activeArticleIndex, setActiveArticleIndex] = useState( - null + null, ); const [type, setType] = useState<"article" | "media">("media"); + const [previewSection, setPreviewSection] = useState(-1); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] = useState(false); @@ -70,12 +74,34 @@ export const CreateRightTab = observer( >(null); const [previewMedia, setPreviewMedia] = useState(null); + const shortNameRef = useRef(null); + + const insertNewline = () => { + const input = shortNameRef.current; + const currentValue = sight[language].name || ""; + if (!input) { + updateSightInfo({ name: currentValue + "\n" }, language); + return; + } + const start = input.selectionStart ?? currentValue.length; + const end = input.selectionEnd ?? start; + const newValue = + currentValue.slice(0, start) + "\n" + currentValue.slice(end); + updateSightInfo({ name: newValue }, language); + requestAnimationFrame(() => { + if (shortNameRef.current) { + shortNameRef.current.selectionStart = start + 1; + shortNameRef.current.selectionEnd = start + 1; + shortNameRef.current.focus(); + } + }); + }; useEffect(() => { if (sight.preview_media) { const fetchMedia = async () => { const response = await authInstance.get( - `/media/${sight.preview_media}` + `/media/${sight.preview_media}`, ); setPreviewMedia(response.data); }; @@ -90,16 +116,17 @@ export const CreateRightTab = observer( ) { setActiveArticleIndex(null); setType("media"); + setPreviewSection(-1); } }, [language, sight[language].right, activeArticleIndex]); const handleSave = async () => { try { - await createSight(language); + const newSightId = await createSight(language); + console.log("[handleSave] newSightId:", newSightId, "needLeaveAgree:", createSightStore.needLeaveAgree); toast.success("Достопримечательность успешно создана!"); - clearCreateSight(); - setActiveArticleIndex(null); - setType("media"); + navigate(`/sight/${newSightId}/edit`); + console.log("[handleSave] navigate called to:", `/sight/${newSightId}/edit`); } catch (error) { console.error("Failed to save sight:", error); toast.error("Ошибка при создании достопримечательности."); @@ -109,6 +136,7 @@ export const CreateRightTab = observer( const handleDisplayArticleFromList = (idx: number) => { setActiveArticleIndex(idx); setType("article"); + setPreviewSection(idx); }; const handleCreateNewLocalArticle = async () => { @@ -116,15 +144,13 @@ export const CreateRightTab = observer( const newArticleId = await createNewRightArticle(); const newIndex = sight[language].right.findIndex( - (a) => a.id === newArticleId + (a) => a.id === newArticleId, ); - if (newIndex > -1) { - setActiveArticleIndex(newIndex); - setType("article"); - } else { - setActiveArticleIndex(sight[language].right.length - 1); - setType("article"); - } + const resolvedIndex = + newIndex > -1 ? newIndex : sight[language].right.length - 1; + setActiveArticleIndex(resolvedIndex); + setType("article"); + setPreviewSection(resolvedIndex); } catch (error) { toast.error("Не удалось создать новую статью."); } @@ -140,7 +166,7 @@ export const CreateRightTab = observer( }; const handleOpenSelectMediaDialog = ( - target: "sightPreview" | "rightArticle" + target: "sightPreview" | "rightArticle", ) => { setMediaTarget(target); setIsSelectMediaDialogOpen(true); @@ -184,11 +210,8 @@ export const CreateRightTab = observer( if (sourceIndex === destinationIndex) return; const newRightArticles = [...sight[language].right]; - const [movedArticle] = newRightArticles.splice(sourceIndex, 1); - newRightArticles.splice(destinationIndex, 0, movedArticle); - updateRightArticles(newRightArticles); }; @@ -212,18 +235,22 @@ export const CreateRightTab = observer(
- - + + { setType("media"); + setPreviewSection(-1); }} - className={`w-full p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-300 ${ + className={`w-full p-4 rounded-2xl cursor-pointer text-sm transition-all duration-300 ${ type === "media" - ? "bg-green-300 font-semibold" - : "bg-green-200" + ? "bg-blue-400 text-white" + : "bg-gray-200 hover:bg-gray-300" }`} > Предпросмотр медиа @@ -249,16 +276,19 @@ export const CreateRightTab = observer( { handleDisplayArticleFromList( - index + index, ); - setType("article"); }} > @@ -269,7 +299,7 @@ export const CreateRightTab = observer( )} - ) + ), ) : null} {provided.placeholder} @@ -300,6 +330,7 @@ export const CreateRightTab = observer( unlinkRightAritcle(currentRightArticle.id); setActiveArticleIndex(null); setType("media"); + setPreviewSection(-1); } }} > @@ -310,9 +341,7 @@ export const CreateRightTab = observer( color="error" size="small" startIcon={} - onClick={async () => { - setIsDeleteModalOpen(true); - }} + onClick={() => setIsDeleteModalOpen(true)} > Удалить @@ -336,7 +365,7 @@ export const CreateRightTab = observer( activeArticleIndex, language, e.target.value, - currentRightArticle.body + currentRightArticle.body, ) } variant="outlined" @@ -351,7 +380,7 @@ export const CreateRightTab = observer( activeArticleIndex, language, currentRightArticle.heading, - mdValue || "" + mdValue || "", ) } /> @@ -373,225 +402,160 @@ export const CreateRightTab = observer( /> - ) : type === "media" ? ( - - <> - {type === "media" && ( - - {previewMedia && ( - <> - - - - - - - - - )} - - {!previewMedia && ( - - - { - linkPreviewMedia(mediaId); - }} - onFilesDrop={() => {}} - contextObjectName={sight[language].name} - contextType="sight" - isArticle={false} - /> - - - )} - - )} - - ) : ( - - - Выберите статью слева или секцию "Предпросмотр медиа" - + + {sight.preview_media && ( + + {previewMedia && ( + <> + + + + + + + + )} + + )} + + {!sight.preview_media && ( + + + { + linkPreviewMedia(mediaId); + }} + onFilesDrop={() => {}} + contextObjectName={sight[language].name} + contextType="sight" + isArticle={false} + /> + + + )} )} - - {type === "article" && activeArticleIndex !== null && sight[language].right[activeArticleIndex] && ( - - + {type === "media" && ( + + { + const raw = e.target.value; + if (raw === "") { + updateSightInfo({ preview_font_size: undefined }); + return; + } + const val = Math.max( + 1, + Math.min(300, Math.round(Number(raw))), + ); + if (Number.isFinite(val)) { + updateSightInfo({ preview_font_size: val }); + } }} - > - {sight[language].right[activeArticleIndex].media.length > - 0 ? ( - - - - ) : ( - - - - )} - - - - {sight[language].right[activeArticleIndex].heading || - "Выберите статью"} - - - - - {sight[language].right[activeArticleIndex].body ? ( - - ) : ( - - Предпросмотр статьи появится здесь - - )} - - - {sight[language].right.length > 0 && - sight[language].right.map((article, index) => ( - - ))} - - - + slotProps={{ input: { min: 1, max: 300 } }} + sx={{ width: "200px" }} + /> + { + if (typeof newValue === "number") { + updateSightInfo({ preview_font_size: newValue }); + } + }} + sx={{ flexGrow: 1 }} + /> + )} + + + updateSightInfo({ name: e.target.value }, language) + } + inputRef={shortNameRef} + sx={{ flexGrow: 1 }} + /> + + + { + handleDisplayArticleFromList(idx); + }} + previewFontSize={sight.preview_font_size} + selectedSection={previewSection} + onSectionChange={(section) => { + setPreviewSection(section); + if (section === -1) { + setType("media"); + } else { + handleDisplayArticleFromList(section); + } + }} + /> @@ -603,7 +567,6 @@ export const CreateRightTab = observer( right: 0, padding: 2, backgroundColor: "background.paper", - width: "100%", display: "flex", justifyContent: "flex-end", @@ -650,10 +613,18 @@ export const CreateRightTab = observer( open={isDeleteModalOpen} onDelete={async () => { try { + const idx = activeArticleIndex ?? 0; await deleteRightArticle(currentRightArticle?.id || 0); setIsDeleteModalOpen(false); - setActiveArticleIndex(null); - setType("media"); + if (idx > 0) { + setActiveArticleIndex(idx - 1); + setPreviewSection(idx - 1); + setType("article"); + } else { + setActiveArticleIndex(null); + setPreviewSection(-1); + setType("media"); + } toast.success("Статья удалена"); } catch { toast.error("Не удалось удалить статью"); @@ -663,5 +634,5 @@ export const CreateRightTab = observer( /> ); - } + }, ); diff --git a/src/widgets/SightTabs/RightWidgetTab/SightFramePreview.tsx b/src/widgets/SightTabs/RightWidgetTab/SightFramePreview.tsx index f1a2fac..f9785a6 100644 --- a/src/widgets/SightTabs/RightWidgetTab/SightFramePreview.tsx +++ b/src/widgets/SightTabs/RightWidgetTab/SightFramePreview.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from "react"; +import React, { useMemo, useRef, useState } from "react"; import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer"; import { ReactMarkdownComponent } from "../../ReactMarkdown"; import { ThreeViewErrorBoundary } from "../../MediaViewer/ThreeViewErrorBoundary"; @@ -34,6 +34,8 @@ interface SightFramePreviewProps { articles: Article[]; onArticleSelect: (index: number) => void; previewFontSize?: number; + selectedSection: number; + onSectionChange: (section: number) => void; } // Matches SightFrame.jsx renderCurrentMedia — same structure, same CSS classes @@ -153,11 +155,10 @@ export function SightFramePreview({ articles, onArticleSelect, previewFontSize, + selectedSection, + onSectionChange, }: SightFramePreviewProps) { const token = localStorage.getItem("token") ?? ""; - - // -1 = intro (section 0 in SightFrame) - const [selectedSection, setSelectedSection] = useState(-1); const [threeViewResetKey, setThreeViewResetKey] = useState(0); const threeViewControlRef = useRef(null); @@ -175,6 +176,17 @@ export function SightFramePreview({ // Replicates processedSightName from SightFrame.jsx const processedSightName = useMemo(() => { if (!sightName) return sightName; + + // Handle \n line breaks + if (sightName.includes("\n")) { + return sightName.split("\n").map((line, i) => ( + + {i > 0 &&
} + {line} +
+ )); + } + const namePattern = /([А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]+)/g; const parts = sightName.split(namePattern); @@ -199,10 +211,9 @@ export function SightFramePreview({ // Replicates titleLineHeight from SightFrame.jsx const titleLineHeight = useMemo(() => { if (!sightName) return "120%"; - const textLength = sightName.length; const calculatedLineHeight = Math.max( 100, - Math.min(120, 120 - (textLength / 10) * 1) + Math.min(120, 120 - (sightName.length / 10) * 1) ); return `${calculatedLineHeight}%`; }, [sightName]); @@ -272,7 +283,7 @@ export function SightFramePreview({
); });