feat: cache delete + empty snapshot + route page

This commit is contained in:
2026-04-28 03:50:29 +03:00
parent 248eea6f85
commit 60c6840db4
21 changed files with 770 additions and 361 deletions

View File

@@ -23,6 +23,7 @@ import {
} from "./types"; } from "./types";
// @ts-ignore // @ts-ignore
import { orderStationsByRoute } from "../../utils/routeStationsUtils"; import { orderStationsByRoute } from "../../utils/routeStationsUtils";
import { resamplePath } from "../../utils/animationUtils";
class ApiStore { class ApiStore {
isLoading = true; isLoading = true;
@@ -88,7 +89,26 @@ class ApiStore {
}; };
getRoute = async () => { 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(); this.updateOrderedRouteStations();
}; };

View File

@@ -428,6 +428,16 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
const processedSightName = useMemo(() => { const processedSightName = useMemo(() => {
if (!sight_name) return sight_name; if (!sight_name) return sight_name;
// Handle \n line breaks (только в правом виджете)
if (sight_name.includes("\n")) {
return sight_name.split("\n").map((line, i) => (
<React.Fragment key={i}>
{i > 0 && <br />}
{line}
</React.Fragment>
));
}
const namePattern = const namePattern =
/([А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]+)/g; /([А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]+)/g;

View File

@@ -6,22 +6,30 @@ export const SimulationSettings = observer(() => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
<div style={{ position: "absolute", top: 12, right: 12, zIndex: 10010 }}> <div style={{ position: "fixed", top: 12, right: 12, zIndex: 2147483646 }}>
<button <button
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
style={{ style={{
width: 36, height: 36, borderRadius: 6, width: 36, height: 36, borderRadius: 6,
border: "1px solid rgba(255,255,255,0.25)", border: "1px solid rgba(255,255,255,0.25)",
background: open ? "rgba(255,255,255,0.15)" : "rgba(0,0,0,0.5)", background: open ? "rgba(255,255,255,0.15)" : "rgba(0,0,0,0.5)",
color: "white", cursor: "pointer", fontSize: 18, color: "white", cursor: "pointer",
display: "flex", alignItems: "center", justifyContent: "center",
padding: 0,
}} }}
> >
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.92c.04-.34.07-.69.07-1.08s-.03-.73-.07-1.08l2.33-1.82c.21-.16.27-.46.13-.7l-2.21-3.83a.55.55 0 0 0-.68-.22l-2.75 1.1a8.1 8.1 0 0 0-1.86-1.08l-.42-2.93A.545.545 0 0 0 14 2h-4c-.27 0-.5.2-.54.46l-.42 2.93c-.68.28-1.3.65-1.86 1.08L4.43 5.37a.543.543 0 0 0-.68.22L1.54 9.42c-.14.24-.08.54.13.7l2.33 1.82c-.04.35-.07.7-.07 1.08s.03.73.07 1.08L1.67 15.92c-.21.16-.27.46-.13.7l2.21 3.83c.14.24.43.31.68.22l2.75-1.1c.56.43 1.18.8 1.86 1.08l.42 2.93c.04.26.27.46.54.46h4c.27 0 .5-.2.54-.46l.42-2.93c.68-.28 1.3-.65 1.86-1.08l2.75 1.1c.25.09.54.02.68-.22l2.21-3.83c.14-.24.08-.54-.13-.7l-2.33-1.82Z"
fill="white"
/>
</svg>
</button> </button>
{open && ( {open && (
<div style={{ <div style={{
marginTop: 6, background: "rgba(20,20,20,0.9)", position: "absolute", top: 42, right: 0,
background: "rgba(20,20,20,0.9)",
border: "1px solid rgba(255,255,255,0.15)", borderRadius: 8, border: "1px solid rgba(255,255,255,0.15)", borderRadius: 8,
padding: "10px 12px", minWidth: 200, fontSize: 13, padding: "10px 12px", minWidth: 200, fontSize: 13,
}}> }}>

View File

@@ -134,6 +134,58 @@ export class PositionAnimator {
}; };
} }
/**
* Передискретизация пути для обеспечения равномерного расстояния между точками
* @param path - массив [lat, lon] или [x, y]
* @param segmentLength - желаемое расстояние между точками (в единицах координат)
* @returns новый массив точек
*/
export const resamplePath = <T extends number[]>(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) * Основано на логике анимации из HTML файла (a.html)

View File

@@ -28,7 +28,7 @@ import { LanguageSwitcher, ImageUploadCard } from "@widgets";
export const CityCreatePage = observer(() => { export const CityCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const { language } = languageStore; const { language } = languageStore;
const { createCityData, setCreateCityData } = cityStore; const { createCityData, setCreateCityData, setCreateCityWeatherCode } = cityStore;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
@@ -139,6 +139,15 @@ export const CityCreatePage = observer(() => {
</Select> </Select>
</FormControl> </FormControl>
<TextField
fullWidth
label="Код города для погоды"
type="number"
value={createCityData.weather_city_code ?? 0}
helperText="Числовой код города в источнике погоды (Кранштат)"
onChange={(e) => setCreateCityWeatherCode(Number(e.target.value))}
/>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto"> <div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard <ImageUploadCard
title="Герб города" title="Герб города"

View File

@@ -40,7 +40,7 @@ export const CityEditPage = observer(() => {
>(null); >(null);
const { language } = languageStore; const { language } = languageStore;
const { id } = useParams(); const { id } = useParams();
const { editCityData, editCity, getCity, setEditCityData } = cityStore; const { editCityData, editCity, getCity, setEditCityData, setEditCityWeatherCode } = cityStore;
const { getCountries } = countryStore; const { getCountries } = countryStore;
const { getMedia, getOneMedia } = mediaStore; const { getMedia, getOneMedia } = mediaStore;
@@ -74,6 +74,7 @@ export const CityEditPage = observer(() => {
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");
setEditCityWeatherCode(ruData.weather_city_code ?? 0);
await getOneMedia(ruData.arms as string); await getOneMedia(ruData.arms as string);
@@ -179,6 +180,15 @@ export const CityEditPage = observer(() => {
</Select> </Select>
</FormControl> </FormControl>
<TextField
fullWidth
label="Код города для погоды"
type="number"
value={editCityData.weather_city_code ?? 0}
helperText="Числовой код города в источнике погоды (Кранштат)"
onChange={(e) => setEditCityWeatherCode(Number(e.target.value))}
/>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto"> <div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard <ImageUploadCard
title="Герб города" title="Герб города"

View File

@@ -12,7 +12,7 @@ import {
CreateRightTab, CreateRightTab,
LeaveAgree, LeaveAgree,
} from "@widgets"; } from "@widgets";
import { useEffect, useState } from "react"; import { useEffect, useState, useRef } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
function a11yProps(index: number) { function a11yProps(index: number) {
@@ -28,7 +28,7 @@ export const CreateSightPage = observer(() => {
const [value, setValue] = useState(0); const [value, setValue] = useState(0);
const { getCities } = cityStore; const { getCities } = cityStore;
const { getArticles } = articlesStore; const { getArticles } = articlesStore;
const { needLeaveAgree } = createSightStore; const needLeave = createSightStore.needLeaveAgree;
const handleChange = (_: React.SyntheticEvent, newValue: number) => { const handleChange = (_: React.SyntheticEvent, newValue: number) => {
setValue(newValue); setValue(newValue);
@@ -36,9 +36,15 @@ export const CreateSightPage = observer(() => {
let blocker = useBlocker( let blocker = useBlocker(
({ currentLocation, nextLocation }) => ({ currentLocation, nextLocation }) =>
needLeaveAgree && currentLocation.pathname !== nextLocation.pathname needLeave && currentLocation.pathname !== nextLocation.pathname,
); );
useEffect(() => {
if (blocker.state === "blocked" && !needLeave) {
blocker.proceed();
}
}, [blocker.state, needLeave]);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
if (!authStore.me) { if (!authStore.me) {

View File

@@ -6,10 +6,10 @@ import { observer } from "mobx-react-lite";
import { Map, Pencil, Trash2, Minus, Monitor } from "lucide-react"; import { Map, Pencil, Trash2, Minus, Monitor } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { Box, CircularProgress } from "@mui/material"; import { Box, CircularProgress, Tooltip } from "@mui/material";
export const RouteListPage = observer(() => { export const RouteListPage = observer(() => {
const { routes, getRoutes, deleteRoute } = routeStore; const { routes, getRoutes, deleteRoute, sightCounts, stationCounts, countsLoading, loadCounts } = routeStore;
const { carriers, getCarriers } = carrierStore; const { carriers, getCarriers } = carrierStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@@ -38,6 +38,9 @@ export const RouteListPage = observer(() => {
await getCarriers("zh"); await getCarriers("zh");
await getRoutes(); await getRoutes();
setIsLoading(false); setIsLoading(false);
const routeIds = routeStore.routes.data.map((r) => r.id);
loadCounts(routeIds);
}; };
fetchData(); fetchData();
}, [language]); }, [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) => (
<Tooltip title="Количество привязанных достопримечательностей">
<span>{params.colDef.headerName}</span>
</Tooltip>
),
renderCell: (params: GridRenderCellParams) => (
<div className="w-full h-full flex items-center justify-center">
{params.value === null ? <CircularProgress size={14} /> : params.value}
</div>
),
},
{
field: "stationCount",
headerName: "Остановки",
width: 120,
align: "center" as const,
headerAlign: "center" as const,
sortable: true,
renderHeader: (params: any) => (
<Tooltip title="Количество привязанных остановок">
<span>{params.colDef.headerName}</span>
</Tooltip>
),
renderCell: (params: GridRenderCellParams) => (
<div className="w-full h-full flex items-center justify-center">
{params.value === null ? <CircularProgress size={14} /> : params.value}
</div>
),
},
...(canShowActionsColumn ? [{ ...(canShowActionsColumn ? [{
field: "actions", field: "actions",
headerName: "Действия", headerName: "Действия",
@@ -195,8 +234,10 @@ export const RouteListPage = observer(() => {
route_sys_number: route.route_sys_number, route_sys_number: route.route_sys_number,
route_direction: route.route_direction ? "Прямой" : "Обратный", route_direction: route.route_direction ? "Прямой" : "Обратный",
route_name: route.route_name, 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 ( return (
<> <>

View File

@@ -1910,7 +1910,8 @@ export const WebGLRouteMapPrototype = observer(() => {
const stationIconSizePercent = const stationIconSizePercent =
liveStationIconSizes.get(station.id) ?? 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 ? station.icon_size
: 100); : 100);
const iconSizePx = Math.max( const iconSizePx = Math.max(
@@ -2277,6 +2278,7 @@ export const WebGLRouteMapPrototype = observer(() => {
position: "absolute", position: "absolute",
inset: 0, inset: 0,
pointerEvents: "none", pointerEvents: "none",
zIndex: 1,
}} }}
> >
{stationData.ru.map((station, index) => { {stationData.ru.map((station, index) => {
@@ -2706,7 +2708,8 @@ export const WebGLRouteMapPrototype = observer(() => {
? camera.scale / ? camera.scale /
Math.max(customSightIconBaseScaleRef.current ?? 1, 1e-6) Math.max(customSightIconBaseScaleRef.current ?? 1, 1e-6)
: 1; : 1;
const sightIconSizePercent = sight.is_default_icon === false const sightIconSizePercent =
sight.is_default_icon === false
? (liveSightIconSizes.get(sight.id) ?? ? (liveSightIconSizes.get(sight.id) ??
(typeof sight.icon_size === "number" && (typeof sight.icon_size === "number" &&
Number.isFinite(sight.icon_size) Number.isFinite(sight.icon_size)
@@ -2723,7 +2726,10 @@ export const WebGLRouteMapPrototype = observer(() => {
resizingSightIconId === sight.id); resizingSightIconId === sight.id);
const iconLeft = cssX - iconSize; const iconLeft = cssX - iconSize;
const iconTop = cssY - 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 sightScaleFactor = 1 + (sightZoomClampedScale - 1) * 0.4;
const labelHeight = 24 * sightScaleFactor; const labelHeight = 24 * sightScaleFactor;
const labelPadding = 6 * sightScaleFactor; const labelPadding = 6 * sightScaleFactor;

View File

@@ -5,7 +5,7 @@ import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { DatabaseBackup, Trash2 } from "lucide-react"; import { DatabaseBackup, Trash2 } from "lucide-react";
import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets"; import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets";
import { 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; const LOW_STORAGE_THRESHOLD_GB = 10;
@@ -30,6 +30,7 @@ export const SnapshotListPage = observer(() => {
restoreSnapshot, restoreSnapshot,
storageInfo, storageInfo,
getStorageInfo, getStorageInfo,
createEmptySnapshot,
} = snapshotStore; } = snapshotStore;
const canWriteDevices = authStore.canWrite("devices"); const canWriteDevices = authStore.canWrite("devices");
const canCreateSnapshot = const canCreateSnapshot =
@@ -42,6 +43,9 @@ export const SnapshotListPage = observer(() => {
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false); const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [isEmptySnapshotModalOpen, setIsEmptySnapshotModalOpen] = useState(false);
const [emptySnapshotName, setEmptySnapshotName] = useState("");
const [isCreatingEmpty, setIsCreatingEmpty] = useState(false);
const [paginationModel, setPaginationModel] = useState({ const [paginationModel, setPaginationModel] = useState({
page: 0, page: 0,
pageSize: 50, pageSize: 50,
@@ -167,6 +171,19 @@ export const SnapshotListPage = observer(() => {
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl ">Экспорт Медиа</h1> <h1 className="text-2xl ">Экспорт Медиа</h1>
<div className="flex gap-3">
{canCreateSnapshot && (
<Button
variant="outlined"
disabled={isLowStorage}
onClick={() => {
setEmptySnapshotName("");
setIsEmptySnapshotModalOpen(true);
}}
>
Создать пустой снапшот
</Button>
)}
{canCreateSnapshot && ( {canCreateSnapshot && (
<CreateButton <CreateButton
label="Создать экспорт медиа" label="Создать экспорт медиа"
@@ -175,6 +192,7 @@ export const SnapshotListPage = observer(() => {
/> />
)} )}
</div> </div>
</div>
{usedGB != null && totalGB != null && ( {usedGB != null && totalGB != null && (
<div className="bg-white rounded-2xl p-5 mb-6 shadow-sm border border-gray-100"> <div className="bg-white rounded-2xl p-5 mb-6 shadow-sm border border-gray-100">
<div className="flex items-baseline gap-3 mb-3"> <div className="flex items-baseline gap-3 mb-3">
@@ -301,6 +319,46 @@ export const SnapshotListPage = observer(() => {
}} }}
/> />
<Dialog
open={isEmptySnapshotModalOpen}
onClose={() => setIsEmptySnapshotModalOpen(false)}
fullWidth
maxWidth="xs"
>
<DialogTitle>Создать пустой снапшот</DialogTitle>
<DialogContent>
<TextField
autoFocus
fullWidth
label="Название"
value={emptySnapshotName}
onChange={(e) => setEmptySnapshotName(e.target.value)}
margin="normal"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsEmptySnapshotModalOpen(false)}>
Отмена
</Button>
<Button
variant="contained"
disabled={!emptySnapshotName.trim() || isCreatingEmpty}
onClick={async () => {
setIsCreatingEmpty(true);
try {
await createEmptySnapshot(emptySnapshotName);
await getSnapshots();
setIsEmptySnapshotModalOpen(false);
} finally {
setIsCreatingEmpty(false);
}
}}
>
{isCreatingEmpty ? <CircularProgress size={20} /> : "Создать"}
</Button>
</DialogActions>
</Dialog>
<SnapshotRestore <SnapshotRestore
open={isRestoreModalOpen} open={isRestoreModalOpen}
loading={isLoading} loading={isLoading}

View File

@@ -1,4 +1,4 @@
import { authStore } from "@shared"; import { authStore, snapshotStore } from "@shared";
import { import {
Power, Power,
LucideIcon, LucideIcon,
@@ -12,7 +12,9 @@ import {
Split, Split,
PersonStanding, PersonStanding,
Cpu, Cpu,
RefreshCcw,
} from "lucide-react"; } from "lucide-react";
import { toast } from "react-toastify";
import carrierIcon from "./carrier.svg"; import carrierIcon from "./carrier.svg";
@@ -165,6 +167,15 @@ export const NAVIGATION_ITEMS: {
}, },
], ],
secondary: [ secondary: [
{
id: "clear-cache",
label: "Очистить кэш",
icon: RefreshCcw,
onClick: () => {
snapshotStore.clearStoreCache();
toast.success("Кэш очищен");
},
},
{ {
id: "logout", id: "logout",
label: "Выйти", label: "Выйти",

View File

@@ -14,6 +14,7 @@ export type City = {
country: string; country: string;
country_code: string; country_code: string;
arms: string; arms: string;
weather_city_code?: number;
}; };
export type CashedCities = { export type CashedCities = {
@@ -132,6 +133,7 @@ class CityStore {
createCityData = { createCityData = {
country_code: "", country_code: "",
arms: "", arms: "",
weather_city_code: 0,
ru: { ru: {
name: "", name: "",
}, },
@@ -159,9 +161,13 @@ class CityStore {
}; };
}; };
setCreateCityWeatherCode = (weather_city_code: number) => {
this.createCityData = { ...this.createCityData, weather_city_code };
};
async createCity() { async createCity() {
const language = languageStore.language as Language; 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]; const { name } = this.createCityData[language];
if (!name || !country_code) { if (!name || !country_code) {
@@ -178,6 +184,7 @@ class CityStore {
)?.name || "", )?.name || "",
country_code, country_code,
...(arms ? { arms } : {}), ...(arms ? { arms } : {}),
weather_city_code: weather_city_code ?? 0,
}; };
const cityResponse = await languageInstance(language).post( const cityResponse = await languageInstance(language).post(
@@ -232,6 +239,7 @@ class CityStore {
this.createCityData = { this.createCityData = {
country_code: "", country_code: "",
arms: "", arms: "",
weather_city_code: 0,
ru: { name: "" }, ru: { name: "" },
en: { name: "" }, en: { name: "" },
zh: { name: "" }, zh: { name: "" },
@@ -246,6 +254,7 @@ class CityStore {
editCityData = { editCityData = {
country_code: "", country_code: "",
arms: "", arms: "",
weather_city_code: 0,
ru: { ru: {
name: "", name: "",
}, },
@@ -267,16 +276,19 @@ class CityStore {
...this.editCityData, ...this.editCityData,
country_code: country_code, country_code: country_code,
arms: arms, arms: arms,
[language]: { [language]: {
name: name, name: name,
}, },
}; };
}; };
setEditCityWeatherCode = (weather_city_code: number) => {
this.editCityData = { ...this.editCityData, weather_city_code };
};
editCity = async (code: string) => { editCity = async (code: string) => {
for (const language of ["ru", "en", "zh"]) { 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 { name } = this.editCityData[language as keyof CashedCities];
const { countries } = countryStore; const { countries } = countryStore;
@@ -289,6 +301,7 @@ class CityStore {
country: country?.name || "", country: country?.name || "",
country_code: country_code, country_code: country_code,
arms, arms,
weather_city_code: weather_city_code ?? 0,
}); });
runInAction(() => { runInAction(() => {

View File

@@ -40,6 +40,7 @@ type SightCommonInfo = {
left_article: number; left_article: number;
preview_media: string | null; preview_media: string | null;
video_preview: string | null; video_preview: string | null;
preview_font_size?: number;
}; };
type SightBaseInfo = SightCommonInfo & { type SightBaseInfo = SightCommonInfo & {
@@ -184,7 +185,7 @@ class CreateSightStore {
index: number, index: number,
language: Language, language: Language,
heading: string, heading: string,
body: string body: string,
) => { ) => {
if (this.sight[language].right[index]) { if (this.sight[language].right[index]) {
this.sight[language].right[index].heading = heading; this.sight[language].right[index].heading = heading;
@@ -195,13 +196,13 @@ class CreateSightStore {
unlinkRightAritcle = (articleId: number) => { unlinkRightAritcle = (articleId: number) => {
runInAction(() => { runInAction(() => {
this.sight.ru.right = this.sight.ru.right.filter( 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( 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( 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}`); await authInstance.delete(`/article/${articleId}`);
runInAction(() => { runInAction(() => {
this.sight.ru.right = this.sight.ru.right.filter( 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( 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( this.sight.zh.right = this.sight.zh.right.filter(
(article) => article.id !== articleId (article) => article.id !== articleId,
); );
}); });
} catch (error) { } catch (error) {
@@ -235,7 +236,7 @@ class CreateSightStore {
runInAction(() => { runInAction(() => {
(["ru", "en", "zh"] as Language[]).forEach((lang) => { (["ru", "en", "zh"] as Language[]).forEach((lang) => {
const article = this.sight[lang].right.find( const article = this.sight[lang].right.find(
(a) => a.id === articleId (a) => a.id === articleId,
); );
if (article) { if (article) {
if (!article.media) article.media = []; if (!article.media) article.media = [];
@@ -257,7 +258,7 @@ class CreateSightStore {
runInAction(() => { runInAction(() => {
(["ru", "en", "zh"] as Language[]).forEach((lang) => { (["ru", "en", "zh"] as Language[]).forEach((lang) => {
const article = this.sight[lang].right.find( const article = this.sight[lang].right.find(
(a) => a.id === articleId (a) => a.id === articleId,
); );
if (article && article.media) { if (article && article.media) {
article.media = article.media.filter((m) => m.id !== mediaId); article.media = article.media.filter((m) => m.id !== mediaId);
@@ -322,13 +323,13 @@ class CreateSightStore {
runInAction(() => { runInAction(() => {
articlesStore.articles.ru = articlesStore.articles.ru.filter( articlesStore.articles.ru = articlesStore.articles.ru.filter(
(article) => article.id !== articleId (article) => article.id !== articleId,
); );
articlesStore.articles.en = articlesStore.articles.en.filter( articlesStore.articles.en = articlesStore.articles.en.filter(
(article) => article.id !== articleId (article) => article.id !== articleId,
); );
articlesStore.articles.zh = articlesStore.articles.zh.filter( articlesStore.articles.zh = articlesStore.articles.zh.filter(
(article) => article.id !== articleId (article) => article.id !== articleId,
); );
}); });
this.unlinkLeftArticle(); this.unlinkLeftArticle();
@@ -431,7 +432,7 @@ class CreateSightStore {
updateSightInfo = ( updateSightInfo = (
content: Partial<SightLanguageInfo | SightCommonInfo>, content: Partial<SightLanguageInfo | SightCommonInfo>,
language?: Language language?: Language,
) => { ) => {
this.needLeaveAgree = true; this.needLeaveAgree = true;
if (language) { if (language) {
@@ -464,15 +465,15 @@ class CreateSightStore {
) { ) {
await languageInstance("ru").patch( await languageInstance("ru").patch(
`/article/${this.sight.left_article}`, `/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( await languageInstance("en").patch(
`/article/${this.sight.left_article}`, `/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( await languageInstance("zh").patch(
`/article/${this.sight.left_article}`, `/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( const rightArticleIdsForLink = this.sight[primaryLanguage].right.map(
(a) => a.id (a) => a.id,
); );
const sightPayload = { const sightPayload = {
@@ -508,16 +509,17 @@ class CreateSightStore {
left_article: finalLeftArticleId === 0 ? null : finalLeftArticleId, left_article: finalLeftArticleId === 0 ? null : finalLeftArticleId,
preview_media: this.sight.preview_media, preview_media: this.sight.preview_media,
video_preview: this.sight.video_preview, video_preview: this.sight.video_preview,
preview_font_size: this.sight.preview_font_size,
}; };
const response = await languageInstance(primaryLanguage).post( const response = await languageInstance(primaryLanguage).post(
"/sight", "/sight",
sightPayload sightPayload,
); );
const newSightId = response.data.id; const newSightId = response.data.id;
const otherLanguages = (["ru", "en", "zh"] as Language[]).filter( const otherLanguages = (["ru", "en", "zh"] as Language[]).filter(
(l) => l !== primaryLanguage (l) => l !== primaryLanguage,
); );
for (const lang of otherLanguages) { for (const lang of otherLanguages) {
await languageInstance(lang).patch(`/sight/${newSightId}`, { await languageInstance(lang).patch(`/sight/${newSightId}`, {
@@ -547,7 +549,10 @@ class CreateSightStore {
}); });
} }
runInAction(() => {
this.needLeaveAgree = false; this.needLeaveAgree = false;
});
return newSightId; return newSightId;
}; };
@@ -555,7 +560,7 @@ class CreateSightStore {
filename: string, filename: string,
type: number, type: number,
file: File, file: File,
media_name?: string media_name?: string,
): Promise<MediaItem> => { ): Promise<MediaItem> => {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
@@ -585,7 +590,7 @@ class CreateSightStore {
createLinkWithLeftArticle = async (media: MediaItem) => { createLinkWithLeftArticle = async (media: MediaItem) => {
if (!this.sight.left_article || this.sight.left_article === 10000000) { if (!this.sight.left_article || this.sight.left_article === 10000000) {
console.warn( 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; return;
@@ -618,7 +623,7 @@ class CreateSightStore {
(["ru", "en", "zh"] as Language[]).forEach((lang) => { (["ru", "en", "zh"] as Language[]).forEach((lang) => {
if (this.sight[lang].left.media) { if (this.sight[lang].left.media) {
this.sight[lang].left.media = this.sight[lang].left.media.filter( 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 sortArticles = (existing: any[]) => {
const articleMap = new Map( const articleMap = new Map(
existing.map((article) => [article.id, article]) existing.map((article) => [article.id, article]),
); );
return articlesIds return articlesIds
.map((id) => articleMap.get(id)) .map((id) => articleMap.get(id))
.filter( .filter(
(article): article is (typeof existing)[number] => (article): article is (typeof existing)[number] =>
article !== undefined article !== undefined,
); );
}; };

View File

@@ -200,6 +200,49 @@ class RouteStore {
}); });
}; };
sightCounts: Map<number, number> = new Map();
stationCounts: Map<number, number> = 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; selectedStationId = 0;
setSelectedStationId = (id: number) => { setSelectedStationId = (id: number) => {

View File

@@ -49,6 +49,50 @@ class SnapshotStore {
makeAutoObservable(this); 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 = () => { private clearAllCaches = () => {
articlesStore.articleList = { articlesStore.articleList = {
ru: { data: [], loaded: false }, ru: { data: [], loaded: false },
@@ -297,6 +341,12 @@ class SnapshotStore {
await authInstance.post(`/snapshots/${id}/restore`); await authInstance.post(`/snapshots/${id}/restore`);
}; };
createEmptySnapshot = async (name: string) => {
await authInstance.post(`/snapshots/empty`, {
name: name.trim(),
});
};
createSnapshot = async (name: string) => { createSnapshot = async (name: string) => {
this.lastRequestId = uuidv4(); this.lastRequestId = uuidv4();

View File

@@ -38,6 +38,7 @@ import { Save } from "lucide-react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { SaveWithoutCityAgree } from "@widgets"; import { SaveWithoutCityAgree } from "@widgets";
@@ -50,6 +51,7 @@ export const CreateInformationTab = observer(
const { language } = languageStore; const { language } = languageStore;
const { sight, updateSightInfo, createSight } = createSightStore; const { sight, updateSightInfo, createSight } = createSightStore;
const data = sight[language]; const data = sight[language];
const navigate = useNavigate();
const [, setCity] = useState<number>(sight.city_id ?? 0); const [, setCity] = useState<number>(sight.city_id ?? 0);
const [coordinates, setCoordinates] = useState<string>(`0, 0`); const [coordinates, setCoordinates] = useState<string>(`0, 0`);
@@ -173,14 +175,16 @@ export const CreateInformationTab = observer(
return; return;
} }
await createSight(language); const newSightId = await createSight(language);
toast.success("Достопримечательность создана"); toast.success("Достопримечательность создана");
navigate(`/sight/${newSightId}/edit`);
}; };
const handleConfirmSave = async () => { const handleConfirmSave = async () => {
setIsSaveWarningOpen(false); setIsSaveWarningOpen(false);
await createSight(language); const newSightId = await createSight(language);
toast.success("Достопримечательность создана"); toast.success("Достопримечательность создана");
navigate(`/sight/${newSightId}/edit`);
}; };
const handleCancelSave = () => { const handleCancelSave = () => {

View File

@@ -20,6 +20,7 @@ import {
} from "@widgets"; } from "@widgets";
import { Trash2, ImagePlus, Unlink, Plus, Save, Search } from "lucide-react"; import { Trash2, ImagePlus, Unlink, Plus, Save, Search } from "lucide-react";
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@@ -41,6 +42,7 @@ export const CreateLeftTab = observer(
uploadMediaOpen, uploadMediaOpen,
setUploadMediaOpen, setUploadMediaOpen,
} = editSightStore; } = editSightStore;
const navigate = useNavigate();
const { language } = languageStore; const { language } = languageStore;
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
@@ -449,8 +451,9 @@ export const CreateLeftTab = observer(
startIcon={<Save color="white" size={18} />} startIcon={<Save color="white" size={18} />}
onClick={async () => { onClick={async () => {
try { try {
await createSight(language); const newSightId = await createSight(language);
toast.success("Страница создана"); toast.success("Страница создана");
navigate(`/sight/${newSightId}/edit`);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }

View File

@@ -1,9 +1,10 @@
import { import {
Box, Box,
Button, Button,
Paper,
Typography, Typography,
TextField, TextField,
Slider,
Stack,
} from "@mui/material"; } from "@mui/material";
import { import {
BackButton, BackButton,
@@ -19,17 +20,18 @@ import {
LanguageSwitcher, LanguageSwitcher,
MediaArea, MediaArea,
MediaAreaForSight, MediaAreaForSight,
ReactMarkdownComponent,
ReactMarkdownEditor, ReactMarkdownEditor,
DeleteModal, DeleteModal,
} from "@widgets"; } 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 { 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 { MediaViewer } from "../../MediaViewer/index";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { authInstance } from "@shared"; import { authInstance } from "@shared";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
import { SightFramePreview } from "../RightWidgetTab/SightFramePreview";
type MediaItemShared = { type MediaItemShared = {
id: string; id: string;
@@ -51,17 +53,19 @@ export const CreateRightTab = observer(
unlinkRightAritcle, unlinkRightAritcle,
deleteRightArticle, deleteRightArticle,
createSight, createSight,
clearCreateSight,
updateRightArticles, updateRightArticles,
updateSightInfo,
} = createSightStore; } = createSightStore;
const { language } = languageStore; const { language } = languageStore;
const navigate = useNavigate();
const { setFileToUpload, setUploadMediaOpen, uploadMediaOpen } = const { setFileToUpload, setUploadMediaOpen, uploadMediaOpen } =
editSightStore; editSightStore;
const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>( const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>(
null null,
); );
const [type, setType] = useState<"article" | "media">("media"); const [type, setType] = useState<"article" | "media">("media");
const [previewSection, setPreviewSection] = useState<number>(-1);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] = const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] =
useState(false); useState(false);
@@ -70,12 +74,34 @@ export const CreateRightTab = observer(
>(null); >(null);
const [previewMedia, setPreviewMedia] = useState<Media | null>(null); const [previewMedia, setPreviewMedia] = useState<Media | null>(null);
const shortNameRef = useRef<HTMLTextAreaElement | null>(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(() => { useEffect(() => {
if (sight.preview_media) { if (sight.preview_media) {
const fetchMedia = async () => { const fetchMedia = async () => {
const response = await authInstance.get( const response = await authInstance.get(
`/media/${sight.preview_media}` `/media/${sight.preview_media}`,
); );
setPreviewMedia(response.data); setPreviewMedia(response.data);
}; };
@@ -90,16 +116,17 @@ export const CreateRightTab = observer(
) { ) {
setActiveArticleIndex(null); setActiveArticleIndex(null);
setType("media"); setType("media");
setPreviewSection(-1);
} }
}, [language, sight[language].right, activeArticleIndex]); }, [language, sight[language].right, activeArticleIndex]);
const handleSave = async () => { const handleSave = async () => {
try { try {
await createSight(language); const newSightId = await createSight(language);
console.log("[handleSave] newSightId:", newSightId, "needLeaveAgree:", createSightStore.needLeaveAgree);
toast.success("Достопримечательность успешно создана!"); toast.success("Достопримечательность успешно создана!");
clearCreateSight(); navigate(`/sight/${newSightId}/edit`);
setActiveArticleIndex(null); console.log("[handleSave] navigate called to:", `/sight/${newSightId}/edit`);
setType("media");
} catch (error) { } catch (error) {
console.error("Failed to save sight:", error); console.error("Failed to save sight:", error);
toast.error("Ошибка при создании достопримечательности."); toast.error("Ошибка при создании достопримечательности.");
@@ -109,6 +136,7 @@ export const CreateRightTab = observer(
const handleDisplayArticleFromList = (idx: number) => { const handleDisplayArticleFromList = (idx: number) => {
setActiveArticleIndex(idx); setActiveArticleIndex(idx);
setType("article"); setType("article");
setPreviewSection(idx);
}; };
const handleCreateNewLocalArticle = async () => { const handleCreateNewLocalArticle = async () => {
@@ -116,15 +144,13 @@ export const CreateRightTab = observer(
const newArticleId = await createNewRightArticle(); const newArticleId = await createNewRightArticle();
const newIndex = sight[language].right.findIndex( const newIndex = sight[language].right.findIndex(
(a) => a.id === newArticleId (a) => a.id === newArticleId,
); );
if (newIndex > -1) { const resolvedIndex =
setActiveArticleIndex(newIndex); newIndex > -1 ? newIndex : sight[language].right.length - 1;
setActiveArticleIndex(resolvedIndex);
setType("article"); setType("article");
} else { setPreviewSection(resolvedIndex);
setActiveArticleIndex(sight[language].right.length - 1);
setType("article");
}
} catch (error) { } catch (error) {
toast.error("Не удалось создать новую статью."); toast.error("Не удалось создать новую статью.");
} }
@@ -140,7 +166,7 @@ export const CreateRightTab = observer(
}; };
const handleOpenSelectMediaDialog = ( const handleOpenSelectMediaDialog = (
target: "sightPreview" | "rightArticle" target: "sightPreview" | "rightArticle",
) => { ) => {
setMediaTarget(target); setMediaTarget(target);
setIsSelectMediaDialogOpen(true); setIsSelectMediaDialogOpen(true);
@@ -184,11 +210,8 @@ export const CreateRightTab = observer(
if (sourceIndex === destinationIndex) return; if (sourceIndex === destinationIndex) return;
const newRightArticles = [...sight[language].right]; const newRightArticles = [...sight[language].right];
const [movedArticle] = newRightArticles.splice(sourceIndex, 1); const [movedArticle] = newRightArticles.splice(sourceIndex, 1);
newRightArticles.splice(destinationIndex, 0, movedArticle); newRightArticles.splice(destinationIndex, 0, movedArticle);
updateRightArticles(newRightArticles); updateRightArticles(newRightArticles);
}; };
@@ -212,18 +235,22 @@ export const CreateRightTab = observer(
</div> </div>
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}> <Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}>
<Box className="flex flex-col w-[75%] gap-2"> <Box
className="flex flex-col gap-2"
sx={{ flexGrow: 1, minWidth: 0 }}
>
<Box className="w-full flex gap-2"> <Box className="w-full flex gap-2">
<Box className="relative w-[20%] h-[70vh] flex flex-col rounded-2xl overflow-y-auto gap-3 border border-gray-300 p-3"> <Box className="relative w-[20%] h-[70vh] flex flex-col rounded-2xl overflow-y-auto gap-3 border border-gray-300 p-3">
<Box className="flex flex-col gap-3 max-h-[60vh] overflow-y-auto"> <Box className="flex flex-col gap-3 max-h-[60vh] overflow-y-auto">
<Box <Box
onClick={() => { onClick={() => {
setType("media"); 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" type === "media"
? "bg-green-300 font-semibold" ? "bg-blue-400 text-white"
: "bg-green-200" : "bg-gray-200 hover:bg-gray-300"
}`} }`}
> >
<Typography>Предпросмотр медиа</Typography> <Typography>Предпросмотр медиа</Typography>
@@ -249,16 +276,19 @@ export const CreateRightTab = observer(
<Box <Box
ref={provided.innerRef} ref={provided.innerRef}
{...provided.draggableProps} {...provided.draggableProps}
className={`w-full bg-gray-200 p-4 rounded-2xl text-sm cursor-pointer hover:bg-gray-300 ${ className={`w-full p-4 rounded-2xl text-sm cursor-pointer transition-all duration-300 ${
snapshot.isDragging snapshot.isDragging
? "shadow-lg" ? "shadow-lg bg-gray-200"
: "" : activeArticleIndex ===
index &&
type === "article"
? "bg-blue-400 text-white"
: "bg-gray-200 hover:bg-gray-300"
}`} }`}
onClick={() => { onClick={() => {
handleDisplayArticleFromList( handleDisplayArticleFromList(
index index,
); );
setType("article");
}} }}
> >
<Box {...provided.dragHandleProps}> <Box {...provided.dragHandleProps}>
@@ -269,7 +299,7 @@ export const CreateRightTab = observer(
</Box> </Box>
)} )}
</Draggable> </Draggable>
) ),
) )
: null} : null}
{provided.placeholder} {provided.placeholder}
@@ -300,6 +330,7 @@ export const CreateRightTab = observer(
unlinkRightAritcle(currentRightArticle.id); unlinkRightAritcle(currentRightArticle.id);
setActiveArticleIndex(null); setActiveArticleIndex(null);
setType("media"); setType("media");
setPreviewSection(-1);
} }
}} }}
> >
@@ -310,9 +341,7 @@ export const CreateRightTab = observer(
color="error" color="error"
size="small" size="small"
startIcon={<Trash2 size={18} />} startIcon={<Trash2 size={18} />}
onClick={async () => { onClick={() => setIsDeleteModalOpen(true)}
setIsDeleteModalOpen(true);
}}
> >
Удалить Удалить
</Button> </Button>
@@ -336,7 +365,7 @@ export const CreateRightTab = observer(
activeArticleIndex, activeArticleIndex,
language, language,
e.target.value, e.target.value,
currentRightArticle.body currentRightArticle.body,
) )
} }
variant="outlined" variant="outlined"
@@ -351,7 +380,7 @@ export const CreateRightTab = observer(
activeArticleIndex, activeArticleIndex,
language, language,
currentRightArticle.heading, currentRightArticle.heading,
mdValue || "" mdValue || "",
) )
} }
/> />
@@ -373,11 +402,10 @@ export const CreateRightTab = observer(
/> />
</Box> </Box>
</Box> </Box>
) : 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 justify-center items-center">
<> {sight.preview_media && (
{type === "media" && ( <Box className="w-full 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">
@@ -388,8 +416,7 @@ export const CreateRightTab = observer(
<X size={20} color="red" /> <X size={20} color="red" />
</button> </button>
</Box> </Box>
<Box className="w-1/2 h-1/2 flex justify-center items-center">
<Box className="w-1/2 h-1/2">
<MediaViewer <MediaViewer
media={{ media={{
id: previewMedia.id || "", id: previewMedia.id || "",
@@ -402,8 +429,10 @@ export const CreateRightTab = observer(
</Box> </Box>
</> </>
)} )}
</Box>
)}
{!previewMedia && ( {!sight.preview_media && (
<Box className="w-full h-full flex justify-center items-center"> <Box className="w-full h-full flex justify-center items-center">
<Box <Box
sx={{ sx={{
@@ -429,169 +458,104 @@ export const CreateRightTab = observer(
)} )}
</Box> </Box>
)} )}
</>
</Box>
) : (
<Box className="w-[80%] h-[70vh] border border-gray-300 rounded-2xl p-3 flex justify-center items-center">
<Typography variant="h6" color="text.secondary">
Выберите статью слева или секцию "Предпросмотр медиа"
</Typography>
</Box>
)}
</Box> </Box>
</Box> </Box>
<Box className="w-[25%] mr-10">
{type === "article" && activeArticleIndex !== null && sight[language].right[activeArticleIndex] && (
<Paper
className="flex-1 flex flex-col max-w-[500px]"
sx={{
borderRadius: "10px",
overflow: "hidden",
}}
elevation={2}
>
<Box <Box
className=" overflow-hidden"
sx={{ sx={{
width: "100%", flexShrink: 0,
height: "100%", width: "550px",
overflow: "hidden",
background: "#877361",
borderColor: "grey.300",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
}}
>
{sight[language].right[activeArticleIndex].media.length >
0 ? (
<Box
sx={{
overflow: "hidden",
width: "100%",
padding: "2px 2px 0px 2px",
"& img": {
borderTopLeftRadius: "10px",
borderTopRightRadius: "10px",
width: "100%",
height: "auto",
},
}}
>
<MediaViewer
media={
sight[language].right[activeArticleIndex].media[0]
}
fullWidth
fullHeight
/>
</Box>
) : (
<Box
sx={{
width: "100%",
height: 200,
flexShrink: 0,
backgroundColor: "rgba(0,0,0,0.1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<ImagePlus size={48} color="white" />
</Box>
)}
<Box
sx={{
p: 1,
wordBreak: "break-word",
fontSize: "24px",
fontWeight: 700,
lineHeight: "120%",
backdropFilter: "blur(12px)",
borderBottom: "1px solid #A89F90",
boxShadow:
"inset 4px 4px 12px 0 rgba(255,255,255,0.12)",
background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
}}
>
<Typography variant="h6" color="white">
{sight[language].right[activeArticleIndex].heading ||
"Выберите статью"}
</Typography>
</Box>
<Box
sx={{
padding: 1,
minHeight: "200px",
maxHeight: "300px",
overflowY: "auto",
"&::-webkit-scrollbar": {
display: "none",
},
"&": {
scrollbarWidth: "none",
},
background:
"rgba(179, 165, 152, 0.4), linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 100%)",
flexGrow: 1,
}}
>
{sight[language].right[activeArticleIndex].body ? (
<ReactMarkdownComponent
value={sight[language].right[activeArticleIndex].body}
/>
) : (
<Typography
color="rgba(255,255,255,0.7)"
sx={{ textAlign: "center", mt: 4 }}
>
Предпросмотр статьи появится здесь
</Typography>
)}
</Box>
<Box
sx={{
p: 2,
display: "flex",
justifyContent: "space-between",
fontSize: "24px",
fontWeight: 700,
lineHeight: "120%",
flexWrap: "wrap",
gap: 1, gap: 1,
backdropFilter: "blur(12px)",
boxShadow:
"inset 4px 4px 12px 0 rgba(255,255,255,0.12)",
background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
}} }}
> >
{sight[language].right.length > 0 && {type === "media" && (
sight[language].right.map((article, index) => ( <Stack direction="row" spacing={2} alignItems="center">
<button <TextField
className={`inline-block text-left text-xs text-white ${ type="number"
activeArticleIndex === index ? "underline" : "" label="Размер шрифта превью (px)"
}`} size="small"
onClick={() => { value={sight.preview_font_size ?? ""}
setActiveArticleIndex(index); onChange={(e) => {
setType("article"); 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 });
}
}} }}
> slotProps={{ input: { min: 1, max: 300 } }}
{article.heading} sx={{ width: "200px" }}
</button> />
))} <Slider
</Box> value={sight.preview_font_size ?? 40}
</Box> min={1}
</Paper> max={300}
step={1}
onChange={(_, newValue) => {
if (typeof newValue === "number") {
updateSightInfo({ preview_font_size: newValue });
}
}}
sx={{ flexGrow: 1 }}
/>
</Stack>
)} )}
<Stack direction="row" spacing={1} alignItems="flex-start">
<TextField
label="Полное название (поддерживает перенос ↵)"
multiline
minRows={1}
maxRows={4}
size="small"
value={sight[language].name}
onChange={(e) =>
updateSightInfo({ name: e.target.value }, language)
}
inputRef={shortNameRef}
sx={{ flexGrow: 1 }}
/>
<Button
variant="outlined"
size="small"
onClick={insertNewline}
title="Вставить перенос строки"
sx={{
minWidth: 40,
height: 40,
fontSize: 18,
p: 0,
flexShrink: 0,
}}
>
</Button>
</Stack>
<SightFramePreview
sightName={sight[language].name}
previewMedia={previewMedia}
articles={sight[language].right}
onArticleSelect={(idx) => {
handleDisplayArticleFromList(idx);
}}
previewFontSize={sight.preview_font_size}
selectedSection={previewSection}
onSectionChange={(section) => {
setPreviewSection(section);
if (section === -1) {
setType("media");
} else {
handleDisplayArticleFromList(section);
}
}}
/>
</Box> </Box>
</Box> </Box>
@@ -603,7 +567,6 @@ export const CreateRightTab = observer(
right: 0, right: 0,
padding: 2, padding: 2,
backgroundColor: "background.paper", backgroundColor: "background.paper",
width: "100%", width: "100%",
display: "flex", display: "flex",
justifyContent: "flex-end", justifyContent: "flex-end",
@@ -650,10 +613,18 @@ export const CreateRightTab = observer(
open={isDeleteModalOpen} open={isDeleteModalOpen}
onDelete={async () => { onDelete={async () => {
try { try {
const idx = activeArticleIndex ?? 0;
await deleteRightArticle(currentRightArticle?.id || 0); await deleteRightArticle(currentRightArticle?.id || 0);
setIsDeleteModalOpen(false); setIsDeleteModalOpen(false);
if (idx > 0) {
setActiveArticleIndex(idx - 1);
setPreviewSection(idx - 1);
setType("article");
} else {
setActiveArticleIndex(null); setActiveArticleIndex(null);
setPreviewSection(-1);
setType("media"); setType("media");
}
toast.success("Статья удалена"); toast.success("Статья удалена");
} catch { } catch {
toast.error("Не удалось удалить статью"); toast.error("Не удалось удалить статью");
@@ -663,5 +634,5 @@ export const CreateRightTab = observer(
/> />
</TabPanel> </TabPanel>
); );
} },
); );

View File

@@ -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 { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
import { ReactMarkdownComponent } from "../../ReactMarkdown"; import { ReactMarkdownComponent } from "../../ReactMarkdown";
import { ThreeViewErrorBoundary } from "../../MediaViewer/ThreeViewErrorBoundary"; import { ThreeViewErrorBoundary } from "../../MediaViewer/ThreeViewErrorBoundary";
@@ -34,6 +34,8 @@ interface SightFramePreviewProps {
articles: Article[]; articles: Article[];
onArticleSelect: (index: number) => void; onArticleSelect: (index: number) => void;
previewFontSize?: number; previewFontSize?: number;
selectedSection: number;
onSectionChange: (section: number) => void;
} }
// Matches SightFrame.jsx renderCurrentMedia — same structure, same CSS classes // Matches SightFrame.jsx renderCurrentMedia — same structure, same CSS classes
@@ -153,11 +155,10 @@ export function SightFramePreview({
articles, articles,
onArticleSelect, onArticleSelect,
previewFontSize, previewFontSize,
selectedSection,
onSectionChange,
}: SightFramePreviewProps) { }: SightFramePreviewProps) {
const token = localStorage.getItem("token") ?? ""; const token = localStorage.getItem("token") ?? "";
// -1 = intro (section 0 in SightFrame)
const [selectedSection, setSelectedSection] = useState<number>(-1);
const [threeViewResetKey, setThreeViewResetKey] = useState(0); const [threeViewResetKey, setThreeViewResetKey] = useState(0);
const threeViewControlRef = useRef<ThreeViewHandle | null>(null); const threeViewControlRef = useRef<ThreeViewHandle | null>(null);
@@ -175,6 +176,17 @@ export function SightFramePreview({
// Replicates processedSightName from SightFrame.jsx // Replicates processedSightName from SightFrame.jsx
const processedSightName = useMemo(() => { const processedSightName = useMemo(() => {
if (!sightName) return sightName; if (!sightName) return sightName;
// Handle \n line breaks
if (sightName.includes("\n")) {
return sightName.split("\n").map((line, i) => (
<React.Fragment key={i}>
{i > 0 && <br />}
{line}
</React.Fragment>
));
}
const namePattern = const namePattern =
/([А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]+)/g; /([А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]+)/g;
const parts = sightName.split(namePattern); const parts = sightName.split(namePattern);
@@ -199,10 +211,9 @@ export function SightFramePreview({
// Replicates titleLineHeight from SightFrame.jsx // Replicates titleLineHeight from SightFrame.jsx
const titleLineHeight = useMemo(() => { const titleLineHeight = useMemo(() => {
if (!sightName) return "120%"; if (!sightName) return "120%";
const textLength = sightName.length;
const calculatedLineHeight = Math.max( const calculatedLineHeight = Math.max(
100, 100,
Math.min(120, 120 - (textLength / 10) * 1) Math.min(120, 120 - (sightName.length / 10) * 1)
); );
return `${calculatedLineHeight}%`; return `${calculatedLineHeight}%`;
}, [sightName]); }, [sightName]);
@@ -272,7 +283,7 @@ export function SightFramePreview({
<button <button
className="sfp-back-btn" className="sfp-back-btn"
type="button" type="button"
onClick={() => setSelectedSection(-1)} onClick={() => onSectionChange(-1)}
> >
<svg <svg
width="20" width="20"
@@ -320,7 +331,7 @@ export function SightFramePreview({
type="button" type="button"
className={`sfp-sight-frame-menu-point${selectedSection === index ? " active" : ""}`} className={`sfp-sight-frame-menu-point${selectedSection === index ? " active" : ""}`}
onClick={() => { onClick={() => {
setSelectedSection(index); onSectionChange(index);
onArticleSelect(index); onArticleSelect(index);
}} }}
> >

View File

@@ -24,7 +24,7 @@ import {
} from "@widgets"; } from "@widgets";
import { Plus, Save, Trash2, Unlink, X } from "lucide-react"; import { Plus, Save, Trash2, Unlink, X } from "lucide-react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { MediaViewer } from "../../MediaViewer/index"; import { MediaViewer } from "../../MediaViewer/index";
import { import {
@@ -55,6 +55,27 @@ export const RightWidgetTab = observer(
} = editSightStore; } = editSightStore;
const [previewMedia, setPreviewMedia] = useState<any | null>(null); const [previewMedia, setPreviewMedia] = useState<any | null>(null);
const shortNameRef = useRef<HTMLTextAreaElement | null>(null);
const insertNewline = () => {
const input = shortNameRef.current;
const currentValue = sight[language].name || "";
if (!input) {
updateSightInfo(language, { name: currentValue + "\n" });
return;
}
const start = input.selectionStart ?? currentValue.length;
const end = input.selectionEnd ?? start;
const newValue = currentValue.slice(0, start) + "\n" + currentValue.slice(end);
updateSightInfo(language, { name: newValue });
requestAnimationFrame(() => {
if (shortNameRef.current) {
shortNameRef.current.selectionStart = start + 1;
shortNameRef.current.selectionEnd = start + 1;
shortNameRef.current.focus();
}
});
};
useEffect(() => { useEffect(() => {
const fetchPreviewMedia = async () => { const fetchPreviewMedia = async () => {
@@ -88,13 +109,23 @@ export const RightWidgetTab = observer(
const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>( const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>(
null null
); );
const [previewSection, setPreviewSection] = useState<number>(-1);
const [isSelectMediaModalOpen, setIsSelectMediaModalOpen] = useState(false); const [isSelectMediaModalOpen, setIsSelectMediaModalOpen] = useState(false);
const [isDeleteArticleModalOpen, setIsDeleteArticleModalOpen] = const [isDeleteArticleModalOpen, setIsDeleteArticleModalOpen] =
useState(false); useState(false);
const handleDeleteArticle = () => { const handleDeleteArticle = () => {
deleteRightArticle(sight[language].right[activeArticleIndex || 0].id); const idx = activeArticleIndex || 0;
deleteRightArticle(sight[language].right[idx].id);
if (idx > 0) {
setActiveArticleIndex(idx - 1);
setPreviewSection(idx - 1);
setType("article");
} else {
setActiveArticleIndex(null); setActiveArticleIndex(null);
setPreviewSection(-1);
setType("media");
}
}; };
const handleSelectArticle = (index: number) => { const handleSelectArticle = (index: number) => {
@@ -111,6 +142,7 @@ export const RightWidgetTab = observer(
if (newIndex > -1) { if (newIndex > -1) {
setActiveArticleIndex(newIndex); setActiveArticleIndex(newIndex);
setType("article"); setType("article");
setPreviewSection(newIndex);
} }
} catch (error) { } catch (error) {
console.error("Error creating new article:", error); console.error("Error creating new article:", error);
@@ -178,8 +210,15 @@ export const RightWidgetTab = observer(
<Box className="relative w-[20%] h-[70vh] flex flex-col rounded-2xl overflow-y-auto gap-3 border border-gray-300 p-3"> <Box className="relative w-[20%] h-[70vh] flex flex-col rounded-2xl overflow-y-auto gap-3 border border-gray-300 p-3">
<Box className="flex flex-col gap-3 max-h-[60vh] overflow-y-auto"> <Box className="flex flex-col gap-3 max-h-[60vh] overflow-y-auto">
<Box <Box
onClick={() => setType("media")} onClick={() => {
className="w-full bg-green-200 p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-300" setType("media");
setPreviewSection(-1);
}}
className={`w-full p-4 rounded-2xl cursor-pointer text-sm transition-all duration-300 ${
type === "media"
? "bg-blue-400 text-white"
: "bg-gray-200 hover:bg-gray-300"
}`}
> >
<Typography>Предпросмотр медиа</Typography> <Typography>Предпросмотр медиа</Typography>
</Box> </Box>
@@ -204,14 +243,17 @@ export const RightWidgetTab = observer(
<Box <Box
ref={provided.innerRef} ref={provided.innerRef}
{...provided.draggableProps} {...provided.draggableProps}
className={`w-full bg-gray-200 p-4 rounded-2xl text-sm cursor-pointer hover:bg-gray-300 ${ className={`w-full p-4 rounded-2xl text-sm cursor-pointer transition-all duration-300 ${
snapshot.isDragging snapshot.isDragging
? "shadow-lg" ? "shadow-lg bg-gray-200"
: "" : activeArticleIndex === index && type === "article"
? "bg-blue-400 text-white"
: "bg-gray-200 hover:bg-gray-300"
}`} }`}
onClick={() => { onClick={() => {
handleSelectArticle(index); handleSelectArticle(index);
setType("article"); setType("article");
setPreviewSection(index);
}} }}
> >
<Box {...provided.dragHandleProps}> <Box {...provided.dragHandleProps}>
@@ -399,6 +441,7 @@ export const RightWidgetTab = observer(
</Box> </Box>
<Box sx={{ flexShrink: 0, width: "550px", display: "flex", flexDirection: "column", gap: 1 }}> <Box sx={{ flexShrink: 0, width: "550px", display: "flex", flexDirection: "column", gap: 1 }}>
{type === "media" && (
<Stack direction="row" spacing={2} alignItems="center"> <Stack direction="row" spacing={2} alignItems="center">
<TextField <TextField
type="number" type="number"
@@ -432,6 +475,29 @@ export const RightWidgetTab = observer(
sx={{ flexGrow: 1 }} sx={{ flexGrow: 1 }}
/> />
</Stack> </Stack>
)}
<Stack direction="row" spacing={1} alignItems="flex-start">
<TextField
label="Полное название (поддерживает перенос ↵)"
multiline
minRows={1}
maxRows={4}
size="small"
value={sight[language].name}
onChange={(e) => updateSightInfo(language, { name: e.target.value })}
inputRef={shortNameRef}
sx={{ flexGrow: 1 }}
/>
<Button
variant="outlined"
size="small"
onClick={insertNewline}
title="Вставить перенос строки"
sx={{ minWidth: 40, height: 40, fontSize: 18, p: 0, flexShrink: 0 }}
>
</Button>
</Stack>
<SightFramePreview <SightFramePreview
sightName={sight[language].name} sightName={sight[language].name}
previewMedia={previewMedia} previewMedia={previewMedia}
@@ -439,8 +505,19 @@ export const RightWidgetTab = observer(
onArticleSelect={(idx) => { onArticleSelect={(idx) => {
handleSelectArticle(idx); handleSelectArticle(idx);
setType("article"); setType("article");
setPreviewSection(idx);
}} }}
previewFontSize={sight.common.preview_font_size} previewFontSize={sight.common.preview_font_size}
selectedSection={previewSection}
onSectionChange={(section) => {
setPreviewSection(section);
if (section === -1) {
setType("media");
} else {
handleSelectArticle(section);
setType("article");
}
}}
/> />
</Box> </Box>
</Box> </Box>

View File

@@ -37,7 +37,8 @@ export const TestingModeBanner = observer(() => {
pointerEvents: "none", pointerEvents: "none",
}} }}
> >
ПРОВОДИТСЯ ТЕСТИРОВАНИЕ. ПРОСЬБА НЕ ВЗАИМОДЕЙСТВОВАТЬ С АДМИН-ПАНЕЛЬЮ ПРОВОДИТСЯ ТЕСТИРОВАНИЕ. ПРОСЬБА НЕ ВЗАИМОДЕЙСТВОВАТЬ С ПАНЕЛЬЮ
АДМИНИСТРИРОВАНИЯ
</div> </div>
); );
}); });