fix: Delete ai comments

This commit is contained in:
2025-11-06 00:58:10 +03:00
parent 5298fb9f60
commit 1917b2cf5a
41 changed files with 203 additions and 1107 deletions

View File

@@ -16,12 +16,7 @@ import {
SnapshotListPage, SnapshotListPage,
CarrierListPage, CarrierListPage,
StationListPage, StationListPage,
// VehicleListPage,
ArticleListPage, ArticleListPage,
// CountryPreviewPage,
// VehiclePreviewPage,
// CarrierPreviewPage,
SnapshotCreatePage, SnapshotCreatePage,
CountryCreatePage, CountryCreatePage,
CityCreatePage, CityCreatePage,
@@ -31,7 +26,6 @@ import {
CityEditPage, CityEditPage,
UserCreatePage, UserCreatePage,
UserEditPage, UserEditPage,
// VehicleEditPage,
CarrierEditPage, CarrierEditPage,
StationCreatePage, StationCreatePage,
StationPreviewPage, StationPreviewPage,
@@ -75,7 +69,6 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>; return <>{children}</>;
}; };
// Чтобы очистка сторов происходила при смене локации
const ClearStoresWrapper: React.FC<{ children: React.ReactNode }> = ({ const ClearStoresWrapper: React.FC<{ children: React.ReactNode }> = ({
children, children,
}) => { }) => {
@@ -116,65 +109,45 @@ const router = createBrowserRouter([
children: [ children: [
{ index: true, element: <MainPage /> }, { index: true, element: <MainPage /> },
// Sight
{ path: "sight", element: <SightListPage /> }, { path: "sight", element: <SightListPage /> },
{ path: "sight/create", element: <CreateSightPage /> }, { path: "sight/create", element: <CreateSightPage /> },
{ path: "sight/:id/edit", element: <EditSightPage /> }, { path: "sight/:id/edit", element: <EditSightPage /> },
// Device
{ path: "devices", element: <DevicesPage /> }, { path: "devices", element: <DevicesPage /> },
// Map
{ path: "map", element: <MapPage /> }, { path: "map", element: <MapPage /> },
// Media
{ path: "media", element: <MediaListPage /> }, { path: "media", element: <MediaListPage /> },
{ path: "media/:id", element: <MediaPreviewPage /> }, { path: "media/:id", element: <MediaPreviewPage /> },
{ path: "media/:id/edit", element: <MediaEditPage /> }, { path: "media/:id/edit", element: <MediaEditPage /> },
// Country
{ path: "country", element: <CountryListPage /> }, { path: "country", element: <CountryListPage /> },
{ path: "country/create", element: <CountryCreatePage /> }, { path: "country/create", element: <CountryCreatePage /> },
{ path: "country/add", element: <CountryAddPage /> }, { path: "country/add", element: <CountryAddPage /> },
// { path: "country/:id", element: <CountryPreviewPage /> },
{ path: "country/:id/edit", element: <CountryEditPage /> }, { path: "country/:id/edit", element: <CountryEditPage /> },
// City
{ path: "city", element: <CityListPage /> }, { path: "city", element: <CityListPage /> },
{ path: "city/create", element: <CityCreatePage /> }, { path: "city/create", element: <CityCreatePage /> },
// { path: "city/:id", element: <CityPreviewPage /> },
{ path: "city/:id/edit", element: <CityEditPage /> }, { path: "city/:id/edit", element: <CityEditPage /> },
// Route
{ path: "route", element: <RouteListPage /> }, { path: "route", element: <RouteListPage /> },
{ path: "route/create", element: <RouteCreatePage /> }, { path: "route/create", element: <RouteCreatePage /> },
{ path: "route/:id/edit", element: <RouteEditPage /> }, { path: "route/:id/edit", element: <RouteEditPage /> },
// User
{ path: "user", element: <UserListPage /> }, { path: "user", element: <UserListPage /> },
{ path: "user/create", element: <UserCreatePage /> }, { path: "user/create", element: <UserCreatePage /> },
{ path: "user/:id/edit", element: <UserEditPage /> }, { path: "user/:id/edit", element: <UserEditPage /> },
// Snapshot
{ path: "snapshot", element: <SnapshotListPage /> }, { path: "snapshot", element: <SnapshotListPage /> },
{ path: "snapshot/create", element: <SnapshotCreatePage /> }, { path: "snapshot/create", element: <SnapshotCreatePage /> },
// Carrier
{ path: "carrier", element: <CarrierListPage /> }, { path: "carrier", element: <CarrierListPage /> },
{ path: "carrier/create", element: <CarrierCreatePage /> }, { path: "carrier/create", element: <CarrierCreatePage /> },
// { path: "carrier/:id", element: <CarrierPreviewPage /> },
{ path: "carrier/:id/edit", element: <CarrierEditPage /> }, { path: "carrier/:id/edit", element: <CarrierEditPage /> },
// Station
{ path: "station", element: <StationListPage /> }, { path: "station", element: <StationListPage /> },
{ path: "station/create", element: <StationCreatePage /> }, { path: "station/create", element: <StationCreatePage /> },
{ path: "station/:id", element: <StationPreviewPage /> }, { path: "station/:id", element: <StationPreviewPage /> },
{ path: "station/:id/edit", element: <StationEditPage /> }, { path: "station/:id/edit", element: <StationEditPage /> },
// Vehicle
// { path: "vehicle", element: <VehicleListPage /> },
{ path: "vehicle/create", element: <VehicleCreatePage /> }, { path: "vehicle/create", element: <VehicleCreatePage /> },
// { path: "vehicle/:id", element: <VehiclePreviewPage /> },
// { path: "vehicle/:id/edit", element: <VehicleEditPage /> },
// Article
{ path: "article", element: <ArticleListPage /> }, { path: "article", element: <ArticleListPage /> },
{ path: "article/:id", element: <ArticlePreviewPage /> }, { path: "article/:id", element: <ArticlePreviewPage /> },
// { path: "media/create", element: <CreateMediaPage /> },
], ],
}, },
]); ]);

View File

