feat: cache delete + empty snapshot + route page
This commit is contained in:
@@ -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();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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="Герб города"
|
||||||
|
|||||||
@@ -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="Герб города"
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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: "Выйти",
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ export const TestingModeBanner = observer(() => {
|
|||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
ПРОВОДИТСЯ ТЕСТИРОВАНИЕ. ПРОСЬБА НЕ ВЗАИМОДЕЙСТВОВАТЬ С АДМИН-ПАНЕЛЬЮ
|
ПРОВОДИТСЯ ТЕСТИРОВАНИЕ. ПРОСЬБА НЕ ВЗАИМОДЕЙСТВОВАТЬ С ПАНЕЛЬЮ
|
||||||
|
АДМИНИСТРИРОВАНИЯ
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user