Compare commits
7 Commits
d67df0c2e1
...
feature/we
| Author | SHA1 | Date | |
|---|---|---|---|
| e3469763ce | |||
| 7f8a327329 | |||
| 53b8ce7095 | |||
| 94f512e0e4 | |||
| 60c6840db4 | |||
| 248eea6f85 | |||
| 7f8b90c15e |
14
.env
14
.env
@@ -1,8 +1,8 @@
|
||||
# VITE_API_URL='https://wn.st.unprism.ru'
|
||||
# VITE_REACT_APP ='https://wn.st.unprism.ru/'
|
||||
# VITE_KRBL_MEDIA='https://wn.st.unprism.ru/media/'
|
||||
# VITE_NEED_AUTH='true'
|
||||
VITE_API_URL='https://wn.krbl.ru'
|
||||
VITE_REACT_APP ='https://wn.krbl.ru/'
|
||||
VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
|
||||
VITE_API_URL='https://wn.st.unprism.ru'
|
||||
VITE_REACT_APP ='https://wn.st.unprism.ru/'
|
||||
VITE_KRBL_MEDIA='https://wn.st.unprism.ru/media/'
|
||||
VITE_NEED_AUTH='true'
|
||||
# VITE_API_URL='https://wn.krbl.ru'
|
||||
# VITE_REACT_APP ='https://wn.krbl.ru/'
|
||||
# VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
|
||||
# VITE_NEED_AUTH='true'
|
||||
|
||||
4
Subtract.svg
Normal file
4
Subtract.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="21" viewBox="0 0 24 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.3888 2.85031C11.7638 2.66434 12.1989 2.66434 12.5739 2.85031L21.0037 7.06792C21.4462 7.29108 21.7245 7.73709 21.7245 8.22802V20.145H21.7464C21.7462 20.6134 21.3641 20.9999 20.8843 21H15.7689C15.3788 21 15.0562 20.6874 15.0562 20.2932V13.807C15.0562 12.1333 13.6909 10.7792 12.0033 10.7792C10.3159 10.7794 8.95116 12.1334 8.95115 13.807V20.2932C8.95115 20.68 8.63599 21 8.23847 21H3.10101C2.62861 21 2.23837 20.6209 2.23818 20.145V8.22802C2.23819 7.73714 2.51576 7.2911 2.95818 7.06792L11.3888 2.85031Z" fill="white"/>
|
||||
<path d="M10.7962 0.268599C11.5012 -0.088374 12.3413 -0.0882526 13.0463 0.261335L23.6142 5.54243C23.9592 5.71352 24.1018 6.1379 23.9218 6.48751C23.7943 6.73297 23.5464 6.87397 23.2839 6.87397C23.179 6.87392 23.0663 6.85189 22.9689 6.79987L12.401 1.51877C12.101 1.37 11.7408 1.37 11.4408 1.51877L1.03036 6.79261C0.677932 6.97088 0.250655 6.82952 0.0781632 6.48751C-0.101845 6.14534 0.0407786 5.72096 0.385795 5.54243L10.7962 0.268599Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
4
src/client/src/App.d.ts
vendored
Normal file
4
src/client/src/App.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
import React from "react";
|
||||
|
||||
declare const App: React.FC;
|
||||
export default App;
|
||||
@@ -21,7 +21,9 @@ import {
|
||||
GetCityResponse,
|
||||
GetSightArticleResponse,
|
||||
} from "./types";
|
||||
// @ts-ignore
|
||||
import { orderStationsByRoute } from "../../utils/routeStationsUtils";
|
||||
import { resamplePath } from "../../utils/animationUtils";
|
||||
|
||||
class ApiStore {
|
||||
isLoading = true;
|
||||
@@ -54,13 +56,14 @@ class ApiStore {
|
||||
carrier: GetCarrierResponse | null = null;
|
||||
city: GetCityResponse | null = null;
|
||||
|
||||
private positionIndex = 0;
|
||||
positionIndex = 0;
|
||||
private positionInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
simulationSpeed = 1;
|
||||
simulationDirection: 1 | -1 = 1;
|
||||
simulationPaused = false;
|
||||
simulationInstantMove = false;
|
||||
showHitboxes = false;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
@@ -87,7 +90,26 @@ class ApiStore {
|
||||
};
|
||||
|
||||
getRoute = async () => {
|
||||
this.route = await getRoute(this.routeId!);
|
||||
const route = await getRoute(this.routeId!);
|
||||
if (route.path && route.path.length > 1) {
|
||||
// Рассчитываем общую дистанцию для выбора адекватного шага ресемплинга
|
||||
let totalDist = 0;
|
||||
for (let i = 0; i < route.path.length - 1; i++) {
|
||||
const p1 = route.path[i];
|
||||
const p2 = route.path[i + 1];
|
||||
totalDist += Math.sqrt(
|
||||
Math.pow(p2[0] - p1[0], 2) + Math.pow(p2[1] - p1[1], 2)
|
||||
);
|
||||
}
|
||||
// Хотим иметь примерно 2000 точек для равномерности и плавности
|
||||
const segmentLength = totalDist / 2000;
|
||||
if (segmentLength > 0) {
|
||||
route.path = resamplePath(route.path as [number, number][], segmentLength);
|
||||
}
|
||||
}
|
||||
runInAction(() => {
|
||||
this.route = route;
|
||||
});
|
||||
this.updateOrderedRouteStations();
|
||||
};
|
||||
|
||||
@@ -170,6 +192,10 @@ class ApiStore {
|
||||
this.simulationInstantMove = !this.simulationInstantMove;
|
||||
};
|
||||
|
||||
toggleShowHitboxes = () => {
|
||||
this.showHitboxes = !this.showHitboxes;
|
||||
};
|
||||
|
||||
startPositionSimulation = () => {
|
||||
if (this.positionInterval) return;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export type GetContextResponse = {
|
||||
};
|
||||
endStopId: string;
|
||||
nearestSightId: string;
|
||||
nearestStationId?: string | null;
|
||||
rawCoordinates: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
@@ -105,6 +106,7 @@ export type GetRouteSightsResponse = {
|
||||
icon?: string;
|
||||
alt_icon?: string;
|
||||
is_default_icon?: boolean;
|
||||
short_name?: string;
|
||||
}[];
|
||||
|
||||
export type GetRouteStationsResponse = {
|
||||
|
||||
9
src/client/src/api/apiConfig.d.ts
vendored
Normal file
9
src/client/src/api/apiConfig.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import { AxiosInstance } from "axios";
|
||||
|
||||
export declare const apiBaseURL: string;
|
||||
export declare const geoBaseURL: string;
|
||||
export declare const weatherBaseURL: string;
|
||||
export declare const getMediaUrl: (id: string) => string;
|
||||
export declare const apiInstance: AxiosInstance;
|
||||
export declare const geoInstance: AxiosInstance;
|
||||
export declare const weatherInstance: AxiosInstance;
|
||||
8
src/client/src/assets/Constants.d.ts
vendored
Normal file
8
src/client/src/assets/Constants.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
export declare const UP_SCALE: number;
|
||||
export declare const PATH_WIDTH: number;
|
||||
export declare const STATION_RADIUS: number;
|
||||
export declare const STATION_OUTLINE_WIDTH: number;
|
||||
export declare const SIGHT_SIZE: number;
|
||||
export declare const SCALE_FACTOR: number;
|
||||
export declare const BACKGROUND_COLOR: number;
|
||||
export declare const PATH_COLOR: number;
|
||||
4
src/client/src/assets/icons/subtract-home.svg
Normal file
4
src/client/src/assets/icons/subtract-home.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="21" viewBox="0 0 24 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.3888 2.85031C11.7638 2.66434 12.1989 2.66434 12.5739 2.85031L21.0037 7.06792C21.4462 7.29108 21.7245 7.73709 21.7245 8.22802V20.145H21.7464C21.7462 20.6134 21.3641 20.9999 20.8843 21H15.7689C15.3788 21 15.0562 20.6874 15.0562 20.2932V13.807C15.0562 12.1333 13.6909 10.7792 12.0033 10.7792C10.3159 10.7794 8.95116 12.1334 8.95115 13.807V20.2932C8.95115 20.68 8.63599 21 8.23847 21H3.10101C2.62861 21 2.23837 20.6209 2.23818 20.145V8.22802C2.23819 7.73714 2.51576 7.2911 2.95818 7.06792L11.3888 2.85031Z" fill="white"/>
|
||||
<path d="M10.7962 0.268599C11.5012 -0.088374 12.3413 -0.0882526 13.0463 0.261335L23.6142 5.54243C23.9592 5.71352 24.1018 6.1379 23.9218 6.48751C23.7943 6.73297 23.5464 6.87397 23.2839 6.87397C23.179 6.87392 23.0663 6.85189 22.9689 6.79987L12.401 1.51877C12.101 1.37 11.7408 1.37 11.4408 1.51877L1.03036 6.79261C0.677932 6.97088 0.250655 6.82952 0.0781632 6.48751C-0.101845 6.14534 0.0407786 5.72096 0.385795 5.54243L10.7962 0.268599Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
3
src/client/src/assets/icons/three-view-pan.svg
Normal file
3
src/client/src/assets/icons/three-view-pan.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.9941 5.96193C13.028 4.86487 12.0189 3.81 11.0097 2.73404C10.5374 2.2277 9.78591 2.14332 9.18473 2.50197C8.86267 2.69185 8.64796 2.98721 8.51914 3.32476C7.51002 2.33319 6.47943 1.42601 5.47031 0.434434C4.69737 -0.325069 3.47355 -0.0508043 3.10855 0.961866C3.10855 1.00406 3.08707 1.00406 3.0656 1.02516C2.65766 0.750893 2.18531 0.708699 1.69148 0.919672C1.11178 1.17284 0.811185 1.61588 0.789714 2.2488C0.789714 2.56526 0.897071 2.86062 1.06884 3.13489C0.746777 3.24038 0.489128 3.40915 0.295893 3.66232C-0.154991 4.25305 -0.09058 5.05474 0.489127 5.60327C2.16384 7.24886 3.83854 8.89445 5.53472 10.5611C5.57767 10.6033 5.62061 10.6455 5.68502 10.7088C5.66355 10.7088 5.64208 10.7088 5.62061 10.7088C4.63296 10.8565 3.62384 11.0253 2.63619 11.173C1.88472 11.2784 1.3909 11.6793 1.17619 12.3966C0.918542 13.2405 1.43383 14 2.29266 14C4.71884 14 7.12355 14 9.54973 14C10.7091 14 11.6968 13.5992 12.5127 12.7975C12.9421 12.3755 13.3715 11.9536 13.8009 11.5316C15.3253 9.99151 15.4112 7.56532 13.9727 5.94083L13.9941 5.96193ZM13.2212 11.0675C12.8133 11.4683 12.4053 11.8692 11.9974 12.27C11.3103 12.924 10.4944 13.2616 9.52826 13.2616C8.36885 13.2616 7.18796 13.2616 6.02855 13.2616C4.86913 13.2616 3.60236 13.2616 2.37854 13.2616C1.99207 13.2616 1.82031 13.0506 1.92766 12.692C2.01354 12.2911 2.3356 12.0169 2.76501 11.9536C4.01031 11.7637 5.27708 11.5738 6.52237 11.3839C6.69414 11.3628 6.84443 11.2995 6.88737 11.1308C6.95178 10.9409 6.88737 10.7932 6.73708 10.6666C4.84767 8.81006 2.95825 6.9535 1.06884 5.09694C0.83266 4.86487 0.746777 4.6117 0.85413 4.31634C1.00442 3.87329 1.56266 3.72561 1.94913 3.99988C2.01354 4.04207 2.05648 4.10536 2.1209 4.14756C3.34472 5.35011 4.59001 6.57375 5.81384 7.77629C5.92119 7.88178 6.02855 7.96617 6.17884 7.94507C6.47943 7.94507 6.62972 7.60752 6.47943 7.37545C6.43649 7.31215 6.37208 7.24886 6.32914 7.20667C4.84767 5.72986 3.34472 4.27414 1.84177 2.81843C1.64854 2.62855 1.54119 2.39648 1.6056 2.12222C1.73443 1.61588 2.3356 1.42601 2.74354 1.76356C2.78648 1.80576 2.82943 1.84795 2.89384 1.89015C4.35384 3.32476 5.83532 4.78048 7.29532 6.2151C7.33826 6.25729 7.35973 6.29948 7.40267 6.32058C7.57444 6.46826 7.81061 6.48936 7.9609 6.32058C8.1112 6.1729 8.1112 5.94083 7.9609 5.77205C7.72473 5.53998 7.25237 5.07584 7.25237 5.07584L6.24325 4.08427C6.24325 4.08427 5.49178 3.34586 5.12678 2.98721C4.76178 2.62855 4.41825 2.291 4.05325 1.93234C3.88149 1.74247 3.77413 1.55259 3.83855 1.27833C3.9459 0.814185 4.50413 0.62431 4.89061 0.919672C4.95502 0.961866 4.99796 1.02516 5.0409 1.06735C6.1359 2.14332 7.25238 3.11379 8.34738 4.18975C8.54061 4.37963 8.71238 4.56951 8.90561 4.73828C9.09885 4.92816 9.33502 4.94926 9.50679 4.80158C9.67855 4.65389 9.65708 4.42182 9.46385 4.21085C9.18473 3.89439 9.2062 3.47245 9.50679 3.21928C9.80738 2.96611 10.2368 2.98721 10.5159 3.28257C11.4606 4.29524 12.4053 5.32901 13.35 6.34168C14.5953 7.713 14.5524 9.73834 13.2427 11.0464L13.2212 11.0675Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
3
src/client/src/assets/icons/three-view-rotate.svg
Normal file
3
src/client/src/assets/icons/three-view-rotate.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="15" height="12" viewBox="0 0 15 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.993 3.76793C13.0258 2.64439 12.0155 1.56407 11.0053 0.462135C10.5324 -0.0564207 9.78007 -0.142847 9.17822 0.224464C8.8558 0.418922 8.64085 0.721413 8.51188 1.06712C7.69509 0.505348 6.57737 0.786232 6.19046 1.82334C6.19046 1.82334 6.19046 1.82334 6.19046 1.88816C5.78206 1.62889 5.33068 1.60728 4.85779 1.82334C4.40641 2.0178 4.14847 2.3419 4.0195 2.75242C3.6326 2.36351 2.60085 1.348 2.47189 1.21836C2.0205 0.786232 1.46164 0.699806 0.902776 0.959084C0.773808 1.0023 0.687829 1.06712 0.580356 1.15354C0.365409 1.30479 0.236441 1.49925 0.128968 1.73692C0.128968 1.78013 0.107473 1.82334 0.0859786 1.86656C0 1.9962 0 2.14744 0 2.29869C0 2.53636 0.064484 2.75242 0.150463 2.96849C0.150463 2.96849 0.343915 3.27098 0.472883 3.40062C2.14947 5.08592 3.82605 6.77123 5.52413 8.47814C5.56712 8.52135 5.61011 8.56457 5.67459 8.62939C5.6531 8.62939 5.6316 8.62939 5.61011 8.62939C4.62135 8.78063 3.6111 8.95348 2.62235 9.10473C1.87004 9.21276 1.37566 9.62329 1.16071 10.3579C0.902776 11.2222 1.41865 12 2.27843 12C4.70733 12 7.11473 12 9.54363 12C10.7043 12 11.6931 11.5895 12.5099 10.7684C12.9398 10.3363 13.3697 9.90417 13.7996 9.47204C15.3257 7.89477 15.4117 5.41002 13.9715 3.74632L13.993 3.76793ZM13.2192 8.9967C12.8108 9.40722 12.4024 9.81774 11.994 10.2283C11.3062 10.8981 10.4894 11.2438 9.52213 11.2438C8.36142 11.2438 7.17922 11.2438 6.0185 11.2438C4.85779 11.2438 3.58961 11.2438 2.36441 11.2438C1.97751 11.2438 1.80555 11.0277 1.91302 10.6604C1.999 10.2499 2.32142 9.96899 2.75132 9.90417C3.99801 9.70971 5.26619 9.51525 6.51288 9.3208C6.68484 9.29919 6.8353 9.23437 6.87829 9.06152C6.94278 8.86706 6.87829 8.71581 6.72783 8.58617C4.8363 6.6848 2.94477 4.78343 1.05324 2.88206C0.92427 2.75242 0.838292 2.60118 0.795302 2.44993C0.795302 2.44993 0.795302 2.40672 0.795302 2.38511C0.795302 2.38511 0.795302 2.3419 0.795302 2.32029C0.795302 2.27708 0.795302 2.23387 0.795302 2.16905C0.795302 2.16905 0.795302 2.14744 0.795302 2.12583C0.795302 2.10423 0.795302 2.08262 0.816797 2.06101C0.816797 2.03941 0.838292 1.9962 0.859786 1.97459C1.07473 1.60728 1.56911 1.49925 1.93452 1.80174C1.97751 1.84495 2.0205 1.88816 2.08498 1.93138C3.05224 2.90367 4.0195 3.87596 4.98676 4.84825C5.2447 5.10753 5.52413 5.38841 5.78206 5.64769C5.88954 5.75572 5.99701 5.84215 6.14747 5.82054C6.4484 5.82054 6.59886 5.47484 6.4484 5.23717C6.40541 5.17235 6.34092 5.10753 6.29794 5.06432C5.84655 4.61058 5.37366 4.13524 4.92228 3.6815C4.77181 3.50865 4.70733 3.29258 4.77181 3.05491C4.90078 2.53636 5.50263 2.3419 5.91103 2.6876C5.95402 2.73082 5.99701 2.77403 6.06149 2.81724C6.38391 3.14134 6.68484 3.44383 7.00726 3.76793C7.09324 3.85435 7.17922 3.94078 7.28669 4.04881C7.32968 4.09202 7.35117 4.13524 7.39416 4.15684C7.56612 4.30809 7.80256 4.3297 7.95302 4.15684C8.10349 4.0056 8.10349 3.76793 7.95302 3.59508C7.80256 3.44383 7.6521 3.27098 7.48014 3.11973C7.35117 2.99009 7.2437 2.88206 7.11473 2.75242C6.8568 2.42833 6.92128 2.0178 7.15772 1.78013C7.45865 1.47764 7.86705 1.49925 8.23245 1.86656C8.4689 2.10423 8.68384 2.32029 8.92028 2.55796C9.11374 2.75242 9.35018 2.77403 9.52213 2.62278C9.69409 2.47154 9.6726 2.23387 9.47915 2.0178C9.19971 1.6937 9.22121 1.26158 9.52213 1.0023C9.82306 0.743019 10.253 0.764626 10.5324 1.06712C11.4781 2.10423 12.4239 3.16295 13.3697 4.20006C14.6164 5.60448 14.5734 7.6787 13.2622 9.0183L13.2192 8.9967Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
3
src/client/src/assets/icons/three-view-zoom.svg
Normal file
3
src/client/src/assets/icons/three-view-zoom.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="15" height="13" viewBox="0 0 15 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.67979 9.7487C5.63689 9.68658 5.57254 9.64516 5.52964 9.60374C3.85647 7.98845 2.16185 6.37316 0.488679 4.73716C-0.0904948 4.17802 -0.154849 3.41179 0.295619 2.83194C0.488677 2.58343 0.746089 2.41776 1.06785 2.31422C0.896245 2.045 0.767539 1.77579 0.78899 1.44444C0.78899 0.823178 1.11075 0.388292 1.68993 0.139785C2.2691 -0.108722 2.80537 -0.0258861 3.25584 0.388291C3.87792 0.96814 4.47854 1.5687 5.07917 2.14855C5.44383 2.5006 5.8085 2.85265 6.19461 3.22541C6.58073 2.23138 7.69618 1.94146 8.51131 2.5006C8.64001 2.14855 8.85452 1.87933 9.17629 1.69295C9.77691 1.3409 10.5277 1.42374 10.9996 1.92075C12.0078 2.9769 13.016 4.01235 13.9813 5.08921C15.4185 6.68379 15.3112 9.06531 13.8097 10.5771C13.3807 10.9912 12.9516 11.4054 12.5226 11.8196C11.7075 12.5858 10.7207 12.9793 9.5624 13C7.13845 13 4.73595 13 2.312 13C1.45397 13 0.917694 12.2752 1.19656 11.4261C1.41106 10.722 1.92589 10.3493 2.65522 10.225C3.64196 10.08 4.65015 9.91438 5.63689 9.76941C5.63689 9.76941 5.65834 9.76941 5.70124 9.76941L5.67979 9.7487ZM6.023 12.2545C7.18135 12.2545 8.36115 12.2545 9.5195 12.2545C10.4848 12.2545 11.2999 11.9231 11.9864 11.2812C12.3939 10.8877 12.8015 10.4942 13.2091 10.1008C14.5176 8.81681 14.5819 6.82875 13.3163 5.48268C12.3725 4.46794 11.4286 3.47391 10.4848 2.47989C10.2059 2.18996 9.77691 2.14855 9.4766 2.41776C9.17629 2.68698 9.15484 3.10115 9.4337 3.39108C9.62675 3.59817 9.62675 3.80526 9.4766 3.97093C9.30499 4.11589 9.09048 4.09518 8.87597 3.9088C8.64001 3.70171 8.4255 3.47391 8.18955 3.24612C7.84633 2.91477 7.41731 2.87336 7.117 3.16328C6.85959 3.41179 6.79524 3.86738 7.18135 4.19873C7.43877 4.40581 7.65327 4.65432 7.88923 4.88212C8.06084 5.04779 8.06084 5.27559 7.88923 5.42055C7.73908 5.56551 7.50312 5.56551 7.33151 5.42055C7.28861 5.37913 7.24571 5.33771 7.22426 5.317C5.7656 3.9088 4.28548 2.47989 2.82682 1.07168C2.78392 1.03027 2.74102 0.988849 2.67667 0.947431C2.2691 0.616089 1.66848 0.802469 1.53977 1.29948C1.47542 1.5687 1.58267 1.7965 1.77573 1.98288C3.27729 3.41179 4.7574 4.86141 6.25897 6.29032C6.32332 6.35245 6.36622 6.39387 6.40912 6.45599C6.55928 6.7045 6.40912 6.99442 6.10881 7.01513C5.95865 7.01513 5.8514 6.9323 5.74415 6.84946C4.52145 5.66906 3.27729 4.46794 2.05459 3.28753C1.99024 3.22541 1.94734 3.18399 1.88299 3.14257C1.49687 2.87336 0.960597 3.01832 0.78899 3.45321C0.681736 3.76384 0.78899 4.01235 1.0035 4.21943C2.89118 6.04182 4.77885 7.8642 6.66653 9.68658C6.79524 9.81083 6.88104 9.95579 6.81669 10.1422C6.75234 10.3078 6.62363 10.37 6.45202 10.3907C5.20787 10.5771 3.94227 10.7634 2.69812 10.9498C2.2691 11.0119 1.96879 11.2812 1.86153 11.6746C1.77573 12.0474 1.92589 12.2338 2.312 12.2338C3.5347 12.2338 4.73595 12.2338 5.95865 12.2338L6.023 12.2545Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -16,6 +16,10 @@ import { ThreeViewErrorBoundary } from "../ThreeViewErrorBoundary";
|
||||
import { apiStore } from "../../api/ApiStore/store";
|
||||
import { ReactMarkdownComponent } from "../ReactMarkdown";
|
||||
import { TouchableLayout } from "../TouchableLayout";
|
||||
import rotate3DIcon from "../../assets/icons/three-view-rotate.svg";
|
||||
import zoom3DIcon from "../../assets/icons/three-view-zoom.svg";
|
||||
import pan3DIcon from "../../assets/icons/three-view-pan.svg";
|
||||
import subtractHomeIcon from "../../assets/icons/subtract-home.svg";
|
||||
|
||||
const Watermark = ({ path }) => {
|
||||
if (!path) return null;
|
||||
@@ -364,6 +368,87 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{isFullscreen3D && <div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 94,
|
||||
right: 10,
|
||||
zIndex: 10,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="cluster-sights-list"
|
||||
style={{
|
||||
background: `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(0, 111, 58, 0.4)`,
|
||||
backdropFilter: "blur(10px)",
|
||||
borderRadius: "8px",
|
||||
width: 200,
|
||||
boxShadow:
|
||||
"0 0 0 1px rgba(255, 255, 255, 0.3) inset, 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: "8px 13px",
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{
|
||||
label: "Вращать",
|
||||
icon: <img src={rotate3DIcon} alt="" width="14" height="14" />,
|
||||
},
|
||||
{
|
||||
label: "Приблизить / Отдалить",
|
||||
icon: <img src={zoom3DIcon} alt="" width="14" height="14" />,
|
||||
},
|
||||
{
|
||||
label: "Переместить",
|
||||
icon: <img src={pan3DIcon} alt="" width="14" height="14" />,
|
||||
},
|
||||
].map((item, index, arr) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
height: "30px",
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
padding: "0 4px",
|
||||
borderBottom:
|
||||
index < arr.length - 1
|
||||
? "1px solid rgba(255, 255, 255, 0.1)"
|
||||
: "none",
|
||||
transition: "background-color 0.2s",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
marginRight: "8px",
|
||||
flexShrink: 0,
|
||||
lineHeight: 0,
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: "white",
|
||||
fontSize: "12px",
|
||||
lineHeight: "1.5",
|
||||
fontWeight: "400",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
@@ -428,6 +513,16 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
const processedSightName = useMemo(() => {
|
||||
if (!sight_name) return sight_name;
|
||||
|
||||
// Handle \n line breaks (только в правом виджете)
|
||||
if (sight_name.includes("\n")) {
|
||||
return sight_name.split("\n").map((line, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <br />}
|
||||
{line}
|
||||
</React.Fragment>
|
||||
));
|
||||
}
|
||||
|
||||
const namePattern =
|
||||
/([А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]+)/g;
|
||||
|
||||
@@ -454,7 +549,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
|
||||
const titleLineHeight = useMemo(() => {
|
||||
if (!sight_name) return "120%";
|
||||
const textLength = sight_name.length;
|
||||
const textLength = sight_name.replace(/\n/g, "").length;
|
||||
const calculatedLineHeight = Math.max(
|
||||
100,
|
||||
Math.min(120, 120 - (textLength / 10) * 1),
|
||||
@@ -510,7 +605,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
overflowWrap: "break-word",
|
||||
}}
|
||||
>
|
||||
{selectedSection === 0 ? processedSightName : sight_name}
|
||||
{selectedSection === 0 ? processedSightName : (sightData?.short_name || sight_name)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -542,44 +637,9 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
paddingBottom: "4.5px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onPointerUp={() => setSelectedSection(0)}
|
||||
onPointerUp={() => { setSelectedSection(0); setIsFullscreen3D(false); }}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="25"
|
||||
viewBox="0 0 20 25"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ display: "block" }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="sightFrameGradient3"
|
||||
x1="0%"
|
||||
y1="0%"
|
||||
x2="100%"
|
||||
y2="100%"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor="rgba(255, 255, 255, 0.2)" />
|
||||
<stop offset="100%" stopColor="rgba(255, 255, 255, 0)" />
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_662_97446">
|
||||
<rect
|
||||
width="20"
|
||||
height="25"
|
||||
fill="white"
|
||||
transform="translate(12.5 0.5) rotate(90)"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clipPath="url(#clip0_662_97446)">
|
||||
<path
|
||||
d="M4.03158 11.0738C4.21879 11.2087 4.34702 11.2766 4.44531 11.3747C6.93048 13.8687 9.41098 16.3665 11.8962 18.8605C12.3408 19.3067 12.5186 19.8207 12.3361 20.4339C12.0281 21.4658 10.7776 21.8393 9.95295 21.1498C9.86309 21.0743 9.78165 20.9894 9.69928 20.9064C6.85279 18.0446 4.00537 15.1817 1.15982 12.3189C0.280876 11.4341 0.281813 10.6456 1.16169 9.75982C4.04 6.86305 6.91457 3.96155 9.80786 1.07986C10.0597 0.828952 10.4144 0.619547 10.7561 0.537482C11.4019 0.382786 12.015 0.72142 12.3193 1.28644C12.6263 1.85334 12.5392 2.56079 12.0806 3.05129C11.7286 3.4286 11.3561 3.78704 10.991 4.15209C8.79601 6.35557 6.60194 8.55904 4.40599 10.7606C4.32362 10.8436 4.22721 10.9106 4.03158 11.0729L4.03158 11.0738Z"
|
||||
fill="url(#sightFrameGradient3)"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<img src={subtractHomeIcon} alt="" width="24" height="21" style={{ display: "block" }} />
|
||||
</div>
|
||||
)}
|
||||
{contentError ? (
|
||||
@@ -589,7 +649,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
articleSections.length > 1 &&
|
||||
articleSections.slice(1).map((section, index) => (
|
||||
<div
|
||||
onPointerUp={() => setSelectedSection(index + 1)}
|
||||
onPointerUp={() => { setSelectedSection(index + 1); setIsFullscreen3D(false); }}
|
||||
key={section.id || section.heading || index}
|
||||
className={`sight-frame-menu-point ${
|
||||
index + 1 === selectedSection ? "active" : ""
|
||||
|
||||
13
src/client/src/components/OverlayScrollbarsWrapper.d.ts
vendored
Normal file
13
src/client/src/components/OverlayScrollbarsWrapper.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
|
||||
export declare const OverlayScrollbarsWrapper: React.ForwardRefExoticComponent<
|
||||
React.PropsWithChildren<{
|
||||
className?: string;
|
||||
onScroll?: (event: Event) => void;
|
||||
overflowX?: string;
|
||||
overflowY?: string;
|
||||
scrollbarVisibility?: string;
|
||||
[key: string]: any;
|
||||
}> &
|
||||
React.RefAttributes<HTMLElement>
|
||||
>;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import "./ReactMarkdown.css";
|
||||
|
||||
@@ -6,22 +6,30 @@ export const SimulationSettings = observer(() => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div style={{ position: "absolute", top: 12, right: 12, zIndex: 10010 }}>
|
||||
<div style={{ position: "fixed", top: 12, right: 12, zIndex: 2147483646 }}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
style={{
|
||||
width: 36, height: 36, borderRadius: 6,
|
||||
border: "1px solid rgba(255,255,255,0.25)",
|
||||
background: open ? "rgba(255,255,255,0.15)" : "rgba(0,0,0,0.5)",
|
||||
color: "white", cursor: "pointer", fontSize: 18,
|
||||
color: "white", cursor: "pointer",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
⚙
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.92c.04-.34.07-.69.07-1.08s-.03-.73-.07-1.08l2.33-1.82c.21-.16.27-.46.13-.7l-2.21-3.83a.55.55 0 0 0-.68-.22l-2.75 1.1a8.1 8.1 0 0 0-1.86-1.08l-.42-2.93A.545.545 0 0 0 14 2h-4c-.27 0-.5.2-.54.46l-.42 2.93c-.68.28-1.3.65-1.86 1.08L4.43 5.37a.543.543 0 0 0-.68.22L1.54 9.42c-.14.24-.08.54.13.7l2.33 1.82c-.04.35-.07.7-.07 1.08s.03.73.07 1.08L1.67 15.92c-.21.16-.27.46-.13.7l2.21 3.83c.14.24.43.31.68.22l2.75-1.1c.56.43 1.18.8 1.86 1.08l.42 2.93c.04.26.27.46.54.46h4c.27 0 .5-.2.54-.46l.42-2.93c.68-.28 1.3-.65 1.86-1.08l2.75 1.1c.25.09.54.02.68-.22l2.21-3.83c.14-.24.08-.54-.13-.7l-2.33-1.82Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div style={{
|
||||
marginTop: 6, background: "rgba(20,20,20,0.9)",
|
||||
position: "absolute", top: 42, right: 0,
|
||||
background: "rgba(20,20,20,0.9)",
|
||||
border: "1px solid rgba(255,255,255,0.15)", borderRadius: 8,
|
||||
padding: "10px 12px", minWidth: 200, fontSize: 13,
|
||||
}}>
|
||||
@@ -72,6 +80,15 @@ export const SimulationSettings = observer(() => {
|
||||
onClick={apiStore.toggleSimulationInstantMove}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
{/* Хитбоксы */}
|
||||
<Row>
|
||||
<span>Хитбоксы</span>
|
||||
<Toggle
|
||||
on={apiStore.showHitboxes}
|
||||
onClick={apiStore.toggleShowHitboxes}
|
||||
/>
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { FederatedPointerEvent, FederatedWheelEvent } from "pixi.js";
|
||||
import { ReactNode, useEffect, useState, useRef, useCallback } from "react";
|
||||
import { ReactNode, useEffect, useState, useRef } from "react";
|
||||
import { useTransform } from "./transformContext";
|
||||
import { BACKGROUND_COLOR, SCALE_FACTOR } from "../../assets/Constants";
|
||||
import { useApplication } from "@pixi/react";
|
||||
import { useGeolocation } from "../../context/GeolocationContext";
|
||||
import ContentAPI from "../../api/content/content.api";
|
||||
import React from "react";
|
||||
import { useGeolocationStore } from "../../stores/hooks/useGeolocationStore";
|
||||
import { useCameraAnimationStore } from "../../stores";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import debounce from "lodash/debounce";
|
||||
@@ -25,7 +21,6 @@ export const InfiniteCanvas = observer(
|
||||
setIsAutoMode,
|
||||
userActivityTimestamp,
|
||||
updateUserActivity,
|
||||
autoModeStartTimestamp,
|
||||
setAutoModeStartTimestamp,
|
||||
} = useTransform();
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
@@ -49,17 +44,14 @@ export const InfiniteCanvas = observer(
|
||||
position: { x: number; y: number };
|
||||
} | null>(null);
|
||||
// Keep these for backward compatibility, but we'll use pinchStartData for calculations
|
||||
const [initialPinchDistance, setInitialPinchDistance] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [initialPinchMidpoint, setInitialPinchMidpoint] = useState<{
|
||||
const [, setInitialPinchDistance] = useState<number | null>(null);
|
||||
const [, setInitialPinchMidpoint] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
const [scaleMin, setScaleMin] = useState(0.1); // Default min scale
|
||||
const [scaleMax, setScaleMax] = useState(3); // Default max scale
|
||||
const store = useGeolocationStore();
|
||||
const cameraAnimationStore = useCameraAnimationStore();
|
||||
|
||||
// Add debounced version of syncState to reduce jittering
|
||||
@@ -269,13 +261,14 @@ export const InfiniteCanvas = observer(
|
||||
setInitialPinchMidpoint(null);
|
||||
pinchStartData.current = null;
|
||||
|
||||
if (isDragging) {
|
||||
const newPosition = {
|
||||
x: startPosition.x - startMousePosition.x + e.globalX,
|
||||
y: startPosition.y - startMousePosition.y + e.globalY,
|
||||
};
|
||||
setPosition(newPosition);
|
||||
syncStateDebounced(newPosition, scale);
|
||||
if (isDragging) {
|
||||
const newPosition = {
|
||||
x: startPosition.x - startMousePosition.x + e.globalX,
|
||||
y: startPosition.y - startMousePosition.y + e.globalY,
|
||||
};
|
||||
setPosition(newPosition);
|
||||
syncStateDebounced(newPosition, scale);
|
||||
}
|
||||
}
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
@@ -1,31 +1,20 @@
|
||||
import React, {
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Application, extend } from "@pixi/react";
|
||||
import { extend } from "@pixi/react";
|
||||
import { Container, Graphics, Sprite, Text } from "pixi.js";
|
||||
import { MapDataProvider, useMapData } from "./MapDataContext";
|
||||
import { TransformProvider, useTransform } from "./transformContext";
|
||||
import { InfiniteCanvas } from "./InfiniteCanvas";
|
||||
import { TravelPath } from "./TravelPath";
|
||||
import { Station } from "./Station";
|
||||
import { SightsLayer } from "./Sight";
|
||||
// @ts-ignore
|
||||
import Loader from "../Loader";
|
||||
import {
|
||||
BACKGROUND_COLOR,
|
||||
BUS_COLOR,
|
||||
STATION_OUTLINE_WIDTH,
|
||||
STATION_RADIUS,
|
||||
UP_SCALE,
|
||||
} from "./Constants";
|
||||
import { UP_SCALE } from "./Constants";
|
||||
import "../../styles/MapLayer.css";
|
||||
import { useGeolocationStore, useCameraAnimationStore } from "../../stores";
|
||||
import { useCameraAnimationStore } from "../../stores";
|
||||
import { coordinatesToLocal } from "./utils";
|
||||
import { TramIcon } from "./TramIcon";
|
||||
import { SCALE_FACTOR } from "../../assets/Constants";
|
||||
import { apiStore } from "../../api/ApiStore/store";
|
||||
import WebGLMap from "./WebGLMap";
|
||||
@@ -42,7 +31,7 @@ export function Map() {
|
||||
);
|
||||
}
|
||||
|
||||
function rotatePoint(x, y, originX, originY, angle) {
|
||||
function rotatePoint(x: number, y: number, originX: number, originY: number, angle: number) {
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const dx = x - originX;
|
||||
@@ -53,8 +42,6 @@ function rotatePoint(x, y, originX, originY, angle) {
|
||||
}
|
||||
|
||||
const RouteMap = observer(() => {
|
||||
const store = useGeolocationStore();
|
||||
const { contextData } = store;
|
||||
const {
|
||||
routeData,
|
||||
stationData,
|
||||
@@ -77,7 +64,6 @@ const RouteMap = observer(() => {
|
||||
scale,
|
||||
} = useTransform();
|
||||
const cameraAnimationStore = useCameraAnimationStore();
|
||||
const parentRef = useRef(null);
|
||||
|
||||
const [rotationAngle, setRotationAngle] = useState(0);
|
||||
|
||||
@@ -143,7 +129,7 @@ const RouteMap = observer(() => {
|
||||
const rotationOriginY = 0;
|
||||
|
||||
const transformGeoToMapLocal = useCallback(
|
||||
(latitude, longitude) => {
|
||||
(latitude: number, longitude: number) => {
|
||||
if (centerLat === undefined || centerLon === undefined) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
@@ -239,99 +225,6 @@ const RouteMap = observer(() => {
|
||||
transformedStations,
|
||||
]);
|
||||
|
||||
const drawActualBusPos = useCallback(
|
||||
(g: Graphics) => {
|
||||
g.clear();
|
||||
if (transformedCurrentCoordinates) {
|
||||
g.circle(
|
||||
transformedCurrentCoordinates.x,
|
||||
transformedCurrentCoordinates.y,
|
||||
STATION_RADIUS / scale < 10
|
||||
? 10
|
||||
: STATION_RADIUS / scale > 20
|
||||
? 20
|
||||
: STATION_RADIUS / scale
|
||||
);
|
||||
g.fill({ color: BUS_COLOR });
|
||||
g.stroke({ color: BACKGROUND_COLOR, width: STATION_OUTLINE_WIDTH });
|
||||
}
|
||||
},
|
||||
[transformedCurrentCoordinates, scale]
|
||||
);
|
||||
|
||||
const scaledPoints = useMemo(() => {
|
||||
if (!routeData?.path) return [];
|
||||
|
||||
return routeData.path.map(([latitude, longitude]) => {
|
||||
const { x, y } = transformGeoToMapLocal(latitude, longitude);
|
||||
return rotatePoint(x, y, rotationOriginX, rotationOriginY, rotationAngle);
|
||||
});
|
||||
}, [
|
||||
routeData?.path,
|
||||
transformGeoToMapLocal,
|
||||
rotationOriginX,
|
||||
rotationOriginY,
|
||||
rotationAngle,
|
||||
]);
|
||||
|
||||
const transformedStationsEn = useMemo(() => {
|
||||
if (!stationDataEn) return [];
|
||||
|
||||
return stationDataEn.map((station) => {
|
||||
const { x, y } = transformGeoToMapLocal(
|
||||
station.latitude,
|
||||
station.longitude
|
||||
);
|
||||
const rotatedCoords = rotatePoint(
|
||||
x,
|
||||
y,
|
||||
rotationOriginX,
|
||||
rotationOriginY,
|
||||
rotationAngle
|
||||
);
|
||||
return {
|
||||
...station,
|
||||
longitude: rotatedCoords.x,
|
||||
latitude: rotatedCoords.y,
|
||||
};
|
||||
});
|
||||
}, [
|
||||
stationDataEn,
|
||||
transformGeoToMapLocal,
|
||||
rotationOriginX,
|
||||
rotationOriginY,
|
||||
rotationAngle,
|
||||
]);
|
||||
|
||||
const transformedStationsZh = useMemo(() => {
|
||||
if (!stationDataZh) return [];
|
||||
|
||||
return stationDataZh.map((station) => {
|
||||
const { x, y } = transformGeoToMapLocal(
|
||||
station.latitude,
|
||||
station.longitude
|
||||
);
|
||||
const rotatedCoords = rotatePoint(
|
||||
x,
|
||||
y,
|
||||
rotationOriginX,
|
||||
rotationOriginY,
|
||||
rotationAngle
|
||||
);
|
||||
return {
|
||||
...station,
|
||||
longitude: rotatedCoords.x,
|
||||
latitude: rotatedCoords.y,
|
||||
};
|
||||
});
|
||||
}, [
|
||||
stationDataZh,
|
||||
transformGeoToMapLocal,
|
||||
rotationOriginX,
|
||||
rotationOriginY,
|
||||
rotationAngle,
|
||||
]);
|
||||
|
||||
if (
|
||||
!routeData ||
|
||||
!stationData ||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// SightsLayer.tsx
|
||||
import React from "react";
|
||||
import { Graphics, Assets, Texture, TextStyle } from "pixi.js";
|
||||
import { useCallback, useEffect, useState, useMemo } from "react";
|
||||
import { useTransform } from "./transformContext";
|
||||
@@ -9,7 +8,6 @@ import { useGeolocationStore } from "../../stores"; // Импортируем us
|
||||
|
||||
const BASE_ICON_SIZE = 30;
|
||||
const CLUSTER_RADIUS_BASE = 10;
|
||||
const CLUSTER_COLOR = 0x1a73e8;
|
||||
|
||||
type Cluster = {
|
||||
id: string;
|
||||
@@ -150,7 +148,7 @@ function SingleSight({
|
||||
readonly sight: SightData;
|
||||
onSightClick: (sightId: string) => void;
|
||||
}) {
|
||||
const { scale } = useTransform();
|
||||
useTransform();
|
||||
const [texture, setTexture] = useState<Texture>(Texture.EMPTY);
|
||||
const store = useGeolocationStore();
|
||||
const { setIsGovernorWidgetOpen } = store;
|
||||
@@ -197,7 +195,7 @@ function SightCluster({
|
||||
}) {
|
||||
const store = useGeolocationStore();
|
||||
const { setIsGovernorWidgetOpen } = store;
|
||||
const { scale } = useTransform();
|
||||
useTransform();
|
||||
const radius = CLUSTER_RADIUS_BASE;
|
||||
const [texture, setTexture] = useState<Texture>(Texture.EMPTY);
|
||||
const fontSize = 14;
|
||||
@@ -334,7 +332,7 @@ export function SightsLayer({
|
||||
sights,
|
||||
pathPoints,
|
||||
}: Readonly<SightsLayerProps>) {
|
||||
const { scale } = useTransform();
|
||||
useTransform();
|
||||
const distanceThreshold = BASE_ICON_SIZE * 3;
|
||||
|
||||
const store = useGeolocationStore(); // Получаем доступ к MobX хранилищу
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from "react";
|
||||
import { Texture, Assets, Graphics } from "pixi.js";
|
||||
import { Texture, Assets } from "pixi.js";
|
||||
import { useEffect, useState, useMemo, useRef } from "react";
|
||||
import { useTransform } from "./transformContext";
|
||||
import { lerp, lerpAngle } from "../../utils/animationUtils";
|
||||
@@ -11,8 +10,6 @@ const basePath = new URL(
|
||||
const tramPath = new URL("../../assets/tramPosition/Tram.svg", import.meta.url)
|
||||
.href;
|
||||
|
||||
// Константы анимации (как в HTML файле)
|
||||
const ANIMATION_DURATION = 1200; // 1.2 секунды
|
||||
const LERP_SPEED = 0.1; // Скорость интерполяции (10% каждый кадр)
|
||||
|
||||
// Функция для проверки расстояния до ближайшей точки маршрута
|
||||
@@ -101,7 +98,7 @@ const getDistanceToStations = (
|
||||
offset_x?: number;
|
||||
offset_y?: number;
|
||||
}[],
|
||||
debug: boolean = false
|
||||
_debug: boolean = false
|
||||
) => {
|
||||
if (!stations || stations.length === 0) {
|
||||
return Infinity;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { lerp, lerpAngle } from "../../utils/animationUtils";
|
||||
import { lerpAngle } from "../../utils/animationUtils";
|
||||
import tramBase from "../../assets/tramPosition/Tram Base.svg";
|
||||
import tramSvg from "../../assets/tramPosition/Tram_Second.svg";
|
||||
import { getMediaUrl } from "../../api/apiConfig";
|
||||
|
||||
@@ -18,8 +18,6 @@ import {
|
||||
BUS_COLOR,
|
||||
BASE_ICON_SIZE,
|
||||
CLUSTER_RADIUS_BASE,
|
||||
CLUSTER_COLOR,
|
||||
ACTIVE_STATION_COLOR,
|
||||
} from "./Constants";
|
||||
import { SCALE_FACTOR } from "../../assets/Constants";
|
||||
import { apiStore } from "../../api/ApiStore/store";
|
||||
@@ -40,7 +38,7 @@ const YELLOW_ICON_FILTER =
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(max, Math.max(min, value));
|
||||
|
||||
const debugWebglLog = (...args: unknown[]) => {
|
||||
const debugWebglLog = (..._args: unknown[]) => {
|
||||
if (!DEBUG_WEBGL_ROUTE_MAP) return;
|
||||
};
|
||||
|
||||
@@ -424,7 +422,7 @@ export const WebGLMap = observer(() => {
|
||||
}, []);
|
||||
|
||||
const clampPosition = useCallback(
|
||||
(pos: { x: number; y: number }, currentScale: number) => {
|
||||
(pos: { x: number; y: number }, _currentScale: number) => {
|
||||
return pos;
|
||||
},
|
||||
[],
|
||||
@@ -799,7 +797,8 @@ export const WebGLMap = observer(() => {
|
||||
const textBlockPositionX = rx + labelOffsetX;
|
||||
const textBlockPositionY = ry + labelOffsetY;
|
||||
|
||||
const approximateTextWidth = st.name.length * fontSize * 0.6;
|
||||
const normalizedName = st.name.replace(/\\n|\n/g, "");
|
||||
const approximateTextWidth = normalizedName.length * fontSize * 0.6;
|
||||
const textWidthInMapCoords = approximateTextWidth / scale;
|
||||
|
||||
let anchorXOffset = 0;
|
||||
@@ -829,8 +828,8 @@ export const WebGLMap = observer(() => {
|
||||
result.push({
|
||||
x: sx,
|
||||
y: sy,
|
||||
name: st.name,
|
||||
sub,
|
||||
name: normalizedName,
|
||||
sub: sub ? sub.replace(/\\n|\n/g, "") : sub,
|
||||
anchorX: anchorX,
|
||||
anchorY: anchorY,
|
||||
distance: distanceInPixels,
|
||||
@@ -880,6 +879,32 @@ export const WebGLMap = observer(() => {
|
||||
rotationAngle,
|
||||
]);
|
||||
|
||||
// Сегментный индекс каждой упорядоченной станции на routePath (canvas-пространство)
|
||||
const orderedStationSegs = useMemo(() => {
|
||||
if (!orderedRouteStations || !stationData || routePath.length < 4) return [] as number[];
|
||||
return (orderedRouteStations as any[]).map((ordStation) => {
|
||||
const stIdx = stationData.findIndex((s: any) => String(s.id) === String(ordStation.id));
|
||||
if (stIdx < 0) return -1;
|
||||
const sx = stationPoints[stIdx * 2];
|
||||
const sy = stationPoints[stIdx * 2 + 1];
|
||||
if (sx === undefined || sy === undefined) return -1;
|
||||
let best = -1, bestD = Infinity;
|
||||
for (let i = 0; i < routePath.length - 2; i += 2) {
|
||||
const p1x = routePath[i], p1y = routePath[i + 1];
|
||||
const p2x = routePath[i + 2], p2y = routePath[i + 3];
|
||||
const dx = p2x - p1x, dy = p2y - p1y;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (!len2) continue;
|
||||
const t = ((sx - p1x) * dx + (sy - p1y) * dy) / len2;
|
||||
const cl = Math.max(0, Math.min(1, t));
|
||||
const px = p1x + cl * dx, py = p1y + cl * dy;
|
||||
const d = Math.hypot(sx - px, sy - py);
|
||||
if (d < bestD) { bestD = d; best = i / 2; }
|
||||
}
|
||||
return best;
|
||||
});
|
||||
}, [orderedRouteStations, stationData, stationPoints, routePath]);
|
||||
|
||||
const sightPoints = useMemo(() => {
|
||||
if (!sightData || !routeData) return new Float32Array();
|
||||
const centerLat = routeData.center_latitude;
|
||||
@@ -1097,6 +1122,8 @@ export const WebGLMap = observer(() => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const prevPositionIndexRef = useRef<number>(-1);
|
||||
|
||||
useEffect(() => {
|
||||
const centerLat = routeData?.center_latitude;
|
||||
const centerLon = routeData?.center_longitude;
|
||||
@@ -1114,7 +1141,14 @@ export const WebGLMap = observer(() => {
|
||||
const rx = x * cos - y * sin;
|
||||
const ry = x * sin + y * cos;
|
||||
|
||||
if (apiStore.simulationInstantMove) {
|
||||
const curIdx = apiStore.positionIndex;
|
||||
const prevIdx = prevPositionIndexRef.current;
|
||||
const pathLen = apiStore.route?.path?.length ?? 0;
|
||||
const isWrap = prevIdx >= 0 && pathLen > 0 &&
|
||||
Math.abs(curIdx - prevIdx) > pathLen / 4;
|
||||
prevPositionIndexRef.current = curIdx;
|
||||
|
||||
if (apiStore.simulationInstantMove || isWrap) {
|
||||
setYellowDotImmediate(rx, ry);
|
||||
} else {
|
||||
animateYellowDotTo(rx, ry);
|
||||
@@ -1165,8 +1199,8 @@ export const WebGLMap = observer(() => {
|
||||
gl.enableVertexAttribArray(attribs.a_pos);
|
||||
gl.vertexAttribPointer(attribs.a_pos, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
const vcount = routePath.length / 2;
|
||||
let tramSegIndex = getCurrentSegIndex();
|
||||
const simulationDirection = apiStore.simulationDirection;
|
||||
|
||||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
const desiredRouteWidthCss = 7;
|
||||
@@ -1264,23 +1298,69 @@ export const WebGLMap = observer(() => {
|
||||
const r1 = ((PATH_COLOR >> 16) & 0xff) / 255;
|
||||
const g1 = ((PATH_COLOR >> 8) & 0xff) / 255;
|
||||
const b1 = (PATH_COLOR & 0xff) / 255;
|
||||
gl.uniform4f(uniforms.u_color, r1, g1, b1, 1);
|
||||
const r2 = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255;
|
||||
const g2 = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255;
|
||||
const b2 = (UNPASSED_STATION_COLOR & 0xff) / 255;
|
||||
|
||||
if (tramSegIndex >= 0) {
|
||||
const animatedPos = animatedYellowDotPosition;
|
||||
if (
|
||||
animatedPos &&
|
||||
animatedPos.x !== undefined &&
|
||||
animatedPos.y !== undefined
|
||||
) {
|
||||
const animatedPos = animatedYellowDotPosition;
|
||||
if (
|
||||
tramSegIndex >= 0 &&
|
||||
animatedPos &&
|
||||
animatedPos.x !== undefined &&
|
||||
animatedPos.y !== undefined
|
||||
) {
|
||||
if (simulationDirection === 1) {
|
||||
// Вперёд: закрашено от начала до трамвая
|
||||
gl.uniform4f(uniforms.u_color, r1, g1, b1, 1);
|
||||
const passedPoints: number[] = [];
|
||||
|
||||
for (let i = 0; i <= tramSegIndex; i++) {
|
||||
passedPoints.push(routePath[i * 2], routePath[i * 2 + 1]);
|
||||
}
|
||||
|
||||
passedPoints.push(animatedPos.x, animatedPos.y);
|
||||
|
||||
if (passedPoints.length >= 4) {
|
||||
const thickLineVertices = generateThickLine(
|
||||
new Float32Array(passedPoints),
|
||||
lineWidth,
|
||||
);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2);
|
||||
}
|
||||
gl.uniform4f(uniforms.u_color, r2, g2, b2, 1);
|
||||
const unpassedPoints: number[] = [];
|
||||
unpassedPoints.push(animatedPos.x, animatedPos.y);
|
||||
for (let i = tramSegIndex + 1; i < vertexCount; i++) {
|
||||
unpassedPoints.push(routePath[i * 2], routePath[i * 2 + 1]);
|
||||
}
|
||||
if (unpassedPoints.length >= 4) {
|
||||
const thickLineVertices = generateThickLine(
|
||||
new Float32Array(unpassedPoints),
|
||||
lineWidth,
|
||||
);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2);
|
||||
}
|
||||
} else {
|
||||
// Назад: закрашено от трамвая до конца
|
||||
gl.uniform4f(uniforms.u_color, r2, g2, b2, 1);
|
||||
const unpassedPoints: number[] = [];
|
||||
for (let i = 0; i <= tramSegIndex; i++) {
|
||||
unpassedPoints.push(routePath[i * 2], routePath[i * 2 + 1]);
|
||||
}
|
||||
unpassedPoints.push(animatedPos.x, animatedPos.y);
|
||||
if (unpassedPoints.length >= 4) {
|
||||
const thickLineVertices = generateThickLine(
|
||||
new Float32Array(unpassedPoints),
|
||||
lineWidth,
|
||||
);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2);
|
||||
}
|
||||
gl.uniform4f(uniforms.u_color, r1, g1, b1, 1);
|
||||
const passedPoints: number[] = [];
|
||||
passedPoints.push(animatedPos.x, animatedPos.y);
|
||||
for (let i = tramSegIndex + 1; i < vertexCount; i++) {
|
||||
passedPoints.push(routePath[i * 2], routePath[i * 2 + 1]);
|
||||
}
|
||||
if (passedPoints.length >= 4) {
|
||||
const thickLineVertices = generateThickLine(
|
||||
new Float32Array(passedPoints),
|
||||
@@ -1290,30 +1370,16 @@ export const WebGLMap = observer(() => {
|
||||
gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const r2 = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255;
|
||||
const g2 = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255;
|
||||
const b2 = (UNPASSED_STATION_COLOR & 0xff) / 255;
|
||||
gl.uniform4f(uniforms.u_color, r2, g2, b2, 1);
|
||||
|
||||
const animatedPos = animatedYellowDotPosition;
|
||||
if (
|
||||
animatedPos &&
|
||||
animatedPos.x !== undefined &&
|
||||
animatedPos.y !== undefined
|
||||
) {
|
||||
const unpassedPoints: number[] = [];
|
||||
|
||||
unpassedPoints.push(animatedPos.x, animatedPos.y);
|
||||
|
||||
for (let i = tramSegIndex + 1; i < vertexCount; i++) {
|
||||
unpassedPoints.push(routePath[i * 2], routePath[i * 2 + 1]);
|
||||
} else {
|
||||
// Позиция трамвая неизвестна — рисуем весь маршрут серым
|
||||
gl.uniform4f(uniforms.u_color, r2, g2, b2, 1);
|
||||
const allPoints: number[] = [];
|
||||
for (let i = 0; i < vertexCount; i++) {
|
||||
allPoints.push(routePath[i * 2], routePath[i * 2 + 1]);
|
||||
}
|
||||
|
||||
if (unpassedPoints.length >= 4) {
|
||||
if (allPoints.length >= 4) {
|
||||
const thickLineVertices = generateThickLine(
|
||||
new Float32Array(unpassedPoints),
|
||||
new Float32Array(allPoints),
|
||||
lineWidth,
|
||||
);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW);
|
||||
@@ -1348,92 +1414,32 @@ export const WebGLMap = observer(() => {
|
||||
|
||||
gl.uniform1f(u_pointSize, pointInnerSizePx);
|
||||
|
||||
let currentStationIndexInOrdered = -1;
|
||||
if (currentStationId && orderedRouteStations) {
|
||||
currentStationIndexInOrdered = orderedRouteStations.findIndex(
|
||||
(station: any) => String(station.id) === String(currentStationId),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
currentStationIndexInOrdered >= 0 &&
|
||||
orderedRouteStations &&
|
||||
stationData
|
||||
) {
|
||||
const passedStations: number[] = [];
|
||||
|
||||
for (let i = 0; i < currentStationIndexInOrdered; i++) {
|
||||
const orderedStation = orderedRouteStations[i];
|
||||
if (orderedStation) {
|
||||
const stationIndexInData = stationData.findIndex(
|
||||
(station: any) =>
|
||||
String(station.id) === String(orderedStation.id),
|
||||
);
|
||||
if (stationIndexInData >= 0) {
|
||||
passedStations.push(
|
||||
stationPoints[stationIndexInData * 2] as number,
|
||||
stationPoints[stationIndexInData * 2 + 1] as number,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (tramSegIndex >= 0 && orderedRouteStations && stationData && orderedStationSegs.length > 0) {
|
||||
const passedPts1: number[] = [];
|
||||
const unpassedPts1: number[] = [];
|
||||
for (let i = 0; i < orderedRouteStations.length; i++) {
|
||||
const orderedStation = (orderedRouteStations as any[])[i];
|
||||
const stationSeg = orderedStationSegs[i] ?? -1;
|
||||
if (!orderedStation || stationSeg < 0) continue;
|
||||
const isPassed = simulationDirection === 1 ? stationSeg < tramSegIndex : stationSeg > tramSegIndex;
|
||||
const stIdx = stationData.findIndex((s: any) => String(s.id) === String(orderedStation.id));
|
||||
if (stIdx < 0) continue;
|
||||
const sx = stationPoints[stIdx * 2] as number;
|
||||
const sy = stationPoints[stIdx * 2 + 1] as number;
|
||||
if (isPassed) { passedPts1.push(sx, sy); } else { unpassedPts1.push(sx, sy); }
|
||||
}
|
||||
if (passedStations.length > 0) {
|
||||
const r_passed = ((PATH_COLOR >> 16) & 0xff) / 255;
|
||||
const g_passed = ((PATH_COLOR >> 8) & 0xff) / 255;
|
||||
const b_passed = (PATH_COLOR & 0xff) / 255;
|
||||
gl.uniform4f(u_color_pts, r_passed, g_passed, b_passed, 1);
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
new Float32Array(passedStations),
|
||||
gl.STATIC_DRAW,
|
||||
);
|
||||
gl.drawArrays(gl.POINTS, 0, passedStations.length / 2);
|
||||
if (passedPts1.length > 0) {
|
||||
gl.uniform4f(u_color_pts, ((PATH_COLOR >> 16) & 0xff) / 255, ((PATH_COLOR >> 8) & 0xff) / 255, (PATH_COLOR & 0xff) / 255, 1);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(passedPts1), gl.STATIC_DRAW);
|
||||
gl.drawArrays(gl.POINTS, 0, passedPts1.length / 2);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
currentStationIndexInOrdered >= 0 &&
|
||||
orderedRouteStations &&
|
||||
stationData
|
||||
) {
|
||||
const unpassedStations: number[] = [];
|
||||
|
||||
for (
|
||||
let i = currentStationIndexInOrdered + 1;
|
||||
i < orderedRouteStations.length;
|
||||
i++
|
||||
) {
|
||||
const orderedStation = orderedRouteStations[i];
|
||||
if (orderedStation) {
|
||||
const stationIndexInData = stationData.findIndex(
|
||||
(station: any) =>
|
||||
String(station.id) === String(orderedStation.id),
|
||||
);
|
||||
if (stationIndexInData >= 0) {
|
||||
unpassedStations.push(
|
||||
stationPoints[stationIndexInData * 2] as number,
|
||||
stationPoints[stationIndexInData * 2 + 1] as number,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (unpassedStations.length > 0) {
|
||||
const r_unpassed = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255;
|
||||
const g_unpassed = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255;
|
||||
const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255;
|
||||
gl.uniform4f(u_color_pts, r_unpassed, g_unpassed, b_unpassed, 1);
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
new Float32Array(unpassedStations),
|
||||
gl.STATIC_DRAW,
|
||||
);
|
||||
gl.drawArrays(gl.POINTS, 0, unpassedStations.length / 2);
|
||||
if (unpassedPts1.length > 0) {
|
||||
gl.uniform4f(u_color_pts, ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255, ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255, (UNPASSED_STATION_COLOR & 0xff) / 255, 1);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(unpassedPts1), gl.STATIC_DRAW);
|
||||
gl.drawArrays(gl.POINTS, 0, unpassedPts1.length / 2);
|
||||
}
|
||||
} else {
|
||||
const r_unpassed = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255;
|
||||
const g_unpassed = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255;
|
||||
const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255;
|
||||
gl.uniform4f(u_color_pts, r_unpassed, g_unpassed, b_unpassed, 1);
|
||||
gl.uniform4f(u_color_pts, ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255, ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255, (UNPASSED_STATION_COLOR & 0xff) / 255, 1);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, stationPoints, gl.STATIC_DRAW);
|
||||
gl.drawArrays(gl.POINTS, 0, stationPoints.length / 2);
|
||||
}
|
||||
@@ -1452,53 +1458,6 @@ export const WebGLMap = observer(() => {
|
||||
|
||||
const toPointsArray = (arr: number[]) => new Float32Array(arr);
|
||||
|
||||
const pathPts: { x: number; y: number }[] = [];
|
||||
for (let i = 0; i < routePath.length; i += 2)
|
||||
pathPts.push({ x: routePath[i], y: routePath[i + 1] });
|
||||
const getSeg = (px: number, py: number) => {
|
||||
if (pathPts.length < 2) return -1;
|
||||
let best = -1,
|
||||
bestD = Infinity;
|
||||
for (let i = 0; i < pathPts.length - 1; i++) {
|
||||
const p1 = pathPts[i],
|
||||
p2 = pathPts[i + 1];
|
||||
const dx = p2.x - p1.x,
|
||||
dy = p2.y - p1.y;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (!len2) continue;
|
||||
const t = ((px - p1.x) * dx + (py - p1.y) * dy) / len2;
|
||||
const tt = Math.max(0, Math.min(1, t));
|
||||
const cx = p1.x + tt * dx,
|
||||
cy = p1.y + tt * dy;
|
||||
const d = Math.hypot(px - cx, py - cy);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
let tramSegForStations = -1;
|
||||
{
|
||||
const cLat = routeData?.center_latitude,
|
||||
cLon = routeData?.center_longitude;
|
||||
const tram = apiStore?.context?.currentCoordinates as any;
|
||||
if (tram && cLat !== undefined && cLon !== undefined) {
|
||||
const loc = coordinatesToLocal(
|
||||
tram.latitude - cLat,
|
||||
tram.longitude - cLon,
|
||||
);
|
||||
const wx = loc.x * UP_SCALE,
|
||||
wy = loc.y * UP_SCALE;
|
||||
const cosR = Math.cos(rotationAngle),
|
||||
sinR = Math.sin(rotationAngle);
|
||||
const tx = wx * cosR - wy * sinR,
|
||||
ty = wx * sinR + wy * cosR;
|
||||
tramSegForStations = getSeg(tx, ty);
|
||||
}
|
||||
}
|
||||
|
||||
let activeStationIndex = -1;
|
||||
const tramCoords = apiStore?.context?.currentCoordinates;
|
||||
if (
|
||||
@@ -1551,37 +1510,21 @@ export const WebGLMap = observer(() => {
|
||||
}
|
||||
}
|
||||
|
||||
let currentStationIndexInOrdered = -1;
|
||||
if (currentStationId && orderedRouteStations) {
|
||||
currentStationIndexInOrdered = orderedRouteStations.findIndex(
|
||||
(station: any) => String(station.id) === String(currentStationId),
|
||||
);
|
||||
}
|
||||
|
||||
const passedStationIds = new Set<string>();
|
||||
const unpassedStationIds = new Set<string>();
|
||||
|
||||
if (currentStationIndexInOrdered >= 0 && orderedRouteStations) {
|
||||
for (let i = 0; i < currentStationIndexInOrdered; i++) {
|
||||
const station = orderedRouteStations[i];
|
||||
if (station) {
|
||||
passedStationIds.add(String(station.id));
|
||||
}
|
||||
}
|
||||
|
||||
for (
|
||||
let i = currentStationIndexInOrdered;
|
||||
i < orderedRouteStations.length;
|
||||
i++
|
||||
) {
|
||||
const station = orderedRouteStations[i];
|
||||
if (station) {
|
||||
unpassedStationIds.add(String(station.id));
|
||||
}
|
||||
if (tramSegIndex >= 0 && orderedRouteStations && orderedStationSegs.length === orderedRouteStations.length) {
|
||||
for (let i = 0; i < orderedRouteStations.length; i++) {
|
||||
const station = (orderedRouteStations as any[])[i];
|
||||
const seg = orderedStationSegs[i] ?? -1;
|
||||
if (!station || seg < 0) continue;
|
||||
const isPassed = simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex;
|
||||
if (isPassed) passedStationIds.add(String(station.id));
|
||||
else unpassedStationIds.add(String(station.id));
|
||||
}
|
||||
} else {
|
||||
if (orderedRouteStations) {
|
||||
orderedRouteStations.forEach((station: any) => {
|
||||
(orderedRouteStations as any[]).forEach((station) => {
|
||||
unpassedStationIds.add(String(station.id));
|
||||
});
|
||||
}
|
||||
@@ -1718,12 +1661,12 @@ export const WebGLMap = observer(() => {
|
||||
const cos = Math.cos(rotationAngle);
|
||||
const sin = Math.sin(rotationAngle);
|
||||
|
||||
const startStationData = stationData.find(
|
||||
(station) => station.id.toString() === apiStore.context?.startStopId,
|
||||
);
|
||||
const endStationData = stationData.find(
|
||||
(station) => station.id.toString() === apiStore.context?.endStopId,
|
||||
);
|
||||
const startStationData = orderedRouteStations?.[0]
|
||||
? stationData.find((station: any) => station.id.toString() === String(orderedRouteStations[0].id))
|
||||
: stationData.find((station: any) => station.id.toString() === apiStore.context?.startStopId);
|
||||
const endStationData = orderedRouteStations?.length
|
||||
? stationData.find((station: any) => station.id.toString() === String(orderedRouteStations[orderedRouteStations.length - 1].id))
|
||||
: stationData.find((station: any) => station.id.toString() === apiStore.context?.endStopId);
|
||||
|
||||
const terminalStations: number[] = [];
|
||||
|
||||
@@ -1823,7 +1766,7 @@ export const WebGLMap = observer(() => {
|
||||
}
|
||||
return best;
|
||||
})();
|
||||
return tramSegIndex !== -1 && seg !== -1 && seg < tramSegIndex;
|
||||
return tramSegIndex !== -1 && seg !== -1 && (simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex);
|
||||
})()
|
||||
: false;
|
||||
|
||||
@@ -1856,7 +1799,7 @@ export const WebGLMap = observer(() => {
|
||||
}
|
||||
return best;
|
||||
})();
|
||||
return tramSegIndex !== -1 && seg !== -1 && seg < tramSegIndex;
|
||||
return tramSegIndex !== -1 && seg !== -1 && (simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex);
|
||||
})()
|
||||
: false;
|
||||
|
||||
@@ -1882,20 +1825,11 @@ export const WebGLMap = observer(() => {
|
||||
const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255;
|
||||
|
||||
if (startStationData && endStationData) {
|
||||
gl.uniform4f(u_color_pts, r_passed, g_passed, b_passed, 1.0);
|
||||
const startIsPassed = simulationDirection === 1 ? true : isStartPassed;
|
||||
const endIsPassed = simulationDirection === -1 ? true : isEndPassed;
|
||||
gl.uniform4f(u_color_pts, startIsPassed ? r_passed : r_unpassed, startIsPassed ? g_passed : g_unpassed, startIsPassed ? b_passed : b_unpassed, 1.0);
|
||||
gl.drawArrays(gl.POINTS, 0, 1);
|
||||
|
||||
if (isEndPassed) {
|
||||
gl.uniform4f(u_color_pts, r_passed, g_passed, b_passed, 1.0);
|
||||
} else {
|
||||
gl.uniform4f(
|
||||
u_color_pts,
|
||||
r_unpassed,
|
||||
g_unpassed,
|
||||
b_unpassed,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
gl.uniform4f(u_color_pts, endIsPassed ? r_passed : r_unpassed, endIsPassed ? g_passed : g_unpassed, endIsPassed ? b_passed : b_unpassed, 1.0);
|
||||
gl.drawArrays(gl.POINTS, 1, 1);
|
||||
} else {
|
||||
const isStartStation = startStationData !== undefined;
|
||||
@@ -1935,6 +1869,8 @@ export const WebGLMap = observer(() => {
|
||||
nearestStationId,
|
||||
currentStationId,
|
||||
orderedRouteStations,
|
||||
orderedStationSegs,
|
||||
apiStore.simulationDirection,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2331,11 +2267,10 @@ export const WebGLMap = observer(() => {
|
||||
? { right: 0, transform: "none" }
|
||||
: { left: "50%", transform: "translateX(-50%)" };
|
||||
|
||||
const apiBaseUrl = apiBaseURL;
|
||||
const isMediaIdEmptyResult = isMediaIdEmpty(station?.icon);
|
||||
const iconSrc = isMediaIdEmptyResult
|
||||
? null
|
||||
: `${apiBaseUrl}/media/${station?.icon}/download`;
|
||||
: buildMediaDownloadUrl(mediaBaseUrl, station!.icon!, mediaToken);
|
||||
const iconSizePx = Math.round(primaryFontSize * 1.2);
|
||||
|
||||
return (
|
||||
@@ -2481,6 +2416,11 @@ export const WebGLMap = observer(() => {
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
...(apiStore.showHitboxes && {
|
||||
outline: "2px solid rgba(0,255,0,0.8)",
|
||||
outlineOffset: "2px",
|
||||
backgroundColor: "rgba(0,255,0,0.08)",
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -2617,6 +2557,11 @@ export const WebGLMap = observer(() => {
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
zIndex: 10,
|
||||
...(apiStore.showHitboxes && {
|
||||
outline: "2px solid rgba(255,165,0,0.8)",
|
||||
outlineOffset: "2px",
|
||||
backgroundColor: "rgba(255,165,0,0.08)",
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
@@ -2672,6 +2617,11 @@ export const WebGLMap = observer(() => {
|
||||
alignItems: "flex-start",
|
||||
pointerEvents: "auto",
|
||||
zIndex: 100000000000000,
|
||||
...(apiStore.showHitboxes && {
|
||||
outline: "2px solid rgba(0,180,255,0.8)",
|
||||
outlineOffset: "2px",
|
||||
backgroundColor: "rgba(0,180,255,0.08)",
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -4,9 +4,7 @@ import React, {
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { UP_SCALE } from "./Constants";
|
||||
|
||||
const TransformContext = createContext<{
|
||||
position: { x: number; y: number };
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import subtractHomeIcon from "../../assets/icons/subtract-home.svg";
|
||||
|
||||
function BackButtonSVG({ onPointerUp }) {
|
||||
return (
|
||||
<svg
|
||||
<img
|
||||
src={subtractHomeIcon}
|
||||
alt=""
|
||||
width="24"
|
||||
height="21"
|
||||
onPointerUp={onPointerUp}
|
||||
className="sight-frame-get-back"
|
||||
width="13"
|
||||
height="22"
|
||||
viewBox="0 0 13 22"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ cursor: "pointer", transform: "rotate(-90deg)" }}
|
||||
>
|
||||
<path
|
||||
d="M4.03042 11.0738C4.21763 11.2087 4.34586 11.2766 4.44415 11.3747C6.92933 13.8687 9.40982 16.3665 11.895 18.8605C12.3396 19.3066 12.5175 19.8207 12.3349 20.4339C12.027 21.4658 10.7764 21.8393 9.95179 21.1498C9.86193 21.0743 9.78049 20.9894 9.69812 20.9064C6.85163 18.0446 4.00421 15.1817 1.15866 12.3189C0.279718 11.4341 0.280654 10.6456 1.16053 9.75982C4.03884 6.86305 6.91341 3.96155 9.8067 1.07986C10.0585 0.828952 10.4133 0.619547 10.7549 0.537482C11.4008 0.382786 12.0139 0.72142 12.3181 1.28644C12.6251 1.85334 12.5381 2.56079 12.0794 3.05129C11.7275 3.4286 11.3549 3.78704 10.9899 4.15209C8.79485 6.35557 6.60078 8.55904 4.40483 10.7606C4.32246 10.8436 4.22605 10.9106 4.03042 11.0729L4.03042 11.0738Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
style={{ cursor: "pointer", display: "block" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,13 @@ const RouteWidget = observer(() => {
|
||||
}, [context?.endStopId, isLoading, selectedLanguage]);
|
||||
|
||||
const shouldAnimate = (text, maxLength) => text?.length > maxLength;
|
||||
const getNumberSizeClass = (text) => {
|
||||
const length = text?.length || 0;
|
||||
if (length <= 2) return "";
|
||||
if (length === 3) return "route-widget-number--3";
|
||||
if (length === 4) return "route-widget-number--4";
|
||||
return "route-widget-number--5";
|
||||
};
|
||||
const getLabelSizeClass = (text) => {
|
||||
const length = text?.length || 0;
|
||||
|
||||
@@ -77,7 +84,7 @@ const RouteWidget = observer(() => {
|
||||
const routeZhSubtitle = `${startStationZh?.name} - ${endStationZh?.name}`;
|
||||
return (
|
||||
<div className="route-widget">
|
||||
<div className="route-widget-number">
|
||||
<div className={`route-widget-number ${getNumberSizeClass(route?.route_sys_number || context?.routeNumber)}`}>
|
||||
{route?.route_sys_number || context?.routeNumber || ""}
|
||||
</div>
|
||||
<div className="route-widget-content">
|
||||
|
||||
@@ -231,7 +231,6 @@ export const ThreeView: React.FC<ThreeViewProps> = ({
|
||||
height = "100%",
|
||||
onLoad,
|
||||
onError,
|
||||
onAspectRatioCalculated,
|
||||
controlRef,
|
||||
}) => {
|
||||
return (
|
||||
@@ -244,7 +243,7 @@ export const ThreeView: React.FC<ThreeViewProps> = ({
|
||||
}}
|
||||
camera={{ position: [0, 0, 5], fov: 40 }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
onError={(e) => onError?.(e.message)}
|
||||
onError={(e: any) => onError?.(e.message)}
|
||||
>
|
||||
<AutoResize />
|
||||
<TouchController />
|
||||
@@ -269,7 +268,7 @@ export const ThreeView: React.FC<ThreeViewProps> = ({
|
||||
<Stage
|
||||
environment={null}
|
||||
intensity={1}
|
||||
contactShadow={false}
|
||||
castShadow={false}
|
||||
shadows={false}
|
||||
adjustCamera={true}
|
||||
center={{ precise: true }}
|
||||
|
||||
@@ -52,33 +52,6 @@ class CameraAnimationStore {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
}
|
||||
|
||||
private calculateDistance(p1: CameraPosition, p2: CameraPosition): number {
|
||||
return Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2));
|
||||
}
|
||||
|
||||
private isNearStation(
|
||||
tramPos: CameraPosition,
|
||||
stations: Station[]
|
||||
): { isNear: boolean; distance: number } {
|
||||
if (!stations || stations.length === 0)
|
||||
return { isNear: false, distance: Infinity };
|
||||
const threshold = 300; // Порог в координатах карты
|
||||
let minDistance = Infinity;
|
||||
|
||||
for (const station of stations) {
|
||||
const distance = this.calculateDistance(tramPos, {
|
||||
x: station.longitude,
|
||||
y: station.latitude,
|
||||
});
|
||||
minDistance = Math.min(minDistance, distance);
|
||||
}
|
||||
|
||||
return {
|
||||
isNear: minDistance < threshold,
|
||||
distance: minDistance,
|
||||
};
|
||||
}
|
||||
|
||||
public setUpdateCallback(
|
||||
callback: ((pos: CameraPosition, zoom: number) => void) | null
|
||||
) {
|
||||
@@ -140,7 +113,7 @@ class CameraAnimationStore {
|
||||
public followTram(
|
||||
tramMapPos: CameraPosition,
|
||||
screenCenter: CameraPosition,
|
||||
stations: Station[] = []
|
||||
_stations: Station[] = []
|
||||
) {
|
||||
// Анимация начинается с текущего зума и плавно переходит к максимальному зуму
|
||||
// для плавного приближения к желтой точке при слежении
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
rgba(255, 255, 255, 0.2) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
),
|
||||
rgba(179, 165, 152, 0.4);
|
||||
rgba(0, 111, 58, 0.4);
|
||||
backdrop-filter: blur(10px);
|
||||
pointer-events: auto;
|
||||
z-index: 10000001;
|
||||
@@ -59,6 +59,18 @@
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.route-widget-number--3 {
|
||||
font-size: 50px;
|
||||
}
|
||||
|
||||
.route-widget-number--4 {
|
||||
font-size: 38px;
|
||||
}
|
||||
|
||||
.route-widget-number--5 {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.route-widget-content {
|
||||
overflow: hidden;
|
||||
width: 257px;
|
||||
|
||||
@@ -215,6 +215,8 @@
|
||||
transition:
|
||||
transform 0.3s ease-out,
|
||||
opacity 0.3s ease-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.side-menu-sights.slide-in {
|
||||
@@ -227,7 +229,8 @@
|
||||
}
|
||||
|
||||
.side-menu-sights-block {
|
||||
height: calc(100% - 20px);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-left: 20px;
|
||||
margin-top: 8px;
|
||||
touch-action: none;
|
||||
@@ -236,6 +239,7 @@
|
||||
max-width: calc(100% - 20px);
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.side-menu-sight {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
rgba(255, 255, 255, 0.2) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
),
|
||||
rgba(179, 165, 152, 0.4);
|
||||
rgba(0, 111, 58, 0.4);
|
||||
}
|
||||
|
||||
.weather-widget-time {
|
||||
|
||||
@@ -134,6 +134,58 @@ export class PositionAnimator {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Передискретизация пути для обеспечения равномерного расстояния между точками
|
||||
* @param path - массив [lat, lon] или [x, y]
|
||||
* @param segmentLength - желаемое расстояние между точками (в единицах координат)
|
||||
* @returns новый массив точек
|
||||
*/
|
||||
export const resamplePath = <T extends number[]>(path: T[], segmentLength: number): T[] => {
|
||||
if (path.length < 2) return path;
|
||||
|
||||
const newPath: T[] = [path[0]];
|
||||
let leftover = 0;
|
||||
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const p1 = path[i];
|
||||
const p2 = path[i + 1];
|
||||
const dx = p2[0] - p1[0];
|
||||
const dy = p2[1] - p1[1];
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist === 0) continue;
|
||||
|
||||
let currentDist = segmentLength - leftover;
|
||||
while (currentDist <= dist) {
|
||||
const t = currentDist / dist;
|
||||
const point = new Array(p1.length) as T;
|
||||
for (let j = 0; j < p1.length; j++) {
|
||||
point[j] = p1[j] + (p2[j] - p1[j]) * t;
|
||||
}
|
||||
newPath.push(point);
|
||||
currentDist += segmentLength;
|
||||
}
|
||||
leftover = dist - (currentDist - segmentLength);
|
||||
}
|
||||
|
||||
// Добавляем последнюю точку, если она существенно отличается от последней добавленной
|
||||
const lastP = path[path.length - 1];
|
||||
const lastNewP = newPath[newPath.length - 1];
|
||||
let isDifferent = false;
|
||||
for (let j = 0; j < lastP.length; j++) {
|
||||
if (Math.abs(lastP[j] - lastNewP[j]) > 0.0000001) {
|
||||
isDifferent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDifferent) {
|
||||
newPath.push(lastP);
|
||||
}
|
||||
|
||||
return newPath;
|
||||
};
|
||||
|
||||
/**
|
||||
* Класс для анимации по полярным координатам
|
||||
* Основано на логике анимации из HTML файла (a.html)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { authStore, articlesStore, languageStore, SearchInput } from "@shared";
|
||||
import { authStore, articlesStore, languageStore, SearchInput, selectedCityStore } from "@shared";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Trash2, Eye, Minus } from "lucide-react";
|
||||
@@ -75,14 +75,16 @@ export const ArticleListPage = observer(() => {
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
const cityId = selectedCityStore.selectedCityId;
|
||||
return articleList[language].data
|
||||
.filter((article) => !query || (article.heading ?? "").toLowerCase().includes(query))
|
||||
.filter((article) => !cityId || article.city_id === cityId)
|
||||
.map((article) => ({
|
||||
id: article.id,
|
||||
heading: article.heading,
|
||||
body: article.body,
|
||||
}));
|
||||
}, [articleList[language].data, searchQuery]);
|
||||
}, [articleList[language].data, searchQuery, selectedCityStore.selectedCityId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -113,6 +115,7 @@ export const ArticleListPage = observer(() => {
|
||||
columns={columns}
|
||||
checkboxSelection={canWriteArticles}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -168,6 +168,7 @@ export const CarrierListPage = observer(() => {
|
||||
columns={columns}
|
||||
checkboxSelection={canWriteCarriers}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -28,7 +28,8 @@ import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
||||
export const CityCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const { language } = languageStore;
|
||||
const { createCityData, setCreateCityData } = cityStore;
|
||||
const { createCityData, setCreateCityData, setCreateCityWeatherCode } =
|
||||
cityStore;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||||
@@ -72,7 +73,7 @@ export const CityCreatePage = observer(() => {
|
||||
createCityData[language].name,
|
||||
createCityData.country_code,
|
||||
media.id,
|
||||
language
|
||||
language,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -111,7 +112,7 @@ export const CityCreatePage = observer(() => {
|
||||
e.target.value,
|
||||
createCityData.country_code,
|
||||
createCityData.arms,
|
||||
language
|
||||
language,
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -127,7 +128,7 @@ export const CityCreatePage = observer(() => {
|
||||
createCityData[language].name,
|
||||
e.target.value,
|
||||
createCityData.arms,
|
||||
language
|
||||
language,
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -139,6 +140,14 @@ export const CityCreatePage = observer(() => {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Код города для погоды"
|
||||
value={createCityData.weather_city_code ?? 0}
|
||||
onChange={(e) => setCreateCityWeatherCode(Number(e.target.value))}
|
||||
helperText="ID города брать с ресурса openweathermap.org"
|
||||
/>
|
||||
|
||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||
<ImageUploadCard
|
||||
title="Герб города"
|
||||
@@ -153,7 +162,7 @@ export const CityCreatePage = observer(() => {
|
||||
createCityData[language].name,
|
||||
createCityData.country_code,
|
||||
"",
|
||||
language
|
||||
language,
|
||||
);
|
||||
setActiveMenuType(null);
|
||||
}}
|
||||
|
||||
@@ -40,7 +40,13 @@ export const CityEditPage = observer(() => {
|
||||
>(null);
|
||||
const { language } = languageStore;
|
||||
const { id } = useParams();
|
||||
const { editCityData, editCity, getCity, setEditCityData } = cityStore;
|
||||
const {
|
||||
editCityData,
|
||||
editCity,
|
||||
getCity,
|
||||
setEditCityData,
|
||||
setEditCityWeatherCode,
|
||||
} = cityStore;
|
||||
const { getCountries } = countryStore;
|
||||
const { getMedia, getOneMedia } = mediaStore;
|
||||
|
||||
@@ -74,6 +80,7 @@ export const CityEditPage = observer(() => {
|
||||
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
|
||||
setEditCityData(enData.name, enData.country_code, enData.arms, "en");
|
||||
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
|
||||
setEditCityWeatherCode(ruData.weather_city_code ?? 0);
|
||||
|
||||
await getOneMedia(ruData.arms as string);
|
||||
|
||||
@@ -107,7 +114,7 @@ export const CityEditPage = observer(() => {
|
||||
: null;
|
||||
const effectiveArmsUrl = isMediaIdEmpty(editCityData.arms)
|
||||
? null
|
||||
: selectedMedia?.id ?? editCityData.arms;
|
||||
: (selectedMedia?.id ?? editCityData.arms);
|
||||
|
||||
if (isLoadingData) {
|
||||
return (
|
||||
@@ -179,6 +186,14 @@ export const CityEditPage = observer(() => {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Код города для погоды"
|
||||
value={editCityData.weather_city_code ?? 0}
|
||||
onChange={(e) => setEditCityWeatherCode(Number(e.target.value))}
|
||||
helperText="ID города брать с ресурса openweathermap.org"
|
||||
/>
|
||||
|
||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||
<ImageUploadCard
|
||||
title="Герб города"
|
||||
|
||||
@@ -162,6 +162,7 @@ export const CityListPage = observer(() => {
|
||||
columns={columns}
|
||||
checkboxSelection={canWriteCities}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -115,6 +115,7 @@ export const CountryListPage = observer(() => {
|
||||
columns={columns}
|
||||
checkboxSelection={canWriteCountries}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -28,7 +28,7 @@ export const CreateSightPage = observer(() => {
|
||||
const [value, setValue] = useState(0);
|
||||
const { getCities } = cityStore;
|
||||
const { getArticles } = articlesStore;
|
||||
const { needLeaveAgree } = createSightStore;
|
||||
const needLeave = createSightStore.needLeaveAgree;
|
||||
|
||||
const handleChange = (_: React.SyntheticEvent, newValue: number) => {
|
||||
setValue(newValue);
|
||||
@@ -36,9 +36,15 @@ export const CreateSightPage = observer(() => {
|
||||
|
||||
let blocker = useBlocker(
|
||||
({ currentLocation, nextLocation }) =>
|
||||
needLeaveAgree && currentLocation.pathname !== nextLocation.pathname
|
||||
needLeave && currentLocation.pathname !== nextLocation.pathname,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (blocker.state === "blocked" && !needLeave) {
|
||||
blocker.proceed();
|
||||
}
|
||||
}, [blocker.state, needLeave]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!authStore.me) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
FormControl,
|
||||
InputLabel,
|
||||
} from "@mui/material";
|
||||
import { mediaStore, MEDIA_TYPE_LABELS } from "@shared";
|
||||
import { mediaStore, MEDIA_TYPE_LABELS, selectedCityStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@@ -23,7 +23,7 @@ export const MediaCreatePage = observer(() => {
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await mediaStore.createMedia(name, type);
|
||||
await mediaStore.createMedia(name, type, selectedCityStore.selectedCityId);
|
||||
toast.success("Медиа успешно создано");
|
||||
navigate("/media");
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { authStore, languageStore, MEDIA_TYPE_LABELS, mediaStore, SearchInput } from "@shared";
|
||||
import { authStore, languageStore, MEDIA_TYPE_LABELS, mediaStore, SearchInput, selectedCityStore } from "@shared";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Eye, Trash2, Minus } from "lucide-react";
|
||||
@@ -98,14 +98,16 @@ export const MediaListPage = observer(() => {
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
const cityId = selectedCityStore.selectedCityId;
|
||||
return media
|
||||
.filter((item) => !query || (item.media_name ?? "").toLowerCase().includes(query))
|
||||
.filter((item) => !cityId || item.city_id === cityId)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
media_name: item.media_name,
|
||||
media_type: item.media_type,
|
||||
}));
|
||||
}, [media, searchQuery]);
|
||||
}, [media, searchQuery, selectedCityStore.selectedCityId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -129,6 +131,7 @@ export const MediaListPage = observer(() => {
|
||||
columns={columns}
|
||||
checkboxSelection={canWriteMedia}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { apiStore } from "../../../client/src/api/ApiStore/store";
|
||||
import App from "../../../client/src/App";
|
||||
|
||||
@@ -286,9 +286,9 @@ export const RouteCreatePage = observer(() => {
|
||||
newRoute.governor_appeal = governor_appeal;
|
||||
}
|
||||
|
||||
await routeStore.createRoute(newRoute);
|
||||
const newId = await routeStore.createRoute(newRoute);
|
||||
toast.success("Маршрут успешно создан");
|
||||
navigate(-1);
|
||||
navigate(`/route/${newId}/edit`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Произошла ошибка при создании маршрута");
|
||||
|
||||
@@ -6,10 +6,10 @@ import { observer } from "mobx-react-lite";
|
||||
import { Map, Pencil, Trash2, Minus, Monitor } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
import { Box, CircularProgress, Tooltip } from "@mui/material";
|
||||
|
||||
export const RouteListPage = observer(() => {
|
||||
const { routes, getRoutes, deleteRoute } = routeStore;
|
||||
const { routes, getRoutes, deleteRoute, sightCounts, stationCounts, countsLoading, loadCounts } = routeStore;
|
||||
const { carriers, getCarriers } = carrierStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
@@ -38,6 +38,9 @@ export const RouteListPage = observer(() => {
|
||||
await getCarriers("zh");
|
||||
await getRoutes();
|
||||
setIsLoading(false);
|
||||
|
||||
const routeIds = routeStore.routes.data.map((r) => r.id);
|
||||
loadCounts(routeIds);
|
||||
};
|
||||
fetchData();
|
||||
}, [language]);
|
||||
@@ -128,6 +131,42 @@ export const RouteListPage = observer(() => {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "sightCount",
|
||||
headerName: "Достопримечательности",
|
||||
width: 180,
|
||||
align: "center" as const,
|
||||
headerAlign: "center" as const,
|
||||
sortable: true,
|
||||
renderHeader: (params: any) => (
|
||||
<Tooltip title="Количество привязанных достопримечательностей">
|
||||
<span>{params.colDef.headerName}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
{params.value === null ? <CircularProgress size={14} /> : params.value}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: "stationCount",
|
||||
headerName: "Остановки",
|
||||
width: 120,
|
||||
align: "center" as const,
|
||||
headerAlign: "center" as const,
|
||||
sortable: true,
|
||||
renderHeader: (params: any) => (
|
||||
<Tooltip title="Количество привязанных остановок">
|
||||
<span>{params.colDef.headerName}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
{params.value === null ? <CircularProgress size={14} /> : params.value}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...(canShowActionsColumn ? [{
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
@@ -195,8 +234,10 @@ export const RouteListPage = observer(() => {
|
||||
route_sys_number: route.route_sys_number,
|
||||
route_direction: route.route_direction ? "Прямой" : "Обратный",
|
||||
route_name: route.route_name,
|
||||
sightCount: sightCounts.has(route.id) ? sightCounts.get(route.id) : null,
|
||||
stationCount: stationCounts.has(route.id) ? stationCounts.get(route.id) : null,
|
||||
}));
|
||||
}, [routes.data, carriers["ru"].data, selectedCityStore.selectedCityId, searchQuery]);
|
||||
}, [routes.data, carriers["ru"].data, selectedCityStore.selectedCityId, searchQuery, sightCounts.size, stationCounts.size, countsLoading]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -230,6 +271,7 @@ export const RouteListPage = observer(() => {
|
||||
onRowDoubleClick={(params) => canWriteRoutes && navigate(`/route/${params.row.id}/edit`)}
|
||||
checkboxSelection={canWriteRoutes}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -141,6 +141,7 @@ export function RightSidebar() {
|
||||
bgcolor="primary.main"
|
||||
border="1px solid #e0e0e0"
|
||||
borderRadius={2}
|
||||
zIndex={2}
|
||||
>
|
||||
<Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center">
|
||||
Настройка маршрута
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Stack, Typography, Box, IconButton } from "@mui/material";
|
||||
import { Close } from "@mui/icons-material";
|
||||
import { Landmark } from "lucide-react";
|
||||
import { useMapData } from "./MapDataContext";
|
||||
import { RouteWidget } from "./webgl-prototype/RouteWidget";
|
||||
|
||||
export function Widgets() {
|
||||
const { selectedSight, setSelectedSight } = useMapData();
|
||||
@@ -13,22 +14,11 @@ export function Widgets() {
|
||||
position="absolute"
|
||||
top={32}
|
||||
left={32}
|
||||
zIndex={2}
|
||||
sx={{ pointerEvents: "none" }}
|
||||
>
|
||||
<Stack
|
||||
bgcolor="primary.main"
|
||||
width={361}
|
||||
height={96}
|
||||
p={2}
|
||||
m={2}
|
||||
borderRadius={2}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Typography variant="h6" sx={{ color: "#fff" }}>
|
||||
Остановка
|
||||
</Typography>
|
||||
</Stack>
|
||||
{/* Виджет маршрута */}
|
||||
<RouteWidget />
|
||||
|
||||
{/* Виджет выбранной достопримечательности (заменяет виджет погоды) */}
|
||||
<Stack
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
.route-widget-label.marquee {
|
||||
display: inline-block;
|
||||
animation: marquee 14s linear infinite;
|
||||
}
|
||||
|
||||
.route-widget-subtitle.marquee {
|
||||
display: inline-block;
|
||||
animation: marquee 14s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.route-widget {
|
||||
width: 361px;
|
||||
height: 96px;
|
||||
position: fixed;
|
||||
display: inline-flex;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3) inset,
|
||||
/* Внутренняя рамка */ 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset; /* Ваш существующий внутренний shadow */
|
||||
padding: 1px; /* Чтобы контент не прилипал к рамке */
|
||||
background: linear-gradient(
|
||||
to bottom right,
|
||||
rgba(255, 255, 255, 0.2) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
),
|
||||
rgba(179, 165, 152, 0.4);
|
||||
backdrop-filter: blur(10px);
|
||||
pointer-events: auto;
|
||||
z-index: 10000001;
|
||||
}
|
||||
|
||||
.route-widget-number {
|
||||
position: absolute;
|
||||
width: fit-content;
|
||||
left: 0px;
|
||||
top: 0px;
|
||||
min-width: 94px;
|
||||
max-width: 100px;
|
||||
height: 96px;
|
||||
background-color: #fcd500;
|
||||
color: black;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 70px;
|
||||
padding: 14px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.route-widget-number--3 {
|
||||
font-size: 50px;
|
||||
}
|
||||
|
||||
.route-widget-number--4 {
|
||||
font-size: 38px;
|
||||
}
|
||||
|
||||
.route-widget-number--5 {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.route-widget-content {
|
||||
overflow: hidden;
|
||||
width: 257px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 13px;
|
||||
margin-left: 109px;
|
||||
margin-right: 9px;
|
||||
}
|
||||
|
||||
.route-widget-label {
|
||||
white-space: nowrap;
|
||||
font-size: 24px;
|
||||
margin: 1px 0;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.route-widget-label--medium {
|
||||
font-size: 22px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.route-widget-label--small {
|
||||
font-size: 20px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.route-widget-label--xsmall {
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.route-widget-subtitle {
|
||||
white-space: nowrap;
|
||||
color: #cbcbcb;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import styles from "./RouteWidget.module.css";
|
||||
import { useMapData } from "../MapDataContext";
|
||||
import { languageStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
const shouldAnimate = (text: string | undefined, maxLength: number) =>
|
||||
(text?.length ?? 0) > maxLength;
|
||||
|
||||
const getNumberSizeClass = (text: string | undefined) => {
|
||||
const length = text?.length ?? 0;
|
||||
if (length <= 2) return "";
|
||||
if (length === 3) return styles["route-widget-number--3"];
|
||||
if (length === 4) return styles["route-widget-number--4"];
|
||||
return styles["route-widget-number--5"];
|
||||
};
|
||||
|
||||
const getLabelSizeClass = (text: string | undefined) => {
|
||||
const length = text?.length ?? 0;
|
||||
if (length <= 40) return "";
|
||||
if (length <= 60) return styles["route-widget-label--medium"];
|
||||
if (length <= 80) return styles["route-widget-label--small"];
|
||||
return styles["route-widget-label--xsmall"];
|
||||
};
|
||||
|
||||
export const RouteWidget = observer(() => {
|
||||
const { routeData, stationData } = useMapData();
|
||||
const { language } = languageStore;
|
||||
|
||||
const stations = stationData?.[language] ?? stationData?.["ru"] ?? [];
|
||||
const stationsRu = stationData?.["ru"] ?? [];
|
||||
|
||||
const startStation = stations[0];
|
||||
const endStation = stations[stations.length - 1];
|
||||
|
||||
const startStationRu = stationsRu[0];
|
||||
const endStationRu = stationsRu[stationsRu.length - 1];
|
||||
|
||||
const enSubtitle = `${startStationRu?.name ?? ""} - ${endStationRu?.name ?? ""}`;
|
||||
const zhSubtitle = `${startStation?.name ?? ""} - ${endStation?.name ?? ""}`;
|
||||
const subtitle = language === "zh" ? zhSubtitle : enSubtitle;
|
||||
|
||||
return (
|
||||
<div className={styles["route-widget"]} style={{ position: "relative" }}>
|
||||
<div className={[styles["route-widget-number"], getNumberSizeClass(routeData?.route_sys_number)].join(" ")}>
|
||||
{routeData?.route_sys_number || ""}
|
||||
</div>
|
||||
<div className={styles["route-widget-content"]}>
|
||||
<div
|
||||
className={[
|
||||
styles["route-widget-label"],
|
||||
shouldAnimate(startStation?.name, 18) ? styles["marquee"] : "",
|
||||
getLabelSizeClass(startStation?.name),
|
||||
].join(" ")}
|
||||
title={startStation?.name}
|
||||
>
|
||||
{startStation?.name}
|
||||
</div>
|
||||
<div
|
||||
className={[
|
||||
styles["route-widget-label"],
|
||||
shouldAnimate(endStation?.name, 18) ? styles["marquee"] : "",
|
||||
getLabelSizeClass(endStation?.name),
|
||||
].join(" ")}
|
||||
title={endStation?.name}
|
||||
>
|
||||
{endStation?.name}
|
||||
</div>
|
||||
<div
|
||||
className={[
|
||||
styles["route-widget-subtitle"],
|
||||
shouldAnimate(subtitle, 50) ? styles["marquee"] : "",
|
||||
].join(" ")}
|
||||
title={subtitle}
|
||||
>
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1910,7 +1910,8 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
|
||||
const stationIconSizePercent =
|
||||
liveStationIconSizes.get(station.id) ??
|
||||
(typeof station.icon_size === "number" && Number.isFinite(station.icon_size)
|
||||
(typeof station.icon_size === "number" &&
|
||||
Number.isFinite(station.icon_size)
|
||||
? station.icon_size
|
||||
: 100);
|
||||
const iconSizePx = Math.max(
|
||||
@@ -2277,6 +2278,7 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
pointerEvents: "none",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{stationData.ru.map((station, index) => {
|
||||
@@ -2706,13 +2708,14 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
? camera.scale /
|
||||
Math.max(customSightIconBaseScaleRef.current ?? 1, 1e-6)
|
||||
: 1;
|
||||
const sightIconSizePercent = sight.is_default_icon === false
|
||||
? (liveSightIconSizes.get(sight.id) ??
|
||||
(typeof sight.icon_size === "number" &&
|
||||
Number.isFinite(sight.icon_size)
|
||||
? sight.icon_size
|
||||
: 100))
|
||||
: (routeData?.icon_size ?? originalRouteData?.icon_size ?? 100);
|
||||
const sightIconSizePercent =
|
||||
sight.is_default_icon === false
|
||||
? (liveSightIconSizes.get(sight.id) ??
|
||||
(typeof sight.icon_size === "number" &&
|
||||
Number.isFinite(sight.icon_size)
|
||||
? sight.icon_size
|
||||
: 100))
|
||||
: (routeData?.icon_size ?? originalRouteData?.icon_size ?? 100);
|
||||
const iconSize =
|
||||
30 *
|
||||
clamp(sightIconSizePercent / 100, 0.1, 10) *
|
||||
@@ -2723,7 +2726,10 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
resizingSightIconId === sight.id);
|
||||
const iconLeft = cssX - iconSize;
|
||||
const iconTop = cssY - iconSize;
|
||||
const sightZoomClampedScale = Math.min(Math.max(camera.scale, 1), 3);
|
||||
const sightZoomClampedScale = Math.min(
|
||||
Math.max(camera.scale, 1),
|
||||
3,
|
||||
);
|
||||
const sightScaleFactor = 1 + (sightZoomClampedScale - 1) * 0.4;
|
||||
const labelHeight = 24 * sightScaleFactor;
|
||||
const labelPadding = 6 * sightScaleFactor;
|
||||
|
||||
@@ -185,6 +185,7 @@ export const SightListPage = observer(() => {
|
||||
onRowDoubleClick={(params) => canWriteSights && navigate(`/sight/${params.row.id}/edit`)}
|
||||
checkboxSelection={canWriteSights}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useEffect, useState, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { DatabaseBackup, Trash2 } from "lucide-react";
|
||||
import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets";
|
||||
import { Alert, Box, CircularProgress } from "@mui/material";
|
||||
import { Alert, Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from "@mui/material";
|
||||
|
||||
const LOW_STORAGE_THRESHOLD_GB = 10;
|
||||
|
||||
@@ -30,6 +30,7 @@ export const SnapshotListPage = observer(() => {
|
||||
restoreSnapshot,
|
||||
storageInfo,
|
||||
getStorageInfo,
|
||||
createEmptySnapshot,
|
||||
} = snapshotStore;
|
||||
const canWriteDevices = authStore.canWrite("devices");
|
||||
const canCreateSnapshot =
|
||||
@@ -42,6 +43,9 @@ export const SnapshotListPage = observer(() => {
|
||||
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isEmptySnapshotModalOpen, setIsEmptySnapshotModalOpen] = useState(false);
|
||||
const [emptySnapshotName, setEmptySnapshotName] = useState("");
|
||||
const [isCreatingEmpty, setIsCreatingEmpty] = useState(false);
|
||||
const [paginationModel, setPaginationModel] = useState({
|
||||
page: 0,
|
||||
pageSize: 50,
|
||||
@@ -167,13 +171,27 @@ export const SnapshotListPage = observer(() => {
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl ">Экспорт Медиа</h1>
|
||||
|
||||
{canCreateSnapshot && (
|
||||
<CreateButton
|
||||
label="Создать экспорт медиа"
|
||||
path="/snapshot/create"
|
||||
disabled={isLowStorage}
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
{canCreateSnapshot && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={isLowStorage}
|
||||
onClick={() => {
|
||||
setEmptySnapshotName("");
|
||||
setIsEmptySnapshotModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Создать пустой снапшот
|
||||
</Button>
|
||||
)}
|
||||
{canCreateSnapshot && (
|
||||
<CreateButton
|
||||
label="Создать экспорт медиа"
|
||||
path="/snapshot/create"
|
||||
disabled={isLowStorage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{usedGB != null && totalGB != null && (
|
||||
<div className="bg-white rounded-2xl p-5 mb-6 shadow-sm border border-gray-100">
|
||||
@@ -301,6 +319,46 @@ export const SnapshotListPage = observer(() => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={isEmptySnapshotModalOpen}
|
||||
onClose={() => setIsEmptySnapshotModalOpen(false)}
|
||||
fullWidth
|
||||
maxWidth="xs"
|
||||
>
|
||||
<DialogTitle>Создать пустой снапшот</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
label="Название"
|
||||
value={emptySnapshotName}
|
||||
onChange={(e) => setEmptySnapshotName(e.target.value)}
|
||||
margin="normal"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setIsEmptySnapshotModalOpen(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!emptySnapshotName.trim() || isCreatingEmpty}
|
||||
onClick={async () => {
|
||||
setIsCreatingEmpty(true);
|
||||
try {
|
||||
await createEmptySnapshot(emptySnapshotName);
|
||||
await getSnapshots();
|
||||
setIsEmptySnapshotModalOpen(false);
|
||||
} finally {
|
||||
setIsCreatingEmpty(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isCreatingEmpty ? <CircularProgress size={20} /> : "Создать"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<SnapshotRestore
|
||||
open={isRestoreModalOpen}
|
||||
loading={isLoading}
|
||||
|
||||
@@ -68,9 +68,9 @@ export const StationCreatePage = observer(() => {
|
||||
const executeCreate = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await createStation();
|
||||
const data = await createStation();
|
||||
toast.success("Остановка успешно создана");
|
||||
navigate("/station");
|
||||
navigate(`/station/${data.id}/edit`);
|
||||
} catch (error) {
|
||||
console.error("Error creating station:", error);
|
||||
toast.error("Ошибка при создании остановки");
|
||||
|
||||
@@ -226,6 +226,7 @@ export const StationListPage = observer(() => {
|
||||
onRowDoubleClick={(params) => canWriteStations && navigate(`/station/${params.row.id}/edit`)}
|
||||
checkboxSelection={canWriteStations}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -326,6 +326,9 @@ export const UserEditPage = observer(() => {
|
||||
if (!next.includes("snapshot_create")) {
|
||||
next.push("snapshot_create");
|
||||
}
|
||||
if (!next.includes("devices_maintenance_rw")) {
|
||||
next.push("devices_maintenance_rw");
|
||||
}
|
||||
next.push("admin");
|
||||
return next;
|
||||
});
|
||||
@@ -347,7 +350,7 @@ export const UserEditPage = observer(() => {
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение/Запись</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>
|
||||
Создание (snapshot_create)
|
||||
Доп. права
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
@@ -386,6 +389,8 @@ export const UserEditPage = observer(() => {
|
||||
});
|
||||
};
|
||||
|
||||
const isDevicesResource = key === "devices";
|
||||
|
||||
const handleSnapshotCreateChange = (checked: boolean) => {
|
||||
if (!isSnapshotResource) {
|
||||
return;
|
||||
@@ -400,6 +405,13 @@ export const UserEditPage = observer(() => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleMaintenanceChange = (checked: boolean) => {
|
||||
setLocalRoles((prev) => {
|
||||
const without = prev.filter((r) => r !== "devices_maintenance_rw");
|
||||
return checked ? [...without, "devices_maintenance_rw"] : without;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow key={key} hover>
|
||||
<TableCell>{label}</TableCell>
|
||||
@@ -447,6 +459,14 @@ export const UserEditPage = observer(() => {
|
||||
handleSnapshotCreateChange(e.target.checked)
|
||||
}
|
||||
size="small"
|
||||
title="Разрешает создавать новые снапшоты"
|
||||
/>
|
||||
) : isDevicesResource ? (
|
||||
<Checkbox
|
||||
checked={localRoles.includes("devices_maintenance_rw")}
|
||||
onChange={(e) => handleMaintenanceChange(e.target.checked)}
|
||||
size="small"
|
||||
title="Разрешает переводить устройства в режим технического обслуживания"
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
|
||||
@@ -153,6 +153,7 @@ export const UserListPage = observer(() => {
|
||||
columns={columns}
|
||||
checkboxSelection={canWriteUsers}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -188,6 +188,7 @@ export const VehicleListPage = observer(() => {
|
||||
columns={columns}
|
||||
checkboxSelection={canWriteVehicles}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { authStore } from "@shared";
|
||||
import { authStore, snapshotStore } from "@shared";
|
||||
import {
|
||||
Power,
|
||||
LucideIcon,
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
Split,
|
||||
PersonStanding,
|
||||
Cpu,
|
||||
RefreshCcw,
|
||||
} from "lucide-react";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import carrierIcon from "./carrier.svg";
|
||||
|
||||
@@ -165,6 +167,15 @@ export const NAVIGATION_ITEMS: {
|
||||
},
|
||||
],
|
||||
secondary: [
|
||||
{
|
||||
id: "clear-cache",
|
||||
label: "Очистить кэш",
|
||||
icon: RefreshCcw,
|
||||
onClick: () => {
|
||||
snapshotStore.clearStoreCache();
|
||||
toast.success("Кэш очищен");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "logout",
|
||||
label: "Выйти",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
Language,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -232,6 +233,7 @@ export const ArticleSelectOrCreateDialog = observer(
|
||||
return;
|
||||
}
|
||||
|
||||
const cityId = selectedCityStore.selectedCityId;
|
||||
const response = await authInstance.post("/article", {
|
||||
translations: {
|
||||
heading: {
|
||||
@@ -245,6 +247,7 @@ export const ArticleSelectOrCreateDialog = observer(
|
||||
zh: newArticleData.zh.body || "Новый текст (ZH)",
|
||||
},
|
||||
},
|
||||
...(cityId ? { city_id: cityId } : {}),
|
||||
});
|
||||
|
||||
const { id } = response.data;
|
||||
@@ -519,9 +522,12 @@ export const ArticleSelectOrCreateDialog = observer(
|
||||
languageStore.setLanguage("ru");
|
||||
};
|
||||
|
||||
const filteredArticles = articles[modalLanguage].filter((article) =>
|
||||
article?.service_name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
const cityId = selectedCityStore.selectedCityId;
|
||||
const filteredArticles = articles[modalLanguage].filter((article) => {
|
||||
if (!article?.service_name?.toLowerCase().includes(searchQuery.toLowerCase())) return false;
|
||||
if (cityId && article.city_id !== cityId) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
||||
const [queuedPreviewId, setQueuedPreviewId] = useState<number | null>(null);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { articlesStore, authInstance, languageStore } from "@shared";
|
||||
import { articlesStore, authInstance, languageStore, selectedCityStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
@@ -116,12 +116,16 @@ export const SelectArticleModal = observer(
|
||||
}
|
||||
};
|
||||
|
||||
const cityId = selectedCityStore.selectedCityId;
|
||||
|
||||
const filteredArticles = articles[languageStore.language].filter(
|
||||
(article) => !linkedArticleIds.includes(article.id)
|
||||
(article) => {
|
||||
if (linkedArticleIds.includes(article.id)) return false;
|
||||
if (searchQuery && !article.service_name?.toLowerCase().includes(searchQuery.toLowerCase())) return false;
|
||||
if (cityId && article.city_id !== cityId) return false;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
// .filter((article) =>
|
||||
// article.service_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
// );
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Media, mediaStore } from "@shared";
|
||||
import { Media, mediaStore, selectedCityStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
@@ -92,11 +92,17 @@ export const SelectMediaDialog = observer(
|
||||
};
|
||||
}, [hoveredMediaId, onSelectMedia, onClose]); // Dependencies for keyboard listener
|
||||
|
||||
const cityId = selectedCityStore.selectedCityId;
|
||||
|
||||
let filteredMedia = media
|
||||
.filter((mediaItem) => !linkedMediaIds.includes(mediaItem.id)) // Use mediaItem to avoid name collision
|
||||
.filter((mediaItem) => !linkedMediaIds.includes(mediaItem.id))
|
||||
.filter((mediaItem) =>
|
||||
mediaItem.media_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
)
|
||||
.filter((mediaItem) => {
|
||||
if (!cityId) return true;
|
||||
return mediaItem.city_id === cityId;
|
||||
});
|
||||
|
||||
if (mediaType) {
|
||||
filteredMedia = filteredMedia.filter(
|
||||
|
||||
@@ -4,9 +4,12 @@ import {
|
||||
editSightStore,
|
||||
generateDefaultMediaName,
|
||||
clearBlobAndGLTFCache,
|
||||
authStore,
|
||||
snapshotStore,
|
||||
} from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
@@ -247,12 +250,16 @@ export const UploadMediaDialog = observer(
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const uploadStartTime = Date.now();
|
||||
|
||||
try {
|
||||
const effectiveMediaType = hardcodeType
|
||||
? (MEDIA_TYPE_VALUES[hardcodeType] as number)
|
||||
: mediaType;
|
||||
|
||||
const media = await uploadMedia(
|
||||
mediaFilename,
|
||||
hardcodeType
|
||||
? (MEDIA_TYPE_VALUES[hardcodeType] as number)
|
||||
: mediaType,
|
||||
effectiveMediaType,
|
||||
mediaFile,
|
||||
mediaName
|
||||
);
|
||||
@@ -263,6 +270,40 @@ export const UploadMediaDialog = observer(
|
||||
await afterUpload(media);
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveMediaType === 2) {
|
||||
const uploadDurationSec = Math.round((Date.now() - uploadStartTime) / 1000);
|
||||
const minutes = Math.floor(uploadDurationSec / 60);
|
||||
const seconds = uploadDurationSec % 60;
|
||||
const durationStr = minutes > 0
|
||||
? `${minutes} мин ${seconds} сек`
|
||||
: `${seconds} сек`;
|
||||
|
||||
const fileSizeMb = mediaFile.size / (1024 * 1024);
|
||||
const fileSizeStr = fileSizeMb >= 1024
|
||||
? `${(fileSizeMb / 1024).toFixed(2)} ГБ`
|
||||
: `${fileSizeMb.toFixed(1)} МБ`;
|
||||
|
||||
if (authStore.canRead("snapshots")) {
|
||||
try {
|
||||
await snapshotStore.getStorageInfo();
|
||||
const storage = snapshotStore.storageInfo;
|
||||
if (storage) {
|
||||
toast.success(
|
||||
`Видео (${fileSizeStr}) загружено за ${durationStr}. Свободно на диске: ${storage.available_disk_space_gb.toFixed(2)} ГБ из ${storage.total_disk_space_gb.toFixed(2)} ГБ`,
|
||||
{ autoClose: 8000 }
|
||||
);
|
||||
} else {
|
||||
toast.success(`Видео (${fileSizeStr}) загружено за ${durationStr}`, { autoClose: 6000 });
|
||||
}
|
||||
} catch {
|
||||
toast.success(`Видео (${fileSizeStr}) загружено за ${durationStr}`, { autoClose: 6000 });
|
||||
}
|
||||
} else {
|
||||
toast.success(`Видео (${fileSizeStr}) загружено за ${durationStr}`, { autoClose: 6000 });
|
||||
}
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -12,6 +12,7 @@ export type Article = {
|
||||
heading: string;
|
||||
body: string;
|
||||
service_name: string;
|
||||
city_id?: number | null;
|
||||
ru?: {
|
||||
heading: string;
|
||||
body: string;
|
||||
|
||||
@@ -14,6 +14,7 @@ export type City = {
|
||||
country: string;
|
||||
country_code: string;
|
||||
arms: string;
|
||||
weather_city_code?: number;
|
||||
};
|
||||
|
||||
export type CashedCities = {
|
||||
@@ -132,6 +133,7 @@ class CityStore {
|
||||
createCityData = {
|
||||
country_code: "",
|
||||
arms: "",
|
||||
weather_city_code: 0,
|
||||
ru: {
|
||||
name: "",
|
||||
},
|
||||
@@ -159,9 +161,13 @@ class CityStore {
|
||||
};
|
||||
};
|
||||
|
||||
setCreateCityWeatherCode = (weather_city_code: number) => {
|
||||
this.createCityData = { ...this.createCityData, weather_city_code };
|
||||
};
|
||||
|
||||
async createCity() {
|
||||
const language = languageStore.language as Language;
|
||||
const { country_code, arms } = this.createCityData;
|
||||
const { country_code, arms, weather_city_code } = this.createCityData;
|
||||
const { name } = this.createCityData[language];
|
||||
|
||||
if (!name || !country_code) {
|
||||
@@ -178,6 +184,7 @@ class CityStore {
|
||||
)?.name || "",
|
||||
country_code,
|
||||
...(arms ? { arms } : {}),
|
||||
weather_city_code: weather_city_code ?? 0,
|
||||
};
|
||||
|
||||
const cityResponse = await languageInstance(language).post(
|
||||
@@ -232,6 +239,7 @@ class CityStore {
|
||||
this.createCityData = {
|
||||
country_code: "",
|
||||
arms: "",
|
||||
weather_city_code: 0,
|
||||
ru: { name: "" },
|
||||
en: { name: "" },
|
||||
zh: { name: "" },
|
||||
@@ -246,6 +254,7 @@ class CityStore {
|
||||
editCityData = {
|
||||
country_code: "",
|
||||
arms: "",
|
||||
weather_city_code: 0,
|
||||
ru: {
|
||||
name: "",
|
||||
},
|
||||
@@ -267,16 +276,19 @@ class CityStore {
|
||||
...this.editCityData,
|
||||
country_code: country_code,
|
||||
arms: arms,
|
||||
|
||||
[language]: {
|
||||
name: name,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
setEditCityWeatherCode = (weather_city_code: number) => {
|
||||
this.editCityData = { ...this.editCityData, weather_city_code };
|
||||
};
|
||||
|
||||
editCity = async (code: string) => {
|
||||
for (const language of ["ru", "en", "zh"]) {
|
||||
const { country_code, arms } = this.editCityData;
|
||||
const { country_code, arms, weather_city_code } = this.editCityData;
|
||||
const { name } = this.editCityData[language as keyof CashedCities];
|
||||
const { countries } = countryStore;
|
||||
|
||||
@@ -289,6 +301,7 @@ class CityStore {
|
||||
country: country?.name || "",
|
||||
country_code: country_code,
|
||||
arms,
|
||||
weather_city_code: weather_city_code ?? 0,
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
authInstance,
|
||||
languageInstance,
|
||||
mediaStore,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
|
||||
@@ -40,6 +41,7 @@ type SightCommonInfo = {
|
||||
left_article: number;
|
||||
preview_media: string | null;
|
||||
video_preview: string | null;
|
||||
preview_font_size?: number;
|
||||
};
|
||||
|
||||
type SightBaseInfo = SightCommonInfo & {
|
||||
@@ -128,6 +130,7 @@ class CreateSightStore {
|
||||
zh: articleZhData.body,
|
||||
},
|
||||
},
|
||||
...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}),
|
||||
});
|
||||
const { id } = articleRes.data;
|
||||
|
||||
@@ -184,7 +187,7 @@ class CreateSightStore {
|
||||
index: number,
|
||||
language: Language,
|
||||
heading: string,
|
||||
body: string
|
||||
body: string,
|
||||
) => {
|
||||
if (this.sight[language].right[index]) {
|
||||
this.sight[language].right[index].heading = heading;
|
||||
@@ -195,13 +198,13 @@ class CreateSightStore {
|
||||
unlinkRightAritcle = (articleId: number) => {
|
||||
runInAction(() => {
|
||||
this.sight.ru.right = this.sight.ru.right.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
this.sight.en.right = this.sight.en.right.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
this.sight.zh.right = this.sight.zh.right.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -211,13 +214,13 @@ class CreateSightStore {
|
||||
await authInstance.delete(`/article/${articleId}`);
|
||||
runInAction(() => {
|
||||
this.sight.ru.right = this.sight.ru.right.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
this.sight.en.right = this.sight.en.right.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
this.sight.zh.right = this.sight.zh.right.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -235,7 +238,7 @@ class CreateSightStore {
|
||||
runInAction(() => {
|
||||
(["ru", "en", "zh"] as Language[]).forEach((lang) => {
|
||||
const article = this.sight[lang].right.find(
|
||||
(a) => a.id === articleId
|
||||
(a) => a.id === articleId,
|
||||
);
|
||||
if (article) {
|
||||
if (!article.media) article.media = [];
|
||||
@@ -257,7 +260,7 @@ class CreateSightStore {
|
||||
runInAction(() => {
|
||||
(["ru", "en", "zh"] as Language[]).forEach((lang) => {
|
||||
const article = this.sight[lang].right.find(
|
||||
(a) => a.id === articleId
|
||||
(a) => a.id === articleId,
|
||||
);
|
||||
if (article && article.media) {
|
||||
article.media = article.media.filter((m) => m.id !== mediaId);
|
||||
@@ -322,13 +325,13 @@ class CreateSightStore {
|
||||
|
||||
runInAction(() => {
|
||||
articlesStore.articles.ru = articlesStore.articles.ru.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
articlesStore.articles.en = articlesStore.articles.en.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
articlesStore.articles.zh = articlesStore.articles.zh.filter(
|
||||
(article) => article.id !== articleId
|
||||
(article) => article.id !== articleId,
|
||||
);
|
||||
});
|
||||
this.unlinkLeftArticle();
|
||||
@@ -345,6 +348,7 @@ class CreateSightStore {
|
||||
const response = await languageInstance("ru").post("/article", {
|
||||
heading: hasAnyName ? ruName : "",
|
||||
body: "",
|
||||
...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}),
|
||||
});
|
||||
const newLeftArticleId = response.data.id;
|
||||
|
||||
@@ -431,7 +435,7 @@ class CreateSightStore {
|
||||
|
||||
updateSightInfo = (
|
||||
content: Partial<SightLanguageInfo | SightCommonInfo>,
|
||||
language?: Language
|
||||
language?: Language,
|
||||
) => {
|
||||
this.needLeaveAgree = true;
|
||||
if (language) {
|
||||
@@ -448,6 +452,7 @@ class CreateSightStore {
|
||||
const res = await languageInstance("ru").post("/article", {
|
||||
heading: this.sight.ru.left.heading,
|
||||
body: this.sight.ru.left.body,
|
||||
...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}),
|
||||
});
|
||||
finalLeftArticleId = res.data.id;
|
||||
await languageInstance("en").patch(`/article/${finalLeftArticleId}`, {
|
||||
@@ -464,15 +469,15 @@ class CreateSightStore {
|
||||
) {
|
||||
await languageInstance("ru").patch(
|
||||
`/article/${this.sight.left_article}`,
|
||||
{ heading: this.sight.ru.left.heading, body: this.sight.ru.left.body }
|
||||
{ heading: this.sight.ru.left.heading, body: this.sight.ru.left.body },
|
||||
);
|
||||
await languageInstance("en").patch(
|
||||
`/article/${this.sight.left_article}`,
|
||||
{ heading: this.sight.en.left.heading, body: this.sight.en.left.body }
|
||||
{ heading: this.sight.en.left.heading, body: this.sight.en.left.body },
|
||||
);
|
||||
await languageInstance("zh").patch(
|
||||
`/article/${this.sight.left_article}`,
|
||||
{ heading: this.sight.zh.left.heading, body: this.sight.zh.left.body }
|
||||
{ heading: this.sight.zh.left.heading, body: this.sight.zh.left.body },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -488,7 +493,7 @@ class CreateSightStore {
|
||||
}
|
||||
}
|
||||
const rightArticleIdsForLink = this.sight[primaryLanguage].right.map(
|
||||
(a) => a.id
|
||||
(a) => a.id,
|
||||
);
|
||||
|
||||
const sightPayload = {
|
||||
@@ -508,16 +513,17 @@ class CreateSightStore {
|
||||
left_article: finalLeftArticleId === 0 ? null : finalLeftArticleId,
|
||||
preview_media: this.sight.preview_media,
|
||||
video_preview: this.sight.video_preview,
|
||||
preview_font_size: this.sight.preview_font_size,
|
||||
};
|
||||
|
||||
const response = await languageInstance(primaryLanguage).post(
|
||||
"/sight",
|
||||
sightPayload
|
||||
sightPayload,
|
||||
);
|
||||
const newSightId = response.data.id;
|
||||
|
||||
const otherLanguages = (["ru", "en", "zh"] as Language[]).filter(
|
||||
(l) => l !== primaryLanguage
|
||||
(l) => l !== primaryLanguage,
|
||||
);
|
||||
for (const lang of otherLanguages) {
|
||||
await languageInstance(lang).patch(`/sight/${newSightId}`, {
|
||||
@@ -547,7 +553,10 @@ class CreateSightStore {
|
||||
});
|
||||
}
|
||||
|
||||
this.needLeaveAgree = false;
|
||||
runInAction(() => {
|
||||
this.needLeaveAgree = false;
|
||||
});
|
||||
|
||||
return newSightId;
|
||||
};
|
||||
|
||||
@@ -555,13 +564,16 @@ class CreateSightStore {
|
||||
filename: string,
|
||||
type: number,
|
||||
file: File,
|
||||
media_name?: string
|
||||
media_name?: string,
|
||||
): Promise<MediaItem> => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("filename", filename);
|
||||
if (media_name) formData.append("media_name", media_name);
|
||||
formData.append("type", type.toString());
|
||||
if (selectedCityStore.selectedCityId) {
|
||||
formData.append("city_id", selectedCityStore.selectedCityId.toString());
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await authInstance.post(`/media`, formData);
|
||||
@@ -585,7 +597,7 @@ class CreateSightStore {
|
||||
createLinkWithLeftArticle = async (media: MediaItem) => {
|
||||
if (!this.sight.left_article || this.sight.left_article === 10000000) {
|
||||
console.warn(
|
||||
"Left article not selected or is a placeholder. Cannot link media yet."
|
||||
"Left article not selected or is a placeholder. Cannot link media yet.",
|
||||
);
|
||||
|
||||
return;
|
||||
@@ -618,7 +630,7 @@ class CreateSightStore {
|
||||
(["ru", "en", "zh"] as Language[]).forEach((lang) => {
|
||||
if (this.sight[lang].left.media) {
|
||||
this.sight[lang].left.media = this.sight[lang].left.media.filter(
|
||||
(m) => m.id !== mediaId
|
||||
(m) => m.id !== mediaId,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -634,13 +646,13 @@ class CreateSightStore {
|
||||
|
||||
const sortArticles = (existing: any[]) => {
|
||||
const articleMap = new Map(
|
||||
existing.map((article) => [article.id, article])
|
||||
existing.map((article) => [article.id, article]),
|
||||
);
|
||||
return articlesIds
|
||||
.map((id) => articleMap.get(id))
|
||||
.filter(
|
||||
(article): article is (typeof existing)[number] =>
|
||||
article !== undefined
|
||||
article !== undefined,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Language,
|
||||
languageInstance,
|
||||
mediaStore,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
|
||||
@@ -270,6 +271,7 @@ class EditSightStore {
|
||||
const response = await languageInstance("ru").post(`/article`, {
|
||||
heading: this.sight.ru.left.heading,
|
||||
body: this.sight.ru.left.body,
|
||||
...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}),
|
||||
});
|
||||
createdLeftArticleId = response.data.id;
|
||||
await languageInstance("en").patch(`/article/${createdLeftArticleId}`, {
|
||||
@@ -412,6 +414,7 @@ class EditSightStore {
|
||||
const response = await languageInstance("ru").post(`/article`, {
|
||||
heading: hasAnyName ? ruName : "",
|
||||
body: "",
|
||||
...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}),
|
||||
});
|
||||
|
||||
this.sight.common.left_article = response.data.id;
|
||||
@@ -510,6 +513,9 @@ class EditSightStore {
|
||||
formData.append("media_name", media_name);
|
||||
}
|
||||
formData.append("type", type.toString());
|
||||
if (selectedCityStore.selectedCityId) {
|
||||
formData.append("city_id", selectedCityStore.selectedCityId.toString());
|
||||
}
|
||||
|
||||
const response = await authInstance.post(`/media`, formData);
|
||||
this.fileToUpload = null;
|
||||
@@ -652,6 +658,7 @@ class EditSightStore {
|
||||
zh: articleZhData.body,
|
||||
},
|
||||
},
|
||||
...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}),
|
||||
});
|
||||
const { id } = articleId.data;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export type Media = {
|
||||
filename: string;
|
||||
media_name: string;
|
||||
media_type: number;
|
||||
city_id?: number | null;
|
||||
};
|
||||
|
||||
class MediaStore {
|
||||
@@ -75,10 +76,11 @@ class MediaStore {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
createMedia = async (name: string, type: string) => {
|
||||
createMedia = async (name: string, type: string, cityId?: number | null) => {
|
||||
const response = await authInstance.post("/media", {
|
||||
media_name: name,
|
||||
media_type: type,
|
||||
...(cityId ? { city_id: cityId } : {}),
|
||||
});
|
||||
runInAction(() => {
|
||||
this.media.push(response.data);
|
||||
|
||||
@@ -53,7 +53,7 @@ class RouteStore {
|
||||
});
|
||||
};
|
||||
|
||||
createRoute = async (route: any) => {
|
||||
createRoute = async (route: any): Promise<number> => {
|
||||
const response = await authInstance.post("/route", route);
|
||||
const id = response.data.id;
|
||||
|
||||
@@ -61,6 +61,8 @@ class RouteStore {
|
||||
this.route[id] = { ...route, id };
|
||||
this.routes.data = [...this.routes.data, { ...route, id }];
|
||||
});
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
deleteRoute = async (id: number) => {
|
||||
@@ -200,6 +202,49 @@ class RouteStore {
|
||||
});
|
||||
};
|
||||
|
||||
sightCounts: Map<number, number> = new Map();
|
||||
stationCounts: Map<number, number> = new Map();
|
||||
countsLoading = false;
|
||||
|
||||
loadCounts = async (routeIds: number[]) => {
|
||||
if (routeIds.length === 0) return;
|
||||
|
||||
runInAction(() => {
|
||||
this.countsLoading = true;
|
||||
});
|
||||
|
||||
const batchSize = 20;
|
||||
for (let i = 0; i < routeIds.length; i += batchSize) {
|
||||
const batch = routeIds.slice(i, i + batchSize);
|
||||
const results = await Promise.allSettled(
|
||||
batch.flatMap((id) => [
|
||||
authInstance.get(`/route/${id}/sight/count`).then((res) => ({ id, type: "sight", data: res.data })),
|
||||
authInstance.get(`/route/${id}/station/count`).then((res) => ({ id, type: "station", data: res.data })),
|
||||
])
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
for (const result of results) {
|
||||
if (result.status === "fulfilled") {
|
||||
const { id, type, data } = result.value;
|
||||
let count = 0;
|
||||
if (typeof data === "number") {
|
||||
count = data;
|
||||
} else if (data && typeof data === "object") {
|
||||
count = Object.values(data).reduce((sum: number, v: any) => sum + (Number(v) || 0), 0);
|
||||
}
|
||||
if (type === "sight") this.sightCounts.set(id, count);
|
||||
else this.stationCounts.set(id, count);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
this.countsLoading = false;
|
||||
});
|
||||
};
|
||||
|
||||
selectedStationId = 0;
|
||||
|
||||
setSelectedStationId = (id: number) => {
|
||||
|
||||
@@ -49,6 +49,50 @@ class SnapshotStore {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
clearStoreCache = () => {
|
||||
runInAction(() => {
|
||||
articlesStore.articleList = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
zh: { data: [], loaded: false },
|
||||
};
|
||||
articlesStore.articlePreview = {};
|
||||
articlesStore.articleData = null;
|
||||
articlesStore.articleMedia = null;
|
||||
|
||||
countryStore.countries = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
zh: { data: [], loaded: false },
|
||||
};
|
||||
|
||||
carrierStore.carriers = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
zh: { data: [], loaded: false },
|
||||
};
|
||||
|
||||
stationsStore.stationLists = {
|
||||
ru: { data: [], loaded: false },
|
||||
en: { data: [], loaded: false },
|
||||
zh: { data: [], loaded: false },
|
||||
};
|
||||
stationsStore.stationPreview = {};
|
||||
|
||||
sightsStore.sights = [];
|
||||
sightsStore.sight = null;
|
||||
|
||||
routeStore.routes = { data: [], loaded: false };
|
||||
|
||||
vehicleStore.vehicles = { data: [], loaded: false };
|
||||
|
||||
userStore.users = { data: [], loaded: false };
|
||||
|
||||
mediaStore.media = [];
|
||||
mediaStore.oneMedia = null;
|
||||
});
|
||||
};
|
||||
|
||||
private clearAllCaches = () => {
|
||||
articlesStore.articleList = {
|
||||
ru: { data: [], loaded: false },
|
||||
@@ -297,6 +341,12 @@ class SnapshotStore {
|
||||
await authInstance.post(`/snapshots/${id}/restore`);
|
||||
};
|
||||
|
||||
createEmptySnapshot = async (name: string) => {
|
||||
await authInstance.post(`/snapshots/empty`, {
|
||||
name: name.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
createSnapshot = async (name: string) => {
|
||||
this.lastRequestId = uuidv4();
|
||||
|
||||
|
||||
@@ -468,7 +468,7 @@ class StationsStore {
|
||||
this.stationLists[language].data.push(response.data);
|
||||
});
|
||||
|
||||
const stationId = response.data.id;
|
||||
const stationId: number = response.data.id;
|
||||
|
||||
for (const lang of ["ru", "en", "zh"].filter(
|
||||
(lang) => lang !== language
|
||||
|
||||
@@ -129,6 +129,8 @@ const transformToRows = (vehicles: Vehicle[]): RowData[] => {
|
||||
|
||||
export const DevicesTable = observer(() => {
|
||||
const canWriteDevices = authStore.canWrite("devices");
|
||||
const canMaintenanceDevices = authStore.hasRole("devices_maintenance_rw");
|
||||
const isMaintenanceOnly = canMaintenanceDevices && !canWriteDevices;
|
||||
const {
|
||||
getDevices,
|
||||
setSelectedDevice,
|
||||
@@ -706,9 +708,24 @@ export const DevicesTable = observer(() => {
|
||||
demoConfirmSubmitting,
|
||||
routes,
|
||||
canWriteDevices,
|
||||
isMaintenanceOnly,
|
||||
],
|
||||
);
|
||||
|
||||
const visibleColumns = useMemo(() => {
|
||||
if (isMaintenanceOnly) {
|
||||
return columns.filter((c) =>
|
||||
["model", "tail_number", "maintenance_mode_on"].includes(c.field),
|
||||
);
|
||||
}
|
||||
if (!canWriteDevices) {
|
||||
return columns.filter(
|
||||
(c) => c.field !== "maintenance_mode_on" && c.field !== "demo_mode_enabled",
|
||||
);
|
||||
}
|
||||
return columns;
|
||||
}, [columns, isMaintenanceOnly, canWriteDevices]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
@@ -900,9 +917,10 @@ export const DevicesTable = observer(() => {
|
||||
<Box sx={{ p: 0 }}>
|
||||
<DataGrid
|
||||
rows={groupRows}
|
||||
columns={columns}
|
||||
columns={visibleColumns}
|
||||
checkboxSelection={canWriteDevices}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -38,6 +38,7 @@ import { Save } from "lucide-react";
|
||||
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { SaveWithoutCityAgree } from "@widgets";
|
||||
|
||||
@@ -50,6 +51,7 @@ export const CreateInformationTab = observer(
|
||||
const { language } = languageStore;
|
||||
const { sight, updateSightInfo, createSight } = createSightStore;
|
||||
const data = sight[language];
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [, setCity] = useState<number>(sight.city_id ?? 0);
|
||||
const [coordinates, setCoordinates] = useState<string>(`0, 0`);
|
||||
@@ -173,14 +175,16 @@ export const CreateInformationTab = observer(
|
||||
return;
|
||||
}
|
||||
|
||||
await createSight(language);
|
||||
const newSightId = await createSight(language);
|
||||
toast.success("Достопримечательность создана");
|
||||
navigate(`/sight/${newSightId}/edit`);
|
||||
};
|
||||
|
||||
const handleConfirmSave = async () => {
|
||||
setIsSaveWarningOpen(false);
|
||||
await createSight(language);
|
||||
const newSightId = await createSight(language);
|
||||
toast.success("Достопримечательность создана");
|
||||
navigate(`/sight/${newSightId}/edit`);
|
||||
};
|
||||
|
||||
const handleCancelSave = () => {
|
||||
@@ -201,7 +205,7 @@ export const CreateInformationTab = observer(
|
||||
>
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
|
||||
<BackButton />
|
||||
<h1 className="text-3xl break-words">{sight[language].name}</h1>
|
||||
<h1 className="text-3xl break-words">{sight[language].name.replace(/\n/g, " ")}</h1>
|
||||
</div>
|
||||
|
||||
<Box
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "@widgets";
|
||||
import { Trash2, ImagePlus, Unlink, Plus, Save, Search } from "lucide-react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
@@ -41,6 +42,7 @@ export const CreateLeftTab = observer(
|
||||
uploadMediaOpen,
|
||||
setUploadMediaOpen,
|
||||
} = editSightStore;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { language } = languageStore;
|
||||
const token = localStorage.getItem("token");
|
||||
@@ -92,7 +94,7 @@ export const CreateLeftTab = observer(
|
||||
>
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
|
||||
<BackButton />
|
||||
<h1 className="text-3xl break-words">{sight[language].name}</h1>
|
||||
<h1 className="text-3xl break-words">{sight[language].name.replace(/\n/g, " ")}</h1>
|
||||
</div>
|
||||
<Paper
|
||||
elevation={2}
|
||||
@@ -449,8 +451,9 @@ export const CreateLeftTab = observer(
|
||||
startIcon={<Save color="white" size={18} />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await createSight(language);
|
||||
const newSightId = await createSight(language);
|
||||
toast.success("Страница создана");
|
||||
navigate(`/sight/${newSightId}/edit`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Paper,
|
||||
Typography,
|
||||
Menu,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Slider,
|
||||
Stack,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
BackButton,
|
||||
createSightStore,
|
||||
editSightStore,
|
||||
languageStore,
|
||||
SelectArticleModal,
|
||||
TabPanel,
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
@@ -22,17 +20,18 @@ import {
|
||||
LanguageSwitcher,
|
||||
MediaArea,
|
||||
MediaAreaForSight,
|
||||
ReactMarkdownComponent,
|
||||
ReactMarkdownEditor,
|
||||
DeleteModal,
|
||||
} from "@widgets";
|
||||
import { ImagePlus, Plus, Save, Trash2, Unlink, X } from "lucide-react";
|
||||
import { Plus, Save, Trash2, Unlink, X } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { MediaViewer } from "../../MediaViewer/index";
|
||||
import { toast } from "react-toastify";
|
||||
import { authInstance } from "@shared";
|
||||
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
||||
import { SightFramePreview } from "../RightWidgetTab/SightFramePreview";
|
||||
|
||||
type MediaItemShared = {
|
||||
id: string;
|
||||
@@ -43,7 +42,6 @@ type MediaItemShared = {
|
||||
|
||||
export const CreateRightTab = observer(
|
||||
({ value, index }: { value: number; index: number }) => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const {
|
||||
sight,
|
||||
createNewRightArticle,
|
||||
@@ -54,21 +52,20 @@ export const CreateRightTab = observer(
|
||||
deleteRightArticleMedia,
|
||||
unlinkRightAritcle,
|
||||
deleteRightArticle,
|
||||
linkExistingRightArticle,
|
||||
createSight,
|
||||
clearCreateSight,
|
||||
updateRightArticles,
|
||||
updateSightInfo,
|
||||
} = createSightStore;
|
||||
const { language } = languageStore;
|
||||
const navigate = useNavigate();
|
||||
const { setFileToUpload, setUploadMediaOpen, uploadMediaOpen } =
|
||||
editSightStore;
|
||||
|
||||
const [selectArticleDialogOpen, setSelectArticleDialogOpen] =
|
||||
useState(false);
|
||||
const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>(
|
||||
null
|
||||
null,
|
||||
);
|
||||
const [type, setType] = useState<"article" | "media">("media");
|
||||
const [previewSection, setPreviewSection] = useState<number>(-1);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] =
|
||||
useState(false);
|
||||
@@ -77,12 +74,34 @@ export const CreateRightTab = observer(
|
||||
>(null);
|
||||
|
||||
const [previewMedia, setPreviewMedia] = useState<Media | null>(null);
|
||||
const shortNameRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const insertNewline = () => {
|
||||
const input = shortNameRef.current;
|
||||
const currentValue = sight[language].name || "";
|
||||
if (!input) {
|
||||
updateSightInfo({ name: currentValue + "\n" }, language);
|
||||
return;
|
||||
}
|
||||
const start = input.selectionStart ?? currentValue.length;
|
||||
const end = input.selectionEnd ?? start;
|
||||
const newValue =
|
||||
currentValue.slice(0, start) + "\n" + currentValue.slice(end);
|
||||
updateSightInfo({ name: newValue }, language);
|
||||
requestAnimationFrame(() => {
|
||||
if (shortNameRef.current) {
|
||||
shortNameRef.current.selectionStart = start + 1;
|
||||
shortNameRef.current.selectionEnd = start + 1;
|
||||
shortNameRef.current.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (sight.preview_media) {
|
||||
const fetchMedia = async () => {
|
||||
const response = await authInstance.get(
|
||||
`/media/${sight.preview_media}`
|
||||
`/media/${sight.preview_media}`,
|
||||
);
|
||||
setPreviewMedia(response.data);
|
||||
};
|
||||
@@ -97,24 +116,17 @@ export const CreateRightTab = observer(
|
||||
) {
|
||||
setActiveArticleIndex(null);
|
||||
setType("media");
|
||||
setPreviewSection(-1);
|
||||
}
|
||||
}, [language, sight[language].right, activeArticleIndex]);
|
||||
|
||||
const openMenu = Boolean(anchorEl);
|
||||
const handleClickMenu = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
const handleCloseMenu = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await createSight(language);
|
||||
const newSightId = await createSight(language);
|
||||
console.log("[handleSave] newSightId:", newSightId, "needLeaveAgree:", createSightStore.needLeaveAgree);
|
||||
toast.success("Достопримечательность успешно создана!");
|
||||
clearCreateSight();
|
||||
setActiveArticleIndex(null);
|
||||
setType("media");
|
||||
navigate(`/sight/${newSightId}/edit`);
|
||||
console.log("[handleSave] navigate called to:", `/sight/${newSightId}/edit`);
|
||||
} catch (error) {
|
||||
console.error("Failed to save sight:", error);
|
||||
toast.error("Ошибка при создании достопримечательности.");
|
||||
@@ -124,48 +136,26 @@ export const CreateRightTab = observer(
|
||||
const handleDisplayArticleFromList = (idx: number) => {
|
||||
setActiveArticleIndex(idx);
|
||||
setType("article");
|
||||
setPreviewSection(idx);
|
||||
};
|
||||
|
||||
const handleCreateNewLocalArticle = async () => {
|
||||
handleCloseMenu();
|
||||
try {
|
||||
const newArticleId = await createNewRightArticle();
|
||||
|
||||
const newIndex = sight[language].right.findIndex(
|
||||
(a) => a.id === newArticleId
|
||||
(a) => a.id === newArticleId,
|
||||
);
|
||||
if (newIndex > -1) {
|
||||
setActiveArticleIndex(newIndex);
|
||||
setType("article");
|
||||
} else {
|
||||
setActiveArticleIndex(sight[language].right.length - 1);
|
||||
setType("article");
|
||||
}
|
||||
const resolvedIndex =
|
||||
newIndex > -1 ? newIndex : sight[language].right.length - 1;
|
||||
setActiveArticleIndex(resolvedIndex);
|
||||
setType("article");
|
||||
setPreviewSection(resolvedIndex);
|
||||
} catch (error) {
|
||||
toast.error("Не удалось создать новую статью.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectExistingArticleAndLink = async (
|
||||
selectedArticleId: number
|
||||
) => {
|
||||
try {
|
||||
const linkedArticleId = await linkExistingRightArticle(
|
||||
selectedArticleId
|
||||
);
|
||||
setSelectArticleDialogOpen(false);
|
||||
const newIndex = sight[language].right.findIndex(
|
||||
(a) => a.id === linkedArticleId
|
||||
);
|
||||
if (newIndex > -1) {
|
||||
setActiveArticleIndex(newIndex);
|
||||
setType("article");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Не удалось привязать существующую статью.");
|
||||
}
|
||||
};
|
||||
|
||||
const currentRightArticle =
|
||||
activeArticleIndex !== null && sight[language].right[activeArticleIndex]
|
||||
? sight[language].right[activeArticleIndex]
|
||||
@@ -176,7 +166,7 @@ export const CreateRightTab = observer(
|
||||
};
|
||||
|
||||
const handleOpenSelectMediaDialog = (
|
||||
target: "sightPreview" | "rightArticle"
|
||||
target: "sightPreview" | "rightArticle",
|
||||
) => {
|
||||
setMediaTarget(target);
|
||||
setIsSelectMediaDialogOpen(true);
|
||||
@@ -220,11 +210,8 @@ export const CreateRightTab = observer(
|
||||
if (sourceIndex === destinationIndex) return;
|
||||
|
||||
const newRightArticles = [...sight[language].right];
|
||||
|
||||
const [movedArticle] = newRightArticles.splice(sourceIndex, 1);
|
||||
|
||||
newRightArticles.splice(destinationIndex, 0, movedArticle);
|
||||
|
||||
updateRightArticles(newRightArticles);
|
||||
};
|
||||
|
||||
@@ -244,22 +231,26 @@ export const CreateRightTab = observer(
|
||||
>
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
|
||||
<BackButton />
|
||||
<h1 className="text-3xl break-words">{sight[language].name}</h1>
|
||||
<h1 className="text-3xl break-words">{sight[language].name.replace(/\n/g, " ")}</h1>
|
||||
</div>
|
||||
|
||||
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}>
|
||||
<Box className="flex flex-col w-[75%] gap-2">
|
||||
<Box className="w-full flex gap-2 ">
|
||||
<Box
|
||||
className="flex flex-col gap-2"
|
||||
sx={{ flexGrow: 1, minWidth: 0 }}
|
||||
>
|
||||
<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="flex flex-col gap-3 max-h-[60vh] overflow-y-auto">
|
||||
<Box
|
||||
onClick={() => {
|
||||
setType("media");
|
||||
setPreviewSection(-1);
|
||||
}}
|
||||
className={`w-full p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-300 ${
|
||||
className={`w-full p-4 rounded-2xl cursor-pointer text-sm transition-all duration-300 ${
|
||||
type === "media"
|
||||
? "bg-green-300 font-semibold"
|
||||
: "bg-green-200"
|
||||
? "bg-blue-400 text-white"
|
||||
: "bg-gray-200 hover:bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
<Typography>Предпросмотр медиа</Typography>
|
||||
@@ -285,16 +276,19 @@ export const CreateRightTab = observer(
|
||||
<Box
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
className={`w-full bg-gray-200 p-4 rounded-2xl text-sm cursor-pointer hover:bg-gray-300 ${
|
||||
className={`w-full p-4 rounded-2xl text-sm cursor-pointer transition-all duration-300 ${
|
||||
snapshot.isDragging
|
||||
? "shadow-lg"
|
||||
: ""
|
||||
? "shadow-lg bg-gray-200"
|
||||
: activeArticleIndex ===
|
||||
index &&
|
||||
type === "article"
|
||||
? "bg-blue-400 text-white"
|
||||
: "bg-gray-200 hover:bg-gray-300"
|
||||
}`}
|
||||
onClick={() => {
|
||||
handleDisplayArticleFromList(
|
||||
index
|
||||
index,
|
||||
);
|
||||
setType("article");
|
||||
}}
|
||||
>
|
||||
<Box {...provided.dragHandleProps}>
|
||||
@@ -305,7 +299,7 @@ export const CreateRightTab = observer(
|
||||
</Box>
|
||||
)}
|
||||
</Draggable>
|
||||
)
|
||||
),
|
||||
)
|
||||
: null}
|
||||
{provided.placeholder}
|
||||
@@ -317,33 +311,10 @@ export const CreateRightTab = observer(
|
||||
</Box>
|
||||
<button
|
||||
className="w-10 h-10 bg-blue-500 rounded-full absolute bottom-5 left-5 flex items-center justify-center hover:bg-blue-600"
|
||||
onClick={handleClickMenu}
|
||||
aria-controls={openMenu ? "add-article-menu" : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={openMenu ? "true" : undefined}
|
||||
onClick={handleCreateNewLocalArticle}
|
||||
>
|
||||
<Plus size={20} color="white" />
|
||||
</button>
|
||||
<Menu
|
||||
id="add-article-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={openMenu}
|
||||
onClose={handleCloseMenu}
|
||||
MenuListProps={{ "aria-labelledby": "basic-button" }}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
<MenuItem onClick={handleCreateNewLocalArticle}>
|
||||
<Typography>Создать новую</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setSelectArticleDialogOpen(true);
|
||||
handleCloseMenu();
|
||||
}}
|
||||
>
|
||||
<Typography>Выбрать существующую статью</Typography>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
|
||||
{type === "article" && currentRightArticle ? (
|
||||
@@ -359,6 +330,7 @@ export const CreateRightTab = observer(
|
||||
unlinkRightAritcle(currentRightArticle.id);
|
||||
setActiveArticleIndex(null);
|
||||
setType("media");
|
||||
setPreviewSection(-1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -369,9 +341,7 @@ export const CreateRightTab = observer(
|
||||
color="error"
|
||||
size="small"
|
||||
startIcon={<Trash2 size={18} />}
|
||||
onClick={async () => {
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
@@ -395,7 +365,7 @@ export const CreateRightTab = observer(
|
||||
activeArticleIndex,
|
||||
language,
|
||||
e.target.value,
|
||||
currentRightArticle.body
|
||||
currentRightArticle.body,
|
||||
)
|
||||
}
|
||||
variant="outlined"
|
||||
@@ -410,7 +380,7 @@ export const CreateRightTab = observer(
|
||||
activeArticleIndex,
|
||||
language,
|
||||
currentRightArticle.heading,
|
||||
mdValue || ""
|
||||
mdValue || "",
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -432,225 +402,168 @@ export const CreateRightTab = observer(
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
) : type === "media" ? (
|
||||
<Box className="w-[80%] border border-gray-300 rounded-2xl relative flex items-center justify-center">
|
||||
<>
|
||||
{type === "media" && (
|
||||
<Box className="w-[80%] h-full rounded-2xl relative flex items-center justify-center">
|
||||
{previewMedia && (
|
||||
<>
|
||||
<Box className="absolute top-4 right-4 z-10">
|
||||
<button
|
||||
className="w-10 h-10 flex items-center justify-center z-10"
|
||||
onClick={handleUnlinkPreviewMedia}
|
||||
>
|
||||
<X size={20} color="red" />
|
||||
</button>
|
||||
</Box>
|
||||
|
||||
<Box className="w-1/2 h-1/2">
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: previewMedia.id || "",
|
||||
media_type: previewMedia.media_type,
|
||||
filename: previewMedia.filename || "",
|
||||
}}
|
||||
fullWidth
|
||||
fullHeight
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!previewMedia && (
|
||||
<Box className="w-full h-full flex justify-center items-center">
|
||||
<Box
|
||||
sx={{
|
||||
maxWidth: "500px",
|
||||
maxHeight: "100%",
|
||||
display: "flex",
|
||||
flexGrow: 1,
|
||||
margin: "0 auto",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<MediaAreaForSight
|
||||
onFinishUpload={(mediaId) => {
|
||||
linkPreviewMedia(mediaId);
|
||||
}}
|
||||
onFilesDrop={() => {}}
|
||||
contextObjectName={sight[language].name}
|
||||
contextType="sight"
|
||||
isArticle={false}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
</Box>
|
||||
) : (
|
||||
<Box className="w-[80%] h-[70vh] border border-gray-300 rounded-2xl p-3 flex justify-center items-center">
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Выберите статью слева или секцию "Предпросмотр медиа"
|
||||
</Typography>
|
||||
<Box className="w-[80%] border border-gray-300 rounded-2xl relative flex justify-center items-center">
|
||||
{sight.preview_media && (
|
||||
<Box className="w-full h-full rounded-2xl relative flex items-center justify-center">
|
||||
{previewMedia && (
|
||||
<>
|
||||
<Box className="absolute top-4 right-4 z-10">
|
||||
<button
|
||||
className="w-10 h-10 flex items-center justify-center z-10"
|
||||
onClick={handleUnlinkPreviewMedia}
|
||||
>
|
||||
<X size={20} color="red" />
|
||||
</button>
|
||||
</Box>
|
||||
<Box className="w-1/2 h-1/2 flex justify-center items-center">
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: previewMedia.id || "",
|
||||
media_type: previewMedia.media_type,
|
||||
filename: previewMedia.filename || "",
|
||||
}}
|
||||
fullWidth
|
||||
fullHeight
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!sight.preview_media && (
|
||||
<Box className="w-full h-full flex justify-center items-center">
|
||||
<Box
|
||||
sx={{
|
||||
maxWidth: "500px",
|
||||
maxHeight: "100%",
|
||||
display: "flex",
|
||||
flexGrow: 1,
|
||||
margin: "0 auto",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<MediaAreaForSight
|
||||
onFinishUpload={(mediaId) => {
|
||||
linkPreviewMedia(mediaId);
|
||||
}}
|
||||
onFilesDrop={() => {}}
|
||||
contextObjectName={sight[language].name}
|
||||
contextType="sight"
|
||||
isArticle={false}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box className="w-[25%] mr-10">
|
||||
{type === "article" && activeArticleIndex !== null && (
|
||||
<Paper
|
||||
className="flex-1 flex flex-col max-w-[500px]"
|
||||
sx={{
|
||||
borderRadius: "10px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
elevation={2}
|
||||
>
|
||||
<Box
|
||||
className=" overflow-hidden"
|
||||
<Box
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
width: "550px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{type === "media" && (
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<TextField
|
||||
type="number"
|
||||
label="Размер шрифта превью (px)"
|
||||
size="small"
|
||||
value={sight.preview_font_size ?? ""}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === "") {
|
||||
updateSightInfo({ preview_font_size: undefined });
|
||||
return;
|
||||
}
|
||||
const val = Math.max(
|
||||
1,
|
||||
Math.min(300, Math.round(Number(raw))),
|
||||
);
|
||||
if (Number.isFinite(val)) {
|
||||
updateSightInfo({ preview_font_size: val });
|
||||
}
|
||||
}}
|
||||
slotProps={{ input: { min: 1, max: 300 } }}
|
||||
sx={{ width: "200px" }}
|
||||
/>
|
||||
<Slider
|
||||
value={sight.preview_font_size ?? 40}
|
||||
min={1}
|
||||
max={300}
|
||||
step={1}
|
||||
onChange={(_, newValue) => {
|
||||
if (typeof newValue === "number") {
|
||||
updateSightInfo({ preview_font_size: newValue });
|
||||
}
|
||||
}}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
{type === "media" && (
|
||||
<Stack direction="row" spacing={1} alignItems="flex-start">
|
||||
<TextField
|
||||
label="Полное название (поддерживает перенос ↵)"
|
||||
multiline
|
||||
minRows={1}
|
||||
maxRows={4}
|
||||
size="small"
|
||||
value={sight[language].name}
|
||||
onChange={(e) =>
|
||||
updateSightInfo({ name: e.target.value }, language)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
insertNewline();
|
||||
}
|
||||
}}
|
||||
inputRef={shortNameRef}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={insertNewline}
|
||||
title="Вставить перенос строки"
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
background: "#877361",
|
||||
borderColor: "grey.300",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minWidth: 40,
|
||||
height: 40,
|
||||
fontSize: 18,
|
||||
p: 0,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{sight[language].right[activeArticleIndex].media.length >
|
||||
0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
width: "100%",
|
||||
padding: "2px 2px 0px 2px",
|
||||
|
||||
"& img": {
|
||||
borderTopLeftRadius: "10px",
|
||||
borderTopRightRadius: "10px",
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MediaViewer
|
||||
media={
|
||||
sight[language].right[activeArticleIndex].media[0]
|
||||
}
|
||||
fullWidth
|
||||
fullHeight
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: 200,
|
||||
flexShrink: 0,
|
||||
|
||||
backgroundColor: "rgba(0,0,0,0.1)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<ImagePlus size={48} color="white" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 1,
|
||||
wordBreak: "break-word",
|
||||
fontSize: "24px",
|
||||
fontWeight: 700,
|
||||
lineHeight: "120%",
|
||||
backdropFilter: "blur(12px)",
|
||||
borderBottom: "1px solid #A89F90",
|
||||
boxShadow:
|
||||
"inset 4px 4px 12px 0 rgba(255,255,255,0.12)",
|
||||
background:
|
||||
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" color="white">
|
||||
{sight[language].right[activeArticleIndex].heading ||
|
||||
"Выберите статью"}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
padding: 1,
|
||||
minHeight: "200px",
|
||||
maxHeight: "300px",
|
||||
overflowY: "auto",
|
||||
"&::-webkit-scrollbar": {
|
||||
display: "none",
|
||||
},
|
||||
"&": {
|
||||
scrollbarWidth: "none",
|
||||
},
|
||||
background:
|
||||
"rgba(179, 165, 152, 0.4), linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 100%)",
|
||||
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
{sight[language].right[activeArticleIndex].body ? (
|
||||
<ReactMarkdownComponent
|
||||
value={sight[language].right[activeArticleIndex].body}
|
||||
/>
|
||||
) : (
|
||||
<Typography
|
||||
color="rgba(255,255,255,0.7)"
|
||||
sx={{ textAlign: "center", mt: 4 }}
|
||||
>
|
||||
Предпросмотр статьи появится здесь
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "24px",
|
||||
fontWeight: 700,
|
||||
lineHeight: "120%",
|
||||
flexWrap: "wrap",
|
||||
|
||||
gap: 1,
|
||||
backdropFilter: "blur(12px)",
|
||||
boxShadow:
|
||||
"inset 4px 4px 12px 0 rgba(255,255,255,0.12)",
|
||||
background:
|
||||
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
|
||||
}}
|
||||
>
|
||||
{sight[language].right.length > 0 &&
|
||||
sight[language].right.map((article, index) => (
|
||||
<button
|
||||
className={`inline-block text-left text-xs text-white ${
|
||||
activeArticleIndex === index ? "underline" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
setActiveArticleIndex(index);
|
||||
setType("article");
|
||||
}}
|
||||
>
|
||||
{article.heading}
|
||||
</button>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
↵
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
<SightFramePreview
|
||||
sightName={sight[language].name}
|
||||
previewMedia={previewMedia}
|
||||
articles={sight[language].right}
|
||||
onArticleSelect={(idx) => {
|
||||
handleDisplayArticleFromList(idx);
|
||||
}}
|
||||
previewFontSize={sight.preview_font_size}
|
||||
selectedSection={previewSection}
|
||||
onSectionChange={(section) => {
|
||||
setPreviewSection(section);
|
||||
if (section === -1) {
|
||||
setType("media");
|
||||
} else {
|
||||
handleDisplayArticleFromList(section);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -662,7 +575,6 @@ export const CreateRightTab = observer(
|
||||
right: 0,
|
||||
padding: 2,
|
||||
backgroundColor: "background.paper",
|
||||
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
@@ -680,12 +592,6 @@ export const CreateRightTab = observer(
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<SelectArticleModal
|
||||
open={selectArticleDialogOpen}
|
||||
onClose={() => setSelectArticleDialogOpen(false)}
|
||||
onSelectArticle={handleSelectExistingArticleAndLink}
|
||||
linkedArticleIds={sight[language].right.map((article) => article.id)}
|
||||
/>
|
||||
<UploadMediaDialog
|
||||
open={uploadMediaOpen}
|
||||
onClose={() => {
|
||||
@@ -715,9 +621,18 @@ export const CreateRightTab = observer(
|
||||
open={isDeleteModalOpen}
|
||||
onDelete={async () => {
|
||||
try {
|
||||
const idx = activeArticleIndex ?? 0;
|
||||
await deleteRightArticle(currentRightArticle?.id || 0);
|
||||
setActiveArticleIndex(null);
|
||||
setType("media");
|
||||
setIsDeleteModalOpen(false);
|
||||
if (idx > 0) {
|
||||
setActiveArticleIndex(idx - 1);
|
||||
setPreviewSection(idx - 1);
|
||||
setType("article");
|
||||
} else {
|
||||
setActiveArticleIndex(null);
|
||||
setPreviewSection(-1);
|
||||
setType("media");
|
||||
}
|
||||
toast.success("Статья удалена");
|
||||
} catch {
|
||||
toast.error("Не удалось удалить статью");
|
||||
@@ -727,5 +642,5 @@ export const CreateRightTab = observer(
|
||||
/>
|
||||
</TabPanel>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -207,7 +207,7 @@ export const InformationTab = observer(
|
||||
>
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
|
||||
<BackButton />
|
||||
<h1 className="text-3xl break-words">{sight[language].name}</h1>
|
||||
<h1 className="text-3xl break-words">{sight[language].name.replace(/\n/g, " ")}</h1>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
SelectArticleModal,
|
||||
UploadMediaDialog,
|
||||
Language,
|
||||
articlesStore,
|
||||
} from "@shared";
|
||||
import {
|
||||
LanguageSwitcher,
|
||||
@@ -85,31 +84,7 @@ export const LeftWidgetTab = observer(
|
||||
) => {
|
||||
setIsSelectArticleDialogOpen(false);
|
||||
|
||||
const ruArticle = await articlesStore.getArticle(articleId, "ru");
|
||||
const enArticle = await articlesStore.getArticle(articleId, "en");
|
||||
const zhArticle = await articlesStore.getArticle(articleId, "zh");
|
||||
|
||||
updateSightInfo("ru", {
|
||||
left: {
|
||||
heading: ruArticle.data.heading,
|
||||
body: ruArticle.data.body,
|
||||
media: ruArticle.data.media || [],
|
||||
},
|
||||
});
|
||||
updateSightInfo("en", {
|
||||
left: {
|
||||
heading: enArticle.data.heading,
|
||||
body: enArticle.data.body,
|
||||
media: enArticle.data.media || [],
|
||||
},
|
||||
});
|
||||
updateSightInfo("zh", {
|
||||
left: {
|
||||
heading: zhArticle.data.heading,
|
||||
body: zhArticle.data.body,
|
||||
media: zhArticle.data.media || [],
|
||||
},
|
||||
});
|
||||
await editSightStore.getLeftArticle(articleId);
|
||||
|
||||
updateSightInfo(
|
||||
languageStore.language,
|
||||
@@ -137,7 +112,7 @@ export const LeftWidgetTab = observer(
|
||||
>
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
|
||||
<BackButton />
|
||||
<h1 className="text-3xl break-words">{sight[language].name}</h1>
|
||||
<h1 className="text-3xl break-words">{sight[language].name.replace(/\n/g, " ")}</h1>
|
||||
</div>
|
||||
|
||||
<Paper
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import subtractHomeIcon from "./subtract-home.svg";
|
||||
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
|
||||
import { ReactMarkdownComponent } from "../../ReactMarkdown";
|
||||
import { ThreeViewErrorBoundary } from "../../MediaViewer/ThreeViewErrorBoundary";
|
||||
@@ -34,6 +35,8 @@ interface SightFramePreviewProps {
|
||||
articles: Article[];
|
||||
onArticleSelect: (index: number) => void;
|
||||
previewFontSize?: number;
|
||||
selectedSection: number;
|
||||
onSectionChange: (section: number) => void;
|
||||
}
|
||||
|
||||
// Matches SightFrame.jsx renderCurrentMedia — same structure, same CSS classes
|
||||
@@ -153,11 +156,10 @@ export function SightFramePreview({
|
||||
articles,
|
||||
onArticleSelect,
|
||||
previewFontSize,
|
||||
selectedSection,
|
||||
onSectionChange,
|
||||
}: SightFramePreviewProps) {
|
||||
const token = localStorage.getItem("token") ?? "";
|
||||
|
||||
// -1 = intro (section 0 in SightFrame)
|
||||
const [selectedSection, setSelectedSection] = useState<number>(-1);
|
||||
const [threeViewResetKey, setThreeViewResetKey] = useState(0);
|
||||
const threeViewControlRef = useRef<ThreeViewHandle | null>(null);
|
||||
|
||||
@@ -175,6 +177,17 @@ export function SightFramePreview({
|
||||
// Replicates processedSightName from SightFrame.jsx
|
||||
const processedSightName = useMemo(() => {
|
||||
if (!sightName) return sightName;
|
||||
|
||||
// Handle \n line breaks
|
||||
if (sightName.includes("\n")) {
|
||||
return sightName.split("\n").map((line, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <br />}
|
||||
{line}
|
||||
</React.Fragment>
|
||||
));
|
||||
}
|
||||
|
||||
const namePattern =
|
||||
/([А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]+)/g;
|
||||
const parts = sightName.split(namePattern);
|
||||
@@ -199,10 +212,9 @@ export function SightFramePreview({
|
||||
// Replicates titleLineHeight from SightFrame.jsx
|
||||
const titleLineHeight = useMemo(() => {
|
||||
if (!sightName) return "120%";
|
||||
const textLength = sightName.length;
|
||||
const calculatedLineHeight = Math.max(
|
||||
100,
|
||||
Math.min(120, 120 - (textLength / 10) * 1)
|
||||
Math.min(120, 120 - (sightName.replace(/\n/g, "").length / 10) * 1)
|
||||
);
|
||||
return `${calculatedLineHeight}%`;
|
||||
}, [sightName]);
|
||||
@@ -272,44 +284,9 @@ export function SightFramePreview({
|
||||
<button
|
||||
className="sfp-back-btn"
|
||||
type="button"
|
||||
onClick={() => setSelectedSection(-1)}
|
||||
onClick={() => onSectionChange(-1)}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="25"
|
||||
viewBox="0 0 20 25"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ display: "block" }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="sfpGradient"
|
||||
x1="0%"
|
||||
y1="0%"
|
||||
x2="100%"
|
||||
y2="100%"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor="rgba(255, 255, 255, 0.2)" />
|
||||
<stop offset="100%" stopColor="rgba(255, 255, 255, 0)" />
|
||||
</linearGradient>
|
||||
<clipPath id="sfpClip">
|
||||
<rect
|
||||
width="20"
|
||||
height="25"
|
||||
fill="white"
|
||||
transform="translate(12.5 0.5) rotate(90)"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clipPath="url(#sfpClip)">
|
||||
<path
|
||||
d="M4.03158 11.0738C4.21879 11.2087 4.34702 11.2766 4.44531 11.3747C6.93048 13.8687 9.41098 16.3665 11.8962 18.8605C12.3408 19.3067 12.5186 19.8207 12.3361 20.4339C12.0281 21.4658 10.7776 21.8393 9.95295 21.1498C9.86309 21.0743 9.78165 20.9894 9.69928 20.9064C6.85279 18.0446 4.00537 15.1817 1.15982 12.3189C0.280876 11.4341 0.281813 10.6456 1.16169 9.75982C4.04 6.86305 6.91457 3.96155 9.80786 1.07986C10.0597 0.828952 10.4144 0.619547 10.7561 0.537482C11.4019 0.382786 12.015 0.72142 12.3193 1.28644C12.6263 1.85334 12.5392 2.56079 12.0806 3.05129C11.7286 3.4286 11.3561 3.78704 10.991 4.15209C8.79601 6.35557 6.60194 8.55904 4.40599 10.7606C4.32362 10.8436 4.22721 10.9106 4.03158 11.0729L4.03158 11.0738Z"
|
||||
fill="url(#sfpGradient)"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<img src={subtractHomeIcon} alt="" width="24" height="21" style={{ display: "block" }} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -320,7 +297,7 @@ export function SightFramePreview({
|
||||
type="button"
|
||||
className={`sfp-sight-frame-menu-point${selectedSection === index ? " active" : ""}`}
|
||||
onClick={() => {
|
||||
setSelectedSection(index);
|
||||
onSectionChange(index);
|
||||
onArticleSelect(index);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -2,8 +2,6 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Typography,
|
||||
Menu,
|
||||
MenuItem,
|
||||
TextField,
|
||||
Slider,
|
||||
Stack,
|
||||
@@ -13,7 +11,6 @@ import {
|
||||
BackButton,
|
||||
editSightStore,
|
||||
languageStore,
|
||||
SelectArticleModal,
|
||||
SelectMediaDialog,
|
||||
TabPanel,
|
||||
UploadMediaDialog,
|
||||
@@ -27,7 +24,7 @@ import {
|
||||
} from "@widgets";
|
||||
import { Plus, Save, Trash2, Unlink, X } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { MediaViewer } from "../../MediaViewer/index";
|
||||
import {
|
||||
@@ -40,8 +37,6 @@ import { SightFramePreview } from "./SightFramePreview";
|
||||
|
||||
export const RightWidgetTab = observer(
|
||||
({ value, index }: { value: number; index: number }) => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
|
||||
const {
|
||||
sight,
|
||||
updateRightArticleInfo,
|
||||
@@ -52,7 +47,6 @@ export const RightWidgetTab = observer(
|
||||
linkPreviewMedia,
|
||||
unlinkRightArticle,
|
||||
deleteRightArticle,
|
||||
linkArticle,
|
||||
deleteRightArticleMedia,
|
||||
createLinkWithRightArticle,
|
||||
setFileToUpload,
|
||||
@@ -61,6 +55,27 @@ export const RightWidgetTab = observer(
|
||||
} = editSightStore;
|
||||
|
||||
const [previewMedia, setPreviewMedia] = useState<any | null>(null);
|
||||
const shortNameRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const insertNewline = () => {
|
||||
const input = shortNameRef.current;
|
||||
const currentValue = sight[language].name || "";
|
||||
if (!input) {
|
||||
updateSightInfo(language, { name: currentValue + "\n" });
|
||||
return;
|
||||
}
|
||||
const start = input.selectionStart ?? currentValue.length;
|
||||
const end = input.selectionEnd ?? start;
|
||||
const newValue = currentValue.slice(0, start) + "\n" + currentValue.slice(end);
|
||||
updateSightInfo(language, { name: newValue });
|
||||
requestAnimationFrame(() => {
|
||||
if (shortNameRef.current) {
|
||||
shortNameRef.current.selectionStart = start + 1;
|
||||
shortNameRef.current.selectionEnd = start + 1;
|
||||
shortNameRef.current.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPreviewMedia = async () => {
|
||||
@@ -94,22 +109,23 @@ export const RightWidgetTab = observer(
|
||||
const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
const [isSelectModalOpen, setIsSelectModalOpen] = useState(false);
|
||||
const [previewSection, setPreviewSection] = useState<number>(-1);
|
||||
const [isSelectMediaModalOpen, setIsSelectMediaModalOpen] = useState(false);
|
||||
const [isDeleteArticleModalOpen, setIsDeleteArticleModalOpen] =
|
||||
useState(false);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const handleDeleteArticle = () => {
|
||||
deleteRightArticle(sight[language].right[activeArticleIndex || 0].id);
|
||||
setActiveArticleIndex(null);
|
||||
};
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
const idx = activeArticleIndex || 0;
|
||||
deleteRightArticle(sight[language].right[idx].id);
|
||||
if (idx > 0) {
|
||||
setActiveArticleIndex(idx - 1);
|
||||
setPreviewSection(idx - 1);
|
||||
setType("article");
|
||||
} else {
|
||||
setActiveArticleIndex(null);
|
||||
setPreviewSection(-1);
|
||||
setType("media");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectArticle = (index: number) => {
|
||||
@@ -119,7 +135,6 @@ export const RightWidgetTab = observer(
|
||||
const handleCreateNew = async () => {
|
||||
try {
|
||||
const newArticleId = await createNewRightArticle();
|
||||
handleClose();
|
||||
|
||||
const newIndex = sight[language].right.findIndex(
|
||||
(article) => article.id === newArticleId
|
||||
@@ -127,38 +142,13 @@ export const RightWidgetTab = observer(
|
||||
if (newIndex > -1) {
|
||||
setActiveArticleIndex(newIndex);
|
||||
setType("article");
|
||||
setPreviewSection(newIndex);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating new article:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectExisting = () => {
|
||||
setIsSelectModalOpen(true);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleCloseSelectModal = () => {
|
||||
setIsSelectModalOpen(false);
|
||||
};
|
||||
|
||||
const handleArticleSelect = async (id: number) => {
|
||||
try {
|
||||
const linkedArticleId = await linkArticle(id);
|
||||
handleCloseSelectModal();
|
||||
|
||||
const newIndex = sight[language].right.findIndex(
|
||||
(article) => article.id === linkedArticleId
|
||||
);
|
||||
if (newIndex > -1) {
|
||||
setActiveArticleIndex(newIndex);
|
||||
setType("article");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error linking article:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMediaSelected = async (media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
@@ -211,7 +201,7 @@ export const RightWidgetTab = observer(
|
||||
>
|
||||
<div className="flex gap-10 items-center mb-5 max-w-[80%]">
|
||||
<BackButton />
|
||||
<h1 className="text-3xl break-words">{sight[language].name}</h1>
|
||||
<h1 className="text-3xl break-words">{sight[language].name.replace(/\n/g, " ")}</h1>
|
||||
</div>
|
||||
|
||||
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}>
|
||||
@@ -220,8 +210,15 @@ export const RightWidgetTab = observer(
|
||||
<Box className="relative w-[20%] h-[70vh] flex flex-col rounded-2xl overflow-y-auto gap-3 border border-gray-300 p-3">
|
||||
<Box className="flex flex-col gap-3 max-h-[60vh] overflow-y-auto">
|
||||
<Box
|
||||
onClick={() => setType("media")}
|
||||
className="w-full bg-green-200 p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-300"
|
||||
onClick={() => {
|
||||
setType("media");
|
||||
setPreviewSection(-1);
|
||||
}}
|
||||
className={`w-full p-4 rounded-2xl cursor-pointer text-sm transition-all duration-300 ${
|
||||
type === "media"
|
||||
? "bg-blue-400 text-white"
|
||||
: "bg-gray-200 hover:bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
<Typography>Предпросмотр медиа</Typography>
|
||||
</Box>
|
||||
@@ -246,14 +243,17 @@ export const RightWidgetTab = observer(
|
||||
<Box
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
className={`w-full bg-gray-200 p-4 rounded-2xl text-sm cursor-pointer hover:bg-gray-300 ${
|
||||
className={`w-full p-4 rounded-2xl text-sm cursor-pointer transition-all duration-300 ${
|
||||
snapshot.isDragging
|
||||
? "shadow-lg"
|
||||
: ""
|
||||
? "shadow-lg bg-gray-200"
|
||||
: activeArticleIndex === index && type === "article"
|
||||
? "bg-blue-400 text-white"
|
||||
: "bg-gray-200 hover:bg-gray-300"
|
||||
}`}
|
||||
onClick={() => {
|
||||
handleSelectArticle(index);
|
||||
setType("article");
|
||||
setPreviewSection(index);
|
||||
}}
|
||||
>
|
||||
<Box {...provided.dragHandleProps}>
|
||||
@@ -276,27 +276,10 @@ export const RightWidgetTab = observer(
|
||||
</Box>
|
||||
<button
|
||||
className="w-10 h-10 bg-blue-500 rounded-full absolute bottom-5 left-5 flex items-center justify-center"
|
||||
onClick={handleClick}
|
||||
onClick={handleCreateNew}
|
||||
>
|
||||
<Plus size={20} color="white" />
|
||||
</button>
|
||||
<Menu
|
||||
id="basic-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{
|
||||
"aria-labelledby": "basic-button",
|
||||
}}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
<MenuItem onClick={handleCreateNew}>
|
||||
<Typography>Создать новую</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleSelectExisting}>
|
||||
<Typography>Выбрать существующую статью</Typography>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
|
||||
{type === "article" && (
|
||||
@@ -458,39 +441,71 @@ export const RightWidgetTab = observer(
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexShrink: 0, width: "550px", display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<TextField
|
||||
type="number"
|
||||
label="Размер шрифта превью (px)"
|
||||
size="small"
|
||||
value={sight.common.preview_font_size ?? ""}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === "") {
|
||||
updateSightInfo(language, { preview_font_size: undefined }, true);
|
||||
return;
|
||||
}
|
||||
const val = Math.max(1, Math.min(300, Math.round(Number(raw))));
|
||||
if (Number.isFinite(val)) {
|
||||
updateSightInfo(language, { preview_font_size: val }, true);
|
||||
}
|
||||
}}
|
||||
slotProps={{ input: { min: 1, max: 300 } }}
|
||||
sx={{ width: "200px" }}
|
||||
/>
|
||||
<Slider
|
||||
value={sight.common.preview_font_size ?? 40}
|
||||
min={1}
|
||||
max={300}
|
||||
step={1}
|
||||
onChange={(_, newValue) => {
|
||||
if (typeof newValue === "number") {
|
||||
updateSightInfo(language, { preview_font_size: newValue }, true);
|
||||
}
|
||||
}}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
</Stack>
|
||||
{type === "media" && (
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<TextField
|
||||
type="number"
|
||||
label="Размер шрифта превью (px)"
|
||||
size="small"
|
||||
value={sight.common.preview_font_size ?? ""}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === "") {
|
||||
updateSightInfo(language, { preview_font_size: undefined }, true);
|
||||
return;
|
||||
}
|
||||
const val = Math.max(1, Math.min(300, Math.round(Number(raw))));
|
||||
if (Number.isFinite(val)) {
|
||||
updateSightInfo(language, { preview_font_size: val }, true);
|
||||
}
|
||||
}}
|
||||
slotProps={{ input: { min: 1, max: 300 } }}
|
||||
sx={{ width: "200px" }}
|
||||
/>
|
||||
<Slider
|
||||
value={sight.common.preview_font_size ?? 40}
|
||||
min={1}
|
||||
max={300}
|
||||
step={1}
|
||||
onChange={(_, newValue) => {
|
||||
if (typeof newValue === "number") {
|
||||
updateSightInfo(language, { preview_font_size: newValue }, true);
|
||||
}
|
||||
}}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
{type === "media" && (
|
||||
<Stack direction="row" spacing={1} alignItems="flex-start">
|
||||
<TextField
|
||||
label="Полное название (поддерживает перенос ↵)"
|
||||
multiline
|
||||
minRows={1}
|
||||
maxRows={4}
|
||||
size="small"
|
||||
value={sight[language].name}
|
||||
onChange={(e) => updateSightInfo(language, { name: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
insertNewline();
|
||||
}
|
||||
}}
|
||||
inputRef={shortNameRef}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={insertNewline}
|
||||
title="Вставить перенос строки"
|
||||
sx={{ minWidth: 40, height: 40, fontSize: 18, p: 0, flexShrink: 0 }}
|
||||
>
|
||||
↵
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
<SightFramePreview
|
||||
sightName={sight[language].name}
|
||||
previewMedia={previewMedia}
|
||||
@@ -498,8 +513,19 @@ export const RightWidgetTab = observer(
|
||||
onArticleSelect={(idx) => {
|
||||
handleSelectArticle(idx);
|
||||
setType("article");
|
||||
setPreviewSection(idx);
|
||||
}}
|
||||
previewFontSize={sight.common.preview_font_size}
|
||||
selectedSection={previewSection}
|
||||
onSectionChange={(section) => {
|
||||
setPreviewSection(section);
|
||||
if (section === -1) {
|
||||
setType("media");
|
||||
} else {
|
||||
handleSelectArticle(section);
|
||||
setType("article");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -557,12 +583,6 @@ export const RightWidgetTab = observer(
|
||||
}}
|
||||
/>
|
||||
|
||||
<SelectArticleModal
|
||||
open={isSelectModalOpen}
|
||||
onClose={handleCloseSelectModal}
|
||||
onSelectArticle={handleArticleSelect}
|
||||
linkedArticleIds={sight[language].right.map((article) => article.id)}
|
||||
/>
|
||||
<SelectMediaDialog
|
||||
open={isSelectMediaModalOpen}
|
||||
onClose={() => setIsSelectMediaModalOpen(false)}
|
||||
|
||||
4
src/widgets/SightTabs/RightWidgetTab/subtract-home.svg
Normal file
4
src/widgets/SightTabs/RightWidgetTab/subtract-home.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="21" viewBox="0 0 24 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.3888 2.85031C11.7638 2.66434 12.1989 2.66434 12.5739 2.85031L21.0037 7.06792C21.4462 7.29108 21.7245 7.73709 21.7245 8.22802V20.145H21.7464C21.7462 20.6134 21.3641 20.9999 20.8843 21H15.7689C15.3788 21 15.0562 20.6874 15.0562 20.2932V13.807C15.0562 12.1333 13.6909 10.7792 12.0033 10.7792C10.3159 10.7794 8.95116 12.1334 8.95115 13.807V20.2932C8.95115 20.68 8.63599 21 8.23847 21H3.10101C2.62861 21 2.23837 20.6209 2.23818 20.145V8.22802C2.23819 7.73714 2.51576 7.2911 2.95818 7.06792L11.3888 2.85031Z" fill="white"/>
|
||||
<path d="M10.7962 0.268599C11.5012 -0.088374 12.3413 -0.0882526 13.0463 0.261335L23.6142 5.54243C23.9592 5.71352 24.1018 6.1379 23.9218 6.48751C23.7943 6.73297 23.5464 6.87397 23.2839 6.87397C23.179 6.87392 23.0663 6.85189 22.9689 6.79987L12.401 1.51877C12.101 1.37 11.7408 1.37 11.4408 1.51877L1.03036 6.79261C0.677932 6.97088 0.250655 6.82952 0.0781632 6.48751C-0.101845 6.14534 0.0407786 5.72096 0.385795 5.54243L10.7962 0.268599Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -37,7 +37,8 @@ export const TestingModeBanner = observer(() => {
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
ПРОВОДИТСЯ ТЕСТИРОВАНИЕ. ПРОСЬБА НЕ ВЗАИМОДЕЙСТВОВАТЬ С АДМИН-ПАНЕЛЬЮ
|
||||
ПРОВОДИТСЯ ТЕСТИРОВАНИЕ. ПРОСЬБА НЕ ВЗАИМОДЕЙСТВОВАТЬ С ПАНЕЛЬЮ
|
||||
АДМИНИСТРИРОВАНИЯ
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user