@@ -48,7 +48,6 @@ export const CarrierCreatePage = observer(() => {
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}, []); }, []);
// Автоматически устанавливаем выбранный город при загрузке страницы
useEffect(() => { useEffect(() => {
if (selectedCityId && !createCarrierData.city_id) { if (selectedCityId && !createCarrierData.city_id) {
setCreateCarrierData( setCreateCarrierData(

View File

@@ -74,7 +74,6 @@ export const CarrierEditPage = observer(() => {
mediaStore.getMedia(); mediaStore.getMedia();
})(); })();
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}, [id]); }, [id]);

View File

@@ -44,7 +44,6 @@ export const CityEditPage = observer(() => {
const { getMedia, getOneMedia } = mediaStore; const { getMedia, getOneMedia } = mediaStore;
useEffect(() => { useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}, []); }, []);
@@ -64,12 +63,11 @@ export const CityEditPage = observer(() => {
(async () => { (async () => {
if (id) { if (id) {
await getCountries("ru"); await getCountries("ru");
// Fetch data for all languages
const ruData = await getCity(id as string, "ru"); const ruData = await getCity(id as string, "ru");
const enData = await getCity(id as string, "en"); const enData = await getCity(id as string, "en");
const zhData = await getCity(id as string, "zh"); const zhData = await getCity(id as string, "zh");
// Set data for each language
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");
@@ -207,7 +205,7 @@ export const CityEditPage = observer(() => {
open={isSelectMediaOpen} open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)} onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect} onSelectMedia={handleMediaSelect}
mediaType={1} // Тип медиа для иконок mediaType={1}
/> />
<UploadMediaDialog <UploadMediaDialog

View File

@@ -17,7 +17,6 @@ export const CountryEditPage = observer(() => {
countryStore; countryStore;
useEffect(() => { useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}, []); }, []);
@@ -36,12 +35,10 @@ export const CountryEditPage = observer(() => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
// Fetch data for all languages
const ruData = await getCountry(id as string, "ru"); const ruData = await getCountry(id as string, "ru");
const enData = await getCountry(id as string, "en"); const enData = await getCountry(id as string, "en");
const zhData = await getCountry(id as string, "zh"); const zhData = await getCountry(id as string, "zh");
// Set data for each language
setEditCountryData(ruData.name, "ru"); setEditCountryData(ruData.name, "ru");
setEditCountryData(enData.name, "en"); setEditCountryData(enData.name, "en");
setEditCountryData(zhData.name, "zh"); setEditCountryData(zhData.name, "zh");

View File

@@ -24,7 +24,6 @@ export const LoginPage = () => {
const { login } = authStore; const { login } = authStore;
const { getUsers } = userStore; const { getUsers } = userStore;
useEffect(() => { useEffect(() => {
// Load saved credentials if they exist
const savedEmail = localStorage.getItem("rememberedEmail"); const savedEmail = localStorage.getItem("rememberedEmail");
const savedPassword = localStorage.getItem("rememberedPassword"); const savedPassword = localStorage.getItem("rememberedPassword");
if (savedEmail && savedPassword) { if (savedEmail && savedPassword) {
@@ -42,7 +41,6 @@ export const LoginPage = () => {
try { try {
await login(email, password); await login(email, password);
// Save or clear credentials based on remember me checkbox
if (rememberMe) { if (rememberMe) {
localStorage.setItem("rememberedEmail", email); localStorage.setItem("rememberedEmail", email);
localStorage.setItem("rememberedPassword", password); localStorage.setItem("rememberedPassword", password);

View File

@@ -60,7 +60,6 @@ import Source from "ol/source/Source";
import { FeatureLike } from "ol/Feature"; import { FeatureLike } from "ol/Feature";
import { createEmpty, extend, getCenter } from "ol/extent"; import { createEmpty, extend, getCenter } from "ol/extent";
// --- CUSTOM SCROLLBAR STYLES ---
const scrollbarStyles = ` const scrollbarStyles = `
.scrollbar-hide { .scrollbar-hide {
-ms-overflow-style: none; -ms-overflow-style: none;
@@ -100,8 +99,6 @@ if (typeof document !== "undefined") {
document.head.appendChild(styleElement); document.head.appendChild(styleElement);
} }
// --- MAP STORE ---
// @ts-ignore
import { languageInstance } from "@shared"; import { languageInstance } from "@shared";
import { makeAutoObservable } from "mobx"; import { makeAutoObservable } from "mobx";
@@ -114,14 +111,11 @@ import {
carrierStore, carrierStore,
} from "@shared"; } from "@shared";
// Функция для сброса кешей карты
export const clearMapCaches = () => { export const clearMapCaches = () => {
// Сброс кешей маршрутов
mapStore.routes = []; mapStore.routes = [];
mapStore.stations = []; mapStore.stations = [];
mapStore.sights = []; mapStore.sights = [];
// Сброс кешей MapService если он доступен
if (typeof window !== "undefined" && (window as any).mapServiceInstance) { if (typeof window !== "undefined" && (window as any).mapServiceInstance) {
(window as any).mapServiceInstance.clearCaches(); (window as any).mapServiceInstance.clearCaches();
} }
@@ -166,7 +160,6 @@ export type SortType =
| "updated_asc" | "updated_asc"
| "updated_desc"; | "updated_desc";
// --- HIDDEN ROUTES STORAGE ---
const HIDDEN_ROUTES_KEY = "mapHiddenRoutes"; const HIDDEN_ROUTES_KEY = "mapHiddenRoutes";
const HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY = "mapHideSightsByHiddenRoutes"; const HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY = "mapHideSightsByHiddenRoutes";
@@ -202,9 +195,9 @@ const saveHiddenRoutes = (hiddenRoutes: Set<number>): void => {
class MapStore { class MapStore {
constructor() { constructor() {
makeAutoObservable(this); makeAutoObservable(this);
// Загружаем скрытые маршруты из localStorage при инициализации
this.hiddenRoutes = getStoredHiddenRoutes(); this.hiddenRoutes = getStoredHiddenRoutes();
// Загружаем настройку скрытия достопримечательностей
try { try {
const stored = localStorage.getItem(HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY); const stored = localStorage.getItem(HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY);
this.hideSightsByHiddenRoutes = stored this.hideSightsByHiddenRoutes = stored
@@ -220,8 +213,8 @@ class MapStore {
sights: ApiSight[] = []; sights: ApiSight[] = [];
hiddenRoutes: Set<number>; hiddenRoutes: Set<number>;
hideSightsByHiddenRoutes: boolean = false; hideSightsByHiddenRoutes: boolean = false;
routeStationsCache: Map<number, number[]> = new Map(); // Кэш станций для маршрутов routeStationsCache: Map<number, number[]> = new Map();
routeSightsCache: Map<number, number[]> = new Map(); // Кэш достопримечательностей для маршрутов routeSightsCache: Map<number, number[]> = new Map();
setHideSightsByHiddenRoutes(val: boolean) { setHideSightsByHiddenRoutes(val: boolean) {
this.hideSightsByHiddenRoutes = val; this.hideSightsByHiddenRoutes = val;
try { try {
@@ -232,11 +225,9 @@ class MapStore {
} catch (e) {} } catch (e) {}
} }
// НОВЫЕ ПОЛЯ ДЛЯ СОРТИРОВКИ
stationSort: SortType = "name_asc"; stationSort: SortType = "name_asc";
sightSort: SortType = "name_asc"; sightSort: SortType = "name_asc";
// НОВЫЕ МЕТОДЫ-СЕТТЕРЫ
setStationSort = (sortType: SortType) => { setStationSort = (sortType: SortType) => {
this.stationSort = sortType; this.stationSort = sortType;
}; };
@@ -245,7 +236,6 @@ class MapStore {
this.sightSort = sortType; this.sightSort = sortType;
}; };
// ПРИВАТНЫЙ МЕТОД ДЛЯ ОБЩЕЙ ЛОГИКИ СОРТИРОВКИ
private sortFeatures<T extends ApiStation | ApiSight>( private sortFeatures<T extends ApiStation | ApiSight>(
features: T[], features: T[],
sortType: SortType sortType: SortType
@@ -269,7 +259,7 @@ class MapStore {
new Date(b.created_at).getTime() new Date(b.created_at).getTime()
); );
} }
// Фоллбэк: сортировка по ID, если дата недоступна
return a.id - b.id; return a.id - b.id;
}); });
case "created_desc": case "created_desc":
@@ -285,7 +275,7 @@ class MapStore {
new Date(a.created_at).getTime() new Date(a.created_at).getTime()
); );
} }
// Фоллбэк: сортировка по ID, если дата недоступна
return b.id - a.id; return b.id - a.id;
}); });
case "updated_asc": case "updated_asc":
@@ -319,7 +309,6 @@ class MapStore {
} }
} }
// НОВЫЕ ГЕТТЕРЫ, ВОЗВРАЩАЮЩИЕ ОТСОРТИРОВАННЫЕ СПИСКИ
get sortedStations(): ApiStation[] { get sortedStations(): ApiStation[] {
return this.sortFeatures(this.stations, this.stationSort); return this.sortFeatures(this.stations, this.stationSort);
} }
@@ -328,7 +317,6 @@ class MapStore {
return this.sortFeatures(this.sights, this.sightSort); return this.sortFeatures(this.sights, this.sightSort);
} }
// ГЕТТЕРЫ ДЛЯ ФИЛЬТРАЦИИ ПО ГОРОДУ
get filteredStations(): ApiStation[] { get filteredStations(): ApiStation[] {
const selectedCityId = selectedCityStore.selectedCityId; const selectedCityId = selectedCityStore.selectedCityId;
if (!selectedCityId) { if (!selectedCityId) {
@@ -345,12 +333,9 @@ class MapStore {
return this.routes; return this.routes;
} }
// Получаем carriers для текущего языка
const carriers = carrierStore.carriers.ru.data; const carriers = carrierStore.carriers.ru.data;
// Фильтруем маршруты по городу через carriers
return this.routes.filter((route: ApiRoute) => { return this.routes.filter((route: ApiRoute) => {
// Находим carrier для маршрута
const carrier = carriers.find((c: any) => c.id === route.carrier_id); const carrier = carriers.find((c: any) => c.id === route.carrier_id);
return carrier && carrier.city_id === selectedCityId; return carrier && carrier.city_id === selectedCityId;
}); });
@@ -366,14 +351,12 @@ class MapStore {
return cityFiltered; return cityFiltered;
} }
// Собираем все достопримечательности, связанные со скрытыми маршрутами
const hiddenSightIds = new Set<number>(); const hiddenSightIds = new Set<number>();
this.hiddenRoutes.forEach((routeId) => { this.hiddenRoutes.forEach((routeId) => {
const sightIds = this.routeSightsCache.get(routeId) || []; const sightIds = this.routeSightsCache.get(routeId) || [];
sightIds.forEach((id) => hiddenSightIds.add(id)); sightIds.forEach((id) => hiddenSightIds.add(id));
}); });
// Фильтруем достопримечательности, исключая привязанные к скрытым маршрутам
return cityFiltered.filter((s) => !hiddenSightIds.has(s.id)); return cityFiltered.filter((s) => !hiddenSightIds.has(s.id));
} }
@@ -397,16 +380,12 @@ class MapStore {
a.route_number.localeCompare(b.route_number) a.route_number.localeCompare(b.route_number)
); );
// Предзагружаем станции для всех маршрутов и кэшируем их
await this.preloadRouteStations(routesIds); await this.preloadRouteStations(routesIds);
// Предзагружаем достопримечательности для всех маршрутов
await this.preloadRouteSights(routesIds); await this.preloadRouteSights(routesIds);
}; };
preloadRouteStations = async (routesIds: number[]) => { preloadRouteStations = async (routesIds: number[]) => {
console.log(
`[MapStore] Preloading stations for ${routesIds.length} routes`
);
const stationPromises = routesIds.map(async (routeId) => { const stationPromises = routesIds.map(async (routeId) => {
try { try {
const stationsResponse = await languageInstance("ru").get( const stationsResponse = await languageInstance("ru").get(
@@ -422,13 +401,9 @@ class MapStore {
} }
}); });
await Promise.all(stationPromises); await Promise.all(stationPromises);
console.log(
`[MapStore] Preloaded stations for ${this.routeStationsCache.size} routes`
);
}; };
preloadRouteSights = async (routesIds: number[]) => { preloadRouteSights = async (routesIds: number[]) => {
console.log(`[MapStore] Preloading sights for ${routesIds.length} routes`);
const sightPromises = routesIds.map(async (routeId) => { const sightPromises = routesIds.map(async (routeId) => {
try { try {
const sightsResponse = await languageInstance("ru").get( const sightsResponse = await languageInstance("ru").get(
@@ -441,9 +416,6 @@ class MapStore {
} }
}); });
await Promise.all(sightPromises); await Promise.all(sightPromises);
console.log(
`[MapStore] Preloaded sights for ${this.routeSightsCache.size} routes`
);
}; };
getStations = async () => { getStations = async () => {
@@ -473,7 +445,7 @@ class MapStore {
createFeature = async (featureType: string, geoJsonFeature: any) => { createFeature = async (featureType: string, geoJsonFeature: any) => {
const { geometry, properties } = geoJsonFeature; const { geometry, properties } = geoJsonFeature;
let createdItem; let createdItem: any;
if (featureType === "station") { if (featureType === "station") {
const name = properties.name || "Остановка 1"; const name = properties.name || "Остановка 1";
@@ -524,7 +496,6 @@ class MapStore {
"EPSG:3857" "EPSG:3857"
); );
// Автоматически назначаем перевозчика из выбранного города
let carrier_id = 0; let carrier_id = 0;
let carrier = ""; let carrier = "";
@@ -581,11 +552,8 @@ class MapStore {
throw new Error(`Unknown feature type for creation: ${featureType}`); throw new Error(`Unknown feature type for creation: ${featureType}`);
} }
// @ts-ignore
if (featureType === "route") this.routes.push(createdItem); if (featureType === "route") this.routes.push(createdItem);
// @ts-ignore
else if (featureType === "station") this.stations.push(createdItem); else if (featureType === "station") this.stations.push(createdItem);
// @ts-ignore
else if (featureType === "sight") this.sights.push(createdItem); else if (featureType === "sight") this.sights.push(createdItem);
return createdItem; return createdItem;
@@ -686,18 +654,15 @@ class MapStore {
const mapStore = new MapStore(); const mapStore = new MapStore();
// Делаем mapStore доступным глобально для сброса кешей
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
(window as any).mapStore = mapStore; (window as any).mapStore = mapStore;
} }
// --- CONFIGURATION ---
export const mapConfig = { export const mapConfig = {
center: [30.311, 59.94] as [number, number], center: [30.311, 59.94] as [number, number],
zoom: 13, zoom: 13,
}; };
// --- MAP POSITION STORAGE ---
const MAP_POSITION_KEY = "mapPosition"; const MAP_POSITION_KEY = "mapPosition";
const ACTIVE_SECTION_KEY = "mapActiveSection"; const ACTIVE_SECTION_KEY = "mapActiveSection";
@@ -736,7 +701,6 @@ const saveMapPosition = (position: MapPosition): void => {
} }
}; };
// --- ACTIVE SECTION STORAGE ---
const getStoredActiveSection = (): string | null => { const getStoredActiveSection = (): string | null => {
try { try {
const stored = localStorage.getItem(ACTIVE_SECTION_KEY); const stored = localStorage.getItem(ACTIVE_SECTION_KEY);
@@ -761,7 +725,6 @@ const saveActiveSection = (section: string | null): void => {
} }
}; };
// --- TYPE DEFINITIONS ---
interface MapServiceConfig { interface MapServiceConfig {
target: HTMLElement; target: HTMLElement;
center: [number, number]; center: [number, number];
@@ -774,15 +737,15 @@ class MapService {
private map: OLMap | null; private map: OLMap | null;
public pointSource: VectorSource<Feature<Point>>; public pointSource: VectorSource<Feature<Point>>;
public lineSource: VectorSource<Feature<LineString>>; public lineSource: VectorSource<Feature<LineString>>;
public clusterLayer: VectorLayer<Cluster>; // Public for the deselect handler public clusterLayer: VectorLayer<Cluster>;
public routeLayer: VectorLayer<VectorSource<Feature<LineString>>>; // Public for deselect public routeLayer: VectorLayer<VectorSource<Feature<LineString>>>;
private clusterSource: Cluster; private clusterSource: Cluster;
private clusterStyleCache: { [key: number]: Style }; private clusterStyleCache: { [key: number]: Style };
private tooltipElement: HTMLElement; private tooltipElement: HTMLElement;
private tooltipOverlay: Overlay | null; private tooltipOverlay: Overlay | null;
private mode: string | null; private mode: string | null;
// @ts-ignore
private currentDrawingType: "Point" | "LineString" | null; private currentDrawingType: "Point" | "LineString" | null;
private currentDrawingFeatureType: FeatureType | null; private currentDrawingFeatureType: FeatureType | null;
private currentInteraction: Draw | null; private currentInteraction: Draw | null;
@@ -801,7 +764,6 @@ class MapService {
null; null;
private isCreating: boolean = false; private isCreating: boolean = false;
// Styles
private defaultStyle: Style; private defaultStyle: Style;
private selectedStyle: Style; private selectedStyle: Style;
private drawStyle: Style; private drawStyle: Style;
@@ -816,7 +778,6 @@ class MapService {
private hoverSightIconStyle: Style; private hoverSightIconStyle: Style;
private universalHoverStyleLine: Style; private universalHoverStyleLine: Style;
// Callbacks
private setLoading: (loading: boolean) => void; private setLoading: (loading: boolean) => void;
private setError: (error: string | null) => void; private setError: (error: string | null) => void;
private onModeChangeCallback: (mode: string) => void; private onModeChangeCallback: (mode: string) => void;
@@ -958,13 +919,12 @@ class MapService {
this.routeLayer = new VectorLayer({ this.routeLayer = new VectorLayer({
source: this.lineSource, source: this.lineSource,
// @ts-ignore
style: (featureLike: FeatureLike) => { style: (featureLike: FeatureLike) => {
const feature = featureLike as Feature<Geometry>; const feature = featureLike as Feature<Geometry>;
if (!feature) return this.defaultStyle; if (!feature) return this.defaultStyle;
const fId = feature.getId(); const fId = feature.getId();
// Все маршруты всегда отображаются, так как они не кластеризуются
const isSelected = const isSelected =
this.selectInteraction?.getFeatures().getArray().includes(feature) || this.selectInteraction?.getFeatures().getArray().includes(feature) ||
(fId !== undefined && this.selectedIds.has(fId)); (fId !== undefined && this.selectedIds.has(fId));
@@ -1029,9 +989,6 @@ class MapService {
}); });
this.clusterSource.on("change", () => { this.clusterSource.on("change", () => {
// Поскольку маршруты больше не добавляются как точки,
// нам не нужно отслеживать unclusteredRouteIds
// Все маршруты всегда отображаются как линии
this.routeLayer.changed(); this.routeLayer.changed();
}); });
@@ -1080,21 +1037,18 @@ class MapService {
new KeyboardZoom(), new KeyboardZoom(),
new PinchZoom(), new PinchZoom(),
new PinchRotate(), new PinchRotate(),
// Отключаем DoubleClickZoom как было изначально
// new DoubleClickZoom(),
new DragPan({ new DragPan({
condition: (event) => { condition: (event) => {
// Разрешаем перетаскивание только при нажатии средней кнопки мыши (колёсико)
const originalEvent = event.originalEvent; const originalEvent = event.originalEvent;
if (!originalEvent) return false; if (!originalEvent) return false;
// Проверяем, что это событие мыши и нажата средняя кнопка
if ( if (
originalEvent.type === "pointerdown" || originalEvent.type === "pointerdown" ||
originalEvent.type === "pointermove" originalEvent.type === "pointermove"
) { ) {
const pointerEvent = originalEvent as PointerEvent; const pointerEvent = originalEvent as PointerEvent;
return pointerEvent.buttons === 4; // 4 = средняя кнопка мыши return pointerEvent.buttons === 4;
} }
return false; return false;
@@ -1167,7 +1121,7 @@ class MapService {
originalFeatures.length === 1 && originalFeatures.length === 1 &&
originalFeatures[0].get("isProxy") originalFeatures[0].get("isProxy")
) )
return false; // Ignore proxy points return false;
return true; return true;
}, },
multi: true, multi: true,
@@ -1302,7 +1256,7 @@ class MapService {
const selected = new Set<string | number>(); const selected = new Set<string | number>();
this.pointSource.forEachFeatureInExtent(extent, (f) => { this.pointSource.forEachFeatureInExtent(extent, (f) => {
if (f.get("isProxy")) return; // Ignore proxy in lasso if (f.get("isProxy")) return;
const geom = f.getGeometry(); const geom = f.getGeometry();
if (geom && geom.getType() === "Point") { if (geom && geom.getType() === "Point") {
const pointCoords = (geom as Point).getCoordinates(); const pointCoords = (geom as Point).getCoordinates();
@@ -1339,7 +1293,6 @@ class MapService {
this.selectInteraction.setActive(false); this.selectInteraction.setActive(false);
this.lassoInteraction.setActive(false); this.lassoInteraction.setActive(false);
// --- ИСПРАВЛЕНИЕ: Главный обработчик выбора объектов и кластеров
this.selectInteraction.on("select", (e: SelectEvent) => { this.selectInteraction.on("select", (e: SelectEvent) => {
if (this.mode !== "edit" || !this.map) return; if (this.mode !== "edit" || !this.map) return;
@@ -1347,13 +1300,11 @@ class MapService {
e.mapBrowserEvent.originalEvent.ctrlKey || e.mapBrowserEvent.originalEvent.ctrlKey ||
e.mapBrowserEvent.originalEvent.metaKey; e.mapBrowserEvent.originalEvent.metaKey;
// Проверяем, был ли клик по кластеру (группе)
if (e.selected.length === 1 && !ctrlKey) { if (e.selected.length === 1 && !ctrlKey) {
const clickedFeature = e.selected[0]; const clickedFeature = e.selected[0];
const originalFeatures = clickedFeature.get("features"); const originalFeatures = clickedFeature.get("features");
if (originalFeatures && originalFeatures.length > 1) { if (originalFeatures && originalFeatures.length > 1) {
// Если да, то приближаем карту
const extent = createEmpty(); const extent = createEmpty();
originalFeatures.forEach((feat: Feature<Point>) => { originalFeatures.forEach((feat: Feature<Point>) => {
const geom = feat.getGeometry(); const geom = feat.getGeometry();
@@ -1364,20 +1315,17 @@ class MapService {
padding: [60, 60, 60, 60], padding: [60, 60, 60, 60],
maxZoom: 18, maxZoom: 18,
}); });
// Сбрасываем выделение, так как мы не хотим "выделять" сам кластер
this.selectInteraction.getFeatures().clear(); this.selectInteraction.getFeatures().clear();
this.setSelectedIds(new Set()); this.setSelectedIds(new Set());
return; // Завершаем обработку return;
} }
} }
// При Ctrl+клик сохраняем предыдущие выделения и добавляем/удаляем только изменённые
// При обычном клике создаём новый набор
const newSelectedIds = ctrlKey const newSelectedIds = ctrlKey
? new Set(this.selectedIds) ? new Set(this.selectedIds)
: new Set<string | number>(); : new Set<string | number>();
// Добавляем новые выбранные элементы
e.selected.forEach((feature) => { e.selected.forEach((feature) => {
const originalFeatures = feature.get("features"); const originalFeatures = feature.get("features");
let targetId: string | number | undefined; let targetId: string | number | undefined;
@@ -1389,8 +1337,6 @@ class MapService {
} }
if (targetId !== undefined) { if (targetId !== undefined) {
// При Ctrl+клик: если элемент уже был выбран, снимаем его (toggle)
// Если не был выбран, добавляем
if (ctrlKey && newSelectedIds.has(targetId)) { if (ctrlKey && newSelectedIds.has(targetId)) {
newSelectedIds.delete(targetId); newSelectedIds.delete(targetId);
} else { } else {
@@ -1399,9 +1345,6 @@ class MapService {
} }
}); });
// При Ctrl+клик игнорируем deselected, так как Select interaction может снимать
// предыдущие выделения, но мы хотим их сохранить
// При обычном клике удаляем deselected элементы
if (!ctrlKey) { if (!ctrlKey) {
e.deselected.forEach((feature) => { e.deselected.forEach((feature) => {
const originalFeatures = feature.get("features"); const originalFeatures = feature.get("features");
@@ -1425,50 +1368,41 @@ class MapService {
this.map.on("pointermove", this.boundHandlePointerMove as any); this.map.on("pointermove", this.boundHandlePointerMove as any);
const targetEl = this.map.getTargetElement(); const targetEl = this.map.getTargetElement();
if (targetEl instanceof HTMLElement) { if (targetEl instanceof HTMLElement) {
// Устанавливаем курсор pointer по умолчанию для всей карты
targetEl.style.cursor = "pointer"; targetEl.style.cursor = "pointer";
targetEl.addEventListener("contextmenu", this.boundHandleContextMenu); targetEl.addEventListener("contextmenu", this.boundHandleContextMenu);
targetEl.addEventListener("pointerleave", this.boundHandlePointerLeave); targetEl.addEventListener("pointerleave", this.boundHandlePointerLeave);
// Добавляем обработчики для изменения курсора при нажатии средней кнопки мыши
targetEl.addEventListener("pointerdown", (e) => { targetEl.addEventListener("pointerdown", (e) => {
if (e.buttons === 4) { if (e.buttons === 4) {
// Средняя кнопка мыши e.preventDefault();
e.preventDefault(); // Предотвращаем скролл страницы
targetEl.style.cursor = "grabbing"; targetEl.style.cursor = "grabbing";
} }
}); });
targetEl.addEventListener("pointerup", (e) => { targetEl.addEventListener("pointerup", (e) => {
if (e.button === 1) { if (e.button === 1) {
// Средняя кнопка мыши отпущена e.preventDefault();
e.preventDefault(); // Предотвращаем скролл страницы
targetEl.style.cursor = "pointer"; targetEl.style.cursor = "pointer";
} }
}); });
// Также добавляем обработчик для mousedown/mouseup для совместимости
targetEl.addEventListener("mousedown", (e) => { targetEl.addEventListener("mousedown", (e) => {
if (e.button === 1) { if (e.button === 1) {
// Средняя кнопка мыши e.preventDefault();
e.preventDefault(); // Предотвращаем скролл страницы
targetEl.style.cursor = "grabbing"; targetEl.style.cursor = "grabbing";
} }
}); });
targetEl.addEventListener("mouseup", (e) => { targetEl.addEventListener("mouseup", (e) => {
if (e.button === 1) { if (e.button === 1) {
// Средняя кнопка мыши отпущена e.preventDefault();
e.preventDefault(); // Предотвращаем скролл страницы
targetEl.style.cursor = "pointer"; targetEl.style.cursor = "pointer";
} }
}); });
// Дополнительная защита от нежелательного поведения средней кнопки мыши
targetEl.addEventListener("auxclick", (e) => { targetEl.addEventListener("auxclick", (e) => {
if (e.button === 1) { if (e.button === 1) {
// Средняя кнопка мыши e.preventDefault();
e.preventDefault(); // Предотвращаем открытие ссылки в новой вкладке
} }
}); });
} }
@@ -1499,16 +1433,10 @@ class MapService {
const pointFeatures: Feature<Point>[] = []; const pointFeatures: Feature<Point>[] = [];
const lineFeatures: Feature<LineString>[] = []; const lineFeatures: Feature<LineString>[] = [];
// Используем фильтрованные данные из mapStore
const filteredStations = mapStore.filteredStations; const filteredStations = mapStore.filteredStations;
const filteredSights = mapStore.filteredSights; const filteredSights = mapStore.filteredSights;
const filteredRoutes = mapStore.filteredRoutes; const filteredRoutes = mapStore.filteredRoutes;
console.log(
`[loadFeaturesFromApi] Loading with ${mapStore.hiddenRoutes.size} hidden routes`
);
// Собираем все станции видимых маршрутов из кэша
const stationsInVisibleRoutes = new Set<number>(); const stationsInVisibleRoutes = new Set<number>();
filteredRoutes filteredRoutes
.filter((route) => !mapStore.hiddenRoutes.has(route.id)) .filter((route) => !mapStore.hiddenRoutes.has(route.id))
@@ -1517,15 +1445,10 @@ class MapService {
stationIds.forEach((id) => stationsInVisibleRoutes.add(id)); stationIds.forEach((id) => stationsInVisibleRoutes.add(id));
}); });
console.log(
`[loadFeaturesFromApi] Found ${stationsInVisibleRoutes.size} stations in visible routes, total stations: ${filteredStations.length}`
);
let skippedStations = 0; let skippedStations = 0;
filteredStations.forEach((station) => { filteredStations.forEach((station) => {
if (station.longitude == null || station.latitude == null) return; if (station.longitude == null || station.latitude == null) return;
// Пропускаем станции, которые принадлежат только скрытым маршрутам
if (!stationsInVisibleRoutes.has(station.id)) { if (!stationsInVisibleRoutes.has(station.id)) {
skippedStations++; skippedStations++;
return; return;
@@ -1562,7 +1485,6 @@ class MapService {
filteredRoutes.forEach((route) => { filteredRoutes.forEach((route) => {
if (!route.path || route.path.length === 0) return; if (!route.path || route.path.length === 0) return;
// Пропускаем скрытые маршруты
if (mapStore.hiddenRoutes.has(route.id)) return; if (mapStore.hiddenRoutes.has(route.id)) return;
const coordinates = route.path const coordinates = route.path
@@ -1583,10 +1505,6 @@ class MapService {
lineFeatures.push(lineFeature); lineFeatures.push(lineFeature);
}); });
console.log(
`[loadFeaturesFromApi] Skipped ${skippedStations} stations (belonging only to hidden routes)`
);
this.pointSource.addFeatures(pointFeatures); this.pointSource.addFeatures(pointFeatures);
this.lineSource.addFeatures(lineFeatures); this.lineSource.addFeatures(lineFeatures);
@@ -1611,7 +1529,6 @@ class MapService {
} }
if (this.tooltipOverlay) this.tooltipOverlay.setPosition(undefined); if (this.tooltipOverlay) this.tooltipOverlay.setPosition(undefined);
// Сбрасываем курсор при покидании области карты
if (this.map) { if (this.map) {
const targetEl = this.map.getTargetElement(); const targetEl = this.map.getTargetElement();
if (targetEl instanceof HTMLElement) { if (targetEl instanceof HTMLElement) {
@@ -1692,10 +1609,9 @@ class MapService {
const fType = this.currentDrawingFeatureType; const fType = this.currentDrawingFeatureType;
if (!fType) return; if (!fType) return;
// Проверяем, не идет ли уже процесс создания
if (this.isCreating) { if (this.isCreating) {
toast.warning("Дождитесь завершения создания предыдущего объекта."); toast.warning("Дождитесь завершения создания предыдущего объекта.");
// Удаляем созданный объект из источника
const sourceForDrawing = const sourceForDrawing =
type === "Point" ? this.pointSource : this.lineSource; type === "Point" ? this.pointSource : this.lineSource;
setTimeout(() => { setTimeout(() => {
@@ -1712,7 +1628,6 @@ class MapService {
switch (fType) { switch (fType) {
case "station": case "station":
// Используем полный список из mapStore, а не отфильтрованный
const stationNumbers = mapStore.stations const stationNumbers = mapStore.stations
.map((station) => { .map((station) => {
const match = station.name?.match(/^Остановка (\d+)$/); const match = station.name?.match(/^Остановка (\d+)$/);
@@ -1724,7 +1639,6 @@ class MapService {
resourceName = `Остановка ${nextStationNumber}`; resourceName = `Остановка ${nextStationNumber}`;
break; break;
case "sight": case "sight":
// Используем полный список из mapStore, а не отфильтрованный
const sightNumbers = mapStore.sights const sightNumbers = mapStore.sights
.map((sight) => { .map((sight) => {
const match = sight.name?.match(/^Достопримечательность (\d+)$/); const match = sight.name?.match(/^Достопримечательность (\d+)$/);
@@ -1736,7 +1650,6 @@ class MapService {
resourceName = `Достопримечательность ${nextSightNumber}`; resourceName = `Достопримечательность ${nextSightNumber}`;
break; break;
case "route": case "route":
// Используем полный список из mapStore, а не отфильтрованный
const routeNumbers = mapStore.routes const routeNumbers = mapStore.routes
.map((route) => { .map((route) => {
const match = route.route_number?.match(/^Маршрут (\d+)$/); const match = route.route_number?.match(/^Маршрут (\d+)$/);
@@ -1778,11 +1691,8 @@ class MapService {
private stopDrawing() { private stopDrawing() {
if (this.map && this.currentInteraction) { if (this.map && this.currentInteraction) {
try { try {
// @ts-ignore
this.currentInteraction.abortDrawing(); this.currentInteraction.abortDrawing();
} catch (e) { } catch (e) {}
/* ignore */
}
this.map.removeInteraction(this.currentInteraction); this.map.removeInteraction(this.currentInteraction);
} }
this.currentInteraction = null; this.currentInteraction = null;
@@ -1793,7 +1703,6 @@ class MapService {
public finishDrawing(): void { public finishDrawing(): void {
if (!this.currentInteraction) return; if (!this.currentInteraction) return;
// Блокируем завершение рисования, если идет процесс создания
if (this.isCreating) { if (this.isCreating) {
toast.warning("Дождитесь завершения создания предыдущего объекта."); toast.warning("Дождитесь завершения создания предыдущего объекта.");
return; return;
@@ -1824,7 +1733,7 @@ class MapService {
layerFilter, layerFilter,
hitTolerance: 5, hitTolerance: 5,
}); });
// Устанавливаем курсор pointer для всей карты, чтобы показать возможность перетаскивания колёсиком
this.map.getTargetElement().style.cursor = hit ? "pointer" : "pointer"; this.map.getTargetElement().style.cursor = hit ? "pointer" : "pointer";
const featureAtPixel: Feature<Geometry> | undefined = const featureAtPixel: Feature<Geometry> | undefined =
@@ -1838,7 +1747,7 @@ class MapService {
if (featureAtPixel) { if (featureAtPixel) {
const originalFeatures = featureAtPixel.get("features"); const originalFeatures = featureAtPixel.get("features");
if (originalFeatures && originalFeatures.length > 0) { if (originalFeatures && originalFeatures.length > 0) {
if (originalFeatures[0].get("isProxy")) return; // Ignore hover on proxy if (originalFeatures[0].get("isProxy")) return;
finalFeature = originalFeatures[0]; finalFeature = originalFeatures[0];
} else { } else {
finalFeature = featureAtPixel; finalFeature = featureAtPixel;
@@ -2087,13 +1996,11 @@ class MapService {
return this.map; return this.map;
} }
// Метод для сброса кешей карты
public clearCaches() { public clearCaches() {
this.clusterStyleCache = {}; this.clusterStyleCache = {};
this.hoveredFeatureId = null; this.hoveredFeatureId = null;
this.selectedIds.clear(); this.selectedIds.clear();
// Очищаем источники данных
if (this.pointSource) { if (this.pointSource) {
this.pointSource.clear(); this.pointSource.clear();
} }
@@ -2101,7 +2008,6 @@ class MapService {
this.lineSource.clear(); this.lineSource.clear();
} }
// Обновляем слои
if (this.clusterLayer) { if (this.clusterLayer) {
this.clusterLayer.changed(); this.clusterLayer.changed();
} }
@@ -2151,10 +2057,9 @@ class MapService {
const featureType = feature.get("featureType") as FeatureType; const featureType = feature.get("featureType") as FeatureType;
if (!featureType || !this.map) return; if (!featureType || !this.map) return;
// Проверяем, не идет ли уже процесс создания
if (this.isCreating) { if (this.isCreating) {
toast.warning("Дождитесь завершения создания предыдущего объекта."); toast.warning("Дождитесь завершения создания предыдущего объекта.");
// Удаляем незавершенный объект с карты
if (feature.getGeometry()?.getType() === "LineString") { if (feature.getGeometry()?.getType() === "LineString") {
if (this.lineSource.hasFeature(feature as Feature<LineString>)) if (this.lineSource.hasFeature(feature as Feature<LineString>))
this.lineSource.removeFeature(feature as Feature<LineString>); this.lineSource.removeFeature(feature as Feature<LineString>);
@@ -2181,37 +2086,28 @@ class MapService {
); );
const newFeatureId = `${featureType}-${createdFeatureData.id}`; const newFeatureId = `${featureType}-${createdFeatureData.id}`;
// @ts-ignore
const displayName = const displayName =
featureType === "route" featureType === "route"
? // @ts-ignore ? createdFeatureData.route_number
createdFeatureData.route_number : createdFeatureData.name;
: // @ts-ignore
createdFeatureData.name;
if (featureType === "route") { if (featureType === "route") {
// @ts-ignore
const routeData = createdFeatureData as ApiRoute; const routeData = createdFeatureData as ApiRoute;
const projection = this.map.getView().getProjection(); const projection = this.map.getView().getProjection();
// Update existing line feature
feature.setId(newFeatureId); feature.setId(newFeatureId);
feature.set("name", displayName); feature.set("name", displayName);
// Optionally update geometry if server modified it
const lineGeom = new LineString( const lineGeom = new LineString(
routeData.path.map((c) => routeData.path.map((c) =>
transform([c[1], c[0]], "EPSG:4326", projection) transform([c[1], c[0]], "EPSG:4326", projection)
) )
); );
feature.setGeometry(lineGeom); feature.setGeometry(lineGeom);
// Не создаем прокси-точку для маршрута - только линия
} else { } else {
// For points: update existing
feature.setId(newFeatureId); feature.setId(newFeatureId);
feature.set("name", displayName); feature.set("name", displayName);
// No need to remove and re-add since it's already in the source
} }
this.updateFeaturesInReact(); this.updateFeaturesInReact();
@@ -2233,7 +2129,6 @@ class MapService {
} }
} }
// --- MAP CONTROLS COMPONENT ---
interface MapControlsProps { interface MapControlsProps {
mapService: MapService | null; mapService: MapService | null;
activeMode: string; activeMode: string;
@@ -2331,7 +2226,6 @@ const MapControls: React.FC<MapControlsProps> = ({
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// --- MAP SIGHTBAR COMPONENT ---
interface MapSightbarProps { interface MapSightbarProps {
mapService: MapService | null; mapService: MapService | null;
mapFeatures: Feature<Geometry>[]; mapFeatures: Feature<Geometry>[];
@@ -2364,7 +2258,6 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
[mapFeatures] [mapFeatures]
); );
// Создаем объединенный список всех объектов для поиска
const allFeatures = useMemo(() => { const allFeatures = useMemo(() => {
const stations = mapStore.filteredStations.map((station) => { const stations = mapStore.filteredStations.map((station) => {
const feature = new Feature({ const feature = new Feature({
@@ -2437,7 +2330,6 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
const ctrlKey = event?.ctrlKey || event?.metaKey; const ctrlKey = event?.ctrlKey || event?.metaKey;
if (ctrlKey) { if (ctrlKey) {
// Множественный выбор: добавляем к существующему
const newSet = new Set(selectedIds); const newSet = new Set(selectedIds);
if (newSet.has(id)) { if (newSet.has(id)) {
newSet.delete(id); newSet.delete(id);
@@ -2447,7 +2339,6 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
setSelectedIds(newSet); setSelectedIds(newSet);
mapService.setSelectedIds(newSet); mapService.setSelectedIds(newSet);
} else { } else {
// Одиночный выбор: используем стандартный метод
mapService.selectFeature(id); mapService.selectFeature(id);
} }
}, },
@@ -2505,32 +2396,19 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
if (isNaN(numericRouteId)) return; if (isNaN(numericRouteId)) return;
const isHidden = mapStore.hiddenRoutes.has(numericRouteId); const isHidden = mapStore.hiddenRoutes.has(numericRouteId);
console.log(
`[handleHideRoute] Route ${numericRouteId}, isHidden: ${isHidden}`
);
try { try {
if (isHidden) { if (isHidden) {
console.log(`[handleHideRoute] Showing route ${numericRouteId}`);
// Показываем маршрут обратно
const route = mapStore.routes.find((r) => r.id === numericRouteId); const route = mapStore.routes.find((r) => r.id === numericRouteId);
if (!route) { if (!route) {
console.warn(
`[handleHideRoute] Route ${numericRouteId} not found in mapStore`
);
return; return;
} }
const projection = mapService.getMap()?.getView().getProjection(); const projection = mapService.getMap()?.getView().getProjection();
if (!projection) { if (!projection) {
console.error(`[handleHideRoute] Failed to get map projection`);
return; return;
} }
console.log(
`[handleHideRoute] Route ${numericRouteId} (${route.route_number}) found, showing`
);
// Показываем сам маршрут
const coordinates = route.path const coordinates = route.path
.filter((c) => c && c[0] != null && c[1] != null) .filter((c) => c && c[0] != null && c[1] != null)
.map((c: [number, number]) => .map((c: [number, number]) =>
@@ -2546,33 +2424,19 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
lineFeature.setId(routeId); lineFeature.setId(routeId);
lineFeature.set("featureType", "route"); lineFeature.set("featureType", "route");
mapService.lineSource.addFeature(lineFeature); mapService.lineSource.addFeature(lineFeature);
console.log(`[handleHideRoute] Added route line to map`);
} else { } else {
console.warn(
`[handleHideRoute] No valid coordinates for route ${numericRouteId}`
);
} }
// Получаем станции текущего маршрута из кэша
const routeStationIds = const routeStationIds =
mapStore.routeStationsCache.get(numericRouteId) || []; mapStore.routeStationsCache.get(numericRouteId) || [];
console.log(
`[handleHideRoute] Route ${numericRouteId} has ${routeStationIds.length} stations`
);
// Получаем все маршруты для проверки
const allRouteIds = mapStore.routes.map((r) => r.id); const allRouteIds = mapStore.routes.map((r) => r.id);
// Исключаем скрытые маршруты из проверки
const visibleRouteIds = allRouteIds.filter( const visibleRouteIds = allRouteIds.filter(
(id: number) => (id: number) =>
id !== numericRouteId && !mapStore.hiddenRoutes.has(id) id !== numericRouteId && !mapStore.hiddenRoutes.has(id)
); );
console.log(
`[handleHideRoute] Checking against ${visibleRouteIds.length} visible routes (excluding hidden)`
);
// Собираем все станции видимых маршрутов из кэша
const stationsInVisibleRoutes = new Set<number>(); const stationsInVisibleRoutes = new Set<number>();
visibleRouteIds.forEach((otherRouteId) => { visibleRouteIds.forEach((otherRouteId) => {
const stationIds = const stationIds =
@@ -2582,16 +2446,10 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
); );
}); });
console.log(
`[handleHideRoute] Found ${stationsInVisibleRoutes.size} unique stations in visible routes`
);
// Показываем станции, которые не используются в других ВИДИМЫХ маршрутах
const stationsToShow = routeStationIds.filter( const stationsToShow = routeStationIds.filter(
(id: number) => !stationsInVisibleRoutes.has(id) (id: number) => !stationsInVisibleRoutes.has(id)
); );
// Показываем станции на карте
for (const stationId of stationsToShow) { for (const stationId of stationsToShow) {
const station = mapStore.stations.find((s) => s.id === stationId); const station = mapStore.stations.find((s) => s.id === stationId);
if (!station) continue; if (!station) continue;
@@ -2610,7 +2468,6 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
feature.setId(`station-${station.id}`); feature.setId(`station-${station.id}`);
feature.set("featureType", "station"); feature.set("featureType", "station");
// Добавляем станцию только если её еще нет на карте
const existingFeature = mapService.pointSource.getFeatureById( const existingFeature = mapService.pointSource.getFeatureById(
`station-${station.id}` `station-${station.id}`
); );
@@ -2619,36 +2476,19 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
} }
} }
// Удаляем из скрытых
mapStore.hiddenRoutes.delete(numericRouteId); mapStore.hiddenRoutes.delete(numericRouteId);
saveHiddenRoutes(mapStore.hiddenRoutes); // Сохраняем в localStorage saveHiddenRoutes(mapStore.hiddenRoutes);
console.log(
`[handleHideRoute] Removed ${numericRouteId} from hiddenRoutes, stations to show: ${stationsToShow.length}`
);
} else { } else {
// Скрываем маршрут
console.log(`[handleHideRoute] Hiding route ${numericRouteId}`);
// Получаем станции текущего маршрута из кэша
const routeStationIds = const routeStationIds =
mapStore.routeStationsCache.get(numericRouteId) || []; mapStore.routeStationsCache.get(numericRouteId) || [];
console.log(
`[handleHideRoute] Route ${numericRouteId} has ${routeStationIds.length} stations`
);
// Получаем все маршруты для проверки
const allRouteIds = mapStore.routes.map((r) => r.id); const allRouteIds = mapStore.routes.map((r) => r.id);
// Исключаем скрытые маршруты из проверки
const visibleRouteIds = allRouteIds.filter( const visibleRouteIds = allRouteIds.filter(
(id: number) => (id: number) =>
id !== numericRouteId && !mapStore.hiddenRoutes.has(id) id !== numericRouteId && !mapStore.hiddenRoutes.has(id)
); );
console.log(
`[handleHideRoute] Checking against ${visibleRouteIds.length} visible routes (excluding hidden)`
);
// Собираем все станции видимых маршрутов из кэша
const stationsInVisibleRoutes = new Set<number>(); const stationsInVisibleRoutes = new Set<number>();
visibleRouteIds.forEach((otherRouteId) => { visibleRouteIds.forEach((otherRouteId) => {
const stationIds = const stationIds =
@@ -2658,16 +2498,10 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
); );
}); });
console.log(
`[handleHideRoute] Found ${stationsInVisibleRoutes.size} unique stations in visible routes`
);
// Скрываем станции, которые не используются в других ВИДИМЫХ маршрутах
const stationsToHide = routeStationIds.filter( const stationsToHide = routeStationIds.filter(
(id: number) => !stationsInVisibleRoutes.has(id) (id: number) => !stationsInVisibleRoutes.has(id)
); );
// Скрываем станции с карты
stationsToHide.forEach((stationId: number) => { stationsToHide.forEach((stationId: number) => {
const pointFeature = mapService.pointSource.getFeatureById( const pointFeature = mapService.pointSource.getFeatureById(
`station-${stationId}` `station-${stationId}`
@@ -2679,7 +2513,6 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
} }
}); });
// Скрываем сам маршрут с карты
const lineFeature = mapService.lineSource.getFeatureById(routeId); const lineFeature = mapService.lineSource.getFeatureById(routeId);
if (lineFeature) { if (lineFeature) {
mapService.lineSource.removeFeature( mapService.lineSource.removeFeature(
@@ -2687,15 +2520,10 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
); );
} }
// Добавляем в скрытые
mapStore.hiddenRoutes.add(numericRouteId); mapStore.hiddenRoutes.add(numericRouteId);
saveHiddenRoutes(mapStore.hiddenRoutes); // Сохраняем в localStorage saveHiddenRoutes(mapStore.hiddenRoutes);
console.log(
`[handleHideRoute] Added ${numericRouteId} to hiddenRoutes, stations to hide: ${stationsToHide.length}`
);
} }
// Снимаем выделение
mapService.unselect(); mapService.unselect();
} catch (error) { } catch (error) {
console.error( console.error(
@@ -2801,12 +2629,11 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
{features.length > 0 ? ( {features.length > 0 ? (
features.map((feature) => { features.map((feature) => {
const fId = feature.getId(); const fId = feature.getId();
if (fId === undefined) return null; // TypeScript-safe if (fId === undefined) return null;
const fName = (feature.get("name") as string) || "Без названия"; const fName = (feature.get("name") as string) || "Без названия";
const isSelected = selectedFeature?.getId() === fId; const isSelected = selectedFeature?.getId() === fId;
const isChecked = selectedIds.has(fId); const isChecked = selectedIds.has(fId);
// Проверяем, скрыт ли маршрут
const numericRouteId = const numericRouteId =
featureType === "route" featureType === "route"
? parseInt(String(fId).split("-")[1], 10) ? parseInt(String(fId).split("-")[1], 10)
@@ -3146,7 +2973,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
); );
} }
); );
// --- MAP PAGE COMPONENT ---
export const MapPage: React.FC = observer(() => { export const MapPage: React.FC = observer(() => {
const mapRef = useRef<HTMLDivElement | null>(null); const mapRef = useRef<HTMLDivElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null); const tooltipRef = useRef<HTMLDivElement | null>(null);
@@ -3177,7 +3004,6 @@ export const MapPage: React.FC = observer(() => {
const handleFeatureSelectForSidebar = useCallback( const handleFeatureSelectForSidebar = useCallback(
(feat: Feature<Geometry> | null) => { (feat: Feature<Geometry> | null) => {
// Logic to sync sidebar selection with map
setSelectedFeatureForSidebar(feat); setSelectedFeatureForSidebar(feat);
if (feat) { if (feat) {
const featureType = feat.get("featureType"); const featureType = feat.get("featureType");
@@ -3239,7 +3065,6 @@ export const MapPage: React.FC = observer(() => {
); );
setMapServiceInstance(service); setMapServiceInstance(service);
// Делаем mapServiceInstance доступным глобально для сброса кешей
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
(window as any).mapServiceInstance = service; (window as any).mapServiceInstance = service;
} }
@@ -3257,15 +3082,12 @@ export const MapPage: React.FC = observer(() => {
service?.destroy(); service?.destroy();
setMapServiceInstance(null); setMapServiceInstance(null);
// Удаляем глобальную ссылку
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
delete (window as any).mapServiceInstance; delete (window as any).mapServiceInstance;
} }
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// --- ИСПРАВЛЕНИЕ: Этот хук отвечает ТОЛЬКО за клики по ПУСТОМУ месту
useEffect(() => { useEffect(() => {
const olMap = mapServiceInstance?.getMap(); const olMap = mapServiceInstance?.getMap();
if (!olMap || !mapServiceInstance) return; if (!olMap || !mapServiceInstance) return;
@@ -3280,11 +3102,9 @@ export const MapPage: React.FC = observer(() => {
hitTolerance: 5, hitTolerance: 5,
}); });
// Если клик был НЕ по объекту, снимаем выделение
if (!hit) { if (!hit) {
mapServiceInstance.unselect(); mapServiceInstance.unselect();
} }
// Если клик был ПО объекту, НИЧЕГО не делаем. За это отвечает selectInteraction.
}; };
olMap.on("click", handleMapClickForDeselect); olMap.on("click", handleMapClickForDeselect);
@@ -3337,14 +3157,11 @@ export const MapPage: React.FC = observer(() => {
saveActiveSection(activeSectionFromParent); saveActiveSection(activeSectionFromParent);
}, [activeSectionFromParent]); }, [activeSectionFromParent]);
// Перезагружаем данные при изменении города
useEffect(() => { useEffect(() => {
if (mapServiceInstance && !isDataLoading) { if (mapServiceInstance && !isDataLoading) {
// Очищаем текущие объекты на карте
mapServiceInstance.pointSource.clear(); mapServiceInstance.pointSource.clear();
mapServiceInstance.lineSource.clear(); mapServiceInstance.lineSource.clear();
// Загружаем новые данные с фильтрацией по городу
mapServiceInstance.loadFeaturesFromApi( mapServiceInstance.loadFeaturesFromApi(
mapStore.stations, mapStore.stations,
mapStore.routes, mapStore.routes,
@@ -3353,14 +3170,11 @@ export const MapPage: React.FC = observer(() => {
} }
}, [selectedCityId, mapServiceInstance, isDataLoading]); }, [selectedCityId, mapServiceInstance, isDataLoading]);
// Перезагружаем данные при изменении настройки скрытия достопримечательностей
useEffect(() => { useEffect(() => {
if (mapServiceInstance && !isDataLoading) { if (mapServiceInstance && !isDataLoading) {
// Очищаем текущие объекты на карте
mapServiceInstance.pointSource.clear(); mapServiceInstance.pointSource.clear();
mapServiceInstance.lineSource.clear(); mapServiceInstance.lineSource.clear();
// Загружаем новые данные с учетом фильтрации достопримечательностей
mapServiceInstance.loadFeaturesFromApi( mapServiceInstance.loadFeaturesFromApi(
mapStore.stations, mapStore.stations,
mapStore.routes, mapStore.routes,

View File

@@ -22,10 +22,8 @@ interface ApiSight {
longitude: number; longitude: number;
} }
// Допуск для сравнения координат, чтобы избежать ошибок с точностью чисел.
const COORDINATE_PRECISION_TOLERANCE = 1e-9; const COORDINATE_PRECISION_TOLERANCE = 1e-9;
// Вспомогательная функция, обновленная для сравнения с допуском.
const arePathsEqual = ( const arePathsEqual = (
path1: [number, number][], path1: [number, number][],
path2: [number, number][] path2: [number, number][]
@@ -136,7 +134,6 @@ class MapStore {
longitude: geometry.coordinates[0], longitude: geometry.coordinates[0],
}; };
// ИЗМЕНЕНИЕ: Сравнение координат с допуском
if ( if (
originalStation.name !== currentStation.name || originalStation.name !== currentStation.name ||
Math.abs(originalStation.latitude - currentStation.latitude) > Math.abs(originalStation.latitude - currentStation.latitude) >
@@ -155,7 +152,6 @@ class MapStore {
path: geometry.coordinates, path: geometry.coordinates,
}; };
// ИЗМЕНЕНИЕ: Используется новая функция arePathsEqual с допуском
if ( if (
originalRoute.route_number !== currentRoute.route_number || originalRoute.route_number !== currentRoute.route_number ||
!arePathsEqual(originalRoute.path, currentRoute.path) !arePathsEqual(originalRoute.path, currentRoute.path)
@@ -173,7 +169,6 @@ class MapStore {
longitude: geometry.coordinates[0], longitude: geometry.coordinates[0],
}; };
// ИЗМЕНЕНИЕ: Сравнение координат с допуском
if ( if (
originalSight.name !== currentSight.name || originalSight.name !== currentSight.name ||
originalSight.description !== currentSight.description || originalSight.description !== currentSight.description ||

View File

@@ -33,7 +33,7 @@ export const MediaEditPage = observer(() => {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [newFile, setNewFile] = useState<File | null>(null); const [newFile, setNewFile] = useState<File | null>(null);
const [uploadDialogOpen, setUploadDialogOpen] = useState(false); // State for the upload dialog const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
const media = id ? mediaStore.media.find((m) => m.id === id) : null; const media = id ? mediaStore.media.find((m) => m.id === id) : null;
const [mediaName, setMediaName] = useState(media?.media_name ?? ""); const [mediaName, setMediaName] = useState(media?.media_name ?? "");
@@ -55,51 +55,27 @@ export const MediaEditPage = observer(() => {
setMediaFilename(media.filename); setMediaFilename(media.filename);
setMediaType(media.media_type); setMediaType(media.media_type);
// Set available media types based on current file extension
const extension = media.filename.split(".").pop()?.toLowerCase(); const extension = media.filename.split(".").pop()?.toLowerCase();
if (extension) { if (extension) {
if (["glb", "gltf"].includes(extension)) { if (["glb", "gltf"].includes(extension)) {
setAvailableMediaTypes([6]); // 3D model setAvailableMediaTypes([6]);
} else if ( } else if (
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes( ["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
extension extension
) )
) { ) {
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama setAvailableMediaTypes([1, 3, 4, 5]);
} else if (["mp4", "webm", "mov"].includes(extension)) { } else if (["mp4", "webm", "mov"].includes(extension)) {
setAvailableMediaTypes([2]); // Video setAvailableMediaTypes([2]);
} }
} }
} }
}, [media]); }, [media]);
useEffect(() => { useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}, []); }, []);
// const handleDrop = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault();
// e.stopPropagation();
// setIsDragging(false);
// const files = Array.from(e.dataTransfer.files);
// if (files.length > 0) {
// setNewFile(files[0]);
// setMediaFilename(files[0].name);
// setUploadDialogOpen(true); // Open dialog on file drop
// }
// };
// const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault();
// setIsDragging(true);
// };
// const handleDragLeave = () => {
// setIsDragging(false);
// };
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files; const files = e.target.files;
if (files && files.length > 0) { if (files && files.length > 0) {
@@ -107,26 +83,25 @@ export const MediaEditPage = observer(() => {
setNewFile(file); setNewFile(file);
setMediaFilename(file.name); setMediaFilename(file.name);
// Determine media type based on file extension
const extension = file.name.split(".").pop()?.toLowerCase(); const extension = file.name.split(".").pop()?.toLowerCase();
if (extension) { if (extension) {
if (["glb", "gltf"].includes(extension)) { if (["glb", "gltf"].includes(extension)) {
setAvailableMediaTypes([6]); // 3D model setAvailableMediaTypes([6]);
setMediaType(6); setMediaType(6);
} else if ( } else if (
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes( ["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
extension extension
) )
) { ) {
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama setAvailableMediaTypes([1, 3, 4, 5]);
setMediaType(1); // Default to Photo setMediaType(1);
} else if (["mp4", "webm", "mov"].includes(extension)) { } else if (["mp4", "webm", "mov"].includes(extension)) {
setAvailableMediaTypes([2]); // Video setAvailableMediaTypes([2]);
setMediaType(2); setMediaType(2);
} }
} }
setUploadDialogOpen(true); // Open dialog on file selection setUploadDialogOpen(true);
} }
}; };
@@ -143,11 +118,6 @@ export const MediaEditPage = observer(() => {
type: mediaType, type: mediaType,
}); });
// If a new file was selected, the actual file upload will happen
// via the UploadMediaDialog. We just need to make sure the metadata
// is updated correctly before or after.
// Since the dialog handles the actual upload, we don't call updateMediaFile here.
setSuccess(true); setSuccess(true);
handleUploadSuccess(); handleUploadSuccess();
} catch (err) { } catch (err) {
@@ -158,17 +128,15 @@ export const MediaEditPage = observer(() => {
}; };
const handleUploadSuccess = () => { const handleUploadSuccess = () => {
// After successful upload in the dialog, refresh media data if needed
if (id) { if (id) {
mediaStore.getOneMedia(id); mediaStore.getOneMedia(id);
} }
setNewFile(null); // Clear the new file state after successful upload setNewFile(null);
setUploadDialogOpen(false); setUploadDialogOpen(false);
setSuccess(true); setSuccess(true);
}; };
if (!media && id) { if (!media && id) {
// Only show loading if an ID is present and media is not yet loaded
return ( return (
<Box className="flex justify-center items-center h-screen"> <Box className="flex justify-center items-center h-screen">
<CircularProgress /> <CircularProgress />

View File

@@ -42,7 +42,6 @@ import {
} from "@shared"; } from "@shared";
import { EditStationModal } from "../../widgets/modals/EditStationModal"; import { EditStationModal } from "../../widgets/modals/EditStationModal";
// Helper function to insert an item at a specific position (1-based index)
function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] { function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] {
const index = pos - 1; const index = pos - 1;
const result = [...arr]; const result = [...arr];
@@ -54,7 +53,6 @@ function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] {
return result; return result;
} }
// Helper function to reorder items after drag and drop
const reorder = <T,>(list: T[], startIndex: number, endIndex: number): T[] => { const reorder = <T,>(list: T[], startIndex: number, endIndex: number): T[] => {
const result = Array.from(list); const result = Array.from(list);
const [removed] = result.splice(startIndex, 1); const [removed] = result.splice(startIndex, 1);
@@ -152,13 +150,11 @@ const LinkedItemsContentsInner = <
const availableItems = allItems const availableItems = allItems
.filter((item) => !linkedItems.some((linked) => linked.id === item.id)) .filter((item) => !linkedItems.some((linked) => linked.id === item.id))
.filter((item) => { .filter((item) => {
// Если направление маршрута не указано, показываем все станции
if (routeDirection === undefined) return true; if (routeDirection === undefined) return true;
// Фильтруем станции по направлению маршрута
return item.direction === routeDirection; return item.direction === routeDirection;
}) })
.filter((item) => { .filter((item) => {
// Фильтруем по городу из навбара
const selectedCityId = selectedCityStore.selectedCityId; const selectedCityId = selectedCityStore.selectedCityId;
if (selectedCityId && "city_id" in item) { if (selectedCityId && "city_id" in item) {
return item.city_id === selectedCityId; return item.city_id === selectedCityId;
@@ -167,7 +163,6 @@ const LinkedItemsContentsInner = <
}) })
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
// Фильтрация по поиску для массового режима
const filteredAvailableItems = availableItems.filter((item) => { const filteredAvailableItems = availableItems.filter((item) => {
if (!searchQuery.trim()) return true; if (!searchQuery.trim()) return true;
return String(item.name).toLowerCase().includes(searchQuery.toLowerCase()); return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());

View File

@@ -113,7 +113,6 @@ export const RouteCreatePage = observer(() => {
const handleArticleSelect = (articleId: number) => { const handleArticleSelect = (articleId: number) => {
setGovernorAppeal(articleId.toString()); setGovernorAppeal(articleId.toString());
setIsSelectArticleDialogOpen(false); setIsSelectArticleDialogOpen(false);
// Обновляем список статей после создания новой
articlesStore.getArticleList(); articlesStore.getArticleList();
}; };
@@ -155,7 +154,6 @@ export const RouteCreatePage = observer(() => {
try { try {
setIsLoading(true); setIsLoading(true);
// Валидация обязательных полей
if (!routeName.trim()) { if (!routeName.trim()) {
toast.error("Заполните название маршрута"); toast.error("Заполните название маршрута");
setIsLoading(false); setIsLoading(false);
@@ -189,10 +187,9 @@ export const RouteCreatePage = observer(() => {
return; return;
} }
// Валидация масштабов
const scale_min = scaleMin ? Number(scaleMin) : null; const scale_min = scaleMin ? Number(scaleMin) : null;
const scale_max = scaleMax ? Number(scaleMax) : null; const scale_max = scaleMax ? Number(scaleMax) : null;
console.log(scale_min, scale_max);
if ( if (
scale_min === 0 || scale_min === 0 ||
scale_max === 0 || scale_max === 0 ||
@@ -215,7 +212,6 @@ export const RouteCreatePage = observer(() => {
return; return;
} }
// Преобразуем значения в нужные типы
const carrier_id = Number(carrier); const carrier_id = Number(carrier);
const governor_appeal = Number(governorAppeal); const governor_appeal = Number(governorAppeal);
const rotate = turn ? Number(turn) : undefined; const rotate = turn ? Number(turn) : undefined;
@@ -223,7 +219,6 @@ export const RouteCreatePage = observer(() => {
const center_longitude = centerLng ? Number(centerLng) : undefined; const center_longitude = centerLng ? Number(centerLng) : undefined;
const route_direction = direction === "forward"; const route_direction = direction === "forward";
// Координаты маршрута как массив массивов чисел
const path = routeCoords const path = routeCoords
.trim() .trim()
.split("\n") .split("\n")
@@ -235,7 +230,6 @@ export const RouteCreatePage = observer(() => {
return [lat, lon]; return [lat, lon];
}); });
// Собираем объект маршрута
const newRoute: Partial<Route> = { const newRoute: Partial<Route> = {
carrier: carrier:
carrierStore.carriers[ carrierStore.carriers[
@@ -268,7 +262,6 @@ export const RouteCreatePage = observer(() => {
} }
}; };
// Получаем название выбранной статьи для отображения
const selectedArticle = articlesStore.articleList.ru.data.find( const selectedArticle = articlesStore.articleList.ru.data.find(
(article) => article.id === Number(governorAppeal) (article) => article.id === Number(governorAppeal)
); );
@@ -429,7 +422,6 @@ export const RouteCreatePage = observer(() => {
onChange={(e) => { onChange={(e) => {
const value = e.target.value; const value = e.target.value;
setScaleMin(value); setScaleMin(value);
// Если максимальный масштаб стал меньше минимального, обновляем его
if (value && scaleMax && Number(value) > Number(scaleMax)) { if (value && scaleMax && Number(value) > Number(scaleMax)) {
setScaleMax(value); setScaleMax(value);
} }

View File

@@ -47,10 +47,8 @@ export function InfiniteCanvas({
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 }); const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
const [isPointerDown, setIsPointerDown] = useState(false); const [isPointerDown, setIsPointerDown] = useState(false);
// Флаг для предотвращения конфликта между пользовательским вводом и данными маршрута
const [isUserInteracting, setIsUserInteracting] = useState(false); const [isUserInteracting, setIsUserInteracting] = useState(false);
// Реф для отслеживания последнего значения originalRouteData?.rotate
const lastOriginalRotation = useRef<number | undefined>(undefined); const lastOriginalRotation = useRef<number | undefined>(undefined);
useEffect(() => { useEffect(() => {
@@ -68,7 +66,7 @@ export function InfiniteCanvas({
const handlePointerDown = (e: FederatedMouseEvent) => { const handlePointerDown = (e: FederatedMouseEvent) => {
setIsPointerDown(true); setIsPointerDown(true);
setIsDragging(false); setIsDragging(false);
setIsUserInteracting(true); // Устанавливаем флаг взаимодействия пользователя setIsUserInteracting(true);
setStartPosition({ setStartPosition({
x: position.x, x: position.x,
y: position.y, y: position.y,
@@ -81,13 +79,9 @@ export function InfiniteCanvas({
e.stopPropagation(); e.stopPropagation();
}; };
// Устанавливаем rotation только при изменении originalRouteData и отсутствии взаимодействия пользователя
useEffect(() => { useEffect(() => {
const newRotation = originalRouteData?.rotate ?? 0; const newRotation = originalRouteData?.rotate ?? 0;
// Обновляем rotation только если:
// 1. Пользователь не взаимодействует с канвасом
// 2. Значение действительно изменилось
if (!isUserInteracting && lastOriginalRotation.current !== newRotation) { if (!isUserInteracting && lastOriginalRotation.current !== newRotation) {
setRotation((newRotation * Math.PI) / 180); setRotation((newRotation * Math.PI) / 180);
lastOriginalRotation.current = newRotation; lastOriginalRotation.current = newRotation;
@@ -97,7 +91,6 @@ export function InfiniteCanvas({
const handlePointerMove = (e: FederatedMouseEvent) => { const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isPointerDown) return; if (!isPointerDown) return;
// Проверяем, началось ли перетаскивание
if (!isDragging) { if (!isDragging) {
const dx = e.globalX - startMousePosition.x; const dx = e.globalX - startMousePosition.x;
const dy = e.globalY - startMousePosition.y; const dy = e.globalY - startMousePosition.y;
@@ -119,10 +112,8 @@ export function InfiniteCanvas({
e.globalX - center.x e.globalX - center.x
); );
// Calculate rotation difference in radians
const rotationDiff = currentAngle - startAngle; const rotationDiff = currentAngle - startAngle;
// Update rotation
setRotation(startRotation + rotationDiff); setRotation(startRotation + rotationDiff);
const cosDelta = Math.cos(rotationDiff); const cosDelta = Math.cos(rotationDiff);
@@ -149,15 +140,13 @@ export function InfiniteCanvas({
}; };
const handlePointerUp = (e: FederatedMouseEvent) => { const handlePointerUp = (e: FederatedMouseEvent) => {
// Если не было перетаскивания, то это простой клик - закрываем виджет
if (!isDragging) { if (!isDragging) {
setSelectedSight(undefined); setSelectedSight(undefined);
} }
setIsPointerDown(false); setIsPointerDown(false);
setIsDragging(false); setIsDragging(false);
// Сбрасываем флаг взаимодействия через небольшую задержку
// чтобы избежать немедленного срабатывания useEffect
setTimeout(() => { setTimeout(() => {
setIsUserInteracting(false); setIsUserInteracting(false);
}, 100); }, 100);
@@ -166,29 +155,25 @@ export function InfiniteCanvas({
const handleWheel = (e: FederatedWheelEvent) => { const handleWheel = (e: FederatedWheelEvent) => {
e.stopPropagation(); e.stopPropagation();
setIsUserInteracting(true); // Устанавливаем флаг при зуме setIsUserInteracting(true);
// Get mouse position relative to canvas
const mouseX = e.globalX - position.x; const mouseX = e.globalX - position.x;
const mouseY = e.globalY - position.y; const mouseY = e.globalY - position.y;
// Calculate new scale
const scaleMin = (routeData?.scale_min ?? 10) / SCALE_FACTOR; const scaleMin = (routeData?.scale_min ?? 10) / SCALE_FACTOR;
const scaleMax = (routeData?.scale_max ?? 20) / SCALE_FACTOR; const scaleMax = (routeData?.scale_max ?? 20) / SCALE_FACTOR;
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; // Zoom out/in const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor)); const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor));
const actualZoomFactor = newScale / scale; const actualZoomFactor = newScale / scale;
if (scale === newScale) { if (scale === newScale) {
// Сбрасываем флаг, если зум не изменился
setTimeout(() => { setTimeout(() => {
setIsUserInteracting(false); setIsUserInteracting(false);
}, 100); }, 100);
return; return;
} }
// Update position to zoom towards mouse cursor
setPosition({ setPosition({
x: position.x + mouseX * (1 - actualZoomFactor), x: position.x + mouseX * (1 - actualZoomFactor),
y: position.y + mouseY * (1 - actualZoomFactor), y: position.y + mouseY * (1 - actualZoomFactor),
@@ -196,7 +181,6 @@ export function InfiniteCanvas({
setScale(newScale); setScale(newScale);
// Сбрасываем флаг взаимодействия через задержку
setTimeout(() => { setTimeout(() => {
setIsUserInteracting(false); setIsUserInteracting(false);
}, 100); }, 100);

View File

@@ -141,7 +141,6 @@ export const MapDataProvider = observer(
}, [routeId]); }, [routeId]);
useEffect(() => { useEffect(() => {
// combine changes with original data
if (originalRouteData) if (originalRouteData)
setRouteData({ ...originalRouteData, ...routeChanges }); setRouteData({ ...originalRouteData, ...routeChanges });
if (originalSightData) setSightData(originalSightData); if (originalSightData) setSightData(originalSightData);

View File

@@ -37,11 +37,9 @@ export function RightSidebar() {
useEffect(() => { useEffect(() => {
if (originalRouteData) { if (originalRouteData) {
// Проверяем и сбрасываем минимальный масштаб если нужно
const originalMinScale = originalRouteData.scale_min ?? 1; const originalMinScale = originalRouteData.scale_min ?? 1;
const resetMinScale = originalMinScale < 1 ? 1 : originalMinScale; const resetMinScale = originalMinScale < 1 ? 1 : originalMinScale;
// Проверяем и сбрасываем максимальный масштаб если нужно
const originalMaxScale = originalRouteData.scale_max ?? 5; const originalMaxScale = originalRouteData.scale_max ?? 5;
const resetMaxScale = originalMaxScale < 3 ? 3 : originalMaxScale; const resetMaxScale = originalMaxScale < 3 ? 3 : originalMaxScale;
@@ -130,7 +128,6 @@ export function RightSidebar() {
onChange={(e) => { onChange={(e) => {
let newMinScale = Number(e.target.value); let newMinScale = Number(e.target.value);
// Сбрасываем к 1 если меньше
if (newMinScale < 1) { if (newMinScale < 1) {
newMinScale = 1; newMinScale = 1;
} }
@@ -139,10 +136,10 @@ export function RightSidebar() {
if (maxScale - newMinScale < 2) { if (maxScale - newMinScale < 2) {
let newMaxScale = newMinScale + 2; let newMaxScale = newMinScale + 2;
// Сбрасываем максимальный к 3 если меньше минимального
if (newMaxScale < 3) { if (newMaxScale < 3) {
newMaxScale = 3; newMaxScale = 3;
setMinScale(1); // Сбрасываем минимальный к 1 setMinScale(1);
} }
setMaxScale(newMaxScale); setMaxScale(newMaxScale);
} }
@@ -175,7 +172,6 @@ export function RightSidebar() {
onChange={(e) => { onChange={(e) => {
let newMaxScale = Number(e.target.value); let newMaxScale = Number(e.target.value);
// Сбрасываем к 3 если меньше минимального
if (newMaxScale < 3) { if (newMaxScale < 3) {
newMaxScale = 3; newMaxScale = 3;
} }
@@ -184,10 +180,10 @@ export function RightSidebar() {
if (newMaxScale - minScale < 2) { if (newMaxScale - minScale < 2) {
let newMinScale = newMaxScale - 2; let newMinScale = newMaxScale - 2;
// Сбрасываем минимальный к 1 если меньше
if (newMinScale < 1) { if (newMinScale < 1) {
newMinScale = 1; newMinScale = 1;
setMaxScale(3); // Сбрасываем максимальный к минимальному значению setMaxScale(3);
} }
setMinScale(newMinScale); setMinScale(newMinScale);
} }

View File

@@ -2,7 +2,6 @@ import { FederatedMouseEvent, Graphics } from "pixi.js";
import { useCallback, useState, useEffect, useRef, FC, useMemo } from "react"; import { useCallback, useState, useEffect, useRef, FC, useMemo } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// --- Заглушки для зависимостей (замените на ваши реальные импорты) ---
import { import {
BACKGROUND_COLOR, BACKGROUND_COLOR,
PATH_COLOR, PATH_COLOR,
@@ -15,22 +14,16 @@ import { StationData } from "./types";
import { useMapData } from "./MapDataContext"; import { useMapData } from "./MapDataContext";
import { coordinatesToLocal } from "./utils"; import { coordinatesToLocal } from "./utils";
import { languageStore } from "@shared"; import { languageStore } from "@shared";
// --- Конец заглушек ---
// --- Декларации для react-pixi ---
// (В реальном проекте типы должны быть предоставлены библиотекой @pixi/react-pixi)
declare const pixiContainer: any; declare const pixiContainer: any;
declare const pixiGraphics: any; declare const pixiGraphics: any;
declare const pixiText: any; declare const pixiText: any;
// --- Типы ---
type HorizontalAlign = "left" | "center" | "right"; type HorizontalAlign = "left" | "center" | "right";
type VerticalAlign = "top" | "center" | "bottom"; type VerticalAlign = "top" | "center" | "bottom";
type TextAlign = HorizontalAlign | `${HorizontalAlign} ${VerticalAlign}`; type TextAlign = HorizontalAlign | `${HorizontalAlign} ${VerticalAlign}`;
type LabelAlign = "left" | "center" | "right"; type LabelAlign = "left" | "center" | "right";
// --- Утилиты ---
/** /**
* Преобразует текстовое позиционирование в anchor координаты. * Преобразует текстовое позиционирование в anchor координаты.
*/ */
@@ -39,8 +32,6 @@ type LabelAlign = "left" | "center" | "right";
* Получает координату anchor.x из типа выравнивания. * Получает координату anchor.x из типа выравнивания.
*/ */
// --- Интерфейсы пропсов ---
interface StationProps { interface StationProps {
station: StationData; station: StationData;
ruLabel: string | null; ruLabel: string | null;
@@ -83,10 +74,6 @@ const getAnchorFromOffset = (
return { x: (1 - nx) / 2, y: (1 - ny) / 2 }; return { x: (1 - nx) / 2, y: (1 - ny) / 2 };
}; };
// =========================================================================
// Компонент: Панель управления выравниванием в стиле УрФУ
// =========================================================================
const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
scale, scale,
currentAlign, currentAlign,
@@ -107,7 +94,6 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
(g: Graphics) => { (g: Graphics) => {
g.clear(); g.clear();
// Основной фон с градиентом
g.roundRect( g.roundRect(
-controlWidth / 2, -controlWidth / 2,
0, 0,
@@ -115,9 +101,8 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
controlHeight, controlHeight,
borderRadius borderRadius
); );
g.fill({ color: "#1a1a1a" }); // Темный фон как у УрФУ g.fill({ color: "#1a1a1a" });
// Тонкая рамка
g.roundRect( g.roundRect(
-controlWidth / 2, -controlWidth / 2,
0, 0,
@@ -127,7 +112,6 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
); );
g.stroke({ color: "#333333", width: strokeWidth }); g.stroke({ color: "#333333", width: strokeWidth });
// Разделители между кнопками
for (let i = 1; i < 3; i++) { for (let i = 1; i < 3; i++) {
const x = -controlWidth / 2 + buttonWidth * i; const x = -controlWidth / 2 + buttonWidth * i;
g.moveTo(x, strokeWidth); g.moveTo(x, strokeWidth);
@@ -151,7 +135,7 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
controlHeight - strokeWidth * 2, controlHeight - strokeWidth * 2,
borderRadius / 2 borderRadius / 2
); );
g.fill({ color: "#0066cc", alpha: 0.8 }); // Синий акцент УрФУ g.fill({ color: "#0066cc", alpha: 0.8 });
} }
}, },
[controlWidth, controlHeight, buttonWidth, strokeWidth, borderRadius] [controlWidth, controlHeight, buttonWidth, strokeWidth, borderRadius]
@@ -230,10 +214,6 @@ const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
); );
}; };
// =========================================================================
// Компонент: Метка Станции (с логикой)
// =========================================================================
const StationLabel = observer( const StationLabel = observer(
({ ({
station, station,
@@ -274,48 +254,45 @@ const StationLabel = observer(
hideTimer.current = null; hideTimer.current = null;
} }
setIsHovered(true); setIsHovered(true);
onTextHover?.(true); // Call the callback to indicate text is hovered onTextHover?.(true);
}; };
const handleControlPointerEnter = () => { const handleControlPointerEnter = () => {
// Дополнительная обработка для панели управления
if (hideTimer.current) { if (hideTimer.current) {
clearTimeout(hideTimer.current); clearTimeout(hideTimer.current);
hideTimer.current = null; hideTimer.current = null;
} }
setIsControlHovered(true); setIsControlHovered(true);
setIsHovered(true); setIsHovered(true);
onTextHover?.(true); // Call the callback to indicate text/control is hovered onTextHover?.(true);
}; };
const handleControlPointerLeave = () => { const handleControlPointerLeave = () => {
setIsControlHovered(false); setIsControlHovered(false);
// Если курсор не над основным контейнером, скрываем панель через некоторое время
if (!isHovered) { if (!isHovered) {
hideTimer.current = setTimeout(() => { hideTimer.current = setTimeout(() => {
setIsHovered(false); setIsHovered(false);
onTextHover?.(false); // Call the callback to indicate neither text nor control is hovered onTextHover?.(false);
}, 0); }, 0);
} }
}; };
const handlePointerLeave = () => { const handlePointerLeave = () => {
// Увеличиваем время до скрытия панели и добавляем проверку
hideTimer.current = setTimeout(() => { hideTimer.current = setTimeout(() => {
setIsHovered(false); setIsHovered(false);
// Если курсор не над панелью управления, скрываем и её
if (!isControlHovered) { if (!isControlHovered) {
setIsControlHovered(false); setIsControlHovered(false);
} }
onTextHover?.(false); // Call the callback to indicate text is no longer hovered onTextHover?.(false);
}, 100); // Увеличиваем время до скрытия панели }, 100);
}; };
useEffect(() => { useEffect(() => {
setPosition({ x: station.offset_x ?? 0, y: station.offset_y ?? 0 }); setPosition({ x: station.offset_x ?? 0, y: station.offset_y ?? 0 });
}, [station.offset_x, station.offset_y, station.id]); }, [station.offset_x, station.offset_y, station.id]);
// Функция для конвертации числового align в строковый
const convertNumericAlign = (align: number): LabelAlign => { const convertNumericAlign = (align: number): LabelAlign => {
switch (align) { switch (align) {
case 0: case 0:
@@ -329,7 +306,6 @@ const StationLabel = observer(
} }
}; };
// Функция для конвертации строкового align в числовой
const convertStringAlign = (align: LabelAlign): number => { const convertStringAlign = (align: LabelAlign): number => {
switch (align) { switch (align) {
case "left": case "left":
@@ -353,7 +329,6 @@ const StationLabel = observer(
const compensatedRuFontSize = (26 * 0.75) / scale; const compensatedRuFontSize = (26 * 0.75) / scale;
const compensatedNameFontSize = (16 * 0.75) / scale; const compensatedNameFontSize = (16 * 0.75) / scale;
// Измеряем ширину верхнего лейбла
useEffect(() => { useEffect(() => {
if (ruLabelRef.current && ruLabel) { if (ruLabelRef.current && ruLabel) {
setRuLabelWidth(ruLabelRef.current.width); setRuLabelWidth(ruLabelRef.current.width);
@@ -386,7 +361,6 @@ const StationLabel = observer(
y: dragStartPos.current.y + dy_screen, y: dragStartPos.current.y + dy_screen,
}; };
// Проверяем, изменилась ли позиция
if ( if (
Math.abs(newPosition.x - position.x) > 0.01 || Math.abs(newPosition.x - position.x) > 0.01 ||
Math.abs(newPosition.y - position.y) > 0.01 Math.abs(newPosition.y - position.y) > 0.01
@@ -406,7 +380,7 @@ const StationLabel = observer(
const handleAlignChange = async (align: LabelAlign) => { const handleAlignChange = async (align: LabelAlign) => {
setCurrentLabelAlign(align); setCurrentLabelAlign(align);
onLabelAlignChange?.(align); onLabelAlignChange?.(align);
// Сохраняем в стор
const numericAlign = convertStringAlign(align); const numericAlign = convertStringAlign(align);
setStationAlign(station.id, numericAlign); setStationAlign(station.id, numericAlign);
}; };
@@ -416,34 +390,29 @@ const StationLabel = observer(
[position.x, position.y] [position.x, position.y]
); );
// Функция для расчета позиции нижнего лейбла относительно ширины верхнего
const getSecondLabelPosition = (): number => { const getSecondLabelPosition = (): number => {
if (!ruLabelWidth) return 0; if (!ruLabelWidth) return 0;
switch (currentLabelAlign) { switch (currentLabelAlign) {
case "left": case "left":
// Позиционируем относительно левого края верхнего текста
return -ruLabelWidth / 2; return -ruLabelWidth / 2;
case "center": case "center":
// Центрируем относительно центра верхнего текста
return 0; return 0;
case "right": case "right":
// Позиционируем относительно правого края верхнего текста
return ruLabelWidth / 2; return ruLabelWidth / 2;
default: default:
return 0; return 0;
} }
}; };
// Функция для расчета anchor нижнего лейбла
const getSecondLabelAnchor = (): number => { const getSecondLabelAnchor = (): number => {
switch (currentLabelAlign) { switch (currentLabelAlign) {
case "left": case "left":
return 0; // anchor.x = 0 (левый край) return 0;
case "center": case "center":
return 0.5; // anchor.x = 0.5 (центр) return 0.5;
case "right": case "right":
return 1; // anchor.x = 1 (правый край) return 1;
default: default:
return 0.5; return 0.5;
} }
@@ -522,10 +491,6 @@ const StationLabel = observer(
} }
); );
// =========================================================================
// Главный экспортируемый компонент: Станция
// =========================================================================
export const Station = ({ export const Station = ({
station, station,
ruLabel, ruLabel,
@@ -548,10 +513,9 @@ export const Station = ({
g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius); g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius);
// Change fill color when text is hovered
if (isTextHovered) { if (isTextHovered) {
g.fill({ color: 0x00aaff }); // Highlight color when hovered g.fill({ color: 0x00aaff });
g.stroke({ color: 0xffffff, width: strokeWidth + 1 }); // Brighter outline when hovered g.stroke({ color: 0xffffff, width: strokeWidth + 1 });
} else { } else {
g.fill({ color: PATH_COLOR }); g.fill({ color: PATH_COLOR });
g.stroke({ color: BACKGROUND_COLOR, width: strokeWidth }); g.stroke({ color: BACKGROUND_COLOR, width: strokeWidth });

View File

@@ -50,7 +50,6 @@ const TransformContext = createContext<{
setScaleAtCenter: () => {}, setScaleAtCenter: () => {},
}); });
// Provider component
export const TransformProvider = ({ children }: { children: ReactNode }) => { export const TransformProvider = ({ children }: { children: ReactNode }) => {
const [position, setPosition] = useState({ x: 0, y: 0 }); const [position, setPosition] = useState({ x: 0, y: 0 });
const [scale, setScale] = useState(1); const [scale, setScale] = useState(1);
@@ -59,12 +58,10 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
const screenToLocal = useCallback( const screenToLocal = useCallback(
(screenX: number, screenY: number) => { (screenX: number, screenY: number) => {
// Translate point relative to current pan position
const translatedX = (screenX - position.x) / scale; const translatedX = (screenX - position.x) / scale;
const translatedY = (screenY - position.y) / scale; const translatedY = (screenY - position.y) / scale;
// Rotate point around center const cosRotation = Math.cos(-rotation);
const cosRotation = Math.cos(-rotation); // Negative rotation to reverse transform
const sinRotation = Math.sin(-rotation); const sinRotation = Math.sin(-rotation);
const rotatedX = translatedX * cosRotation - translatedY * sinRotation; const rotatedX = translatedX * cosRotation - translatedY * sinRotation;
const rotatedY = translatedX * sinRotation + translatedY * cosRotation; const rotatedY = translatedX * sinRotation + translatedY * cosRotation;
@@ -77,7 +74,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
[position.x, position.y, scale, rotation] [position.x, position.y, scale, rotation]
); );
// Inverse of screenToLocal
const localToScreen = useCallback( const localToScreen = useCallback(
(localX: number, localY: number) => { (localX: number, localY: number) => {
const upscaledX = localX * UP_SCALE; const upscaledX = localX * UP_SCALE;
@@ -120,7 +116,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
(currentFromPosition.x - center.x) * sinDelta, (currentFromPosition.x - center.x) * sinDelta,
}; };
// Update both rotation and position in a single batch to avoid stale closure
setRotation(to); setRotation(to);
setPosition(newPosition); setPosition(newPosition);
}, },
@@ -150,13 +145,11 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
const cosRot = Math.cos(selectedRotation); const cosRot = Math.cos(selectedRotation);
const sinRot = Math.sin(selectedRotation); const sinRot = Math.sin(selectedRotation);
// Translate point relative to center, rotate, then translate back
const dx = newPosition.x; const dx = newPosition.x;
const dy = newPosition.y; const dy = newPosition.y;
newPosition.x = dx * cosRot - dy * sinRot + center.x; newPosition.x = dx * cosRot - dy * sinRot + center.x;
newPosition.y = dx * sinRot + dy * cosRot + center.y; newPosition.y = dx * sinRot + dy * cosRot + center.y;
// Batch state updates to avoid intermediate renders
setPosition(newPosition); setPosition(newPosition);
setRotation(selectedRotation); setRotation(selectedRotation);
setScale(selectedScale); setScale(selectedScale);
@@ -184,7 +177,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
); );
const setScaleOnly = useCallback((newScale: number) => { const setScaleOnly = useCallback((newScale: number) => {
// Изменяем только масштаб, не трогая позицию и поворот
setScale(newScale); setScale(newScale);
}, []); }, []);
@@ -237,7 +229,6 @@ export const TransformProvider = ({ children }: { children: ReactNode }) => {
); );
}; };
// Custom hook for easy access to transform values
export const useTransform = () => { export const useTransform = () => {
const context = useContext(TransformContext); const context = useContext(TransformContext);
if (!context) { if (!context) {

View File

@@ -53,13 +53,11 @@ export const WebGLMap = observer(() => {
const cameraAnimationStore = useCameraAnimationStore(); const cameraAnimationStore = useCameraAnimationStore();
// Ref для хранения ограничений масштаба
const scaleLimitsRef = useRef({ const scaleLimitsRef = useRef({
min: null as number | null, min: null as number | null,
max: null as number | null, max: null as number | null,
}); });
// Обновляем ограничения масштаба при изменении routeData
useEffect(() => { useEffect(() => {
if ( if (
routeData?.scale_min !== undefined && routeData?.scale_min !== undefined &&
@@ -72,7 +70,6 @@ export const WebGLMap = observer(() => {
} }
}, [routeData?.scale_min, routeData?.scale_max]); }, [routeData?.scale_min, routeData?.scale_max]);
// Функция для ограничения масштаба значениями с бекенда
const clampScale = useCallback((value: number) => { const clampScale = useCallback((value: number) => {
const { min, max } = scaleLimitsRef.current; const { min, max } = scaleLimitsRef.current;
@@ -90,7 +87,6 @@ export const WebGLMap = observer(() => {
const setPositionRef = useRef(setPosition); const setPositionRef = useRef(setPosition);
const setScaleRef = useRef(setScale); const setScaleRef = useRef(setScale);
// Обновляем refs при изменении функций
useEffect(() => { useEffect(() => {
setPositionRef.current = setPosition; setPositionRef.current = setPosition;
}, [setPosition]); }, [setPosition]);
@@ -99,7 +95,6 @@ export const WebGLMap = observer(() => {
setScaleRef.current = setScale; setScaleRef.current = setScale;
}, [setScale]); }, [setScale]);
// Логирование данных маршрута для отладки
useEffect(() => { useEffect(() => {
if (routeData) { if (routeData) {
} }
@@ -124,7 +119,6 @@ export const WebGLMap = observer(() => {
setPositionImmediate: setYellowDotPositionImmediate, setPositionImmediate: setYellowDotPositionImmediate,
} = useAnimatedPolarPosition(0, 0, 800); } = useAnimatedPolarPosition(0, 0, 800);
// Build transformed route path (map coords)
const routePath = useMemo(() => { const routePath = useMemo(() => {
if (!routeData?.path || routeData?.path.length === 0) if (!routeData?.path || routeData?.path.length === 0)
return new Float32Array(); return new Float32Array();
@@ -180,7 +174,6 @@ export const WebGLMap = observer(() => {
rotationAngle, rotationAngle,
]); ]);
// Настройка CameraAnimationStore callback - только один раз при монтировании
useEffect(() => { useEffect(() => {
const callback = (newPos: { x: number; y: number }, newZoom: number) => { const callback = (newPos: { x: number; y: number }, newZoom: number) => {
setPosition(newPos); setPosition(newPos);
@@ -189,15 +182,13 @@ export const WebGLMap = observer(() => {
cameraAnimationStore.setUpdateCallback(callback); cameraAnimationStore.setUpdateCallback(callback);
// Синхронизируем начальное состояние только один раз
cameraAnimationStore.syncState(position, scale); cameraAnimationStore.syncState(position, scale);
return () => { return () => {
cameraAnimationStore.setUpdateCallback(null); cameraAnimationStore.setUpdateCallback(null);
}; };
}, []); // Пустой массив - выполняется только при монтировании }, []);
// Установка границ зума
useEffect(() => { useEffect(() => {
if ( if (
routeData?.scale_min !== undefined && routeData?.scale_min !== undefined &&
@@ -208,28 +199,23 @@ export const WebGLMap = observer(() => {
} }
}, [routeData?.scale_min, routeData?.scale_max, cameraAnimationStore]); }, [routeData?.scale_min, routeData?.scale_max, cameraAnimationStore]);
// Автоматический режим - таймер для включения через 5 секунд бездействия
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
const timeSinceActivity = Date.now() - userActivityTimestamp; const timeSinceActivity = Date.now() - userActivityTimestamp;
if (timeSinceActivity >= 5000 && !isAutoMode) { if (timeSinceActivity >= 5000 && !isAutoMode) {
// 5 секунд бездействия - включаем авто режим
setIsAutoMode(true); setIsAutoMode(true);
} }
}, 1000); // Проверяем каждую секунду }, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [userActivityTimestamp, isAutoMode, setIsAutoMode]); }, [userActivityTimestamp, isAutoMode, setIsAutoMode]);
// Следование за желтой точкой с зумом при включенном авто режиме
useEffect(() => { useEffect(() => {
// Пропускаем обновление если анимация уже идет
if (cameraAnimationStore.isActivelyAnimating) { if (cameraAnimationStore.isActivelyAnimating) {
return; return;
} }
if (isAutoMode && transformedTramCoords && screenCenter) { if (isAutoMode && transformedTramCoords && screenCenter) {
// Преобразуем станции в формат для CameraAnimationStore
const transformedStations = stationData const transformedStations = stationData
? stationData ? stationData
.map((station: any) => { .map((station: any) => {
@@ -270,10 +256,8 @@ export const WebGLMap = observer(() => {
cameraAnimationStore.setMaxZoom(scaleLimitsRef.current!.max); cameraAnimationStore.setMaxZoom(scaleLimitsRef.current!.max);
cameraAnimationStore.setMinZoom(scaleLimitsRef.current!.min); cameraAnimationStore.setMinZoom(scaleLimitsRef.current!.min);
// Синхронизируем текущее состояние камеры перед запуском анимации
cameraAnimationStore.syncState(positionRef.current, scaleRef.current); cameraAnimationStore.syncState(positionRef.current, scaleRef.current);
// Запускаем анимацию к желтой точке
cameraAnimationStore.followTram( cameraAnimationStore.followTram(
transformedTramCoords, transformedTramCoords,
screenCenter, screenCenter,
@@ -293,7 +277,6 @@ export const WebGLMap = observer(() => {
rotationAngle, rotationAngle,
]); ]);
// Station label overlay positions (DOM overlay)
const stationLabels = useMemo(() => { const stationLabels = useMemo(() => {
if (!stationData || !routeData) if (!stationData || !routeData)
return [] as Array<{ x: number; y: number; name: string; sub?: string }>; return [] as Array<{ x: number; y: number; name: string; sub?: string }>;
@@ -356,7 +339,6 @@ export const WebGLMap = observer(() => {
selectedLanguage as any, selectedLanguage as any,
]); ]);
// Build transformed stations (map coords)
const stationPoints = useMemo(() => { const stationPoints = useMemo(() => {
if (!stationData || !routeData) return new Float32Array(); if (!stationData || !routeData) return new Float32Array();
const centerLat = routeData.center_latitude; const centerLat = routeData.center_latitude;
@@ -386,7 +368,6 @@ export const WebGLMap = observer(() => {
rotationAngle, rotationAngle,
]); ]);
// Build transformed sights (map coords)
const sightPoints = useMemo(() => { const sightPoints = useMemo(() => {
if (!sightData || !routeData) return new Float32Array(); if (!sightData || !routeData) return new Float32Array();
const centerLat = routeData.center_latitude; const centerLat = routeData.center_latitude;
@@ -530,8 +511,6 @@ export const WebGLMap = observer(() => {
const handleResize = () => { const handleResize = () => {
const changed = resizeCanvasToDisplaySize(canvas); const changed = resizeCanvasToDisplaySize(canvas);
if (!gl) return; if (!gl) return;
// Update screen center when canvas size changes
// Use physical pixels (canvas.width) instead of CSS pixels
setScreenCenter({ setScreenCenter({
x: canvas.width / 2, x: canvas.width / 2,
y: canvas.height / 2, y: canvas.height / 2,
@@ -567,7 +546,6 @@ export const WebGLMap = observer(() => {
const rx = x * cos - y * sin; const rx = x * cos - y * sin;
const ry = x * sin + y * cos; const ry = x * sin + y * cos;
// В авторежиме используем анимацию, иначе мгновенное обновление
if (isAutoMode) { if (isAutoMode) {
animateYellowDotTo(rx, ry); animateYellowDotTo(rx, ry);
} else { } else {
@@ -666,21 +644,18 @@ export const WebGLMap = observer(() => {
const vertexCount = routePath.length / 2; const vertexCount = routePath.length / 2;
if (vertexCount > 1) { if (vertexCount > 1) {
// Generate thick line geometry using triangles with proper joins
const generateThickLine = (points: Float32Array, width: number) => { const generateThickLine = (points: Float32Array, width: number) => {
const vertices: number[] = []; const vertices: number[] = [];
const halfWidth = width / 2; const halfWidth = width / 2;
if (points.length < 4) return new Float32Array(); if (points.length < 4) return new Float32Array();
// Process each segment
for (let i = 0; i < points.length - 2; i += 2) { for (let i = 0; i < points.length - 2; i += 2) {
const x1 = points[i]; const x1 = points[i];
const y1 = points[i + 1]; const y1 = points[i + 1];
const x2 = points[i + 2]; const x2 = points[i + 2];
const y2 = points[i + 3]; const y2 = points[i + 3];
// Calculate perpendicular vector
const dx = x2 - x1; const dx = x2 - x1;
const dy = y2 - y1; const dy = y2 - y1;
const length = Math.sqrt(dx * dx + dy * dy); const length = Math.sqrt(dx * dx + dy * dy);
@@ -689,18 +664,14 @@ export const WebGLMap = observer(() => {
const perpX = (-dy / length) * halfWidth; const perpX = (-dy / length) * halfWidth;
const perpY = (dx / length) * halfWidth; const perpY = (dx / length) * halfWidth;
// Create quad (two triangles) for this line segment
// Triangle 1
vertices.push(x1 + perpX, y1 + perpY); vertices.push(x1 + perpX, y1 + perpY);
vertices.push(x1 - perpX, y1 - perpY); vertices.push(x1 - perpX, y1 - perpY);
vertices.push(x2 + perpX, y2 + perpY); vertices.push(x2 + perpX, y2 + perpY);
// Triangle 2
vertices.push(x1 - perpX, y1 - perpY); vertices.push(x1 - perpX, y1 - perpY);
vertices.push(x2 - perpX, y2 - perpY); vertices.push(x2 - perpX, y2 - perpY);
vertices.push(x2 + perpX, y2 + perpY); vertices.push(x2 + perpX, y2 + perpY);
// Add simple join triangles to fill gaps
if (i < points.length - 4) { if (i < points.length - 4) {
const x3 = points[i + 4]; const x3 = points[i + 4];
const y3 = points[i + 5]; const y3 = points[i + 5];
@@ -712,7 +683,6 @@ export const WebGLMap = observer(() => {
const perpX2 = (-dy2 / length2) * halfWidth; const perpX2 = (-dy2 / length2) * halfWidth;
const perpY2 = (dx2 / length2) * halfWidth; const perpY2 = (dx2 / length2) * halfWidth;
// Simple join - just connect the endpoints
vertices.push(x2 + perpX, y2 + perpY); vertices.push(x2 + perpX, y2 + perpY);
vertices.push(x2 - perpX, y2 - perpY); vertices.push(x2 - perpX, y2 - perpY);
vertices.push(x2 + perpX2, y2 + perpY2); vertices.push(x2 + perpX2, y2 + perpY2);
@@ -734,22 +704,18 @@ export const WebGLMap = observer(() => {
gl.uniform4f(uniforms.u_color, r1, g1, b1, 1); gl.uniform4f(uniforms.u_color, r1, g1, b1, 1);
if (tramSegIndex >= 0) { if (tramSegIndex >= 0) {
// Используем точную позицию желтой точки для определения конца красной линии
const animatedPos = animatedYellowDotPosition; const animatedPos = animatedYellowDotPosition;
if ( if (
animatedPos && animatedPos &&
animatedPos.x !== undefined && animatedPos.x !== undefined &&
animatedPos.y !== undefined animatedPos.y !== undefined
) { ) {
// Создаем массив точек от начала маршрута до позиции желтой точки
const passedPoints: number[] = []; const passedPoints: number[] = [];
// Добавляем все точки до текущего сегмента
for (let i = 0; i <= tramSegIndex; i++) { for (let i = 0; i <= tramSegIndex; i++) {
passedPoints.push(routePath[i * 2], routePath[i * 2 + 1]); passedPoints.push(routePath[i * 2], routePath[i * 2 + 1]);
} }
// Добавляем точную позицию желтой точки как конечную точку
passedPoints.push(animatedPos.x, animatedPos.y); passedPoints.push(animatedPos.x, animatedPos.y);
if (passedPoints.length >= 4) { if (passedPoints.length >= 4) {
@@ -768,7 +734,6 @@ export const WebGLMap = observer(() => {
const b2 = (UNPASSED_STATION_COLOR & 0xff) / 255; const b2 = (UNPASSED_STATION_COLOR & 0xff) / 255;
gl.uniform4f(uniforms.u_color, r2, g2, b2, 1); gl.uniform4f(uniforms.u_color, r2, g2, b2, 1);
// Серая линия начинается точно от позиции желтой точки
const animatedPos = animatedYellowDotPosition; const animatedPos = animatedYellowDotPosition;
if ( if (
animatedPos && animatedPos &&
@@ -777,10 +742,8 @@ export const WebGLMap = observer(() => {
) { ) {
const unpassedPoints: number[] = []; const unpassedPoints: number[] = [];
// Добавляем позицию желтой точки как начальную точку серой линии
unpassedPoints.push(animatedPos.x, animatedPos.y); unpassedPoints.push(animatedPos.x, animatedPos.y);
// Добавляем все точки после текущего сегмента
for (let i = tramSegIndex + 1; i < vertexCount; i++) { for (let i = tramSegIndex + 1; i < vertexCount; i++) {
unpassedPoints.push(routePath[i * 2], routePath[i * 2 + 1]); unpassedPoints.push(routePath[i * 2], routePath[i * 2 + 1]);
} }
@@ -796,7 +759,6 @@ export const WebGLMap = observer(() => {
} }
} }
// Draw stations
if (stationPoints.length > 0) { if (stationPoints.length > 0) {
gl.useProgram(pprog); gl.useProgram(pprog);
const a_pos_pts = gl.getAttribLocation(pprog, "a_pos"); const a_pos_pts = gl.getAttribLocation(pprog, "a_pos");
@@ -814,7 +776,6 @@ export const WebGLMap = observer(() => {
gl.enableVertexAttribArray(a_pos_pts); gl.enableVertexAttribArray(a_pos_pts);
gl.vertexAttribPointer(a_pos_pts, 2, gl.FLOAT, false, 0, 0); gl.vertexAttribPointer(a_pos_pts, 2, gl.FLOAT, false, 0, 0);
// Draw station outlines (black background)
gl.uniform1f(u_pointSize, 10 * scale * 1.5); gl.uniform1f(u_pointSize, 10 * scale * 1.5);
const r_outline = ((BACKGROUND_COLOR >> 16) & 0xff) / 255; const r_outline = ((BACKGROUND_COLOR >> 16) & 0xff) / 255;
const g_outline = ((BACKGROUND_COLOR >> 8) & 0xff) / 255; const g_outline = ((BACKGROUND_COLOR >> 8) & 0xff) / 255;
@@ -822,15 +783,12 @@ export const WebGLMap = observer(() => {
gl.uniform4f(u_color_pts, r_outline, g_outline, b_outline, 1); gl.uniform4f(u_color_pts, r_outline, g_outline, b_outline, 1);
gl.drawArrays(gl.POINTS, 0, stationPoints.length / 2); gl.drawArrays(gl.POINTS, 0, stationPoints.length / 2);
// Draw station cores (colored based on passed/unpassed)
gl.uniform1f(u_pointSize, 8.0 * scale * 1.5); gl.uniform1f(u_pointSize, 8.0 * scale * 1.5);
// Draw passed stations (red)
if (tramSegIndex >= 0) { if (tramSegIndex >= 0) {
const passedStations = []; const passedStations = [];
for (let i = 0; i < stationData.length; i++) { for (let i = 0; i < stationData.length; i++) {
if (i <= tramSegIndex) { if (i <= tramSegIndex) {
// @ts-ignore
passedStations.push(stationPoints[i * 2], stationPoints[i * 2 + 1]); passedStations.push(stationPoints[i * 2], stationPoints[i * 2 + 1]);
} }
} }
@@ -848,13 +806,11 @@ export const WebGLMap = observer(() => {
} }
} }
// Draw unpassed stations (gray)
if (tramSegIndex >= 0) { if (tramSegIndex >= 0) {
const unpassedStations = []; const unpassedStations = [];
for (let i = 0; i < stationData.length; i++) { for (let i = 0; i < stationData.length; i++) {
if (i > tramSegIndex) { if (i > tramSegIndex) {
unpassedStations.push( unpassedStations.push(
// @ts-ignore
stationPoints[i * 2], stationPoints[i * 2],
stationPoints[i * 2 + 1] stationPoints[i * 2 + 1]
); );
@@ -873,7 +829,6 @@ export const WebGLMap = observer(() => {
gl.drawArrays(gl.POINTS, 0, unpassedStations.length / 2); gl.drawArrays(gl.POINTS, 0, unpassedStations.length / 2);
} }
} else { } else {
// If no tram position, draw all stations as unpassed
const r_unpassed = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255; const r_unpassed = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255;
const g_unpassed = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255; const g_unpassed = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255;
const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255; const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255;
@@ -1015,7 +970,6 @@ export const WebGLMap = observer(() => {
if (passedStations.length) if (passedStations.length)
gl.drawArrays(gl.POINTS, 0, passedStations.length / 2); gl.drawArrays(gl.POINTS, 0, passedStations.length / 2);
// Draw black dots with white outline for terminal stations (startStopId and endStopId) - 5x larger
if ( if (
stationData && stationData &&
stationData.length > 0 && stationData.length > 0 &&
@@ -1028,7 +982,6 @@ export const WebGLMap = observer(() => {
const cos = Math.cos(rotationAngle); const cos = Math.cos(rotationAngle);
const sin = Math.sin(rotationAngle); const sin = Math.sin(rotationAngle);
// Find terminal stations using startStopId and endStopId from context
const startStationData = stationData.find( const startStationData = stationData.find(
(station) => station.id.toString() === apiStore.context?.startStopId (station) => station.id.toString() === apiStore.context?.startStopId
); );
@@ -1038,7 +991,6 @@ export const WebGLMap = observer(() => {
const terminalStations: number[] = []; const terminalStations: number[] = [];
// Transform start station coordinates if found
if (startStationData) { if (startStationData) {
const startLocal = coordinatesToLocal( const startLocal = coordinatesToLocal(
startStationData.latitude - centerLat, startStationData.latitude - centerLat,
@@ -1051,7 +1003,6 @@ export const WebGLMap = observer(() => {
terminalStations.push(startRx, startRy); terminalStations.push(startRx, startRy);
} }
// Transform end station coordinates if found
if (endStationData) { if (endStationData) {
const endLocal = coordinatesToLocal( const endLocal = coordinatesToLocal(
endStationData.latitude - centerLat, endStationData.latitude - centerLat,
@@ -1065,12 +1016,10 @@ export const WebGLMap = observer(() => {
} }
if (terminalStations.length > 0) { if (terminalStations.length > 0) {
// Determine if each terminal station is passed
const terminalStationData: any[] = []; const terminalStationData: any[] = [];
if (startStationData) terminalStationData.push(startStationData); if (startStationData) terminalStationData.push(startStationData);
if (endStationData) terminalStationData.push(endStationData); if (endStationData) terminalStationData.push(endStationData);
// Get tram segment index for comparison
let tramSegIndex = -1; let tramSegIndex = -1;
const coords: any = apiStore?.context?.currentCoordinates; const coords: any = apiStore?.context?.currentCoordinates;
if (coords && centerLat !== undefined && centerLon !== undefined) { if (coords && centerLat !== undefined && centerLon !== undefined) {
@@ -1085,7 +1034,6 @@ export const WebGLMap = observer(() => {
const tx = wx * cosR - wy * sinR; const tx = wx * cosR - wy * sinR;
const ty = wx * sinR + wy * cosR; const ty = wx * sinR + wy * cosR;
// Find closest segment to tram position
let best = -1; let best = -1;
let bestD = Infinity; let bestD = Infinity;
for (let i = 0; i < routePath.length - 2; i += 2) { for (let i = 0; i < routePath.length - 2; i += 2) {
@@ -1110,7 +1058,6 @@ export const WebGLMap = observer(() => {
tramSegIndex = best; tramSegIndex = best;
} }
// Check if each terminal station is passed
const isStartPassed = startStationData const isStartPassed = startStationData
? (() => { ? (() => {
const sx = terminalStations[0]; const sx = terminalStations[0];
@@ -1186,46 +1133,41 @@ export const WebGLMap = observer(() => {
gl.enableVertexAttribArray(a_pos_pts); gl.enableVertexAttribArray(a_pos_pts);
gl.vertexAttribPointer(a_pos_pts, 2, gl.FLOAT, false, 0, 0); gl.vertexAttribPointer(a_pos_pts, 2, gl.FLOAT, false, 0, 0);
// Draw colored outline based on passed status - 24 pixels (x2)
gl.uniform1f(u_pointSize, 18.0 * scale); gl.uniform1f(u_pointSize, 18.0 * scale);
if (startStationData && endStationData) { if (startStationData && endStationData) {
// Both stations - draw each with its own color
if (isStartPassed) { if (isStartPassed) {
gl.uniform4f(u_color_pts, 1.0, 0.4, 0.4, 1.0); // Ярко-красный для пройденных gl.uniform4f(u_color_pts, 1.0, 0.4, 0.4, 1.0);
} else { } else {
gl.uniform4f(u_color_pts, 0.7, 0.7, 0.7, 1.0); // Светло-серый для непройденных gl.uniform4f(u_color_pts, 0.7, 0.7, 0.7, 1.0);
} }
gl.drawArrays(gl.POINTS, 0, 1); // Draw start station gl.drawArrays(gl.POINTS, 0, 1);
if (isEndPassed) { if (isEndPassed) {
gl.uniform4f(u_color_pts, 1.0, 0.4, 0.4, 1.0); // Ярко-красный для пройденных gl.uniform4f(u_color_pts, 1.0, 0.4, 0.4, 1.0);
} else { } else {
gl.uniform4f(u_color_pts, 0.7, 0.7, 0.7, 1.0); // Светло-серый для непройденных gl.uniform4f(u_color_pts, 0.7, 0.7, 0.7, 1.0);
} }
gl.drawArrays(gl.POINTS, 1, 1); // Draw end station gl.drawArrays(gl.POINTS, 1, 1);
} else { } else {
// Single station - use appropriate color
const isPassed = startStationData ? isStartPassed : isEndPassed; const isPassed = startStationData ? isStartPassed : isEndPassed;
if (isPassed) { if (isPassed) {
gl.uniform4f(u_color_pts, 1.0, 0.4, 0.4, 1.0); // Ярко-красный для пройденных gl.uniform4f(u_color_pts, 1.0, 0.4, 0.4, 1.0);
} else { } else {
gl.uniform4f(u_color_pts, 0.7, 0.7, 0.7, 1.0); // Светло-серый для непройденных gl.uniform4f(u_color_pts, 0.7, 0.7, 0.7, 1.0);
} }
gl.drawArrays(gl.POINTS, 0, terminalStations.length / 2); gl.drawArrays(gl.POINTS, 0, terminalStations.length / 2);
} }
// Draw dark center - 12 pixels (x2)
gl.uniform1f(u_pointSize, 11.0 * scale); gl.uniform1f(u_pointSize, 11.0 * scale);
const r_center = ((BACKGROUND_COLOR >> 16) & 0xff) / 255; const r_center = ((BACKGROUND_COLOR >> 16) & 0xff) / 255;
const g_center = ((BACKGROUND_COLOR >> 8) & 0xff) / 255; const g_center = ((BACKGROUND_COLOR >> 8) & 0xff) / 255;
const b_center = (BACKGROUND_COLOR & 0xff) / 255; const b_center = (BACKGROUND_COLOR & 0xff) / 255;
gl.uniform4f(u_color_pts, r_center, g_center, b_center, 1.0); // Dark color gl.uniform4f(u_color_pts, r_center, g_center, b_center, 1.0);
gl.drawArrays(gl.POINTS, 0, terminalStations.length / 2); gl.drawArrays(gl.POINTS, 0, terminalStations.length / 2);
} }
} }
} }
// Draw yellow dot for tram position
if (animatedYellowDotPosition) { if (animatedYellowDotPosition) {
const rx = animatedYellowDotPosition.x; const rx = animatedYellowDotPosition.x;
const ry = animatedYellowDotPosition.y; const ry = animatedYellowDotPosition.y;
@@ -1327,7 +1269,6 @@ export const WebGLMap = observer(() => {
}); });
const onPointerDown = (e: PointerEvent) => { const onPointerDown = (e: PointerEvent) => {
// Отслеживаем активность пользователя
updateUserActivity(); updateUserActivity();
if (isAutoMode) { if (isAutoMode) {
setIsAutoMode(false); setIsAutoMode(false);
@@ -1360,7 +1301,6 @@ export const WebGLMap = observer(() => {
const onPointerMove = (e: PointerEvent) => { const onPointerMove = (e: PointerEvent) => {
if (!activePointers.has(e.pointerId)) return; if (!activePointers.has(e.pointerId)) return;
// Отслеживаем активность пользователя
updateUserActivity(); updateUserActivity();
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
@@ -1386,7 +1326,6 @@ export const WebGLMap = observer(() => {
}; };
} }
// Process the pinch gesture
if (pinchStart) { if (pinchStart) {
const currentDistance = getDistance(p1, p2); const currentDistance = getDistance(p1, p2);
const zoomFactor = currentDistance / pinchStart.distance; const zoomFactor = currentDistance / pinchStart.distance;
@@ -1405,7 +1344,6 @@ export const WebGLMap = observer(() => {
} else if (isDragging && activePointers.size === 1) { } else if (isDragging && activePointers.size === 1) {
const p = Array.from(activePointers.values())[0]; const p = Array.from(activePointers.values())[0];
// Проверяем валидность значений
if ( if (
!startMouse || !startMouse ||
!startPos || !startPos ||
@@ -1433,7 +1371,6 @@ export const WebGLMap = observer(() => {
}; };
const onPointerUp = (e: PointerEvent) => { const onPointerUp = (e: PointerEvent) => {
// Отслеживаем активность пользователя
updateUserActivity(); updateUserActivity();
canvas.releasePointerCapture(e.pointerId); canvas.releasePointerCapture(e.pointerId);
@@ -1453,7 +1390,6 @@ export const WebGLMap = observer(() => {
}; };
const onPointerCancel = (e: PointerEvent) => { const onPointerCancel = (e: PointerEvent) => {
// Handle pointer cancellation (e.g., when touch is interrupted)
updateUserActivity(); updateUserActivity();
canvas.releasePointerCapture(e.pointerId); canvas.releasePointerCapture(e.pointerId);
activePointers.delete(e.pointerId); activePointers.delete(e.pointerId);
@@ -1467,7 +1403,6 @@ export const WebGLMap = observer(() => {
const onWheel = (e: WheelEvent) => { const onWheel = (e: WheelEvent) => {
e.preventDefault(); e.preventDefault();
// Отслеживаем активность пользователя
updateUserActivity(); updateUserActivity();
if (isAutoMode) { if (isAutoMode) {
setIsAutoMode(false); setIsAutoMode(false);
@@ -1475,7 +1410,6 @@ export const WebGLMap = observer(() => {
cameraAnimationStore.stopAnimation(); cameraAnimationStore.stopAnimation();
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
// Convert mouse coordinates from CSS pixels to physical canvas pixels
const mouseX = const mouseX =
(e.clientX - rect.left) * (canvas.width / canvas.clientWidth); (e.clientX - rect.left) * (canvas.width / canvas.clientWidth);
const mouseY = const mouseY =
@@ -1582,7 +1516,6 @@ export const WebGLMap = observer(() => {
const sy = (ry * scale + position.y) / dpr; const sy = (ry * scale + position.y) / dpr;
const size = 30; const size = 30;
// Обработчик клика для выбора достопримечательности
const handleSightClick = () => { const handleSightClick = () => {
const { const {
setSelectedSightId, setSelectedSightId,

View File

@@ -35,7 +35,7 @@ export const StationCreatePage = observer(() => {
const { cities, getCities } = cityStore; const { cities, getCities } = cityStore;
const { selectedCityId, selectedCity } = useSelectedCity(); const { selectedCityId, selectedCity } = useSelectedCity();
const [coordinates, setCoordinates] = useState<string>(""); const [coordinates, setCoordinates] = useState<string>("");
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false); const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => { useEffect(() => {
@@ -49,7 +49,6 @@ export const StationCreatePage = observer(() => {
} }
}, [createStationData.common.latitude, createStationData.common.longitude]); }, [createStationData.common.latitude, createStationData.common.longitude]);
// НОВАЯ ФУНКЦИЯ: Фактическое создание (вызывается после подтверждения)
const executeCreate = async () => { const executeCreate = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
@@ -64,11 +63,13 @@ export const StationCreatePage = observer(() => {
} }
}; };
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или создания
const handleCreate = async () => { const handleCreate = async () => {
const isCityMissing = !createStationData.common.city_id; const isCityMissing = !createStationData.common.city_id;
// Проверяем названия на всех языках
const isNameMissing = !createStationData.ru.name || !createStationData.en.name || !createStationData.zh.name; const isNameMissing =
!createStationData.ru.name ||
!createStationData.en.name ||
!createStationData.zh.name;
if (isCityMissing || isNameMissing) { if (isCityMissing || isNameMissing) {
setIsSaveWarningOpen(true); setIsSaveWarningOpen(true);
@@ -78,13 +79,11 @@ export const StationCreatePage = observer(() => {
await executeCreate(); await executeCreate();
}; };
// Обработчик "Да" в предупреждающем окне
const handleConfirmCreate = async () => { const handleConfirmCreate = async () => {
setIsSaveWarningOpen(false); setIsSaveWarningOpen(false);
await executeCreate(); await executeCreate();
}; };
// Обработчик "Нет" в предупреждающем окне
const handleCancelCreate = () => { const handleCancelCreate = () => {
setIsSaveWarningOpen(false); setIsSaveWarningOpen(false);
}; };
@@ -99,7 +98,6 @@ export const StationCreatePage = observer(() => {
fetchCities(); fetchCities();
}, []); }, []);
// Автоматически устанавливаем выбранный город при загрузке страницы
useEffect(() => { useEffect(() => {
if (selectedCityId && selectedCity && !createStationData.common.city_id) { if (selectedCityId && selectedCity && !createStationData.common.city_id) {
setCreateCommonData({ setCreateCommonData({
@@ -237,7 +235,7 @@ export const StationCreatePage = observer(() => {
className="w-min flex gap-2 items-center" className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />} startIcon={<Save size={20} />}
onClick={handleCreate} onClick={handleCreate}
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleCreate disabled={isLoading}
> >
{isLoading ? ( {isLoading ? (
<Loader2 size={20} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />

View File

@@ -32,7 +32,7 @@ export const StationEditPage = observer(() => {
} = stationsStore; } = stationsStore;
const { cities, getCities } = cityStore; const { cities, getCities } = cityStore;
const [coordinates, setCoordinates] = useState<string>(""); const [coordinates, setCoordinates] = useState<string>("");
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false); const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => { useEffect(() => {
@@ -50,7 +50,6 @@ export const StationEditPage = observer(() => {
} }
}, [editStationData.common.latitude, editStationData.common.longitude]); }, [editStationData.common.latitude, editStationData.common.longitude]);
// НОВАЯ ФУНКЦИЯ: Фактическое редактирование (вызывается после подтверждения)
const executeEdit = async () => { const executeEdit = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
@@ -64,10 +63,9 @@ export const StationEditPage = observer(() => {
} }
}; };
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или редактирования
const handleEdit = async () => { const handleEdit = async () => {
const isCityMissing = !editStationData.common.city_id; const isCityMissing = !editStationData.common.city_id;
// Проверяем названия на всех языках
const isNameMissing = const isNameMissing =
!editStationData.ru.name || !editStationData.ru.name ||
!editStationData.en.name || !editStationData.en.name ||
@@ -81,13 +79,11 @@ export const StationEditPage = observer(() => {
await executeEdit(); await executeEdit();
}; };
// Обработчик "Да" в предупреждающем окне
const handleConfirmEdit = async () => { const handleConfirmEdit = async () => {
setIsSaveWarningOpen(false); setIsSaveWarningOpen(false);
await executeEdit(); await executeEdit();
}; };
// Обработчик "Нет" в предупреждающем окне
const handleCancelEdit = () => { const handleCancelEdit = () => {
setIsSaveWarningOpen(false); setIsSaveWarningOpen(false);
}; };
@@ -243,7 +239,7 @@ export const StationEditPage = observer(() => {
className="w-min flex gap-2 items-center" className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />} startIcon={<Save size={20} />}
onClick={handleEdit} onClick={handleEdit}
disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleEdit disabled={isLoading}
> >
{isLoading ? ( {isLoading ? (
<Loader2 size={20} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />

View File

@@ -8,13 +8,10 @@ import {
Earth, Earth,
Landmark, Landmark,
GitBranch, GitBranch,
// Car,
Table, Table,
Split, Split,
// Newspaper,
PersonStanding, PersonStanding,
Cpu, Cpu,
// BookImage,
} from "lucide-react"; } from "lucide-react";
import carrierIcon from "./carrier.svg"; import carrierIcon from "./carrier.svg";
@@ -57,12 +54,6 @@ export const NAVIGATION_ITEMS: {
path: "/devices", path: "/devices",
for_admin: true, for_admin: true,
}, },
// {
// id: "vehicles",
// label: "Транспорт",
// icon: Car,
// path: "/vehicle",
// },
{ {
id: "users", id: "users",
label: "Пользователи", label: "Пользователи",
@@ -75,18 +66,6 @@ export const NAVIGATION_ITEMS: {
label: "Справочник", label: "Справочник",
icon: Table, icon: Table,
nestedItems: [ nestedItems: [
// {
// id: "media",
// label: "Медиа",
// icon: BookImage,
// path: "/media",
// },
// {
// id: "articles",
// label: "Статьи",
// icon: Newspaper,
// path: "/article",
// },
{ {
id: "attractions", id: "attractions",
label: "Достопримечательности", label: "Достопримечательности",
@@ -124,7 +103,7 @@ export const NAVIGATION_ITEMS: {
id: "carriers", id: "carriers",
label: "Перевозчики", label: "Перевозчики",
// @ts-ignore // @ts-ignore
icon: () => <img src={carrierIcon} alt="Перевозчики"/>, icon: () => <img src={carrierIcon} alt="Перевозчики" />,
path: "/carrier", path: "/carrier",
for_admin: true, for_admin: true,
}, },

View File

@@ -1,8 +1,3 @@
/**
* Утилита для управления кешем GLTF и blob URL
*/
// Динамический импорт useGLTF для избежания проблем с SSR
let useGLTF: any = null; let useGLTF: any = null;
const initializeUseGLTF = async () => { const initializeUseGLTF = async () => {
@@ -20,9 +15,6 @@ const initializeUseGLTF = async () => {
return useGLTF; return useGLTF;
}; };
/**
* Очищает кеш GLTF для конкретного URL
*/
export const clearGLTFCacheForUrl = async (url: string) => { export const clearGLTFCacheForUrl = async (url: string) => {
try { try {
const gltf = await initializeUseGLTF(); const gltf = await initializeUseGLTF();
@@ -32,9 +24,6 @@ export const clearGLTFCacheForUrl = async (url: string) => {
} catch (error) {} } catch (error) {}
}; };
/**
* Очищает весь кеш GLTF
*/
export const clearAllGLTFCache = async () => { export const clearAllGLTFCache = async () => {
try { try {
const gltf = await initializeUseGLTF(); const gltf = await initializeUseGLTF();
@@ -44,9 +33,6 @@ export const clearAllGLTFCache = async () => {
} catch (error) {} } catch (error) {}
}; };
/**
* Очищает blob URL из памяти браузера
*/
export const revokeBlobURL = (url: string) => { export const revokeBlobURL = (url: string) => {
if (url && url.startsWith("blob:")) { if (url && url.startsWith("blob:")) {
try { try {
@@ -55,25 +41,16 @@ export const revokeBlobURL = (url: string) => {
} }
}; };
/**
* Комплексная очистка: blob URL + кеш GLTF
*/
export const clearBlobAndGLTFCache = async (url: string) => { export const clearBlobAndGLTFCache = async (url: string) => {
// Сначала отзываем blob URL
revokeBlobURL(url); revokeBlobURL(url);
// Затем очищаем кеш GLTF
await clearGLTFCacheForUrl(url); await clearGLTFCacheForUrl(url);
}; };
/**
* Очистка при смене медиа (для предотвращения конфликтов)
*/
export const clearMediaTransitionCache = async ( export const clearMediaTransitionCache = async (
previousMediaId: string | number | null, previousMediaId: string | number | null,
newMediaType?: number newMediaType?: number
) => { ) => {
// Если переключаемся с/на 3D модель, очищаем весь кеш
if (newMediaType === 6 || previousMediaId) { if (newMediaType === 6 || previousMediaId) {
await clearAllGLTFCache(); await clearAllGLTFCache();
} }

View File

@@ -2,34 +2,17 @@ export * from "./mui/theme";
export * from "./DecodeJWT"; export * from "./DecodeJWT";
export * from "./gltfCacheManager"; export * from "./gltfCacheManager";
/**
* Генерирует название медиа по умолчанию в разных форматах
*
* Примеры использования:
* - Для достопримечательности с названием: "Михайловский замок_mikhail-zamok_Фото"
* - Для достопримечательности без названия: "Название_mikhail-zamok_Фото"
* - Для статьи: "Михайловский замок_mikhail-zamok_Факты" (где "Факты" - название статьи)
*
* @param objectName - Название объекта (достопримечательности, города и т.д.)
* @param fileName - Название файла
* @param mediaType - Тип медиа (число) или название статьи
* @param isArticle - Флаг, указывающий что медиа добавляется к статье
* @returns Строка в нужном формате
*/
export const generateDefaultMediaName = ( export const generateDefaultMediaName = (
objectName: string, objectName: string,
fileName: string, fileName: string,
mediaType: number | string, mediaType: number | string,
isArticle: boolean = false isArticle: boolean = false
): string => { ): string => {
// Убираем расширение из названия файла
const fileNameWithoutExtension = fileName.split(".").slice(0, -1).join("."); const fileNameWithoutExtension = fileName.split(".").slice(0, -1).join(".");
if (isArticle && typeof mediaType === "string") { if (isArticle && typeof mediaType === "string") {
// Для статей: "Название достопримечательности_название файлаазвание статьи"
return `${objectName}_${fileNameWithoutExtension}_${mediaType}`; return `${objectName}_${fileNameWithoutExtension}_${mediaType}`;
} else if (typeof mediaType === "number") { } else if (typeof mediaType === "number") {
// Получаем название типа медиа
const mediaTypeLabels: Record<number, string> = { const mediaTypeLabels: Record<number, string> = {
1: "Фото", 1: "Фото",
2: "Видео", 2: "Видео",
@@ -42,14 +25,11 @@ export const generateDefaultMediaName = (
const mediaTypeLabel = mediaTypeLabels[mediaType] || "Медиа"; const mediaTypeLabel = mediaTypeLabels[mediaType] || "Медиа";
if (objectName && objectName.trim() !== "") { if (objectName && objectName.trim() !== "") {
// Если есть название объекта: "Название объектаазвание файла_тип медиа"
return `${objectName}_${fileNameWithoutExtension}_${mediaTypeLabel}`; return `${objectName}_${fileNameWithoutExtension}_${mediaTypeLabel}`;
} else { } else {
// Если нет названия объекта: "Названиеазвание файла_тип медиа"
return `Название_${fileNameWithoutExtension}_${mediaTypeLabel}`; return `Название_${fileNameWithoutExtension}_${mediaTypeLabel}`;
} }
} }
// Fallback
return `${objectName || "Название"}_${fileNameWithoutExtension}_Медиа`; return `${objectName || "Название"}_${fileNameWithoutExtension}_Медиа`;
}; };

View File

@@ -523,7 +523,6 @@ export const ArticleSelectOrCreateDialog = observer(
article?.service_name?.toLowerCase().includes(searchQuery.toLowerCase()) article?.service_name?.toLowerCase().includes(searchQuery.toLowerCase())
); );
// Preview-by-click logic with request serialization to avoid concurrent requests
const [isPreviewLoading, setIsPreviewLoading] = useState(false); const [isPreviewLoading, setIsPreviewLoading] = useState(false);
const [queuedPreviewId, setQueuedPreviewId] = useState<number | null>(null); const [queuedPreviewId, setQueuedPreviewId] = useState<number | null>(null);
const clickTimerRef = (typeof window !== "undefined" const clickTimerRef = (typeof window !== "undefined"
@@ -551,7 +550,7 @@ export const ArticleSelectOrCreateDialog = observer(
if (queuedPreviewId && queuedPreviewId !== articleId) { if (queuedPreviewId && queuedPreviewId !== articleId) {
const nextId = queuedPreviewId; const nextId = queuedPreviewId;
setQueuedPreviewId(null); setQueuedPreviewId(null);
// Run the next queued preview
runPreviewFetch(nextId); runPreviewFetch(nextId);
} else { } else {
setQueuedPreviewId(null); setQueuedPreviewId(null);
@@ -560,7 +559,6 @@ export const ArticleSelectOrCreateDialog = observer(
}; };
const handleListItemClick = (articleId: number) => { const handleListItemClick = (articleId: number) => {
// Delay to allow double-click to cancel preview
if (clickTimerRef.current) clearTimeout(clickTimerRef.current); if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
clickTimerRef.current = setTimeout(() => { clickTimerRef.current = setTimeout(() => {
if (tabValue === 0 && !selectedArticleId && !tempArticleId) { if (tabValue === 0 && !selectedArticleId && !tempArticleId) {
@@ -570,7 +568,6 @@ export const ArticleSelectOrCreateDialog = observer(
}; };
const handleListItemDoubleClick = (articleId: number) => { const handleListItemDoubleClick = (articleId: number) => {
// Cancel pending single-click preview and proceed to select
if (clickTimerRef.current) { if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current); clearTimeout(clickTimerRef.current);
(clickTimerRef as any).current = null; (clickTimerRef as any).current = null;

View File

@@ -54,7 +54,7 @@ interface UploadMediaDialogProps {
| "station"; | "station";
isArticle?: boolean; isArticle?: boolean;
articleName?: string; articleName?: string;
initialFile?: File; // <--- добавлено initialFile?: File;
} }
export const UploadMediaDialog = observer( export const UploadMediaDialog = observer(
@@ -68,7 +68,7 @@ export const UploadMediaDialog = observer(
isArticle, isArticle,
articleName, articleName,
initialFile, // <--- добавлено initialFile,
}: UploadMediaDialogProps) => { }: UploadMediaDialogProps) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -87,7 +87,6 @@ export const UploadMediaDialog = observer(
useEffect(() => { useEffect(() => {
if (initialFile) { if (initialFile) {
// Очищаем предыдущий blob URL если он существует
if ( if (
previousMediaUrlRef.current && previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:") previousMediaUrlRef.current.startsWith("blob:")
@@ -106,7 +105,6 @@ export const UploadMediaDialog = observer(
} }
}, [initialFile]); }, [initialFile]);
// Очистка blob URL при размонтировании компонента
useEffect(() => { useEffect(() => {
return () => { return () => {
if ( if (
@@ -116,13 +114,13 @@ export const UploadMediaDialog = observer(
clearBlobAndGLTFCache(previousMediaUrlRef.current); clearBlobAndGLTFCache(previousMediaUrlRef.current);
} }
}; };
}, []); // Пустой массив зависимостей - выполняется только при размонтировании }, []);
useEffect(() => { useEffect(() => {
if (fileToUpload) { if (fileToUpload) {
setMediaFile(fileToUpload); setMediaFile(fileToUpload);
setMediaFilename(fileToUpload.name); setMediaFilename(fileToUpload.name);
// Try to determine media type from file extension
const extension = fileToUpload.name.split(".").pop()?.toLowerCase(); const extension = fileToUpload.name.split(".").pop()?.toLowerCase();
if (extension) { if (extension) {
if (["glb", "gltf"].includes(extension)) { if (["glb", "gltf"].includes(extension)) {
@@ -134,22 +132,18 @@ export const UploadMediaDialog = observer(
extension extension
) )
) { ) {
// Для изображений доступны все типы кроме видео setAvailableMediaTypes([1, 3, 4, 5]);
setAvailableMediaTypes([1, 3, 4, 5]); // Фото, Иконка, Водяной знак, Панорама, 3Д-модель setMediaType(1);
setMediaType(1); // По умолчанию Фото
} else if (["mp4", "webm", "mov"].includes(extension)) { } else if (["mp4", "webm", "mov"].includes(extension)) {
// Для видео только тип Видео
setAvailableMediaTypes([2]); setAvailableMediaTypes([2]);
setMediaType(2); setMediaType(2);
} }
} }
// Генерируем название по умолчанию если есть контекст
if (fileToUpload.name) { if (fileToUpload.name) {
let defaultName = ""; let defaultName = "";
if (isArticle && articleName && contextObjectName) { if (isArticle && articleName && contextObjectName) {
// Для статей: "Название достопримечательности_название файлаазвание статьи"
defaultName = generateDefaultMediaName( defaultName = generateDefaultMediaName(
contextObjectName, contextObjectName,
fileToUpload.name, fileToUpload.name,
@@ -157,10 +151,9 @@ export const UploadMediaDialog = observer(
true true
); );
} else if (contextObjectName && contextObjectName.trim() !== "") { } else if (contextObjectName && contextObjectName.trim() !== "") {
// Для обычных медиа с названием объекта
const currentMediaType = hardcodeType const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType] ? MEDIA_TYPE_VALUES[hardcodeType]
: 1; // По умолчанию фото : 1;
defaultName = generateDefaultMediaName( defaultName = generateDefaultMediaName(
contextObjectName, contextObjectName,
fileToUpload.name, fileToUpload.name,
@@ -168,10 +161,9 @@ export const UploadMediaDialog = observer(
false false
); );
} else { } else {
// Для медиа без названия объекта
const currentMediaType = hardcodeType const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType] ? MEDIA_TYPE_VALUES[hardcodeType]
: 1; // По умолчанию фото : 1;
defaultName = generateDefaultMediaName( defaultName = generateDefaultMediaName(
"", "",
fileToUpload.name, fileToUpload.name,
@@ -185,13 +177,11 @@ export const UploadMediaDialog = observer(
} }
}, [fileToUpload, contextObjectName, hardcodeType, isArticle, articleName]); }, [fileToUpload, contextObjectName, hardcodeType, isArticle, articleName]);
// Обновляем название при изменении типа медиа
useEffect(() => { useEffect(() => {
if (mediaFilename && mediaType > 0) { if (mediaFilename && mediaType > 0) {
let defaultName = ""; let defaultName = "";
if (isArticle && articleName && contextObjectName) { if (isArticle && articleName && contextObjectName) {
// Для статей: "Название достопримечательности_название файлаазвание статьи"
defaultName = generateDefaultMediaName( defaultName = generateDefaultMediaName(
contextObjectName, contextObjectName,
mediaFilename, mediaFilename,
@@ -199,7 +189,6 @@ export const UploadMediaDialog = observer(
true true
); );
} else if (contextObjectName && contextObjectName.trim() !== "") { } else if (contextObjectName && contextObjectName.trim() !== "") {
// Для обычных медиа с названием объекта
const currentMediaType = hardcodeType const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType] ? MEDIA_TYPE_VALUES[hardcodeType]
: mediaType; : mediaType;
@@ -210,7 +199,6 @@ export const UploadMediaDialog = observer(
false false
); );
} else { } else {
// Для медиа без названия объекта
const currentMediaType = hardcodeType const currentMediaType = hardcodeType
? MEDIA_TYPE_VALUES[hardcodeType] ? MEDIA_TYPE_VALUES[hardcodeType]
: mediaType; : mediaType;
@@ -235,7 +223,6 @@ export const UploadMediaDialog = observer(
useEffect(() => { useEffect(() => {
if (mediaFile) { if (mediaFile) {
// Очищаем предыдущий blob URL и кеш GLTF если он существует
if ( if (
previousMediaUrlRef.current && previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:") previousMediaUrlRef.current.startsWith("blob:")
@@ -245,22 +232,10 @@ export const UploadMediaDialog = observer(
const newBlobUrl = URL.createObjectURL(mediaFile as Blob); const newBlobUrl = URL.createObjectURL(mediaFile as Blob);
setMediaUrl(newBlobUrl); setMediaUrl(newBlobUrl);
previousMediaUrlRef.current = newBlobUrl; // Сохраняем новый URL в ref previousMediaUrlRef.current = newBlobUrl;
setIsPreviewLoaded(false); // Сбрасываем состояние загрузки при смене файла setIsPreviewLoaded(false);
} }
}, [mediaFile]); // Убираем mediaUrl из зависимостей чтобы избежать зацикливания }, [mediaFile]);
// const fileFormat = useEffect(() => {
// const handleKeyPress = (event: KeyboardEvent) => {
// if (event.key.toLowerCase() === "enter" && !event.ctrlKey) {
// event.preventDefault();
// onClose();
// }
// };
// window.addEventListener("keydown", handleKeyPress);
// return () => window.removeEventListener("keydown", handleKeyPress);
// }, [onClose]);
const handleSave = async () => { const handleSave = async () => {
if (!mediaFile) return; if (!mediaFile) return;
@@ -285,10 +260,10 @@ export const UploadMediaDialog = observer(
} }
} }
setSuccess(true); setSuccess(true);
// Закрываем модальное окно после успешного сохранения
setTimeout(() => { setTimeout(() => {
handleClose(); handleClose();
}, 1000); // Небольшая задержка, чтобы пользователь увидел сообщение об успехе }, 1000);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to save media"); setError(err instanceof Error ? err.message : "Failed to save media");
} finally { } finally {
@@ -297,7 +272,6 @@ export const UploadMediaDialog = observer(
}; };
const handleClose = () => { const handleClose = () => {
// Очищаем blob URL и кеш GLTF при закрытии диалога
if ( if (
previousMediaUrlRef.current && previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:") previousMediaUrlRef.current.startsWith("blob:")
@@ -310,7 +284,7 @@ export const UploadMediaDialog = observer(
setMediaUrl(null); setMediaUrl(null);
setMediaFile(null); setMediaFile(null);
setIsPreviewLoaded(false); setIsPreviewLoaded(false);
previousMediaUrlRef.current = null; // Очищаем ref previousMediaUrlRef.current = null;
onClose(); onClose();
}; };

View File

@@ -171,7 +171,6 @@ class CarrierStore {
this.carriers[language].data.push(response.data); this.carriers[language].data.push(response.data);
}); });
// Create translations for other languages
for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) { for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) {
const patchPayload = { const patchPayload = {
// @ts-ignore // @ts-ignore

View File

@@ -1,4 +1,3 @@
// @shared/stores/createSightStore.ts
import { import {
articlesStore, articlesStore,
Language, Language,
@@ -27,7 +26,6 @@ type SightLanguageInfo = {
}; };
type SightCommonInfo = { type SightCommonInfo = {
// id: number; // ID is 0 until created
city_id: number; city_id: number;
city: string; city: string;
latitude: number; latitude: number;
@@ -35,13 +33,11 @@ type SightCommonInfo = {
thumbnail: string | null; thumbnail: string | null;
watermark_lu: string | null; watermark_lu: string | null;
watermark_rd: string | null; watermark_rd: string | null;
left_article: number; // Can be 0 or a real ID, or placeholder like 10000000 left_article: number;
preview_media: string | null; preview_media: string | null;
video_preview: string | null; video_preview: string | null;
}; };
// SightBaseInfo combines common info with language-specific info
// The 'id' for the sight itself will be assigned upon creation by the backend.
type SightBaseInfo = SightCommonInfo & { type SightBaseInfo = SightCommonInfo & {
[key in Language]: SightLanguageInfo; [key in Language]: SightLanguageInfo;
}; };
@@ -78,7 +74,7 @@ const initialSightState: SightBaseInfo = {
}; };
class CreateSightStore { class CreateSightStore {
sight: SightBaseInfo = JSON.parse(JSON.stringify(initialSightState)); // Deep copy for reset sight: SightBaseInfo = JSON.parse(JSON.stringify(initialSightState));
uploadMediaOpen = false; uploadMediaOpen = false;
setUploadMediaOpen = (open: boolean) => { setUploadMediaOpen = (open: boolean) => {
@@ -93,9 +89,7 @@ class CreateSightStore {
makeAutoObservable(this); makeAutoObservable(this);
} }
// --- Right Article Management ---
createNewRightArticle = async () => { createNewRightArticle = async () => {
// Create article in DB for all languages
const articleRuData = { const articleRuData = {
heading: "Новый заголовок (RU)", heading: "Новый заголовок (RU)",
body: "Новый текст (RU)", body: "Новый текст (RU)",
@@ -125,7 +119,7 @@ class CreateSightStore {
}, },
}, },
}); });
const { id } = articleRes.data; // New article's ID const { id } = articleRes.data;
runInAction(() => { runInAction(() => {
const newArticleEntry = { id, media: [] }; const newArticleEntry = { id, media: [] };
@@ -133,7 +127,7 @@ class CreateSightStore {
this.sight.en.right.push({ ...newArticleEntry, ...articleEnData }); this.sight.en.right.push({ ...newArticleEntry, ...articleEnData });
this.sight.zh.right.push({ ...newArticleEntry, ...articleZhData }); this.sight.zh.right.push({ ...newArticleEntry, ...articleZhData });
}); });
return id; // Return ID for potential immediate use return id;
} catch (error) { } catch (error) {
console.error("Error creating new right article:", error); console.error("Error creating new right article:", error);
throw error; throw error;
@@ -169,7 +163,7 @@ class CreateSightStore {
}); });
}); });
return articleId; // Return the linked article ID return articleId;
} catch (error) { } catch (error) {
console.error("Error linking existing right article:", error); console.error("Error linking existing right article:", error);
throw error; throw error;
@@ -188,9 +182,7 @@ class CreateSightStore {
} }
}; };
// "Unlink" in create mode means just removing from the list to be created with the sight
unlinkRightAritcle = (articleId: number) => { unlinkRightAritcle = (articleId: number) => {
// Changed from 'unlinkRightAritcle' spelling
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
@@ -202,16 +194,12 @@ class CreateSightStore {
(article) => article.id !== articleId (article) => article.id !== articleId
); );
}); });
// Note: If this article was created via createNewRightArticle, it still exists in the DB.
// Consider if an orphaned article should be deleted here or managed separately.
// For now, it just removes it from the list associated with *this specific sight creation process*.
}; };
deleteRightArticle = async (articleId: number) => { deleteRightArticle = async (articleId: number) => {
try { try {
await authInstance.delete(`/article/${articleId}`); // Delete from backend await authInstance.delete(`/article/${articleId}`);
runInAction(() => { runInAction(() => {
// Remove from local store for all languages
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
); );
@@ -228,12 +216,11 @@ class CreateSightStore {
} }
}; };
// --- Right Article Media Management ---
createLinkWithRightArticle = async (media: MediaItem, articleId: number) => { createLinkWithRightArticle = async (media: MediaItem, articleId: number) => {
try { try {
await authInstance.post(`/article/${articleId}/media`, { await authInstance.post(`/article/${articleId}/media`, {
media_id: media.id, media_id: media.id,
media_order: 1, // Or calculate based on existing media.length + 1 media_order: 1,
}); });
runInAction(() => { runInAction(() => {
(["ru", "en", "zh"] as Language[]).forEach((lang) => { (["ru", "en", "zh"] as Language[]).forEach((lang) => {
@@ -242,7 +229,7 @@ class CreateSightStore {
); );
if (article) { if (article) {
if (!article.media) article.media = []; if (!article.media) article.media = [];
article.media.unshift(media); // Add to the beginning article.media.unshift(media);
} }
}); });
}); });
@@ -273,7 +260,6 @@ class CreateSightStore {
} }
}; };
// --- Left Article Management (largely unchanged from your provided store) ---
updateLeftInfo = (language: Language, heading: string, body: string) => { updateLeftInfo = (language: Language, heading: string, body: string) => {
this.sight[language].left.heading = heading; this.sight[language].left.heading = heading;
this.sight[language].left.body = body; this.sight[language].left.body = body;
@@ -323,7 +309,7 @@ class CreateSightStore {
deleteLeftArticle = async (articleId: number) => { deleteLeftArticle = async (articleId: number) => {
/* ... your existing logic ... */ /* ... your existing logic ... */
await authInstance.delete(`/article/${articleId}`); await authInstance.delete(`/article/${articleId}`);
// articlesStore.getArticles(languageStore.language); // If still neede
runInAction(() => { runInAction(() => {
articlesStore.articles.ru = articlesStore.articles.ru.filter( articlesStore.articles.ru = articlesStore.articles.ru.filter(
(article) => article.id !== articleId (article) => article.id !== articleId
@@ -344,7 +330,6 @@ class CreateSightStore {
const enName = (this.sight.en.name || "").trim(); const enName = (this.sight.en.name || "").trim();
const zhName = (this.sight.zh.name || "").trim(); const zhName = (this.sight.zh.name || "").trim();
// If all names are empty, skip defaulting and use empty headings
const hasAnyName = !!(ruName || enName || zhName); const hasAnyName = !!(ruName || enName || zhName);
const response = await languageInstance("ru").post("/article", { const response = await languageInstance("ru").post("/article", {
@@ -363,7 +348,7 @@ class CreateSightStore {
}); });
runInAction(() => { runInAction(() => {
this.sight.left_article = newLeftArticleId; // Store the actual ID this.sight.left_article = newLeftArticleId;
this.sight.ru.left = { this.sight.ru.left = {
heading: hasAnyName ? ruName : "", heading: hasAnyName ? ruName : "",
body: "", body: "",
@@ -402,9 +387,8 @@ class CreateSightStore {
return newLeftArticleId; return newLeftArticleId;
}; };
// Placeholder for a "new" unsaved left article
setNewLeftArticlePlaceholder = () => { setNewLeftArticlePlaceholder = () => {
this.sight.left_article = 10000000; // Special placeholder ID this.sight.left_article = 10000000;
this.sight.ru.left = { this.sight.ru.left = {
heading: "Новая левая статья", heading: "Новая левая статья",
body: "Заполните контентом", body: "Заполните контентом",
@@ -422,7 +406,6 @@ class CreateSightStore {
}; };
}; };
// --- Sight Preview Media ---
linkPreviewMedia = (mediaId: string) => { linkPreviewMedia = (mediaId: string) => {
this.sight.preview_media = mediaId; this.sight.preview_media = mediaId;
}; };
@@ -431,32 +414,27 @@ class CreateSightStore {
this.sight.preview_media = null; this.sight.preview_media = null;
}; };
// --- General Store Methods ---
clearCreateSight = () => { clearCreateSight = () => {
this.needLeaveAgree = false; this.needLeaveAgree = false;
this.sight = JSON.parse(JSON.stringify(initialSightState)); // Reset to initial this.sight = JSON.parse(JSON.stringify(initialSightState));
}; };
updateSightInfo = ( updateSightInfo = (
content: Partial<SightLanguageInfo | SightCommonInfo>, // Corrected types content: Partial<SightLanguageInfo | SightCommonInfo>,
language?: Language language?: Language
) => { ) => {
this.needLeaveAgree = true; this.needLeaveAgree = true;
if (language) { if (language) {
this.sight[language] = { ...this.sight[language], ...content }; this.sight[language] = { ...this.sight[language], ...content };
} else { } else {
// Assuming content here is for SightCommonInfo
this.sight = { ...this.sight, ...(content as Partial<SightCommonInfo>) }; this.sight = { ...this.sight, ...(content as Partial<SightCommonInfo>) };
} }
}; };
// --- Main Sight Creation Logic ---
createSight = async (primaryLanguage: Language) => { createSight = async (primaryLanguage: Language) => {
let finalLeftArticleId = this.sight.left_article; let finalLeftArticleId = this.sight.left_article;
// 1. Handle Left Article (Create if new, or use existing ID)
if (this.sight.left_article === 10000000) { if (this.sight.left_article === 10000000) {
// Placeholder for new
const res = await languageInstance("ru").post("/article", { const res = await languageInstance("ru").post("/article", {
heading: this.sight.ru.left.heading, heading: this.sight.ru.left.heading,
body: this.sight.ru.left.body, body: this.sight.ru.left.body,
@@ -474,7 +452,6 @@ class CreateSightStore {
this.sight.left_article !== 0 && this.sight.left_article !== 0 &&
this.sight.left_article !== null this.sight.left_article !== null
) { ) {
// Existing, ensure it's up-to-date
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 }
@@ -488,10 +465,7 @@ class CreateSightStore {
{ heading: this.sight.zh.left.heading, body: this.sight.zh.left.body } { heading: this.sight.zh.left.heading, body: this.sight.zh.left.body }
); );
} }
// else: left_article is 0, so no left article
// 2. Right articles are already created in DB and their IDs are in this.sight[lang].right.
// We just need to update their content if changed before saving the sight.
for (const lang of ["ru", "en", "zh"] as Language[]) { for (const lang of ["ru", "en", "zh"] as Language[]) {
for (const article of this.sight[lang].right) { for (const article of this.sight[lang].right) {
if (article.id == 0 || article.id == null) { if (article.id == 0 || article.id == null) {
@@ -501,14 +475,12 @@ class CreateSightStore {
heading: article.heading, heading: article.heading,
body: article.body, body: article.body,
}); });
// Media for these articles are already linked via createLinkWithRightArticle
} }
} }
const rightArticleIdsForLink = this.sight[primaryLanguage].right.map( const rightArticleIdsForLink = this.sight[primaryLanguage].right.map(
(a) => a.id (a) => a.id
); );
// 3. Create Sight object in DB
const sightPayload = { const sightPayload = {
city_id: this.sight.city_id, city_id: this.sight.city_id,
city: this.sight.city, city: this.sight.city,
@@ -528,9 +500,8 @@ class CreateSightStore {
"/sight", "/sight",
sightPayload sightPayload
); );
const newSightId = response.data.id; // ID of the newly created sight const newSightId = response.data.id;
// 4. Update other languages for the sight
const otherLanguages = (["ru", "en", "zh"] as Language[]).filter( const otherLanguages = (["ru", "en", "zh"] as Language[]).filter(
(l) => l !== primaryLanguage (l) => l !== primaryLanguage
); );
@@ -551,20 +522,17 @@ class CreateSightStore {
}); });
} }
// 5. Link Right Articles to the new Sight
for (let i = 0; i < rightArticleIdsForLink.length; i++) { for (let i = 0; i < rightArticleIdsForLink.length; i++) {
await authInstance.post(`/sight/${newSightId}/article`, { await authInstance.post(`/sight/${newSightId}/article`, {
article_id: rightArticleIdsForLink[i], article_id: rightArticleIdsForLink[i],
page_num: i + 1, // Or other logic for page_num page_num: i + 1,
}); });
} }
// Optionally: this.clearCreateSight(); // To reset form after successful creation
this.needLeaveAgree = false; this.needLeaveAgree = false;
return newSightId; return newSightId;
}; };
// --- Media Upload (Generic, used by dialogs) ---
uploadMedia = async ( uploadMedia = async (
filename: string, filename: string,
type: number, type: number,
@@ -583,12 +551,12 @@ class CreateSightStore {
this.fileToUpload = null; this.fileToUpload = null;
this.uploadMediaOpen = false; this.uploadMediaOpen = false;
}); });
mediaStore.getMedia(); // Refresh global media list mediaStore.getMedia();
return { return {
id: response.data.id, id: response.data.id,
filename: filename, // Or response.data.filename if backend returns it filename: filename,
media_name: media_name, // Or response.data.media_name media_name: media_name,
media_type: type, // Or response.data.type media_type: type,
}; };
} catch (error) { } catch (error) {
console.error("Error uploading media:", error); console.error("Error uploading media:", error);
@@ -596,15 +564,12 @@ class CreateSightStore {
} }
}; };
// For Left Article Media
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."
); );
// If it's a placeholder, we could store the media temporarily and link it after the article is created.
// For simplicity, we'll assume the article must exist.
// A more robust solution might involve creating the article first if it's a placeholder.
return; return;
} }
try { try {
@@ -663,7 +628,7 @@ class CreateSightStore {
this.sight.ru.right = sortArticles(this.sight.ru.right); this.sight.ru.right = sortArticles(this.sight.ru.right);
this.sight.en.right = sortArticles(this.sight.en.right); this.sight.en.right = sortArticles(this.sight.en.right);
this.sight.zh.right = sortArticles(this.sight.zh.right); // теперь zh тоже сортируется одинаково this.sight.zh.right = sortArticles(this.sight.zh.right);
this.needLeaveAgree = true; this.needLeaveAgree = true;
}; };

View File

@@ -1,4 +1,3 @@
// @shared/stores/editSightStore.ts
import { import {
articlesStore, articlesStore,
authInstance, authInstance,
@@ -96,13 +95,11 @@ class EditSightStore {
} }
runInAction(() => { runInAction(() => {
// Обновляем языковую часть
this.sight[language] = { this.sight[language] = {
...this.sight[language], ...this.sight[language],
...data, ...data,
}; };
// Только при первом запросе обновляем общую часть
if (!this.hasLoadedCommon) { if (!this.hasLoadedCommon) {
this.sight.common = { this.sight.common = {
...this.sight.common, ...this.sight.common,
@@ -123,7 +120,6 @@ class EditSightStore {
let responseEn = await languageInstance("en").get(`/sight/${id}/article`); let responseEn = await languageInstance("en").get(`/sight/${id}/article`);
let responseZh = await languageInstance("zh").get(`/sight/${id}/article`); let responseZh = await languageInstance("zh").get(`/sight/${id}/article`);
// Create a map of article IDs to their media
const mediaMap = new Map(); const mediaMap = new Map();
for (const article of responseRu.data) { for (const article of responseRu.data) {
const responseMedia = await authInstance.get( const responseMedia = await authInstance.get(
@@ -132,7 +128,6 @@ class EditSightStore {
mediaMap.set(article.id, responseMedia.data); mediaMap.set(article.id, responseMedia.data);
} }
// Function to add media to articles
const addMediaToArticles = (articles: any[]) => { const addMediaToArticles = (articles: any[]) => {
return articles.map((article) => ({ return articles.map((article) => ({
...article, ...article,
@@ -327,28 +322,6 @@ class EditSightStore {
articles: articleIdsInObject, articles: articleIdsInObject,
}); });
// await languageInstance("ru").patch(
// `/sight/${this.sight.common.left_article}/article`,
// {
// heading: this.sight.ru.left.heading,
// body: this.sight.ru.left.body,
// }
// );
// await languageInstance("en").patch(
// `/sight/${this.sight.common.left_article}/article`,
// {
// heading: this.sight.en.left.heading,
// body: this.sight.en.left.body,
// }
// );
// await languageInstance("zh").patch(
// `/sight/${this.sight.common.left_article}/article`,
// {
// heading: this.sight.zh.left.heading,
// body: this.sight.zh.left.body,
// }
// );
this.needLeaveAgree = false; this.needLeaveAgree = false;
}; };
@@ -589,7 +562,7 @@ class EditSightStore {
}); });
}); });
return article_id; // Return the linked article ID return article_id;
}; };
deleteRightArticleMedia = async (article_id: number, media_id: string) => { deleteRightArticleMedia = async (article_id: number, media_id: string) => {
@@ -695,7 +668,7 @@ class EditSightStore {
}); });
}); });
return id; // Return the ID of the newly created article return id;
}; };
createLinkWithRightArticle = async ( createLinkWithRightArticle = async (
@@ -770,7 +743,7 @@ class EditSightStore {
this.sight.ru.right = sortArticles(this.sight.ru.right); this.sight.ru.right = sortArticles(this.sight.ru.right);
this.sight.en.right = sortArticles(this.sight.en.right); this.sight.en.right = sortArticles(this.sight.en.right);
this.sight.zh.right = sortArticles(this.sight.zh.right); // теперь zh тоже сортируется одинаково this.sight.zh.right = sortArticles(this.sight.zh.right);
this.needLeaveAgree = true; this.needLeaveAgree = true;
}; };

View File

@@ -39,12 +39,11 @@ class MediaStore {
updateMedia = async (id: string, data: Partial<Media>) => { updateMedia = async (id: string, data: Partial<Media>) => {
const response = await authInstance.patch(`/media/${id}`, data); const response = await authInstance.patch(`/media/${id}`, data);
runInAction(() => { runInAction(() => {
// Update in media array
const index = this.media.findIndex((m) => m.id === id); const index = this.media.findIndex((m) => m.id === id);
if (index !== -1) { if (index !== -1) {
this.media[index] = { ...this.media[index], ...response.data }; this.media[index] = { ...this.media[index], ...response.data };
} }
// Update oneMedia if it's the current media being viewed
if (this.oneMedia?.id === id) { if (this.oneMedia?.id === id) {
this.oneMedia = { ...this.oneMedia, ...response.data }; this.oneMedia = { ...this.oneMedia, ...response.data };
} }
@@ -64,12 +63,11 @@ class MediaStore {
}); });
runInAction(() => { runInAction(() => {
// Update in media array
const index = this.media.findIndex((m) => m.id === id); const index = this.media.findIndex((m) => m.id === id);
if (index !== -1) { if (index !== -1) {
this.media[index] = { ...this.media[index], ...response.data }; this.media[index] = { ...this.media[index], ...response.data };
} }
// Update oneMedia if it's the current media being viewed
if (this.oneMedia?.id === id) { if (this.oneMedia?.id === id) {
this.oneMedia = { ...this.oneMedia, ...response.data }; this.oneMedia = { ...this.oneMedia, ...response.data };
} }

View File

@@ -15,7 +15,6 @@ class ModelLoadingStore {
makeAutoObservable(this); makeAutoObservable(this);
} }
// Начать отслеживание загрузки модели
startLoading(modelId: string) { startLoading(modelId: string) {
this.loadingStates.set(modelId, { this.loadingStates.set(modelId, {
isLoading: true, isLoading: true,
@@ -25,7 +24,6 @@ class ModelLoadingStore {
}); });
} }
// Обновить прогресс загрузки
updateProgress(modelId: string, progress: number) { updateProgress(modelId: string, progress: number) {
const state = this.loadingStates.get(modelId); const state = this.loadingStates.get(modelId);
if (state) { if (state) {
@@ -33,7 +31,6 @@ class ModelLoadingStore {
} }
} }
// Завершить загрузку модели
finishLoading(modelId: string) { finishLoading(modelId: string) {
const state = this.loadingStates.get(modelId); const state = this.loadingStates.get(modelId);
if (state) { if (state) {
@@ -42,12 +39,10 @@ class ModelLoadingStore {
} }
} }
// Остановить загрузку (в случае ошибки)
stopLoading(modelId: string) { stopLoading(modelId: string) {
this.loadingStates.delete(modelId); this.loadingStates.delete(modelId);
} }
// Обработать ошибку загрузки
handleError(modelId: string, error?: string) { handleError(modelId: string, error?: string) {
const state = this.loadingStates.get(modelId); const state = this.loadingStates.get(modelId);
if (state) { if (state) {
@@ -56,26 +51,22 @@ class ModelLoadingStore {
} }
} }
// Получить состояние загрузки для конкретной модели
getLoadingState(modelId: string): ModelLoadingState | undefined { getLoadingState(modelId: string): ModelLoadingState | undefined {
return this.loadingStates.get(modelId); return this.loadingStates.get(modelId);
} }
// Проверить, загружается ли какая-либо модель
get isAnyModelLoading(): boolean { get isAnyModelLoading(): boolean {
return Array.from(this.loadingStates.values()).some( return Array.from(this.loadingStates.values()).some(
(state) => state.isLoading (state) => state.isLoading
); );
} }
// Получить все загружающиеся модели
get loadingModels(): ModelLoadingState[] { get loadingModels(): ModelLoadingState[] {
return Array.from(this.loadingStates.values()).filter( return Array.from(this.loadingStates.values()).filter(
(state) => state.isLoading (state) => state.isLoading
); );
} }
// Получить общий прогресс всех загружающихся моделей
get overallProgress(): number { get overallProgress(): number {
const loadingModels = this.loadingModels; const loadingModels = this.loadingModels;
if (loadingModels.length === 0) return 100; if (loadingModels.length === 0) return 100;
@@ -87,12 +78,10 @@ class ModelLoadingStore {
return Math.round(totalProgress / loadingModels.length); return Math.round(totalProgress / loadingModels.length);
} }
// Проверить, заблокировано ли сохранение (есть ли загружающиеся модели)
get isSaveBlocked(): boolean { get isSaveBlocked(): boolean {
return this.isAnyModelLoading; return this.isAnyModelLoading;
} }
// Очистить все состояния загрузки
clearAll() { clearAll() {
this.loadingStates.clear(); this.loadingStates.clear();
} }

View File

@@ -58,41 +58,6 @@ class SightsStore {
}); });
}; };
// getSight = async (id: number) => {
// const response = await authInstance.get(`/sight/${id}`);
// runInAction(() => {
// this.sight = response.data;
// editSightStore.sightInfo = {
// ...editSightStore.sightInfo,
// id: response.data.id,
// city_id: response.data.city_id,
// city: response.data.city,
// latitude: response.data.latitude,
// longitude: response.data.longitude,
// thumbnail: response.data.thumbnail,
// watermark_lu: response.data.watermark_lu,
// watermark_rd: response.data.watermark_rd,
// left_article: response.data.left_article,
// preview_media: response.data.preview_media,
// video_preview: response.data.video_preview,
// [languageStore.language]: {
// info: {
// name: response.data.name,
// address: response.data.address,
// },
// left: {
// heading: articlesStore.articles[languageStore.language].find(
// (article) => article.id === response.data.left_article
// )?.heading,
// body: articlesStore.articles[languageStore.language].find(
// },
// },
// };
// });
// };
createSightAction = async ( createSightAction = async (
city: number, city: number,
coordinates: { latitude: number; longitude: number } coordinates: { latitude: number; longitude: number }
@@ -167,16 +132,12 @@ class SightsStore {
common: boolean common: boolean
) => { ) => {
if (common) { if (common) {
// @ts-ignore
this.sight!.common = { this.sight!.common = {
// @ts-ignore
...this.sight!.common, ...this.sight!.common,
...content, ...content,
}; };
} else { } else {
// @ts-ignore
this.sight![language] = { this.sight![language] = {
// @ts-ignore
...this.sight![language], ...this.sight![language],
...content, ...content,
}; };

View File

@@ -1,8 +1,6 @@
import { authInstance } from "@shared"; import { authInstance } from "@shared";
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
// Импорт функции сброса кешей карты
// import { clearMapCaches } from "../../pages/MapPage";
import { import {
articlesStore, articlesStore,
cityStore, cityStore,
@@ -35,9 +33,7 @@ class SnapshotStore {
makeAutoObservable(this); makeAutoObservable(this);
} }
// Функция для сброса всех кешей в приложении
private clearAllCaches = () => { private clearAllCaches = () => {
// Сброс кешей статей
articlesStore.articleList = { articlesStore.articleList = {
ru: { data: [], loaded: false }, ru: { data: [], loaded: false },
en: { data: [], loaded: false }, en: { data: [], loaded: false },
@@ -47,7 +43,6 @@ class SnapshotStore {
articlesStore.articleData = null; articlesStore.articleData = null;
articlesStore.articleMedia = null; articlesStore.articleMedia = null;
// Сброс кешей городов
cityStore.cities = { cityStore.cities = {
ru: { data: [], loaded: false }, ru: { data: [], loaded: false },
en: { data: [], loaded: false }, en: { data: [], loaded: false },
@@ -56,21 +51,18 @@ class SnapshotStore {
cityStore.ruCities = { data: [], loaded: false }; cityStore.ruCities = { data: [], loaded: false };
cityStore.city = {}; cityStore.city = {};
// Сброс кешей стран
countryStore.countries = { countryStore.countries = {
ru: { data: [], loaded: false }, ru: { data: [], loaded: false },
en: { data: [], loaded: false }, en: { data: [], loaded: false },
zh: { data: [], loaded: false }, zh: { data: [], loaded: false },
}; };
// Сброс кешей перевозчиков
carrierStore.carriers = { carrierStore.carriers = {
ru: { data: [], loaded: false }, ru: { data: [], loaded: false },
en: { data: [], loaded: false }, en: { data: [], loaded: false },
zh: { data: [], loaded: false }, zh: { data: [], loaded: false },
}; };
// Сброс кешей станций
stationsStore.stationLists = { stationsStore.stationLists = {
ru: { data: [], loaded: false }, ru: { data: [], loaded: false },
en: { data: [], loaded: false }, en: { data: [], loaded: false },
@@ -78,24 +70,18 @@ class SnapshotStore {
}; };
stationsStore.stationPreview = {}; stationsStore.stationPreview = {};
// Сброс кешей достопримечательностей
sightsStore.sights = []; sightsStore.sights = [];
sightsStore.sight = null; sightsStore.sight = null;
// Сброс кешей маршрутов
routeStore.routes = { data: [], loaded: false }; routeStore.routes = { data: [], loaded: false };
// Сброс кешей транспорта
vehicleStore.vehicles = { data: [], loaded: false }; vehicleStore.vehicles = { data: [], loaded: false };
// Сброс кешей пользователей
userStore.users = { data: [], loaded: false }; userStore.users = { data: [], loaded: false };
// Сброс кешей медиа
mediaStore.media = []; mediaStore.media = [];
mediaStore.oneMedia = null; mediaStore.oneMedia = null;
// Сброс кешей создания и редактирования достопримечательностей
createSightStore.sight = JSON.parse( createSightStore.sight = JSON.parse(
JSON.stringify({ JSON.stringify({
city_id: 0, city_id: 0,
@@ -173,26 +159,21 @@ class SnapshotStore {
editSightStore.fileToUpload = null; editSightStore.fileToUpload = null;
editSightStore.needLeaveAgree = false; editSightStore.needLeaveAgree = false;
// Сброс кешей устройств
devicesStore.devices = []; devicesStore.devices = [];
devicesStore.uuid = null; devicesStore.uuid = null;
devicesStore.sendSnapshotModalOpen = false; devicesStore.sendSnapshotModalOpen = false;
// Сброс кешей авторизации (кроме токена)
authStore.payload = null; authStore.payload = null;
authStore.error = null; authStore.error = null;
authStore.isLoading = false; authStore.isLoading = false;
// Сброс кешей карты (если они загружены)
try { try {
// Сбрасываем кеши mapStore если он доступен
if (typeof window !== "undefined" && (window as any).mapStore) { if (typeof window !== "undefined" && (window as any).mapStore) {
(window as any).mapStore.routes = []; (window as any).mapStore.routes = [];
(window as any).mapStore.stations = []; (window as any).mapStore.stations = [];
(window as any).mapStore.sights = []; (window as any).mapStore.sights = [];
} }
// Сбрасываем кеши MapService если он доступен
if (typeof window !== "undefined" && (window as any).mapServiceInstance) { if (typeof window !== "undefined" && (window as any).mapServiceInstance) {
(window as any).mapServiceInstance.clearCaches(); (window as any).mapServiceInstance.clearCaches();
} }
@@ -200,7 +181,6 @@ class SnapshotStore {
console.warn("Не удалось сбросить кеши карты:", error); console.warn("Не удалось сбросить кеши карты:", error);
} }
// Сброс localStorage кешей (кроме токена авторизации)
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
const rememberedEmail = localStorage.getItem("rememberedEmail"); const rememberedEmail = localStorage.getItem("rememberedEmail");
const rememberedPassword = localStorage.getItem("rememberedPassword"); const rememberedPassword = localStorage.getItem("rememberedPassword");
@@ -208,14 +188,12 @@ class SnapshotStore {
localStorage.clear(); localStorage.clear();
sessionStorage.clear(); sessionStorage.clear();
// Восстанавливаем важные данные
if (token) localStorage.setItem("token", token); if (token) localStorage.setItem("token", token);
if (rememberedEmail) if (rememberedEmail)
localStorage.setItem("rememberedEmail", rememberedEmail); localStorage.setItem("rememberedEmail", rememberedEmail);
if (rememberedPassword) if (rememberedPassword)
localStorage.setItem("rememberedPassword", rememberedPassword); localStorage.setItem("rememberedPassword", rememberedPassword);
// Сброс кешей карты (если они есть)
const mapPositionKey = "mapPosition"; const mapPositionKey = "mapPosition";
const activeSectionKey = "mapActiveSection"; const activeSectionKey = "mapActiveSection";
if (localStorage.getItem(mapPositionKey)) { if (localStorage.getItem(mapPositionKey)) {
@@ -225,7 +203,6 @@ class SnapshotStore {
localStorage.removeItem(activeSectionKey); localStorage.removeItem(activeSectionKey);
} }
// Попытка очистить кеш браузера (если поддерживается)
if ("caches" in window) { if ("caches" in window) {
try { try {
caches.keys().then((cacheNames) => { caches.keys().then((cacheNames) => {
@@ -240,7 +217,6 @@ class SnapshotStore {
} }
} }
// Попытка очистить IndexedDB (если поддерживается)
if ("indexedDB" in window) { if ("indexedDB" in window) {
try { try {
indexedDB.databases().then((databases) => { indexedDB.databases().then((databases) => {
@@ -284,10 +260,8 @@ class SnapshotStore {
}; };
restoreSnapshot = async (id: string) => { restoreSnapshot = async (id: string) => {
// Сначала сбрасываем все кеши
this.clearAllCaches(); this.clearAllCaches();
// Затем восстанавливаем снапшот
await authInstance.post(`/snapshots/${id}/restore`); await authInstance.post(`/snapshots/${id}/restore`);
}; };

View File

@@ -7,7 +7,7 @@ type StationLanguageData = {
name: string; name: string;
system_name: string; system_name: string;
address: string; address: string;
loaded: boolean; // Indicates if this language's data has been loaded/modified loaded: boolean;
}; };
type StationCommonData = { type StationCommonData = {
@@ -92,7 +92,6 @@ class StationsStore {
}, },
}; };
// This will store the full station data, keyed by ID and then by language
stationPreview: Record< stationPreview: Record<
string, string,
Record<string, { loaded: boolean; data: Station }> Record<string, { loaded: boolean; data: Station }>
@@ -264,7 +263,6 @@ class StationsStore {
}; };
}; };
// Sets language-specific station data
setLanguageEditStationData = ( setLanguageEditStationData = (
language: Language, language: Language,
data: Partial<StationLanguageData> data: Partial<StationLanguageData>
@@ -295,7 +293,7 @@ class StationsStore {
`/station/${id}`, `/station/${id}`,
{ {
name: name || "", name: name || "",
system_name: name || "", // system_name is often derived from name system_name: name || "",
description: description || "", description: description || "",
address: address || "", address: address || "",
...commonDataPayload, ...commonDataPayload,
@@ -303,7 +301,6 @@ class StationsStore {
); );
runInAction(() => { runInAction(() => {
// Update the cached preview data and station lists after successful patch
if (this.stationPreview[id]) { if (this.stationPreview[id]) {
this.stationPreview[id][language] = { this.stationPreview[id][language] = {
loaded: true, loaded: true,
@@ -343,11 +340,11 @@ class StationsStore {
runInAction(() => { runInAction(() => {
this.stations = this.stations.filter((station) => station.id !== id); this.stations = this.stations.filter((station) => station.id !== id);
// Also clear from stationPreview cache
if (this.stationPreview[id]) { if (this.stationPreview[id]) {
delete this.stationPreview[id]; delete this.stationPreview[id];
} }
// Clear from stationLists as well for all languages
for (const lang of ["ru", "en", "zh"] as const) { for (const lang of ["ru", "en", "zh"] as const) {
if (this.stationLists[lang].data) { if (this.stationLists[lang].data) {
this.stationLists[lang].data = this.stationLists[lang].data.filter( this.stationLists[lang].data = this.stationLists[lang].data.filter(
@@ -421,12 +418,11 @@ class StationsStore {
delete commonDataPayload.icon; delete commonDataPayload.icon;
} }
// First create station in Russian
const { name, address } = this.createStationData[language]; const { name, address } = this.createStationData[language];
const description = this.createStationData.common.description; const description = this.createStationData.common.description;
const response = await languageInstance(language).post("/station", { const response = await languageInstance(language).post("/station", {
name: name || "", name: name || "",
system_name: name || "", // system_name is often derived from name system_name: name || "",
description: description || "", description: description || "",
address: address || "", address: address || "",
...commonDataPayload, ...commonDataPayload,
@@ -438,7 +434,6 @@ class StationsStore {
const stationId = response.data.id; const stationId = response.data.id;
// Then update for other languages
for (const lang of ["ru", "en", "zh"].filter( for (const lang of ["ru", "en", "zh"].filter(
(lang) => lang !== language (lang) => lang !== language
) as Language[]) { ) as Language[]) {
@@ -448,7 +443,7 @@ class StationsStore {
`/station/${stationId}`, `/station/${stationId}`,
{ {
name: name || "", name: name || "",
system_name: name || "", // system_name is often derived from name system_name: name || "",
description: description || "", description: description || "",
address: address || "", address: address || "",
...commonDataPayload, ...commonDataPayload,
@@ -507,7 +502,6 @@ class StationsStore {
return response.data; return response.data;
}; };
// Reset editStationData when navigating away or after saving
resetEditStationData = () => { resetEditStationData = () => {
this.editStationData = { this.editStationData = {
ru: { ru: {

View File

@@ -11,8 +11,8 @@ import {
devicesStore, devicesStore,
Modal, Modal,
snapshotStore, snapshotStore,
vehicleStore, // Not directly used in this component's rendering logic anymore vehicleStore,
} from "@shared"; // Assuming @shared exports these } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Button, Checkbox, Typography } from "@mui/material"; import { Button, Checkbox, Typography } from "@mui/material";
@@ -23,12 +23,10 @@ import { useNavigate } from "react-router-dom";
export type ConnectedDevice = string; export type ConnectedDevice = string;
interface Snapshot { interface Snapshot {
ID: string; // Assuming ID is string based on usage ID: string;
Name: string; Name: string;
// Add other snapshot properties if needed
} }
// --- HELPER FUNCTIONS ---
const formatDate = (dateString: string | null) => { const formatDate = (dateString: string | null) => {
if (!dateString) return "Нет данных"; if (!dateString) return "Нет данных";
try { try {
@@ -76,12 +74,7 @@ function createData(
}; };
} }
// This function transforms the raw device data (which includes vehicle and device_status) const transformDevicesToRows = (vehicles: Vehicle[]): TableRowData[] => {
// into the format expected by the table. It now filters for devices that have a UUID.
const transformDevicesToRows = (
vehicles: Vehicle[]
// devices: ConnectedDevice[]
): TableRowData[] => {
return vehicles.map((vehicle) => { return vehicles.map((vehicle) => {
const uuid = vehicle.vehicle.uuid; const uuid = vehicle.vehicle.uuid;
if (!uuid) if (!uuid)
@@ -115,26 +108,21 @@ export const DevicesTable = observer(() => {
} = devicesStore; } = devicesStore;
const { snapshots, getSnapshots } = snapshotStore; const { snapshots, getSnapshots } = snapshotStore;
const { getVehicles, vehicles } = vehicleStore; // Removed as devicesStore.devices should be the source of truth const { getVehicles, vehicles } = vehicleStore;
const { devices } = devicesStore; const { devices } = devicesStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [selectedDeviceUuids, setSelectedDeviceUuids] = useState<string[]>([]); const [selectedDeviceUuids, setSelectedDeviceUuids] = useState<string[]>([]);
// Transform the raw devices data into rows suitable for the table const currentTableRows = transformDevicesToRows(vehicles.data as Vehicle[]);
// This will also filter out devices without a UUID, as those cannot be acted upon.
const currentTableRows = transformDevicesToRows(
vehicles.data as Vehicle[]
// devices as ConnectedDevice[]
);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
await getVehicles(); // Not strictly needed if devicesStore.devices is populated correctly by getDevices await getVehicles();
await getDevices(); // This should fetch the combined vehicle/device_status data await getDevices();
await getSnapshots(); await getSnapshots();
}; };
fetchData(); fetchData();
}, [getDevices, getSnapshots]); // Added dependencies }, [getDevices, getSnapshots]);
const isAllSelected = const isAllSelected =
currentTableRows.length > 0 && currentTableRows.length > 0 &&
@@ -144,7 +132,6 @@ export const DevicesTable = observer(() => {
if (isAllSelected) { if (isAllSelected) {
setSelectedDeviceUuids([]); setSelectedDeviceUuids([]);
} else { } else {
// Select all device UUIDs from the *currently visible and selectable* rows
setSelectedDeviceUuids( setSelectedDeviceUuids(
currentTableRows.map((row) => row.device_uuid ?? "") currentTableRows.map((row) => row.device_uuid ?? "")
); );
@@ -171,14 +158,13 @@ export const DevicesTable = observer(() => {
}; };
const handleReloadStatus = async (uuid: string) => { const handleReloadStatus = async (uuid: string) => {
setSelectedDevice(uuid); // Sets the device in the store, useful for context elsewhere setSelectedDevice(uuid);
try { try {
await authInstance.post(`/devices/${uuid}/request-status`); await authInstance.post(`/devices/${uuid}/request-status`);
await getVehicles(); await getVehicles();
await getDevices(); // Refresh devices to show updated status await getDevices();
} catch (error) { } catch (error) {
console.error(`Error requesting status for device ${uuid}:`, error); console.error(`Error requesting status for device ${uuid}:`, error);
// Optionally: show a user-facing error message
} }
}; };
@@ -200,22 +186,16 @@ export const DevicesTable = observer(() => {
} }
}; };
try { try {
// Create an array of promises for all snapshot requests
const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => { const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => {
return send(deviceUuid); return send(deviceUuid);
}); });
// Wait for all promises to settle (either resolve or reject)
await Promise.allSettled(snapshotPromises); await Promise.allSettled(snapshotPromises);
// After all requests are attempted await getDevices();
await getDevices(); // Refresh the device list setSelectedDeviceUuids([]);
setSelectedDeviceUuids([]); // Clear the selection toggleSendSnapshotModal();
toggleSendSnapshotModal(); // Close the modal
} catch (error) { } catch (error) {
// This catch block might not be hit if Promise.allSettled is used,
// as it doesn't reject on individual promise failures.
// Individual errors should be handled if needed within the .map or by checking results.
console.error("Error in snapshot sending process:", error); console.error("Error in snapshot sending process:", error);
} }
}; };
@@ -235,7 +215,7 @@ export const DevicesTable = observer(() => {
</div> </div>
<div className="flex justify-end p-3 gap-2"> <div className="flex justify-end p-3 gap-2">
<Button <Button
variant="outlined" // Changed to outlined for distinction variant="outlined"
onClick={handleSelectAllDevices} onClick={handleSelectAllDevices}
size="small" size="small"
> >
@@ -286,7 +266,6 @@ export const DevicesTable = observer(() => {
)} )}
selected={selectedDeviceUuids.includes(row.device_uuid ?? "")} selected={selectedDeviceUuids.includes(row.device_uuid ?? "")}
onClick={(event) => { onClick={(event) => {
// Allow clicking row to toggle checkbox, if not clicking on button
if ( if (
(event.target as HTMLElement).closest("button") === null && (event.target as HTMLElement).closest("button") === null &&
(event.target as HTMLElement).closest( (event.target as HTMLElement).closest(
@@ -308,7 +287,6 @@ export const DevicesTable = observer(() => {
} }
} }
// Only toggle checkbox if Shift key is not pressed
if (!event.shiftKey) { if (!event.shiftKey) {
handleSelectDevice( handleSelectDevice(
{ {
@@ -317,7 +295,7 @@ export const DevicesTable = observer(() => {
row.device_uuid ?? "" row.device_uuid ?? ""
), ),
}, },
} as React.ChangeEvent<HTMLInputElement>, // Simulate event } as React.ChangeEvent<HTMLInputElement>,
row.device_uuid ?? "" row.device_uuid ?? ""
); );
} }
@@ -445,7 +423,7 @@ export const DevicesTable = observer(() => {
</strong> </strong>
</Typography> </Typography>
<div className="mt-2 flex flex-col gap-2 max-h-[300px] overflow-y-auto pr-2"> <div className="mt-2 flex flex-col gap-2 max-h-[300px] overflow-y-auto pr-2">
{snapshots && (snapshots as Snapshot[]).length > 0 ? ( // Cast snapshots {snapshots && (snapshots as Snapshot[]).length > 0 ? (
(snapshots as Snapshot[]).map((snapshot) => ( (snapshots as Snapshot[]).map((snapshot) => (
<Button <Button
variant="outlined" variant="outlined"

View File

@@ -1,6 +1,6 @@
import React, { useRef, DragEvent } from "react"; import React, { useRef, DragEvent } from "react";
import { Paper, Box, Typography, Button, Tooltip } from "@mui/material"; import { Paper, Box, Typography, Button, Tooltip } from "@mui/material";
import { X, Info, Plus } from "lucide-react"; // Assuming lucide-react for icons import { X, Info, Plus } from "lucide-react";
import { editSightStore } from "@shared"; import { editSightStore } from "@shared";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
interface ImageUploadCardProps { interface ImageUploadCardProps {
@@ -50,14 +50,14 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
toast.error("Пожалуйста, выберите изображение"); toast.error("Пожалуйста, выберите изображение");
} }
} }
// Reset the input value so selecting the same file again triggers change
event.target.value = ""; event.target.value = "";
}; };
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
// --- Drag and Drop Handlers ---
const handleDragOver = (event: DragEvent<HTMLDivElement>) => { const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); // Crucial to allow a drop event.preventDefault();
event.stopPropagation(); event.stopPropagation();
}; };
@@ -67,7 +67,7 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
}; };
const handleDrop = async (event: DragEvent<HTMLDivElement>) => { const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); // Crucial to allow a drop event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const files = event.dataTransfer.files; const files = event.dataTransfer.files;
@@ -120,7 +120,6 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
cursor: imageUrl ? "pointer" : "default", cursor: imageUrl ? "pointer" : "default",
}} }}
onClick={onImageClick} onClick={onImageClick}
// Removed onClick on the main Box to avoid conflicts
> >
{imageUrl && ( {imageUrl && (
<button <button
@@ -153,7 +152,7 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
borderRadius: 1, borderRadius: 1,
cursor: "pointer", cursor: "pointer",
}} }}
onClick={handleZoneClick} // Click handler for the zone onClick={handleZoneClick}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
@@ -167,8 +166,8 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
color="primary" color="primary"
startIcon={<Plus color="white" size={18} />} startIcon={<Plus color="white" size={18} />}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); // Prevent `handleZoneClick` from firing e.stopPropagation();
onSelectFileClick(); // This button might trigger a different modal onSelectFileClick();
}} }}
> >
Выбрать файл Выбрать файл
@@ -179,7 +178,7 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
ref={fileInputRef} ref={fileInputRef}
onChange={handleFileInputChange} onChange={handleFileInputChange}
style={{ display: "none" }} style={{ display: "none" }}
accept="image/*" // Accept only image files accept="image/*"
/> />
</div> </div>
)} )}

View File

@@ -3,11 +3,9 @@ import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
import { useEffect, Suspense } from "react"; import { useEffect, Suspense } from "react";
import { Box, CircularProgress, Typography } from "@mui/material"; import { Box, CircularProgress, Typography } from "@mui/material";
// Утилита для очистки кеша GLTF
const clearGLTFCache = (url?: string) => { const clearGLTFCache = (url?: string) => {
try { try {
if (url) { if (url) {
// Если это blob URL, очищаем его из кеша
if (url.startsWith("blob:")) { if (url.startsWith("blob:")) {
useGLTF.clear(url); useGLTF.clear(url);
} else { } else {
@@ -19,29 +17,23 @@ const clearGLTFCache = (url?: string) => {
} }
}; };
// Утилита для проверки типа файла
const isValid3DFile = (url: string): boolean => { const isValid3DFile = (url: string): boolean => {
try { try {
const urlObj = new URL(url); const urlObj = new URL(url);
const pathname = urlObj.pathname.toLowerCase(); const pathname = urlObj.pathname.toLowerCase();
const searchParams = urlObj.searchParams; const searchParams = urlObj.searchParams;
// Проверяем расширение файла в пути
const validExtensions = [".glb", ".gltf"]; const validExtensions = [".glb", ".gltf"];
const hasValidExtension = validExtensions.some((ext) => const hasValidExtension = validExtensions.some((ext) =>
pathname.endsWith(ext) pathname.endsWith(ext)
); );
// Проверяем параметры запроса на наличие типа файла
const fileType = searchParams.get("type") || searchParams.get("format"); const fileType = searchParams.get("type") || searchParams.get("format");
const hasValidType = const hasValidType =
fileType && ["glb", "gltf"].includes(fileType.toLowerCase()); fileType && ["glb", "gltf"].includes(fileType.toLowerCase());
// Если это blob URL, считаем его валидным (пользователь выбрал файл)
const isBlobUrl = url.startsWith("blob:"); const isBlobUrl = url.startsWith("blob:");
// Если это URL с токеном и нет явного расширения, считаем валидным
// (предполагаем что сервер вернет правильный файл)
const hasToken = searchParams.has("token"); const hasToken = searchParams.has("token");
const isServerUrl = hasToken && !hasValidExtension; const isServerUrl = hasToken && !hasValidExtension;
@@ -51,7 +43,7 @@ const isValid3DFile = (url: string): boolean => {
return isValid; return isValid;
} catch (error) { } catch (error) {
console.warn("⚠️ isValid3DFile: Ошибка при проверке URL", error); console.warn("⚠️ isValid3DFile: Ошибка при проверке URL", error);
// В случае ошибки парсинга URL, считаем валидным (пусть useGLTF сам разберется)
return true; return true;
} }
}; };
@@ -63,13 +55,10 @@ type ModelViewerProps = {
}; };
const Model = ({ fileUrl }: { fileUrl: string }) => { const Model = ({ fileUrl }: { fileUrl: string }) => {
// Очищаем кеш перед загрузкой новой модели
useEffect(() => { useEffect(() => {
// Очищаем кеш для текущего URL
clearGLTFCache(fileUrl); clearGLTFCache(fileUrl);
}, [fileUrl]); }, [fileUrl]);
// Проверяем валидность файла перед загрузкой (только для blob URL)
if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) { if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) {
console.warn("⚠️ Model: Попытка загрузить невалидный 3D файл", { fileUrl }); console.warn("⚠️ Model: Попытка загрузить невалидный 3D файл", { fileUrl });
throw new Error(`Файл не является корректной 3D моделью: ${fileUrl}`); throw new Error(`Файл не является корректной 3D моделью: ${fileUrl}`);
@@ -114,16 +103,13 @@ export const ThreeView = ({
height = "100%", height = "100%",
width = "100%", width = "100%",
}: ModelViewerProps) => { }: ModelViewerProps) => {
// Проверяем валидность файла (только для blob URL)
useEffect(() => { useEffect(() => {
if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) { if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) {
console.warn("⚠️ ThreeView: Невалидный 3D файл", { fileUrl }); console.warn("⚠️ ThreeView: Невалидный 3D файл", { fileUrl });
} }
}, [fileUrl]); }, [fileUrl]);
// Очищаем кеш при размонтировании и при смене URL
useEffect(() => { useEffect(() => {
// Очищаем кеш сразу при монтировании компонента
clearGLTFCache(fileUrl); clearGLTFCache(fileUrl);
return () => { return () => {

View File

@@ -35,7 +35,6 @@ export class ThreeViewErrorBoundary extends Component<Props, State> {
props: Props, props: Props,
state: State state: State
): Partial<State> | null { ): Partial<State> | null {
// Сбрасываем ошибку ТОЛЬКО при смене медиа (когда меняется ID в resetKey)
if ( if (
props.resetKey !== state.lastResetKey && props.resetKey !== state.lastResetKey &&
state.lastResetKey !== undefined state.lastResetKey !== undefined
@@ -43,7 +42,6 @@ export class ThreeViewErrorBoundary extends Component<Props, State> {
const oldMediaId = String(state.lastResetKey).split("-")[0]; const oldMediaId = String(state.lastResetKey).split("-")[0];
const newMediaId = String(props.resetKey).split("-")[0]; const newMediaId = String(props.resetKey).split("-")[0];
// Сбрасываем ошибку только если изменился ID медиа (пользователь выбрал другую модель)
if (oldMediaId !== newMediaId) { if (oldMediaId !== newMediaId) {
return { return {
hasError: false, hasError: false,
@@ -52,9 +50,6 @@ export class ThreeViewErrorBoundary extends Component<Props, State> {
}; };
} }
// Если изменился только счетчик (нажата кнопка "Попробовать снова"), обновляем lastResetKey
// но не сбрасываем ошибку автоматически - ждем результата загрузки
return { return {
lastResetKey: props.resetKey, lastResetKey: props.resetKey,
}; };
@@ -127,15 +122,12 @@ export class ThreeViewErrorBoundary extends Component<Props, State> {
}; };
handleReset = () => { handleReset = () => {
// Сначала сбрасываем состояние ошибки
this.setState( this.setState(
{ {
hasError: false, hasError: false,
error: null, error: null,
}, },
() => { () => {
// После того как состояние обновилось, вызываем callback для изменения resetKey
// Это приведет к пересозданию компонента и новой попытке загрузки
this.props.onReset?.(); this.props.onReset?.();
} }
); );

View File

@@ -1,158 +0,0 @@
// import { Box, Button, Paper, Typography } from "@mui/material";
// import { X, Upload } from "lucide-react";
// import { useCallback, useState } from "react";
// import { useDropzone } from "react-dropzone";
// import { UploadMediaDialog } from "@shared";
// import { createSightStore } from "@shared";
// interface MediaUploadBoxProps {
// title: string;
// tooltip?: string;
// mediaId: string | null;
// onMediaSelect: (mediaId: string) => void;
// onMediaRemove: () => void;
// onPreviewClick: (mediaId: string) => void;
// token: string;
// type: "thumbnail" | "watermark_lu" | "watermark_rd";
// }
// export const MediaUploadBox = ({
// title,
// tooltip,
// mediaId,
// onMediaSelect,
// onMediaRemove,
// onPreviewClick,
// token,
// type,
// }: MediaUploadBoxProps) => {
// const [uploadMediaOpen, setUploadMediaOpen] = useState(false);
// const [fileToUpload, setFileToUpload] = useState<File | null>(null);
// const onDrop = useCallback((acceptedFiles: File[]) => {
// if (acceptedFiles.length > 0) {
// setFileToUpload(acceptedFiles[0]);
// setUploadMediaOpen(true);
// }
// }, []);
// const { getRootProps, getInputProps, isDragActive } = useDropzone({
// onDrop,
// accept: {
// "image/*": [".png", ".jpg", ".jpeg", ".gif"],
// },
// multiple: false,
// });
// const handleUploadComplete = async (media: {
// id: string;
// filename: string;
// media_name?: string;
// media_type: number;
// }) => {
// onMediaSelect(media.id);
// };
// return (
// <>
// <Paper
// elevation={2}
// sx={{
// padding: 2,
// display: "flex",
// flexDirection: "column",
// alignItems: "center",
// gap: 1,
// flex: 1,
// minWidth: 150,
// }}
// >
// <Box sx={{ display: "flex", alignItems: "center" }}>
// <Typography variant="subtitle2" gutterBottom sx={{ mb: 0, mr: 0.5 }}>
// {title}
// </Typography>
// </Box>
// <Box
// {...getRootProps()}
// sx={{
// position: "relative",
// width: "200px",
// height: "200px",
// display: "flex",
// alignItems: "center",
// justifyContent: "center",
// borderRadius: 1,
// mb: 1,
// cursor: mediaId ? "pointer" : "default",
// border: isDragActive ? "2px dashed #1976d2" : "none",
// backgroundColor: isDragActive
// ? "rgba(25, 118, 210, 0.04)"
// : "transparent",
// transition: "all 0.2s ease",
// }}
// >
// <input {...getInputProps()} />
// {mediaId && (
// <button
// className="absolute top-2 right-2 z-10"
// onClick={(e) => {
// e.stopPropagation();
// onMediaRemove();
// }}
// >
// <X color="red" />
// </button>
// )}
// {mediaId ? (
// <img
// src={`${
// import.meta.env.VITE_KRBL_MEDIA
// }${mediaId}/download?token=${token}`}
// alt={title}
// style={{ maxWidth: "100%", maxHeight: "100%" }}
// onClick={(e) => {
// e.stopPropagation();
// onPreviewClick(mediaId);
// }}
// />
// ) : (
// <div className="w-full flex flex-col items-center justify-center gap-3">
// <div
// className={`w-full h-20 border rounded-md flex flex-col items-center transition-all duration-300 justify-center border-dashed ${
// isDragActive
// ? "border-blue-500 bg-blue-50"
// : "border-gray-300"
// } cursor-pointer hover:bg-gray-100`}
// >
// <Upload size={24} className="mb-2" />
// <p>
// {isDragActive ? "Отпустите файл здесь" : "Перетащите файл"}
// </p>
// </div>
// <p>или</p>
// <Button
// variant="contained"
// color="primary"
// onClick={(e) => {
// e.stopPropagation();
// onMediaSelect("");
// }}
// >
// Выбрать файл
// </Button>
// </div>
// )}
// </Box>
// </Paper>
// <UploadMediaDialog
// open={uploadMediaOpen}
// onClose={() => {
// setUploadMediaOpen(false);
// setFileToUpload(null);
// }}
// afterUpload={handleUploadComplete}
// />
// </>
// );
// };

View File

@@ -1,4 +1,3 @@
// @widgets/LeftWidgetTab.tsx
import { Box, Button, TextField, Paper, Typography } from "@mui/material"; import { Box, Button, TextField, Paper, Typography } from "@mui/material";
import { import {
BackButton, BackButton,
@@ -50,17 +49,6 @@ export const CreateLeftTab = observer(
const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] = const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] =
useState(false); useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
// const handleMediaSelected = useCallback(() => {
// // При выборе медиа, обновляем данные для ТЕКУЩЕГО ЯЗЫКА
// // сохраняя текущие heading и body.
// updateSightInfo(language, {
// left: {
// heading: data.left.heading,
// body: data.left.body,
// },
// });
// setIsSelectMediaDialogOpen(false);
// }, [language, data.left.heading, data.left.body]);
const handleCloseArticleDialog = useCallback(() => { const handleCloseArticleDialog = useCallback(() => {
setIsSelectArticleDialogOpen(false); setIsSelectArticleDialogOpen(false);

View File

@@ -13,28 +13,27 @@ import {
languageStore, languageStore,
SelectArticleModal, SelectArticleModal,
TabPanel, TabPanel,
SelectMediaDialog, // Import SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
Media, // Import Media,
} from "@shared"; } from "@shared";
import { import {
LanguageSwitcher, LanguageSwitcher,
MediaArea, // Import MediaArea,
MediaAreaForSight, // Import MediaAreaForSight,
ReactMarkdownComponent, ReactMarkdownComponent,
ReactMarkdownEditor, ReactMarkdownEditor,
DeleteModal, DeleteModal,
} from "@widgets"; } from "@widgets";
import { ImagePlus, Plus, Save, Trash2, Unlink, X } from "lucide-react"; // Import X import { ImagePlus, 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"; // Added useEffect import { useState, useEffect } from "react";
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";
type MediaItemShared = { type MediaItemShared = {
// Define if not already available from @shared
id: string; id: string;
filename: string; filename: string;
media_name?: string; media_name?: string;
@@ -52,14 +51,14 @@ export const CreateRightTab = observer(
unlinkPreviewMedia, unlinkPreviewMedia,
createLinkWithRightArticle, createLinkWithRightArticle,
deleteRightArticleMedia, deleteRightArticleMedia,
setFileToUpload, // From store setFileToUpload,
setUploadMediaOpen, // From store setUploadMediaOpen,
uploadMediaOpen, // From store uploadMediaOpen,
unlinkRightAritcle, // Corrected spelling unlinkRightAritcle,
deleteRightArticle, deleteRightArticle,
linkExistingRightArticle, linkExistingRightArticle,
createSight, createSight,
clearCreateSight, // For resetting form clearCreateSight,
updateRightArticles, updateRightArticles,
} = createSightStore; } = createSightStore;
const { language } = languageStore; const { language } = languageStore;
@@ -78,7 +77,7 @@ export const CreateRightTab = observer(
>(null); >(null);
const [previewMedia, setPreviewMedia] = useState<Media | null>(null); const [previewMedia, setPreviewMedia] = useState<Media | null>(null);
// Reset activeArticleIndex if language changes and index is out of bounds
useEffect(() => { useEffect(() => {
if (sight.preview_media) { if (sight.preview_media) {
const fetchMedia = async () => { const fetchMedia = async () => {
@@ -97,7 +96,7 @@ export const CreateRightTab = observer(
activeArticleIndex >= sight[language].right.length activeArticleIndex >= sight[language].right.length
) { ) {
setActiveArticleIndex(null); setActiveArticleIndex(null);
setType("media"); // Default back to media preview if selected article disappears setType("media");
} }
}, [language, sight[language].right, activeArticleIndex]); }, [language, sight[language].right, activeArticleIndex]);
@@ -113,10 +112,9 @@ export const CreateRightTab = observer(
try { try {
await createSight(language); await createSight(language);
toast.success("Достопримечательность успешно создана!"); toast.success("Достопримечательность успешно создана!");
clearCreateSight(); // Reset form clearCreateSight();
setActiveArticleIndex(null); setActiveArticleIndex(null);
setType("media"); setType("media");
// Potentially navigate away: history.push('/sights-list');
} catch (error) { } catch (error) {
console.error("Failed to save sight:", error); console.error("Failed to save sight:", error);
toast.error("Ошибка при создании достопримечательности."); toast.error("Ошибка при создании достопримечательности.");
@@ -132,7 +130,7 @@ export const CreateRightTab = observer(
handleCloseMenu(); handleCloseMenu();
try { try {
const newArticleId = await createNewRightArticle(); const newArticleId = await createNewRightArticle();
// Automatically select the new article if ID is returned
const newIndex = sight[language].right.findIndex( const newIndex = sight[language].right.findIndex(
(a) => a.id === newArticleId (a) => a.id === newArticleId
); );
@@ -140,7 +138,6 @@ export const CreateRightTab = observer(
setActiveArticleIndex(newIndex); setActiveArticleIndex(newIndex);
setType("article"); setType("article");
} else { } else {
// Fallback if findIndex fails (should not happen if store updates correctly)
setActiveArticleIndex(sight[language].right.length - 1); setActiveArticleIndex(sight[language].right.length - 1);
setType("article"); setType("article");
} }
@@ -156,7 +153,7 @@ export const CreateRightTab = observer(
const linkedArticleId = await linkExistingRightArticle( const linkedArticleId = await linkExistingRightArticle(
selectedArticleId selectedArticleId
); );
setSelectArticleDialogOpen(false); // Close dialog setSelectArticleDialogOpen(false);
const newIndex = sight[language].right.findIndex( const newIndex = sight[language].right.findIndex(
(a) => a.id === linkedArticleId (a) => a.id === linkedArticleId
); );
@@ -174,7 +171,6 @@ export const CreateRightTab = observer(
? sight[language].right[activeArticleIndex] ? sight[language].right[activeArticleIndex]
: null; : null;
// Media Handling for Dialogs
const handleOpenUploadMedia = () => { const handleOpenUploadMedia = () => {
setUploadMediaOpen(true); setUploadMediaOpen(true);
}; };
@@ -203,7 +199,6 @@ export const CreateRightTab = observer(
}; };
const handleMediaUploaded = async (media: MediaItemShared) => { const handleMediaUploaded = async (media: MediaItemShared) => {
// After UploadMediaDialog finishes
setUploadMediaOpen(false); setUploadMediaOpen(false);
setFileToUpload(null); setFileToUpload(null);
if (mediaTarget === "sightPreview") { if (mediaTarget === "sightPreview") {
@@ -211,36 +206,25 @@ export const CreateRightTab = observer(
} else if (mediaTarget === "rightArticle" && currentRightArticle) { } else if (mediaTarget === "rightArticle" && currentRightArticle) {
await createLinkWithRightArticle(media, currentRightArticle.id); await createLinkWithRightArticle(media, currentRightArticle.id);
} }
setMediaTarget(null); // Reset target setMediaTarget(null);
}; };
const handleDragEnd = (result: any) => { const handleDragEnd = (result: any) => {
const { source, destination } = result; const { source, destination } = result;
// 1. Guard clause: If dropped outside any droppable area, do nothing.
if (!destination) return; if (!destination) return;
// Extract source and destination indices
const sourceIndex = source.index; const sourceIndex = source.index;
const destinationIndex = destination.index; const destinationIndex = destination.index;
// 2. Guard clause: If dropped in the same position, do nothing.
if (sourceIndex === destinationIndex) return; if (sourceIndex === destinationIndex) return;
// 3. Create a new array with reordered articles:
// - Create a shallow copy of the current articles array.
// This is important for immutability and triggering re-renders.
const newRightArticles = [...sight[language].right]; const newRightArticles = [...sight[language].right];
// - Remove the dragged article from its original position.
// `splice` returns an array of removed items, so we destructure the first (and only) one.
const [movedArticle] = newRightArticles.splice(sourceIndex, 1); const [movedArticle] = newRightArticles.splice(sourceIndex, 1);
// - Insert the moved article into its new position.
newRightArticles.splice(destinationIndex, 0, movedArticle); newRightArticles.splice(destinationIndex, 0, movedArticle);
// 4. Update the store with the new order:
// This will typically trigger a re-render of the component with the updated list.
updateRightArticles(newRightArticles); updateRightArticles(newRightArticles);
}; };
@@ -254,7 +238,7 @@ export const CreateRightTab = observer(
height: "100%", height: "100%",
minHeight: "calc(100vh - 200px)", minHeight: "calc(100vh - 200px)",
gap: 2, gap: 2,
paddingBottom: "70px", // Space for the save button paddingBottom: "70px",
position: "relative", position: "relative",
}} }}
> >
@@ -264,7 +248,6 @@ export const CreateRightTab = observer(
</div> </div>
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}> <Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}>
{/* Left Column: Navigation & Article List */}
<Box className="flex flex-col w-[75%] gap-2"> <Box className="flex flex-col w-[75%] gap-2">
<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">
@@ -272,7 +255,6 @@ export const CreateRightTab = observer(
<Box <Box
onClick={() => { onClick={() => {
setType("media"); setType("media");
// setActiveArticleIndex(null); // Optional: deselect article when switching to general media view
}} }}
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 hover:bg-gray-300 transition-all duration-300 ${
type === "media" type === "media"
@@ -364,7 +346,6 @@ export const CreateRightTab = observer(
</Menu> </Menu>
</Box> </Box>
{/* Main content area: Article Editor or Sight Media Preview */}
{type === "article" && currentRightArticle ? ( {type === "article" && currentRightArticle ? (
<Box className="w-[80%] border border-gray-300 p-3 flex flex-col gap-2 overflow-hidden"> <Box className="w-[80%] border border-gray-300 p-3 flex flex-col gap-2 overflow-hidden">
<Box className="flex justify-end gap-2 mb-1 flex-shrink-0"> <Box className="flex justify-end gap-2 mb-1 flex-shrink-0">
@@ -375,7 +356,7 @@ export const CreateRightTab = observer(
startIcon={<Unlink color="white" size={18} />} startIcon={<Unlink color="white" size={18} />}
onClick={() => { onClick={() => {
if (currentRightArticle) { if (currentRightArticle) {
unlinkRightAritcle(currentRightArticle.id); // Corrected function name unlinkRightAritcle(currentRightArticle.id);
setActiveArticleIndex(null); setActiveArticleIndex(null);
setType("media"); setType("media");
} }
@@ -435,7 +416,7 @@ export const CreateRightTab = observer(
/> />
</Box> </Box>
<MediaArea <MediaArea
articleId={currentRightArticle.id} // Needs a real ID articleId={currentRightArticle.id}
mediaIds={currentRightArticle.media || []} mediaIds={currentRightArticle.media || []}
onFilesDrop={(files) => { onFilesDrop={(files) => {
if (files.length > 0) { if (files.length > 0) {
@@ -507,7 +488,6 @@ export const CreateRightTab = observer(
</Box> </Box>
</Box> </Box>
{/* Right Column: Live Preview */}
<Box className="w-[25%] mr-10"> <Box className="w-[25%] mr-10">
{type === "article" && activeArticleIndex !== null && ( {type === "article" && activeArticleIndex !== null && (
<Paper <Paper
@@ -662,12 +642,11 @@ export const CreateRightTab = observer(
</Box> </Box>
</Box> </Box>
{/* Sticky Save Button Footer */}
<Box <Box
sx={{ sx={{
position: "absolute", position: "absolute",
bottom: "-20px", bottom: "-20px",
left: 0, // ensure it spans from left left: 0,
right: 0, right: 0,
padding: 2, padding: 2,
backgroundColor: "background.paper", backgroundColor: "background.paper",
@@ -689,19 +668,17 @@ export const CreateRightTab = observer(
</Box> </Box>
</Box> </Box>
{/* Modals */}
<SelectArticleModal <SelectArticleModal
open={selectArticleDialogOpen} open={selectArticleDialogOpen}
onClose={() => setSelectArticleDialogOpen(false)} onClose={() => setSelectArticleDialogOpen(false)}
onSelectArticle={handleSelectExistingArticleAndLink} onSelectArticle={handleSelectExistingArticleAndLink}
// Pass IDs of already linked/added right articles to exclude them from selection
linkedArticleIds={sight[language].right.map((article) => article.id)} linkedArticleIds={sight[language].right.map((article) => article.id)}
/> />
<UploadMediaDialog <UploadMediaDialog
open={uploadMediaOpen} // From store open={uploadMediaOpen}
onClose={() => { onClose={() => {
setUploadMediaOpen(false); setUploadMediaOpen(false);
setFileToUpload(null); // Clear file if dialog is closed without upload setFileToUpload(null);
setMediaTarget(null); setMediaTarget(null);
}} }}
contextObjectName={sight[language].name} contextObjectName={sight[language].name}
@@ -712,7 +689,7 @@ export const CreateRightTab = observer(
? sight[language].right[activeArticleIndex].heading ? sight[language].right[activeArticleIndex].heading
: undefined : undefined
} }
afterUpload={handleMediaUploaded} // This will use the mediaTarget afterUpload={handleMediaUploaded}
/> />
<SelectMediaDialog <SelectMediaDialog
open={isSelectMediaDialogOpen} open={isSelectMediaDialogOpen}

View File

@@ -118,7 +118,7 @@ export const RightWidgetTab = observer(
try { try {
const newArticleId = await createNewRightArticle(); const newArticleId = await createNewRightArticle();
handleClose(); handleClose();
// Automatically select the newly created article
const newIndex = sight[language].right.findIndex( const newIndex = sight[language].right.findIndex(
(article) => article.id === newArticleId (article) => article.id === newArticleId
); );
@@ -144,7 +144,7 @@ export const RightWidgetTab = observer(
try { try {
const linkedArticleId = await linkArticle(id); const linkedArticleId = await linkArticle(id);
handleCloseSelectModal(); handleCloseSelectModal();
// Automatically select the newly linked article
const newIndex = sight[language].right.findIndex( const newIndex = sight[language].right.findIndex(
(article) => article.id === linkedArticleId (article) => article.id === linkedArticleId
); );
@@ -177,30 +177,19 @@ export const RightWidgetTab = observer(
const handleDragEnd = (result: DropResult) => { const handleDragEnd = (result: DropResult) => {
const { source, destination } = result; const { source, destination } = result;
// 1. Guard clause: If dropped outside any droppable area, do nothing.
if (!destination) return; if (!destination) return;
// Extract source and destination indices
const sourceIndex = source.index; const sourceIndex = source.index;
const destinationIndex = destination.index; const destinationIndex = destination.index;
// 2. Guard clause: If dropped in the same position, do nothing.
if (sourceIndex === destinationIndex) return; if (sourceIndex === destinationIndex) return;
// 3. Create a new array with reordered articles:
// - Create a shallow copy of the current articles array.
// This is important for immutability and triggering re-renders.
const newRightArticles = [...sight[language].right]; const newRightArticles = [...sight[language].right];
// - Remove the dragged article from its original position.
// `splice` returns an array of removed items, so we destructure the first (and only) one.
const [movedArticle] = newRightArticles.splice(sourceIndex, 1); const [movedArticle] = newRightArticles.splice(sourceIndex, 1);
// - Insert the moved article into its new position.
newRightArticles.splice(destinationIndex, 0, movedArticle); newRightArticles.splice(destinationIndex, 0, movedArticle);
// 4. Update the store with the new order:
// This will typically trigger a re-render of the component with the updated list.
updateRightArticles(newRightArticles); updateRightArticles(newRightArticles);
}; };

View File

@@ -28,9 +28,8 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
useEffect(() => {}, [isDragOver]); useEffect(() => {}, [isDragOver]);
// --- Click to select file ---
const handleZoneClick = () => { const handleZoneClick = () => {
// Trigger the hidden file input click
fileInputRef.current?.click(); fileInputRef.current?.click();
}; };
@@ -40,19 +39,17 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (file) { if (file) {
if (file.type.startsWith("video/")) { if (file.type.startsWith("video/")) {
// Открываем диалог загрузки медиа с файлом видео
onSelectVideoClick(file); onSelectVideoClick(file);
} else { } else {
toast.error("Пожалуйста, выберите видео файл"); toast.error("Пожалуйста, выберите видео файл");
} }
} }
// Reset the input value so selecting the same file again triggers change
event.target.value = ""; event.target.value = "";
}; };
// --- Drag and Drop Handlers ---
const handleDragOver = (event: DragEvent<HTMLDivElement>) => { const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); // Crucial to allow a drop event.preventDefault();
event.stopPropagation(); event.stopPropagation();
setIsDragOver(true); setIsDragOver(true);
}; };
@@ -64,7 +61,7 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
}; };
const handleDrop = async (event: DragEvent<HTMLDivElement>) => { const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); // Crucial to allow a drop event.preventDefault();
event.stopPropagation(); event.stopPropagation();
setIsDragOver(false); setIsDragOver(false);
@@ -72,7 +69,6 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
if (files && files.length > 0) { if (files && files.length > 0) {
const file = files[0]; const file = files[0];
if (file.type.startsWith("video/")) { if (file.type.startsWith("video/")) {
// Открываем диалог загрузки медиа с файлом видео
onSelectVideoClick(file); onSelectVideoClick(file);
} else { } else {
toast.error("Пожалуйста, выберите видео файл"); toast.error("Пожалуйста, выберите видео файл");
@@ -175,7 +171,7 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
borderRadius: 1, borderRadius: 1,
cursor: "pointer", cursor: "pointer",
}} }}
onClick={handleZoneClick} // Click handler for the zone onClick={handleZoneClick}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
@@ -189,8 +185,8 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
color="primary" color="primary"
startIcon={<Plus color="white" size={18} />} startIcon={<Plus color="white" size={18} />}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); // Prevent `handleZoneClick` from firing e.stopPropagation();
onSelectVideoClick(); // This button triggers the media selection dialog onSelectVideoClick();
}} }}
> >
Выбрать файл Выбрать файл
@@ -201,7 +197,7 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
ref={fileInputRef} ref={fileInputRef}
onChange={handleFileInputChange} onChange={handleFileInputChange}
style={{ display: "none" }} style={{ display: "none" }}
accept="video/*" // Accept only video files accept="video/*"
/> />
</div> </div>
)} )}