Compare commits
19 Commits
94f512e0e4
...
feature/we
| Author | SHA1 | Date | |
|---|---|---|---|
| cc38f2e66c | |||
| d4c5db61ea | |||
| 55cdea17ea | |||
| 3725c7f569 | |||
| 2659c6a5b8 | |||
| 1bb3f43979 | |||
| 7e539f550b | |||
| fbf6b0dc9d | |||
| a997cdb198 | |||
| bf45dcdbfc | |||
| 83ccdef790 | |||
| 51d1870198 | |||
| 193f53c029 | |||
| 4bda233b63 | |||
| d758dbffa6 | |||
| 6af95bb449 | |||
| e3469763ce | |||
| 7f8a327329 | |||
| 53b8ce7095 |
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
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "white-nights",
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "white-nights",
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.8",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "white-nights",
|
||||
"private": true,
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.8",
|
||||
"type": "module",
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Router } from "./router";
|
||||
import { CustomTheme } from "@shared";
|
||||
import { CustomTheme, languageStore } from "@shared";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { GlobalErrorBoundary } from "./GlobalErrorBoundary";
|
||||
import { TestingModeBanner } from "@widgets";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
export const App: React.FC = () => (
|
||||
<GlobalErrorBoundary>
|
||||
<ThemeProvider theme={CustomTheme.Light}>
|
||||
<TestingModeBanner />
|
||||
<ToastContainer />
|
||||
<Router />
|
||||
</ThemeProvider>
|
||||
</GlobalErrorBoundary>
|
||||
);
|
||||
export const App: React.FC = observer(() => {
|
||||
React.useEffect(() => {
|
||||
document.documentElement.lang = languageStore.language;
|
||||
}, [languageStore.language]);
|
||||
|
||||
return (
|
||||
<GlobalErrorBoundary>
|
||||
<ThemeProvider theme={CustomTheme.Light}>
|
||||
<TestingModeBanner />
|
||||
<ToastContainer />
|
||||
<Router />
|
||||
</ThemeProvider>
|
||||
</GlobalErrorBoundary>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -92,6 +92,9 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
requiredPermissions.length > 0 &&
|
||||
!requiredPermissions.every((permission) => authStore.canAccess(permission))
|
||||
) {
|
||||
if (location.pathname === "/devices" && authStore.hasRole("devices_maintenance_rw")) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,54 @@ import {
|
||||
// @ts-ignore
|
||||
import { orderStationsByRoute } from "../../utils/routeStationsUtils";
|
||||
import { resamplePath } from "../../utils/animationUtils";
|
||||
import { colorStore } from "../../stores/ColorStore";
|
||||
|
||||
function hexToRgbString(hex: string): string | null {
|
||||
const clean = hex.trim().replace(/^#/, "");
|
||||
const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
|
||||
if (full.length !== 6) return null;
|
||||
const r = parseInt(full.slice(0, 2), 16);
|
||||
const g = parseInt(full.slice(2, 4), 16);
|
||||
const b = parseInt(full.slice(4, 6), 16);
|
||||
return `${r}, ${g}, ${b}`;
|
||||
}
|
||||
|
||||
function darkenHex(hex: string, amount: number): string {
|
||||
const rgb = hexToRgbString(hex);
|
||||
if (!rgb) return hex;
|
||||
const [r, g, b] = rgb.split(",").map(Number);
|
||||
const factor = 1 - amount;
|
||||
const dr = Math.round(r * factor);
|
||||
const dg = Math.round(g * factor);
|
||||
const db = Math.round(b * factor);
|
||||
return `#${dr.toString(16).padStart(2, "0")}${dg.toString(16).padStart(2, "0")}${db.toString(16).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function lightenRgbString(hex: string, amount: number): string | null {
|
||||
const rgb = hexToRgbString(hex);
|
||||
if (!rgb) return null;
|
||||
const [r, g, b] = rgb.split(",").map(Number);
|
||||
const lr = Math.round(r + (255 - r) * amount);
|
||||
const lg = Math.round(g + (255 - g) * amount);
|
||||
const lb = Math.round(b + (255 - b) * amount);
|
||||
return `${lr}, ${lg}, ${lb}`;
|
||||
}
|
||||
|
||||
function applyCarrierColors(carrier: { main_color?: string; left_color?: string; right_color?: string }) {
|
||||
const mainColor = carrier.main_color || "#006f3a";
|
||||
const leftColor = carrier.left_color || "#006f3a";
|
||||
const rightColor = carrier.right_color || "#006f3a";
|
||||
const mainDark = darkenHex(mainColor, 0.3);
|
||||
|
||||
document.documentElement.style.setProperty("--carrier-main", mainColor);
|
||||
document.documentElement.style.setProperty("--carrier-main-rgb", hexToRgbString(mainColor) ?? "0, 111, 58");
|
||||
document.documentElement.style.setProperty("--carrier-main-dark", mainDark);
|
||||
document.documentElement.style.setProperty("--carrier-left", leftColor);
|
||||
document.documentElement.style.setProperty("--carrier-left-rgb", hexToRgbString(leftColor) ?? "0, 111, 58");
|
||||
document.documentElement.style.setProperty("--carrier-right", rightColor);
|
||||
document.documentElement.style.setProperty("--carrier-right-rgb", hexToRgbString(rightColor) ?? "0, 111, 58");
|
||||
document.documentElement.style.setProperty("--carrier-right-menu-rgb", lightenRgbString(rightColor, 0.38) ?? "179, 165, 152");
|
||||
}
|
||||
|
||||
class ApiStore {
|
||||
isLoading = true;
|
||||
@@ -63,6 +111,7 @@ class ApiStore {
|
||||
simulationDirection: 1 | -1 = 1;
|
||||
simulationPaused = false;
|
||||
simulationInstantMove = false;
|
||||
showHitboxes = false;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
@@ -114,6 +163,10 @@ class ApiStore {
|
||||
|
||||
getCarrier = async () => {
|
||||
this.carrier = await getCarrier(this.route!.carrier_id!);
|
||||
applyCarrierColors(this.carrier);
|
||||
if (this.carrier.main_color) {
|
||||
colorStore.setMainColor(this.carrier.main_color);
|
||||
}
|
||||
};
|
||||
|
||||
getCity = async () => {
|
||||
@@ -191,6 +244,10 @@ class ApiStore {
|
||||
this.simulationInstantMove = !this.simulationInstantMove;
|
||||
};
|
||||
|
||||
toggleShowHitboxes = () => {
|
||||
this.showHitboxes = !this.showHitboxes;
|
||||
};
|
||||
|
||||
startPositionSimulation = () => {
|
||||
if (this.positionInterval) return;
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ export type GetRouteResponse = {
|
||||
center_latitude: number;
|
||||
center_longitude: number;
|
||||
governor_appeal: number;
|
||||
button_text?: string;
|
||||
id: number;
|
||||
path: [number, number][];
|
||||
rotate: number;
|
||||
@@ -78,6 +79,9 @@ export type GetCarrierResponse = {
|
||||
logo: string;
|
||||
short_name: string;
|
||||
slogan: string;
|
||||
main_color?: string;
|
||||
left_color?: string;
|
||||
right_color?: string;
|
||||
};
|
||||
|
||||
export type GetCityResponse = {
|
||||
|
||||
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 |
@@ -37,14 +37,14 @@ const Fullscreen3DModal = ({ isOpen, onClose, fileUrl }) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="fullscreen-3d-actions">
|
||||
<button title="Увеличить масштаб" disabled={scale >= 1}>
|
||||
<button disabled={scale >= 1}>
|
||||
<img src={scale_plus} alt="Увеличить" />
|
||||
</button>
|
||||
<button title="Уменьшить масштаб" disabled={scale <= 0.1}>
|
||||
<button disabled={scale <= 0.1}>
|
||||
<img src={scale_minus} alt="Уменьшить" />
|
||||
</button>
|
||||
|
||||
<button onPointerUp={onClose} title="Закрыть">
|
||||
<button onPointerUp={onClose}>
|
||||
<img src={closeIcon} alt="Закрыть" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -411,13 +411,13 @@ const ListOfSights = observer(() => {
|
||||
}, [currentSelectedSight]);
|
||||
|
||||
return (
|
||||
<div className="right-widget">
|
||||
<div className="right-widget" lang={selectedLanguageRight}>
|
||||
{currentSelectedSight && (
|
||||
<SightFrame
|
||||
key={currentSelectedSight.id}
|
||||
media={sightFrameMedia}
|
||||
sight_id={currentSelectedSight.id}
|
||||
sight_name={currentSelectedSight.short_name || currentSelectedSight.name}
|
||||
sight_name={currentSelectedSight.name}
|
||||
selectedLanguageRight={selectedLanguageRight}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
|
||||
const LANGUAGES = {
|
||||
EN: "en",
|
||||
@@ -18,6 +18,26 @@ const ListHeader = function ListHeader({
|
||||
isTransferWidgetOpen,
|
||||
onBackToNearest,
|
||||
}) {
|
||||
const [isIdle, setIsIdle] = useState(false);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const resetTimer = () => {
|
||||
setIsIdle(false);
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => setIsIdle(true), 15000);
|
||||
};
|
||||
|
||||
const events = ["pointermove", "pointerdown", "touchstart", "keydown"];
|
||||
events.forEach((e) => window.addEventListener(e, resetTimer));
|
||||
resetTimer();
|
||||
|
||||
return () => {
|
||||
clearTimeout(timerRef.current);
|
||||
events.forEach((e) => window.removeEventListener(e, resetTimer));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getTitle = () => {
|
||||
return selectedLanguageRight === LANGUAGES.RU
|
||||
? "Достопримечательности"
|
||||
@@ -41,14 +61,11 @@ const ListHeader = function ListHeader({
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="21"
|
||||
height="12"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 21 12"
|
||||
fill="none"
|
||||
style={{
|
||||
transform: isOpen ? "rotate(180deg)" : "rotate(0deg)",
|
||||
transition: "transform 0.15s ease-in-out",
|
||||
}}
|
||||
className={`chevron-svg${isOpen ? " is-open" : ""}${isIdle ? " is-idle" : ""}`}
|
||||
>
|
||||
<g clipPath="url(#clip0_658_91932)">
|
||||
<path
|
||||
|
||||
@@ -36,7 +36,7 @@ const SightComponent = function SightComponent({
|
||||
aria-label={`Выбрать достопримечательность ${title}`}
|
||||
>
|
||||
<div className="sight-image">{renderThumbnail()}</div>
|
||||
<div className="sight-title" title={title}>
|
||||
<div className="sight-title">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||
import React, { useState, useEffect, useRef, useMemo, useLayoutEffect, useCallback } from "react";
|
||||
import axios from "axios";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useGeolocationStore } from "../../stores";
|
||||
@@ -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;
|
||||
@@ -39,8 +43,95 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
const [threeViewResetKey, setThreeViewResetKey] = useState(0);
|
||||
const threeViewControlRef = useRef(null);
|
||||
const mediaCache = useRef({});
|
||||
const idleTimerRef = useRef(null);
|
||||
|
||||
const textWrapperRef = useRef(null);
|
||||
const menuRef = useRef(null);
|
||||
const [menuNeedsScroll, setMenuNeedsScroll] = useState(false);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
const updateScrollState = useCallback(() => {
|
||||
const menu = menuRef.current;
|
||||
if (!menu) return;
|
||||
setCanScrollLeft(menu.scrollLeft > 2);
|
||||
setCanScrollRight(menu.scrollLeft + menu.clientWidth < menu.scrollWidth - 2);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const menu = menuRef.current;
|
||||
if (!menu || !menuNeedsScroll) {
|
||||
setCanScrollLeft(false);
|
||||
setCanScrollRight(false);
|
||||
return;
|
||||
}
|
||||
updateScrollState();
|
||||
menu.addEventListener('scroll', updateScrollState);
|
||||
return () => menu.removeEventListener('scroll', updateScrollState);
|
||||
}, [menuNeedsScroll, updateScrollState]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const menu = menuRef.current;
|
||||
if (!menu) return;
|
||||
const children = Array.from(menu.querySelectorAll('.sight-frame-menu-point'));
|
||||
if (children.length < 2) {
|
||||
setMenuNeedsScroll(false);
|
||||
return;
|
||||
}
|
||||
const style = getComputedStyle(menu);
|
||||
const availableWidth = menu.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
||||
const totalChildrenWidth = children.reduce((sum, el) => sum + el.offsetWidth, 0);
|
||||
const evenGap = (availableWidth - totalChildrenWidth) / (children.length + 1);
|
||||
setMenuNeedsScroll(evenGap < 10);
|
||||
}, [articleSections, selectedSection]);
|
||||
|
||||
// Автозакрытие fullscreen 3D при бездействии (60 сек)
|
||||
useEffect(() => {
|
||||
if (!isFullscreen3D) {
|
||||
if (idleTimerRef.current) {
|
||||
clearInterval(idleTimerRef.current);
|
||||
idleTimerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let idleSeconds = 0;
|
||||
|
||||
const checkIdle = () => {
|
||||
idleSeconds += 1;
|
||||
if (idleSeconds >= 60) {
|
||||
setIsFullscreen3D(false);
|
||||
}
|
||||
};
|
||||
|
||||
idleTimerRef.current = setInterval(checkIdle, 1000);
|
||||
|
||||
const resetIdle = () => {
|
||||
idleSeconds = 0;
|
||||
};
|
||||
|
||||
const events = [
|
||||
"mousedown",
|
||||
"mousemove",
|
||||
"keypress",
|
||||
"scroll",
|
||||
"touchstart",
|
||||
"click",
|
||||
];
|
||||
events.forEach((event) => {
|
||||
window.addEventListener(event, resetIdle, { passive: true });
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (idleTimerRef.current) {
|
||||
clearInterval(idleTimerRef.current);
|
||||
idleTimerRef.current = null;
|
||||
}
|
||||
events.forEach((event) => {
|
||||
window.removeEventListener(event, resetIdle);
|
||||
});
|
||||
};
|
||||
}, [isFullscreen3D]);
|
||||
|
||||
const {
|
||||
routeSights,
|
||||
@@ -162,7 +253,10 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
const introSection = {
|
||||
id: media?.id || "intro-title",
|
||||
heading:
|
||||
sight?.short_name || sight?.name || sight_name || "Название достопримечательности",
|
||||
sight?.short_name ||
|
||||
sight?.name ||
|
||||
sight_name ||
|
||||
"Название достопримечательности",
|
||||
body: "",
|
||||
};
|
||||
const allSections = [introSection, ...rightArticles];
|
||||
@@ -240,9 +334,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
alt=""
|
||||
className={className}
|
||||
onError={(e) => {
|
||||
console.warn(
|
||||
`Failed to load image: ${currentMediaData.path}`,
|
||||
);
|
||||
console.warn(`Failed to load image: ${currentMediaData.path}`);
|
||||
e.target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
@@ -257,9 +349,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
playsInline
|
||||
className={className}
|
||||
onError={(e) => {
|
||||
console.warn(
|
||||
`Failed to load video: ${currentMediaData.path}`,
|
||||
);
|
||||
console.warn(`Failed to load video: ${currentMediaData.path}`);
|
||||
e.target.style.display = "none";
|
||||
}}
|
||||
>
|
||||
@@ -309,27 +399,20 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
<button
|
||||
type="button"
|
||||
className="three-d-control-btn"
|
||||
title="Уменьшить"
|
||||
onPointerUp={() =>
|
||||
threeViewControlRef.current?.zoomOut?.()
|
||||
}
|
||||
onPointerUp={() => threeViewControlRef.current?.zoomOut?.()}
|
||||
>
|
||||
<MinusIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="three-d-control-btn"
|
||||
title="Увеличить"
|
||||
onPointerUp={() =>
|
||||
threeViewControlRef.current?.zoomIn?.()
|
||||
}
|
||||
onPointerUp={() => threeViewControlRef.current?.zoomIn?.()}
|
||||
>
|
||||
<PlusIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="three-d-control-btn"
|
||||
title={isFullscreen3D ? "Свернуть" : "Развернуть"}
|
||||
onPointerUp={() => {
|
||||
if (isFullscreen3D) {
|
||||
setIsFullscreen3D(false);
|
||||
@@ -344,11 +427,6 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
</div>
|
||||
<button
|
||||
className={"fullscreen-3d-button"}
|
||||
title={
|
||||
isFullscreen3D
|
||||
? "Закрыть полноэкранный режим"
|
||||
: "Открыть в полноэкранном режиме"
|
||||
}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
@@ -364,6 +442,100 @@ 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(var(--carrier-right-rgb, 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:
|
||||
@@ -464,7 +636,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),
|
||||
@@ -520,7 +692,9 @@ 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>
|
||||
)}
|
||||
@@ -538,60 +712,43 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="sight-frame-menu">
|
||||
{selectedSection !== 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "10px",
|
||||
marginTop: "-4.5px",
|
||||
zIndex: 1,
|
||||
paddingLeft: "15px",
|
||||
paddingRight: "7.5px",
|
||||
paddingTop: "4.5px",
|
||||
paddingBottom: "4.5px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onPointerUp={() => setSelectedSection(0)}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<div className="sight-frame-menu-wrapper">
|
||||
<div className="sight-frame-menu-fade left" style={{ opacity: canScrollLeft ? 1 : 0 }} />
|
||||
<div className="sight-frame-menu-fade right" style={{ opacity: canScrollRight ? 1 : 0 }} />
|
||||
<div
|
||||
className="sight-frame-menu"
|
||||
ref={menuRef}
|
||||
style={menuNeedsScroll ? { justifyContent: 'space-between' } : undefined}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "10px",
|
||||
marginTop: "-4.5px",
|
||||
zIndex: 1,
|
||||
paddingLeft: "15px",
|
||||
paddingRight: "7.5px",
|
||||
paddingTop: "4.5px",
|
||||
paddingBottom: "4.5px",
|
||||
cursor: "pointer",
|
||||
opacity: selectedSection !== 0 ? 1 : 0,
|
||||
transform: selectedSection !== 0 ? "scale(1)" : "scale(0.5)",
|
||||
transition: "opacity 0.3s ease, transform 0.3s ease",
|
||||
pointerEvents: selectedSection !== 0 ? "auto" : "none",
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
setSelectedSection(0);
|
||||
setIsFullscreen3D(false);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={subtractHomeIcon}
|
||||
alt=""
|
||||
width="24"
|
||||
height="21"
|
||||
style={{ display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
{contentError ? (
|
||||
<p className="error-message">{contentError}</p>
|
||||
) : (
|
||||
@@ -599,7 +756,10 @@ 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" : ""
|
||||
@@ -612,6 +772,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -95,21 +95,36 @@ const TransferWidget = observer(function TransferWidget({
|
||||
}
|
||||
|
||||
const getTransferLabel = () => {
|
||||
if (selectedLanguageRight === "ru") {
|
||||
return stationName
|
||||
? `Пересадки остановки ${stationName}:`
|
||||
: "Ближайшая остановка не обнаружена";
|
||||
if (!stationName) {
|
||||
if (selectedLanguageRight === "en") return "Nearest station not found";
|
||||
if (selectedLanguageRight === "zh") return "最近的站点未找到";
|
||||
return "Ближайшая остановка не обнаружена";
|
||||
}
|
||||
|
||||
if (selectedLanguageRight === "en") {
|
||||
return stationName
|
||||
? `Available transfers at station ${stationName}`
|
||||
: "Nearest station not found";
|
||||
return (
|
||||
<>
|
||||
Transfer at stop<br />
|
||||
«{stationName}»:
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return stationName
|
||||
? `在车站可用的换乘:${stationName}`
|
||||
: "最近的站点未找到";
|
||||
if (selectedLanguageRight === "zh") {
|
||||
return (
|
||||
<>
|
||||
换乘站<br />
|
||||
«{stationName}»:
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
Пересадка на остановке<br />
|
||||
«{stationName}»:
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getNoTransfersMessage = () => {
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
}
|
||||
|
||||
.react-markdown-container blockquote {
|
||||
border-left: 4px solid #006F3A;
|
||||
border-left: 4px solid var(--carrier-main, #006F3A);
|
||||
padding-left: 16px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
@@ -80,6 +80,15 @@ export const SimulationSettings = observer(() => {
|
||||
onClick={apiStore.toggleSimulationInstantMove}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
{/* Хитбоксы */}
|
||||
<Row>
|
||||
<span>Хитбоксы</span>
|
||||
<Toggle
|
||||
on={apiStore.showHitboxes}
|
||||
onClick={apiStore.toggleShowHitboxes}
|
||||
/>
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
114deg,
|
||||
rgba(255, 255, 255, 0.1) 8.71%,
|
||||
rgba(255, 255, 255, 0.05) 69.69%
|
||||
), #006F3A;
|
||||
), var(--carrier-main, #006F3A);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
color: white;
|
||||
|
||||
@@ -19,7 +19,11 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
||||
height: 60,
|
||||
top: 0,
|
||||
hasScroll: false,
|
||||
isAtTop: true,
|
||||
isAtBottom: false,
|
||||
});
|
||||
const [visible, setVisible] = useState(false);
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
const update = useCallback(() => {
|
||||
@@ -31,8 +35,11 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
||||
const st = el.scrollTop;
|
||||
const th = ch;
|
||||
|
||||
const isAtTop = st <= 0;
|
||||
const isAtBottom = st + ch >= sh - 1;
|
||||
|
||||
if (sh <= ch) {
|
||||
setState({ height: th, top: 0, hasScroll: false });
|
||||
setState((prev) => ({ ...prev, hasScroll: false, isAtTop: true, isAtBottom: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -41,7 +48,7 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
||||
const scrollRange = sh - ch;
|
||||
const top = range <= 0 ? 0 : (st / scrollRange) * range;
|
||||
|
||||
setState({ height: thumbHeight, top, hasScroll: true });
|
||||
setState({ height: thumbHeight, top, hasScroll: true, isAtTop, isAtBottom });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -68,7 +75,24 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
|
||||
};
|
||||
}, [update]);
|
||||
|
||||
return state;
|
||||
useEffect(() => {
|
||||
if (state.hasScroll) {
|
||||
if (hideTimerRef.current) {
|
||||
clearTimeout(hideTimerRef.current);
|
||||
hideTimerRef.current = null;
|
||||
}
|
||||
setVisible(true);
|
||||
} else {
|
||||
hideTimerRef.current = setTimeout(() => {
|
||||
setVisible(false);
|
||||
}, 200);
|
||||
}
|
||||
return () => {
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||
};
|
||||
}, [state.hasScroll]);
|
||||
|
||||
return { ...state, visible };
|
||||
}
|
||||
|
||||
export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
|
||||
@@ -234,9 +258,12 @@ export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
|
||||
};
|
||||
}, [thumb.hasScroll]);
|
||||
|
||||
const containerClassName = className
|
||||
? `scrollable-container ${className}`
|
||||
: "scrollable-container";
|
||||
const containerClassName = [
|
||||
"scrollable-container",
|
||||
className,
|
||||
thumb.isAtTop ? "is-at-top" : "",
|
||||
thumb.isAtBottom ? "is-at-bottom" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
|
||||
const viewportStyle: React.CSSProperties = maxHeight
|
||||
? {
|
||||
@@ -251,15 +278,19 @@ export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
|
||||
<div ref={scrollableRef} className="scrollable">
|
||||
{children}
|
||||
</div>
|
||||
{thumb.hasScroll && (
|
||||
<div ref={trackRef} className="custom-scrollbar-track">
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="custom-scrollbar-track"
|
||||
style={{ opacity: thumb.hasScroll ? 1 : 0 }}
|
||||
>
|
||||
{thumb.visible && (
|
||||
<div
|
||||
ref={thumbRef}
|
||||
className="custom-scrollbar-thumb"
|
||||
style={{ height: thumb.height, top: thumb.top }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
UNPASSED_STATION_COLOR,
|
||||
BUS_COLOR,
|
||||
BASE_ICON_SIZE,
|
||||
CLUSTER_RADIUS_BASE,
|
||||
} from "./Constants";
|
||||
import { SCALE_FACTOR } from "../../assets/Constants";
|
||||
import { apiStore } from "../../api/ApiStore/store";
|
||||
@@ -156,6 +155,19 @@ const useSightClustering = (
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasCustomIcon =
|
||||
sight.is_default_icon === false && !isMediaIdEmpty(sight.icon ?? null);
|
||||
|
||||
if (hasCustomIcon) {
|
||||
sight.visited = true;
|
||||
clusteredResult.push({
|
||||
type: "point",
|
||||
id: String(sight.id),
|
||||
data: sight,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const clusterSights: SightData[] = [];
|
||||
const queue = [sight];
|
||||
sight.visited = true;
|
||||
@@ -165,8 +177,12 @@ const useSightClustering = (
|
||||
clusterSights.push(current);
|
||||
|
||||
for (const potentialNeighbor of unclusteredSights) {
|
||||
const neighborHasCustomIcon =
|
||||
potentialNeighbor.is_default_icon === false &&
|
||||
!isMediaIdEmpty(potentialNeighbor.icon ?? null);
|
||||
if (
|
||||
!potentialNeighbor.visited &&
|
||||
!neighborHasCustomIcon &&
|
||||
clusterSights.length < 4 &&
|
||||
getDistance(current, potentialNeighbor) < distanceThreshold
|
||||
) {
|
||||
@@ -176,6 +192,10 @@ const useSightClustering = (
|
||||
}
|
||||
}
|
||||
|
||||
for (const leftover of queue) {
|
||||
leftover.visited = false;
|
||||
}
|
||||
|
||||
if (clusterSights.length > 1) {
|
||||
let furthestSight: SightData | null = null;
|
||||
let maxDistanceToPath = -1;
|
||||
@@ -382,12 +402,14 @@ export const WebGLMap = observer(() => {
|
||||
return livePercent;
|
||||
}
|
||||
|
||||
if (
|
||||
sight != null &&
|
||||
typeof sight.icon_size === "number" &&
|
||||
Number.isFinite(sight.icon_size)
|
||||
) {
|
||||
return sight.icon_size;
|
||||
if (sight?.is_default_icon === false) {
|
||||
if (
|
||||
typeof sight.icon_size === "number" &&
|
||||
Number.isFinite(sight.icon_size)
|
||||
) {
|
||||
return sight.icon_size;
|
||||
}
|
||||
return 100;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -797,9 +819,8 @@ export const WebGLMap = observer(() => {
|
||||
const textBlockPositionX = rx + labelOffsetX;
|
||||
const textBlockPositionY = ry + labelOffsetY;
|
||||
|
||||
const nameLines = st.name.replace(/\\n/g, '\n').split('\n');
|
||||
const longestLine = nameLines.reduce((a: string, b: string) => a.length > b.length ? a : b, '');
|
||||
const approximateTextWidth = longestLine.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 +850,8 @@ export const WebGLMap = observer(() => {
|
||||
result.push({
|
||||
x: sx,
|
||||
y: sy,
|
||||
name: st.name.replace(/\\n/g, '\n'),
|
||||
sub: sub ? sub.replace(/\\n/g, '\n') : sub,
|
||||
name: normalizedName,
|
||||
sub: sub ? sub.replace(/\\n|\n/g, "") : sub,
|
||||
anchorX: anchorX,
|
||||
anchorY: anchorY,
|
||||
distance: distanceInPixels,
|
||||
@@ -882,25 +903,36 @@ export const WebGLMap = observer(() => {
|
||||
|
||||
// Сегментный индекс каждой упорядоченной станции на routePath (canvas-пространство)
|
||||
const orderedStationSegs = useMemo(() => {
|
||||
if (!orderedRouteStations || !stationData || routePath.length < 4) return [] as number[];
|
||||
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));
|
||||
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;
|
||||
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 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 px = p1x + cl * dx,
|
||||
py = p1y + cl * dy;
|
||||
const d = Math.hypot(sx - px, sy - py);
|
||||
if (d < bestD) { bestD = d; best = i / 2; }
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = i / 2;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
});
|
||||
@@ -1145,7 +1177,9 @@ export const WebGLMap = observer(() => {
|
||||
const curIdx = apiStore.positionIndex;
|
||||
const prevIdx = prevPositionIndexRef.current;
|
||||
const pathLen = apiStore.route?.path?.length ?? 0;
|
||||
const isWrap = prevIdx >= 0 && pathLen > 0 &&
|
||||
const isWrap =
|
||||
prevIdx >= 0 &&
|
||||
pathLen > 0 &&
|
||||
Math.abs(curIdx - prevIdx) > pathLen / 4;
|
||||
prevPositionIndexRef.current = curIdx;
|
||||
|
||||
@@ -1415,32 +1449,72 @@ export const WebGLMap = observer(() => {
|
||||
|
||||
gl.uniform1f(u_pointSize, pointInnerSizePx);
|
||||
|
||||
if (tramSegIndex >= 0 && orderedRouteStations && stationData && orderedStationSegs.length > 0) {
|
||||
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));
|
||||
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 (isPassed) {
|
||||
passedPts1.push(sx, sy);
|
||||
} else {
|
||||
unpassedPts1.push(sx, sy);
|
||||
}
|
||||
}
|
||||
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.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 (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.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 {
|
||||
gl.uniform4f(u_color_pts, ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255, ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255, (UNPASSED_STATION_COLOR & 0xff) / 255, 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);
|
||||
}
|
||||
@@ -1514,12 +1588,17 @@ export const WebGLMap = observer(() => {
|
||||
const passedStationIds = new Set<string>();
|
||||
const unpassedStationIds = new Set<string>();
|
||||
|
||||
if (tramSegIndex >= 0 && orderedRouteStations && orderedStationSegs.length === orderedRouteStations.length) {
|
||||
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;
|
||||
const isPassed =
|
||||
simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex;
|
||||
if (isPassed) passedStationIds.add(String(station.id));
|
||||
else unpassedStationIds.add(String(station.id));
|
||||
}
|
||||
@@ -1663,11 +1742,26 @@ export const WebGLMap = observer(() => {
|
||||
const sin = Math.sin(rotationAngle);
|
||||
|
||||
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);
|
||||
? 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);
|
||||
? 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[] = [];
|
||||
|
||||
@@ -1767,7 +1861,13 @@ export const WebGLMap = observer(() => {
|
||||
}
|
||||
return best;
|
||||
})();
|
||||
return tramSegIndex !== -1 && seg !== -1 && (simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex);
|
||||
return (
|
||||
tramSegIndex !== -1 &&
|
||||
seg !== -1 &&
|
||||
(simulationDirection === 1
|
||||
? seg < tramSegIndex
|
||||
: seg > tramSegIndex)
|
||||
);
|
||||
})()
|
||||
: false;
|
||||
|
||||
@@ -1800,7 +1900,13 @@ export const WebGLMap = observer(() => {
|
||||
}
|
||||
return best;
|
||||
})();
|
||||
return tramSegIndex !== -1 && seg !== -1 && (simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex);
|
||||
return (
|
||||
tramSegIndex !== -1 &&
|
||||
seg !== -1 &&
|
||||
(simulationDirection === 1
|
||||
? seg < tramSegIndex
|
||||
: seg > tramSegIndex)
|
||||
);
|
||||
})()
|
||||
: false;
|
||||
|
||||
@@ -1826,11 +1932,24 @@ export const WebGLMap = observer(() => {
|
||||
const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255;
|
||||
|
||||
if (startStationData && endStationData) {
|
||||
const startIsPassed = simulationDirection === 1 ? true : isStartPassed;
|
||||
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.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);
|
||||
gl.uniform4f(u_color_pts, endIsPassed ? r_passed : r_unpassed, endIsPassed ? g_passed : g_unpassed, endIsPassed ? b_passed : 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;
|
||||
@@ -2315,7 +2434,7 @@ export const WebGLMap = observer(() => {
|
||||
fontSize: primaryFontSize,
|
||||
textShadow: "0 0 4px rgba(0,0,0,0.6)",
|
||||
pointerEvents: "none",
|
||||
whiteSpace: "pre-line",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{l.name}
|
||||
@@ -2331,7 +2450,7 @@ export const WebGLMap = observer(() => {
|
||||
lineHeight: secondaryLineHeight,
|
||||
color: "#CBCBCB",
|
||||
textShadow: "0 0 3px rgba(0,0,0,0.4)",
|
||||
whiteSpace: "pre-line",
|
||||
whiteSpace: "nowrap",
|
||||
...secondaryPositionStyle,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
@@ -2417,6 +2536,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
|
||||
@@ -2449,14 +2573,8 @@ export const WebGLMap = observer(() => {
|
||||
const rx = cluster.longitude;
|
||||
const ry = cluster.latitude;
|
||||
|
||||
const iconSizePercent = resolveSightIconSizePercent();
|
||||
const iconSize =
|
||||
SIGHT_ICON_BASE_SIZE * clamp(iconSizePercent / 100, 0.1, 10);
|
||||
|
||||
const screenX = (rx * scale + position.x) / dpr;
|
||||
const screenY = (ry * scale + position.y) / dpr;
|
||||
const iconLeft = screenX - iconSize / 2;
|
||||
const iconTop = screenY - iconSize / 2;
|
||||
|
||||
const isExpanded = activeClusterId === cluster.id;
|
||||
const selectedSightInCluster = cluster.sights.find(
|
||||
@@ -2468,6 +2586,20 @@ export const WebGLMap = observer(() => {
|
||||
const selectedIsCustomInCluster =
|
||||
selectedSightInCluster?.is_default_icon === false &&
|
||||
selectedHasIconInCluster;
|
||||
|
||||
const iconSizePercent = resolveSightIconSizePercent(
|
||||
cluster.sights[0],
|
||||
);
|
||||
const clusterCustomScaleFactor = selectedIsCustomInCluster
|
||||
? scale / Math.max(customSightIconBaseScaleRef.current ?? 1, 1e-6)
|
||||
: 1;
|
||||
const iconSize =
|
||||
SIGHT_ICON_BASE_SIZE *
|
||||
clamp(iconSizePercent / 100, 0.1, 10) *
|
||||
clusterCustomScaleFactor;
|
||||
|
||||
const iconLeft = screenX - iconSize;
|
||||
const iconTop = screenY - iconSize;
|
||||
const hasSelectedAltIconInCluster =
|
||||
selectedSightInCluster != null &&
|
||||
selectedIsCustomInCluster &&
|
||||
@@ -2525,7 +2657,7 @@ export const WebGLMap = observer(() => {
|
||||
)
|
||||
: false;
|
||||
|
||||
const badgeColor = "#006F3A";
|
||||
const badgeColor = "var(--carrier-main, #006F3A)";
|
||||
const listPanelWidth = 200;
|
||||
const listItemHeight = 30;
|
||||
const listMaxHeight = 250;
|
||||
@@ -2541,29 +2673,38 @@ export const WebGLMap = observer(() => {
|
||||
onTouchEnd={handleClusterClick}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: iconLeft - CLUSTER_RADIUS_BASE - 10,
|
||||
top: iconTop - CLUSTER_RADIUS_BASE - 10,
|
||||
width: iconSize + CLUSTER_RADIUS_BASE * 2 + 20,
|
||||
height: iconSize + CLUSTER_RADIUS_BASE * 2 + 20,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
left: iconLeft,
|
||||
top: iconTop,
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
pointerEvents: "auto",
|
||||
cursor: "pointer",
|
||||
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" }}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={clusterIconUrl}
|
||||
alt=""
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
style={{
|
||||
display: "block",
|
||||
pointerEvents: "none",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
filter: hasSelectedSight
|
||||
? hasSelectedAltIconInCluster
|
||||
? "none"
|
||||
@@ -2576,6 +2717,7 @@ export const WebGLMap = observer(() => {
|
||||
position: "absolute",
|
||||
top: -6,
|
||||
right: -6,
|
||||
zIndex: 1,
|
||||
width: 15,
|
||||
height: 15,
|
||||
borderRadius: "10px",
|
||||
@@ -2598,16 +2740,20 @@ export const WebGLMap = observer(() => {
|
||||
<div
|
||||
data-expanded-cluster={cluster.id}
|
||||
onTouchStart={handleCircleInteraction}
|
||||
onTouchMove={handleCircleInteraction}
|
||||
onMouseMove={handleCircleInteraction}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: screenX - iconSize / 2,
|
||||
top: screenY - iconSize / 2,
|
||||
left: screenX - iconSize,
|
||||
top: screenY - iconSize,
|
||||
display: "flex",
|
||||
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
|
||||
@@ -2659,15 +2805,14 @@ export const WebGLMap = observer(() => {
|
||||
<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)`,
|
||||
background: `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(var(--carrier-main-rgb, 0, 111, 58), 0.4)`,
|
||||
backdropFilter: "blur(10px)",
|
||||
borderRadius: "8px",
|
||||
width: listPanelWidth,
|
||||
maxHeight: hasMoreThanTwo ? listMaxHeight : undefined,
|
||||
boxShadow:
|
||||
"0 0 0 1px rgba(255, 255, 255, 0.3) inset, 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset",
|
||||
"inset 0 0 0 1px rgba(255, 255, 255, 0.3), inset 4px 4px 12px 0 rgba(255, 255, 255, 0.12)",
|
||||
display: "flex",
|
||||
|
||||
flexDirection: "column",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -2810,7 +2955,6 @@ export const WebGLMap = observer(() => {
|
||||
whiteSpace: "nowrap",
|
||||
flex: 1,
|
||||
}}
|
||||
title={sightName}
|
||||
>
|
||||
{sightName}
|
||||
</span>
|
||||
|
||||
@@ -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" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useGeolocationStore } from "../../stores";
|
||||
import "../../styles/LeftWidget.css";
|
||||
import { apiStore } from "../../api/ApiStore/store";
|
||||
import { apiBaseURL } from "../../api/apiConfig";
|
||||
import { ReactMarkdownComponent } from "../ReactMarkdown";
|
||||
import { TouchableLayout } from "../TouchableLayout";
|
||||
|
||||
const LeftWidget = observer(
|
||||
({ selectedSightId, onClose, isVisible, sightTop }) => {
|
||||
@@ -15,8 +17,7 @@ const LeftWidget = observer(
|
||||
const [isImageLoaded, setIsImageLoaded] = useState(false);
|
||||
const [widgetHeight, setWidgetHeight] = useState(0);
|
||||
|
||||
const textRef = useRef(null);
|
||||
const activeTouch = useRef(null);
|
||||
const layoutRef = useRef(null);
|
||||
const widgetRef = useRef(null);
|
||||
|
||||
const store = useGeolocationStore();
|
||||
@@ -37,64 +38,10 @@ const LeftWidget = observer(
|
||||
}, [selectedSightData, isImageLoaded, isVisible, isLoading, error]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = textRef.current;
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const handleTouchStart = (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.touches.length === 1) {
|
||||
activeTouch.current = {
|
||||
identifier: e.touches[0].identifier,
|
||||
lastY: e.touches[0].clientY,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (e) => {
|
||||
e.preventDefault();
|
||||
if (activeTouch.current) {
|
||||
for (const touch of e.changedTouches) {
|
||||
if (touch.identifier === activeTouch.current.identifier) {
|
||||
const deltaY = touch.clientY - activeTouch.current.lastY;
|
||||
scrollContainer.scrollTop -= deltaY;
|
||||
activeTouch.current.lastY = touch.clientY;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e) => {
|
||||
for (const touch of e.changedTouches) {
|
||||
if (
|
||||
activeTouch.current &&
|
||||
touch.identifier === activeTouch.current.identifier
|
||||
) {
|
||||
activeTouch.current = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
scrollContainer.addEventListener("touchstart", handleTouchStart, {
|
||||
passive: true,
|
||||
});
|
||||
scrollContainer.addEventListener("touchmove", handleTouchMove, {
|
||||
passive: false,
|
||||
});
|
||||
scrollContainer.addEventListener("touchend", handleTouchEnd, {
|
||||
passive: true,
|
||||
});
|
||||
scrollContainer.addEventListener("touchcancel", handleTouchEnd, {
|
||||
passive: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener("touchstart", handleTouchStart);
|
||||
scrollContainer.removeEventListener("touchmove", handleTouchMove);
|
||||
scrollContainer.removeEventListener("touchend", handleTouchEnd);
|
||||
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
|
||||
};
|
||||
if (layoutRef.current) {
|
||||
const scrollable = layoutRef.current.querySelector(".scrollable");
|
||||
if (scrollable) scrollable.scrollTop = 0;
|
||||
}
|
||||
}, [selectedSightData]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -120,8 +67,10 @@ const LeftWidget = observer(
|
||||
selectedLanguage === "ru"
|
||||
? routeSights.find((sight) => sight.id === selectedSightId)
|
||||
: selectedLanguage === "en"
|
||||
? routeSightsEn.find((sight) => sight.id === selectedSightId)
|
||||
: routeSightsZh.find((sight) => sight.id === selectedSightId);
|
||||
? routeSightsEn.find((sight) => sight.id === selectedSightId)
|
||||
: routeSightsZh.find((sight) => sight.id === selectedSightId);
|
||||
|
||||
if (!sight) return;
|
||||
|
||||
const leftArticle = sight.left_article;
|
||||
|
||||
@@ -129,18 +78,20 @@ const LeftWidget = observer(
|
||||
selectedLanguage === "ru"
|
||||
? sightArticles.get(leftArticle + "_" + selectedLanguage)
|
||||
: selectedLanguage === "en"
|
||||
? sightArticlesEn.get(leftArticle + "_" + selectedLanguage)
|
||||
: sightArticlesZh.get(leftArticle + "_" + selectedLanguage);
|
||||
? sightArticlesEn.get(leftArticle + "_" + selectedLanguage)
|
||||
: sightArticlesZh.get(leftArticle + "_" + selectedLanguage);
|
||||
|
||||
if (!leftArticleData?.media?.length) return;
|
||||
|
||||
const media = await ContentAPI.getMediaPreview(
|
||||
leftArticleData.media[0].id,
|
||||
selectedLanguage
|
||||
selectedLanguage,
|
||||
);
|
||||
|
||||
const response = {
|
||||
mediaPath: media.path,
|
||||
mediaType: media.type,
|
||||
title: sight.short_name || sight.name || leftArticleData.heading,
|
||||
title: leftArticleData.heading,
|
||||
text: leftArticleData.body,
|
||||
address: sight.address,
|
||||
};
|
||||
@@ -178,7 +129,7 @@ const LeftWidget = observer(
|
||||
setIsImageLoaded(false);
|
||||
console.error(
|
||||
"Ошибка загрузки изображения для достопримечательности:",
|
||||
selectedSightId
|
||||
selectedSightId,
|
||||
);
|
||||
if (isVisible) {
|
||||
setTimeout(() => {
|
||||
@@ -208,7 +159,7 @@ const LeftWidget = observer(
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={widgetRef} style={widgetTransformStyle} className="left-widget">
|
||||
<div ref={widgetRef} style={widgetTransformStyle} className="left-widget" lang={selectedLanguage}>
|
||||
{isLoading ? (
|
||||
<div>Загрузка информации...</div>
|
||||
) : error ? (
|
||||
@@ -234,9 +185,11 @@ const LeftWidget = observer(
|
||||
<div className="left-widget-address">
|
||||
{selectedSightData.address}
|
||||
</div>
|
||||
<div ref={textRef} className="left-widget-text">
|
||||
{selectedSightData.text}
|
||||
</div>
|
||||
<TouchableLayout ref={layoutRef} className="left-widget-text-scroll">
|
||||
<div className="left-widget-text">
|
||||
<ReactMarkdownComponent value={selectedSightData.text} />
|
||||
</div>
|
||||
</TouchableLayout>
|
||||
</div>
|
||||
</>
|
||||
) : (isVisible || selectedSightData) && !isLoading ? (
|
||||
@@ -244,13 +197,13 @@ const LeftWidget = observer(
|
||||
{selectedLanguage === "ru"
|
||||
? "Выберите достопримечательность для просмотра деталей."
|
||||
: selectedLanguage === "zh"
|
||||
? "选择一个地标来查看详细信息。"
|
||||
: "Select a landmark to view details."}
|
||||
? "选择一个地标来查看详细信息。"
|
||||
: "Select a landmark to view details."}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default LeftWidget;
|
||||
|
||||
@@ -2,7 +2,6 @@ import "../../styles/SideMenu.css";
|
||||
import AppealWidget from "../widgets/AppealWidget";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import gouvermentImage from "../../assets/images/test-image.png";
|
||||
import sideMenuPhoto from "/side-menu-photo.png";
|
||||
import RouteWidget from "../widgets/RouteWidget";
|
||||
import ContentAPI from "../../api/content/content.api";
|
||||
@@ -13,6 +12,7 @@ import StationsList from "./StationsList";
|
||||
import LeftWidget from "./LeftWidget";
|
||||
import { apiStore } from "../../api/ApiStore/store";
|
||||
import { getMediaUrl } from "../../api/apiConfig";
|
||||
import defaultCrest from "../../assets/images/Герб.png";
|
||||
|
||||
const SideMenu = observer(({ onMenuToggle }) => {
|
||||
const {
|
||||
@@ -263,15 +263,20 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const isMenuOpenRef = useRef(isMenuOpen);
|
||||
const handleMenuToggleRef = useRef(handleMenuToggle);
|
||||
useEffect(() => { isMenuOpenRef.current = isMenuOpen; }, [isMenuOpen]);
|
||||
useEffect(() => { handleMenuToggleRef.current = handleMenuToggle; });
|
||||
|
||||
useEffect(() => {
|
||||
// Автоматическое закрытие сайд-меню после 45 секунд бездействия
|
||||
// Автоматическое закрытие сайд-меню после 60 секунд бездействия
|
||||
let idleSeconds = 0;
|
||||
|
||||
const checkIdle = () => {
|
||||
idleSeconds += 1;
|
||||
|
||||
if (idleSeconds >= 45 && isMenuOpen) {
|
||||
handleMenuToggle(false);
|
||||
if (idleSeconds >= 60 && isMenuOpenRef.current) {
|
||||
handleMenuToggleRef.current(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -300,7 +305,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
||||
window.removeEventListener(event, resetIdle);
|
||||
});
|
||||
};
|
||||
}, [isMenuOpen, handleMenuToggle]);
|
||||
}, []);
|
||||
|
||||
// Закрываем и открываем список достопримечательностей при изменении сортировки
|
||||
const prevSortingByRef = useRef(sortingBy);
|
||||
@@ -357,7 +362,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
||||
pointerEvents: "auto",
|
||||
background:
|
||||
isSightsOpen || isStationOpen
|
||||
? `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(76, 175, 75, 0.4)`
|
||||
? `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(var(--carrier-left-rgb, 76, 175, 75), 0.4)`
|
||||
: undefined,
|
||||
backdropFilter:
|
||||
isSightsOpen || isStationOpen ? "blur(10px)" : undefined,
|
||||
@@ -369,13 +374,11 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
||||
"background 0.3s ease, backdrop-filter 0.3s ease, box-shadow 0.3s ease",
|
||||
}}
|
||||
>
|
||||
{designData?.creastPath && (
|
||||
<img
|
||||
className="side-menu-crest"
|
||||
src={designData?.creastPath}
|
||||
alt="Герб"
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
className="side-menu-crest"
|
||||
src={designData?.creastPath || defaultCrest}
|
||||
alt="Герб"
|
||||
/>
|
||||
{carrier?.slogan && (
|
||||
<div className="side-menu-label">{carrier.slogan}</div>
|
||||
)}
|
||||
@@ -444,14 +447,16 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
||||
}}
|
||||
className="appeal-button"
|
||||
>
|
||||
{selectedLanguage == "ru"
|
||||
? "Обращение губернатора"
|
||||
: selectedLanguage == "zh"
|
||||
? "州长致辞"
|
||||
: "Governor's appeal"}
|
||||
{route?.button_text
|
||||
? route.button_text
|
||||
: selectedLanguage == "ru"
|
||||
? "Обращение губернатора"
|
||||
: selectedLanguage == "zh"
|
||||
? "州长致辞"
|
||||
: "Governor's appeal"}
|
||||
</div>
|
||||
)}
|
||||
<div className="side-menu-buttons">
|
||||
<div className="side-menu-buttons" style={{ marginTop: route?.governor_appeal > 0 ? '40px' : '260px' }}>
|
||||
<div
|
||||
onPointerUp={() => {
|
||||
if (!isSightsOpen) {
|
||||
@@ -493,7 +498,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
||||
}, 300);
|
||||
}
|
||||
}}
|
||||
className={`side-menu-button side-menu-button--sights ${
|
||||
className={`side-menu-button ${
|
||||
isSightsOpen ? "side-menu-button--active" : ""
|
||||
}`}
|
||||
>
|
||||
@@ -502,7 +507,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
||||
: selectedLanguage == "zh"
|
||||
? "景点"
|
||||
: "Attractions"}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onPointerUp={() => {
|
||||
if (!isStationOpen) {
|
||||
@@ -550,7 +555,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
||||
: selectedLanguage == "zh"
|
||||
? "车站"
|
||||
: "Stations"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="side-menu-tag">
|
||||
{/* {selectedLanguage == "ru"
|
||||
@@ -579,11 +584,17 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
||||
<RouteWidget />
|
||||
|
||||
<AppealWidget
|
||||
widgetImgPath={gouvermentImage}
|
||||
widgetImgPath={(() => {
|
||||
const m = sightArticles.get(route?.governor_appeal + "_ru")?.media;
|
||||
const mediaId = Array.isArray(m) ? m[0]?.id : m?.id;
|
||||
return mediaId ? getMediaUrl(mediaId) : undefined;
|
||||
})()}
|
||||
isOpen={isWidgetOpen}
|
||||
style={{
|
||||
transform: isWidgetOpen ? "translateX(0)" : "translateX(-200%)",
|
||||
transition: "transform 0.5s ease",
|
||||
zIndex: -1,
|
||||
pointerEvents: isWidgetOpen ? "auto" : "none",
|
||||
}}
|
||||
widgetLabel={
|
||||
selectedLanguage == "ru"
|
||||
|
||||
@@ -64,7 +64,6 @@ const StationItem = ({
|
||||
className="side-menu-sight"
|
||||
onPointerDown={(e) => handlePointerDown(e, station.id)}
|
||||
onPointerUp={(e) => handlePointerUp(e, station.id, handleStationClick)}
|
||||
title={station.name}
|
||||
>
|
||||
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
||||
{station.name}
|
||||
|
||||
@@ -84,6 +84,12 @@ const SightItem = ({
|
||||
return () => window.removeEventListener("resize", checkWidth);
|
||||
}, [sightName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (localSelectedSightId !== sight.id) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
}, [localSelectedSightId, sight.id]);
|
||||
|
||||
const handleClick = (e) => {
|
||||
const newExpanded = !isExpanded;
|
||||
setIsExpanded(newExpanded);
|
||||
@@ -96,16 +102,19 @@ const SightItem = ({
|
||||
const stations = sightStationsCache.get(cacheKey) || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={
|
||||
localSelectedSightId === sight.id
|
||||
? "side-menu-sight-selected-wrapper"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
id={`sight-${sight.id}`}
|
||||
onPointerDown={(e) => handlePointerDown(e, sight.id)}
|
||||
onPointerUp={(e) => handlePointerUp(e, sight.id, handleClick)}
|
||||
className={`side-menu-sight pointer ${
|
||||
localSelectedSightId === sight.id ? "selected" : ""
|
||||
}`}
|
||||
title={sightName}
|
||||
className="side-menu-sight pointer"
|
||||
>
|
||||
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
||||
{sightName}
|
||||
|
||||
@@ -70,7 +70,6 @@ const SightItem = ({
|
||||
className={`side-menu-sight pointer ${
|
||||
localSelectedSightId === sight.id ? "selected" : ""
|
||||
}`}
|
||||
title={sightName}
|
||||
>
|
||||
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
||||
{sightName}
|
||||
|
||||
@@ -11,6 +11,42 @@ import { apiStore } from "../../api/ApiStore/store";
|
||||
import { useClickDetection } from "../../hooks/useClickDetection";
|
||||
import { TouchableLayout } from "../TouchableLayout";
|
||||
|
||||
const SightTransferItem = ({ name, style, onPointerUp }) => {
|
||||
const containerRef = useRef(null);
|
||||
const textRef = useRef(null);
|
||||
const [shouldAnimate, setShouldAnimate] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const checkWidth = () => {
|
||||
if (containerRef.current && textRef.current) {
|
||||
const containerWidth = containerRef.current.offsetWidth;
|
||||
const textWidth = textRef.current.scrollWidth;
|
||||
const shouldAnimateValue = textWidth > containerWidth;
|
||||
setShouldAnimate(shouldAnimateValue);
|
||||
if (shouldAnimateValue) {
|
||||
containerRef.current.style.setProperty("--container-width", `${containerWidth}px`);
|
||||
}
|
||||
}
|
||||
};
|
||||
checkWidth();
|
||||
window.addEventListener("resize", checkWidth);
|
||||
return () => window.removeEventListener("resize", checkWidth);
|
||||
}, [name]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="side-menu-sight-transfer pointer"
|
||||
style={style}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StationItem = ({
|
||||
station,
|
||||
handlePointerDown,
|
||||
@@ -75,7 +111,13 @@ const StationItem = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={
|
||||
selectedStationId === station.id
|
||||
? "side-menu-sight-selected-wrapper"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="side-menu-sight"
|
||||
@@ -88,7 +130,6 @@ const StationItem = ({
|
||||
);
|
||||
}
|
||||
}}
|
||||
title={station.name}
|
||||
>
|
||||
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
||||
{station.name}
|
||||
@@ -101,9 +142,9 @@ const StationItem = ({
|
||||
>
|
||||
{sights.length > 0 ? (
|
||||
sights.map((sight, index) => (
|
||||
<div
|
||||
<SightTransferItem
|
||||
key={sight.id}
|
||||
className="side-menu-sight-transfer pointer"
|
||||
name={getSightName(sight)}
|
||||
style={{
|
||||
borderBottom:
|
||||
index < sights.length - 1
|
||||
@@ -115,19 +156,11 @@ const StationItem = ({
|
||||
onPointerUp={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onSightClick) {
|
||||
// Вычисляем позицию элемента для правильного позиционирования левого виджета
|
||||
const element = e.currentTarget;
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
|
||||
// Используем позицию элемента относительно viewport (elementRect.top)
|
||||
// чтобы верхняя граница виджета совпадала с верхней границей элемента
|
||||
const elementTop = elementRect.top;
|
||||
onSightClick(sight.id, elementTop);
|
||||
const elementRect = e.currentTarget.getBoundingClientRect();
|
||||
onSightClick(sight.id, elementRect.top);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{getSightName(sight)}
|
||||
</div>
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="side-menu-sight-transfer-empty">
|
||||
|
||||
@@ -1,13 +1,50 @@
|
||||
import '../../styles/AppealWidget.css'
|
||||
import { useRef, useEffect } from "react";
|
||||
import "../../styles/AppealWidget.css";
|
||||
import { TouchableLayout } from "../TouchableLayout";
|
||||
import { ReactMarkdownComponent } from "../ReactMarkdown";
|
||||
|
||||
function AppealWidget({widgetImgPath, widgetLabel, widgetText, style}) {
|
||||
return (
|
||||
<div style={style} className='dynamic-widget'>
|
||||
<img className='dynamic-widget-image' src={widgetImgPath} />
|
||||
<div className='dynamic-widget-label'>{widgetLabel}</div>
|
||||
<div className='dynamic-widget-text'>{widgetText}</div>
|
||||
function AppealWidget({
|
||||
widgetImgPath,
|
||||
widgetLabel,
|
||||
widgetText,
|
||||
style,
|
||||
isOpen,
|
||||
}) {
|
||||
const stopProp = (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
};
|
||||
const layoutRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && layoutRef.current) {
|
||||
const scrollable = layoutRef.current.querySelector(".scrollable");
|
||||
if (scrollable) scrollable.scrollTop = 0;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className="dynamic-widget"
|
||||
onPointerDown={stopProp}
|
||||
onPointerMove={stopProp}
|
||||
onPointerUp={stopProp}
|
||||
>
|
||||
{widgetImgPath && (
|
||||
<img className="dynamic-widget-image" src={widgetImgPath} />
|
||||
)}
|
||||
<div className="dynamic-widget-label">{widgetLabel}</div>
|
||||
<TouchableLayout
|
||||
ref={layoutRef}
|
||||
className="dynamic-widget-text-scroll"
|
||||
>
|
||||
<div className="dynamic-widget-text">
|
||||
<ReactMarkdownComponent value={widgetText} />
|
||||
</div>
|
||||
);
|
||||
</TouchableLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppealWidget
|
||||
export default AppealWidget;
|
||||
|
||||
@@ -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">
|
||||
@@ -85,16 +92,14 @@ const RouteWidget = observer(() => {
|
||||
className={`route-widget-label ${
|
||||
shouldAnimate(startStation?.name, 18) ? "marquee" : ""
|
||||
} ${getLabelSizeClass(startStation?.name)}`}
|
||||
title={startStation?.name}
|
||||
>
|
||||
>
|
||||
{startStation?.name}
|
||||
</div>
|
||||
<div
|
||||
className={`route-widget-label ${
|
||||
shouldAnimate(endStation?.name, 18) ? "marquee" : ""
|
||||
} ${getLabelSizeClass(endStation?.name)}`}
|
||||
title={endStation?.name}
|
||||
>
|
||||
>
|
||||
{endStation?.name}
|
||||
</div>
|
||||
{(selectedLanguage === "en" || selectedLanguage === "ru") && (
|
||||
@@ -102,8 +107,7 @@ const RouteWidget = observer(() => {
|
||||
className={`route-widget-subtitle ${
|
||||
shouldAnimate(routeEnSubtitle, 50) ? "marquee" : ""
|
||||
}`}
|
||||
title={routeEnSubtitle}
|
||||
>
|
||||
>
|
||||
{routeEnSubtitle}
|
||||
</div>
|
||||
)}
|
||||
@@ -112,8 +116,7 @@ const RouteWidget = observer(() => {
|
||||
className={`route-widget-subtitle ${
|
||||
shouldAnimate(routeZhSubtitle, 50) ? "marquee" : ""
|
||||
}`}
|
||||
title={routeZhSubtitle}
|
||||
>
|
||||
>
|
||||
{routeZhSubtitle}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Canvas, useThree } from "@react-three/fiber";
|
||||
import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
|
||||
import React, { useEffect, Suspense } from "react";
|
||||
import { Canvas, useThree, useFrame } from "@react-three/fiber";
|
||||
import { OrbitControls, Center, useGLTF } from "@react-three/drei";
|
||||
import React, { useEffect, useRef, Suspense, useCallback } from "react";
|
||||
import { BACKGROUND_COLOR } from "../../assets/Constants";
|
||||
import * as THREE from "three";
|
||||
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
|
||||
@@ -23,6 +23,7 @@ interface ThreeViewProps {
|
||||
const ZOOM_FACTOR = 1.2;
|
||||
const MIN_DISTANCE = 1;
|
||||
const MAX_DISTANCE = 100;
|
||||
const CAMERA_FOV = 40;
|
||||
|
||||
const TouchController = () => {
|
||||
const { camera, controls, gl } = useThree();
|
||||
@@ -197,6 +198,47 @@ const AutoResize = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const FitCamera = ({
|
||||
groupRef,
|
||||
onReady,
|
||||
}: {
|
||||
groupRef: React.RefObject<THREE.Group>;
|
||||
onReady: () => void;
|
||||
}) => {
|
||||
const { camera, controls } = useThree();
|
||||
const fitted = useRef(false);
|
||||
|
||||
useFrame(() => {
|
||||
if (fitted.current) return;
|
||||
const group = groupRef.current;
|
||||
if (!group || group.children.length === 0) return;
|
||||
|
||||
const box = new THREE.Box3().setFromObject(group);
|
||||
const sphere = new THREE.Sphere();
|
||||
box.getBoundingSphere(sphere);
|
||||
|
||||
if (sphere.radius === 0) return;
|
||||
|
||||
const fov = THREE.MathUtils.degToRad(CAMERA_FOV);
|
||||
const dist = sphere.radius / Math.sin(fov / 2);
|
||||
|
||||
camera.position.set(0, 0, dist);
|
||||
camera.lookAt(0, 0, 0);
|
||||
camera.updateProjectionMatrix();
|
||||
|
||||
if (controls) {
|
||||
const orbit = controls as unknown as OrbitControlsImpl;
|
||||
orbit.target.set(0, 0, 0);
|
||||
orbit.update();
|
||||
}
|
||||
|
||||
fitted.current = true;
|
||||
onReady();
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const Model = ({
|
||||
fileUrl,
|
||||
onLoad,
|
||||
@@ -233,21 +275,37 @@ export const ThreeView: React.FC<ThreeViewProps> = ({
|
||||
onError,
|
||||
controlRef,
|
||||
}) => {
|
||||
const [isReady, setIsReady] = React.useState(false);
|
||||
const groupRef = useRef<THREE.Group>(null!);
|
||||
|
||||
const handleReady = useCallback(() => {
|
||||
setIsReady(true);
|
||||
onLoad?.();
|
||||
}, [onLoad]);
|
||||
|
||||
return (
|
||||
<div style={{ width, height, position: "relative", overflow: "hidden" }}>
|
||||
{!isReady && (
|
||||
<div style={{
|
||||
position: "absolute", inset: 0,
|
||||
backgroundColor: `#${BACKGROUND_COLOR.toString(16).padStart(6, "0")}`,
|
||||
zIndex: 1,
|
||||
}} />
|
||||
)}
|
||||
<Canvas
|
||||
gl={{
|
||||
antialias: true,
|
||||
toneMappingExposure: 1.5,
|
||||
outputColorSpace: THREE.SRGBColorSpace,
|
||||
}}
|
||||
camera={{ position: [0, 0, 5], fov: 40 }}
|
||||
camera={{ position: [0, 0, 50], fov: CAMERA_FOV }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
onError={(e: any) => onError?.(e.message)}
|
||||
>
|
||||
<AutoResize />
|
||||
<TouchController />
|
||||
{controlRef && <ZoomController controlRef={controlRef} />}
|
||||
<FitCamera groupRef={groupRef} onReady={handleReady} />
|
||||
<color attach="background" args={[BACKGROUND_COLOR]} />
|
||||
<ambientLight intensity={0.8} />
|
||||
<directionalLight position={[30, 30, 30]} intensity={1.2} />
|
||||
@@ -265,23 +323,18 @@ export const ThreeView: React.FC<ThreeViewProps> = ({
|
||||
<pointLight position={[0, 30, 0]} intensity={0.6} />
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<Stage
|
||||
environment={null}
|
||||
intensity={1}
|
||||
castShadow={false}
|
||||
shadows={false}
|
||||
adjustCamera={true}
|
||||
center={{ precise: true }}
|
||||
>
|
||||
<Model fileUrl={fileUrl} onLoad={onLoad} />
|
||||
</Stage>
|
||||
<Center precise>
|
||||
<group ref={groupRef}>
|
||||
<Model fileUrl={fileUrl} />
|
||||
</group>
|
||||
</Center>
|
||||
</Suspense>
|
||||
|
||||
<OrbitControls
|
||||
makeDefault
|
||||
enableZoom={true}
|
||||
enablePan={true}
|
||||
target={[50, 50, 50]}
|
||||
target={[0, 0, 0]}
|
||||
minDistance={1}
|
||||
maxDistance={100}
|
||||
enableDamping={true}
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
.side-menu-sights-block {
|
||||
height: calc(60%);
|
||||
overflow-y: scroll;
|
||||
margin-left: 20px;
|
||||
margin-left: 0;
|
||||
margin-top: 8px;
|
||||
margin-right: 5px;
|
||||
padding-right: 5px;
|
||||
touch-action: none; /* Отключаем стандартные действия */
|
||||
overscroll-behavior: contain; /* Предотвращаем прокрутку родительских элементов */
|
||||
}
|
||||
|
||||
@@ -1,21 +1,45 @@
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
|
||||
const COLOR_WHITE = { h: 151, s: 0, l: 100 };
|
||||
const COLOR_GREEN = { h: 151, s: 100, l: 22 };
|
||||
|
||||
const TRANSITION_DURATION = 60000;
|
||||
const TICK_INTERVAL = 100;
|
||||
const TICK_STEP = TICK_INTERVAL / TRANSITION_DURATION;
|
||||
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
const clean = hex.trim().replace(/^#/, "");
|
||||
const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
|
||||
if (full.length !== 6) return null;
|
||||
return {
|
||||
r: parseInt(full.slice(0, 2), 16),
|
||||
g: parseInt(full.slice(2, 4), 16),
|
||||
b: parseInt(full.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
function interpolateRgb(
|
||||
from: { r: number; g: number; b: number },
|
||||
to: { r: number; g: number; b: number },
|
||||
t: number
|
||||
): string {
|
||||
const r = Math.round(from.r + (to.r - from.r) * t);
|
||||
const g = Math.round(from.g + (to.g - from.g) * t);
|
||||
const b = Math.round(from.b + (to.b - from.b) * t);
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
const WHITE = { r: 255, g: 255, b: 255 };
|
||||
const DEFAULT_MAIN = { r: 0, g: 111, b: 58 };
|
||||
|
||||
interface ColorStore {
|
||||
currentColor: string;
|
||||
setCurrentColor: (color: string) => void;
|
||||
setMainColor: (hex: string) => void;
|
||||
startColorAnimation: () => void;
|
||||
stopColorAnimation: () => void;
|
||||
}
|
||||
|
||||
class ColorStore implements ColorStore {
|
||||
currentColor: string = "#fff";
|
||||
private mainColor: { r: number; g: number; b: number } = DEFAULT_MAIN;
|
||||
private progress: number = 0;
|
||||
private direction: number = 1;
|
||||
private tickInterval: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -28,12 +52,12 @@ class ColorStore implements ColorStore {
|
||||
this.currentColor = color;
|
||||
};
|
||||
|
||||
private interpolateColor(progress: number): string {
|
||||
const h = Math.round(COLOR_WHITE.h + (COLOR_GREEN.h - COLOR_WHITE.h) * progress);
|
||||
const s = Math.round(COLOR_WHITE.s + (COLOR_GREEN.s - COLOR_WHITE.s) * progress);
|
||||
const l = Math.round(COLOR_WHITE.l + (COLOR_GREEN.l - COLOR_WHITE.l) * progress);
|
||||
return `hsl(${h}, ${s}%, ${l}%)`;
|
||||
}
|
||||
setMainColor = (hex: string) => {
|
||||
const parsed = hexToRgb(hex);
|
||||
if (parsed) {
|
||||
this.mainColor = parsed;
|
||||
}
|
||||
};
|
||||
|
||||
startColorAnimation = () => {
|
||||
if (this.tickInterval) return;
|
||||
@@ -50,7 +74,7 @@ class ColorStore implements ColorStore {
|
||||
this.direction = 1;
|
||||
}
|
||||
|
||||
this.currentColor = this.interpolateColor(this.progress);
|
||||
this.currentColor = interpolateRgb(WHITE, this.mainColor, this.progress);
|
||||
});
|
||||
}, TICK_INTERVAL);
|
||||
};
|
||||
@@ -68,4 +92,4 @@ class ColorStore implements ColorStore {
|
||||
}
|
||||
|
||||
export const colorStore = new ColorStore();
|
||||
export { ColorStore };
|
||||
export { ColorStore };
|
||||
|
||||
@@ -5,37 +5,124 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 420px;
|
||||
max-height: calc(100vh - 150px - 98px);
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(
|
||||
background:
|
||||
linear-gradient(
|
||||
114deg,
|
||||
rgba(255, 255, 255, 0) 8.71%,
|
||||
rgba(255, 255, 255, 0.16) 69.69%
|
||||
),
|
||||
#006F3A;
|
||||
var(--carrier-left, #006f3a);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.dynamic-widget-image {
|
||||
border-radius-top-left: 10px;
|
||||
border-radius-top-right: 10px;
|
||||
padding-top: 4px;
|
||||
margin-left: 4px;
|
||||
margin-right: 4px;
|
||||
width: 412px;
|
||||
padding-top: 2px;
|
||||
margin-left: 2px;
|
||||
margin-right: 2px;
|
||||
width: 416px;
|
||||
border-radius: 10px 10px 0 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.dynamic-widget-label {
|
||||
width: 380px;
|
||||
margin-top: 29px;
|
||||
width: 100%;
|
||||
padding: 10px 20px;
|
||||
box-sizing: border-box;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
border-bottom: 1px solid var(--Glass-stroke, rgba(255, 255, 255, 0.8));
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.2) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
),
|
||||
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.4);
|
||||
}
|
||||
|
||||
.dynamic-widget-text-scroll.scrollable-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
align-self: stretch;
|
||||
margin: 15px 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dynamic-widget-text-scroll .scrollable-viewport {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dynamic-widget-text-scroll .scrollable {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
|
||||
.dynamic-widget-text {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 25px;
|
||||
width: 380px;
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
line-height: 150%;
|
||||
line-height: 135%;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
.dynamic-widget-text .react-markdown-container {
|
||||
font-size: 16px;
|
||||
line-height: 135%;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.dynamic-widget-text .react-markdown-container p {
|
||||
font-size: 16px;
|
||||
line-height: 135%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dynamic-widget-text .react-markdown-container p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.dynamic-widget-text .react-markdown-container h1,
|
||||
.dynamic-widget-text .react-markdown-container h2,
|
||||
.dynamic-widget-text .react-markdown-container h3,
|
||||
.dynamic-widget-text .react-markdown-container h4,
|
||||
.dynamic-widget-text .react-markdown-container h5,
|
||||
.dynamic-widget-text .react-markdown-container h6 {
|
||||
font-size: 18px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dynamic-widget-text .react-markdown-container ul,
|
||||
.dynamic-widget-text .react-markdown-container ol {
|
||||
margin-bottom: 8px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.dynamic-widget-text .react-markdown-container li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dynamic-widget-text .react-markdown-container blockquote {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding-left: 12px;
|
||||
border-left: 3px solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.dynamic-widget-text .react-markdown-container img {
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.dynamic-widget-text .react-markdown-container a {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
rgba(255, 255, 255, 0) 8.71%,
|
||||
rgba(255, 255, 255, 0.16) 69.69%
|
||||
),
|
||||
#006F3A;
|
||||
var(--carrier-left, #006F3A);
|
||||
will-change: transform, opacity;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
@@ -67,17 +67,78 @@
|
||||
line-height: 150%;
|
||||
}
|
||||
|
||||
.left-widget-text {
|
||||
.left-widget-text-scroll.scrollable-container {
|
||||
margin-top: 15px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.left-widget-text-scroll .scrollable-viewport {
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.left-widget-text {
|
||||
color: #fff;
|
||||
font-family: "Roboto";
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
line-height: 135%;
|
||||
max-height: 200px; /* Пример ограничения высоты */
|
||||
overflow-y: auto;
|
||||
touch-action: none;
|
||||
overscroll-behavior: contain;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
.left-widget-text .react-markdown-container {
|
||||
font-size: 16px;
|
||||
line-height: 135%;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.left-widget-text .react-markdown-container p {
|
||||
font-size: 16px;
|
||||
line-height: 135%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.left-widget-text .react-markdown-container p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.left-widget-text .react-markdown-container h1,
|
||||
.left-widget-text .react-markdown-container h2,
|
||||
.left-widget-text .react-markdown-container h3,
|
||||
.left-widget-text .react-markdown-container h4,
|
||||
.left-widget-text .react-markdown-container h5,
|
||||
.left-widget-text .react-markdown-container h6 {
|
||||
font-size: 18px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.left-widget-text .react-markdown-container ul,
|
||||
.left-widget-text .react-markdown-container ol {
|
||||
margin-bottom: 8px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.left-widget-text .react-markdown-container li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.left-widget-text .react-markdown-container blockquote {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding-left: 12px;
|
||||
border-left: 3px solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.left-widget-text .react-markdown-container img {
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.left-widget-text .react-markdown-container a {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.left-widget-image {
|
||||
@@ -93,6 +154,16 @@
|
||||
padding-left: 10px;
|
||||
padding-bottom: 6px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.side-menu-sight-transfer span {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.side-menu-sight-transfer span.marquee-text {
|
||||
animation: side-menu-marquee 14s linear infinite;
|
||||
}
|
||||
|
||||
/* Анимация для списка пересадок */
|
||||
|
||||
@@ -1,3 +1,45 @@
|
||||
@property --fade-top {
|
||||
syntax: "<length>";
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
|
||||
@property --fade-bottom {
|
||||
syntax: "<length>";
|
||||
inherits: false;
|
||||
initial-value: 45px;
|
||||
}
|
||||
|
||||
@keyframes pulse-chevron {
|
||||
0% {
|
||||
transform: rotate(var(--r, 0deg)) translateY(0px) scale(1);
|
||||
}
|
||||
40% {
|
||||
transform: rotate(var(--r, 0deg)) translateY(-4px) scale(1.12);
|
||||
}
|
||||
60% {
|
||||
transform: rotate(var(--r, 0deg)) translateY(-5px) scale(1.14);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(var(--r, 0deg)) translateY(0px) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.chevron-svg {
|
||||
font-size: 20px;
|
||||
animation: pulse-chevron 1.2s ease-in-out infinite;
|
||||
animation-play-state: paused;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.chevron-svg.is-idle {
|
||||
animation-play-state: running;
|
||||
}
|
||||
|
||||
.chevron-svg.is-open {
|
||||
--r: 180deg;
|
||||
}
|
||||
|
||||
.right-widget {
|
||||
position: fixed;
|
||||
right: 32px;
|
||||
@@ -17,7 +59,7 @@
|
||||
rgba(255, 255, 255, 0) 8.71%,
|
||||
rgba(255, 255, 255, 0.16) 69.69%
|
||||
),
|
||||
#006f3a;
|
||||
var(--carrier-right, #806c59);
|
||||
color: white;
|
||||
|
||||
max-height: 68px;
|
||||
@@ -63,7 +105,11 @@
|
||||
border-radius: 10px;
|
||||
width: 128px;
|
||||
|
||||
background-color: #0e8953;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--carrier-right, #806c59) 80%,
|
||||
black
|
||||
);
|
||||
}
|
||||
|
||||
.list-of-sights-title {
|
||||
@@ -90,6 +136,27 @@
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.list-of-sights-content .scrollable {
|
||||
--fade-top: 0px;
|
||||
--fade-bottom: 45px;
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0px,
|
||||
black var(--fade-top),
|
||||
black calc(100% - var(--fade-bottom)),
|
||||
transparent 100%
|
||||
);
|
||||
transition: --fade-top 0.5s ease, --fade-bottom 0.5s ease;
|
||||
}
|
||||
|
||||
.list-of-sights-content:not(.is-at-top) .scrollable {
|
||||
--fade-top: 15px;
|
||||
}
|
||||
|
||||
.list-of-sights-content.is-at-bottom .scrollable {
|
||||
--fade-bottom: 0px;
|
||||
}
|
||||
|
||||
.list-of-sights-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
@@ -103,6 +170,11 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.list-of-sights-content .custom-scrollbar-track {
|
||||
margin-bottom: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sight-component {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -194,7 +266,7 @@
|
||||
rgba(255, 255, 255, 0) 8.71%,
|
||||
rgba(255, 255, 255, 0.16) 69.69%
|
||||
),
|
||||
#006f3a;
|
||||
var(--carrier-right, #806c59);
|
||||
max-height: calc(100vh - 128px);
|
||||
}
|
||||
|
||||
@@ -237,7 +309,7 @@
|
||||
rgba(255, 255, 255, 0.22) 0%,
|
||||
rgba(255, 255, 255, 0.04) 100%
|
||||
),
|
||||
rgba(0, 111, 58, 0.72);
|
||||
rgba(var(--carrier-right-rgb, 128, 108, 89), 0.72);
|
||||
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
||||
box-sizing: border-box;
|
||||
color: white;
|
||||
@@ -246,6 +318,16 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sight-frame-title:not(.intro-title) {
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.2) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
),
|
||||
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.4);
|
||||
}
|
||||
|
||||
.sight-frame-title p {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
@@ -304,7 +386,7 @@
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent 35%,
|
||||
#0e8953 50%,
|
||||
color-mix(in srgb, var(--carrier-right, #806c59) 80%, black) 50%,
|
||||
transparent 65%
|
||||
);
|
||||
border-radius: 3px;
|
||||
@@ -326,13 +408,51 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sight-frame-menu {
|
||||
.sight-frame-menu-wrapper {
|
||||
position: relative;
|
||||
padding: 7px;
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sight-frame-menu-fade {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 120px;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
|
||||
.sight-frame-menu-fade.left {
|
||||
left: 0;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.95),
|
||||
transparent
|
||||
);
|
||||
border-radius: 0 0 0 10px;
|
||||
}
|
||||
|
||||
.sight-frame-menu-fade.right {
|
||||
right: 0;
|
||||
background: linear-gradient(
|
||||
to left,
|
||||
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.95),
|
||||
transparent
|
||||
);
|
||||
border-radius: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.sight-frame-menu {
|
||||
z-index: 10000;
|
||||
position: relative;
|
||||
padding: 7px 60px;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
justify-content: space-evenly;
|
||||
border-radius: 0px 0px 10px 10px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.8);
|
||||
background:
|
||||
@@ -341,11 +461,22 @@
|
||||
rgba(255, 255, 255, 0.2) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
),
|
||||
rgba(0, 111, 58, 0.4);
|
||||
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.4);
|
||||
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
||||
backdrop-filter: blur(10px);
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.sight-frame-menu::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sight-frame-menu {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.sight-frame-menu-point {
|
||||
@@ -356,16 +487,17 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
padding: 8px 12px;
|
||||
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition:
|
||||
background-color 0.1s ease,
|
||||
color 0.1s ease;
|
||||
}
|
||||
|
||||
.sight-frame-menu-point.active {
|
||||
border-bottom: 2px solid #fff;
|
||||
font-weight: 600;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
|
||||
.sight-frame-text-wrapper::-webkit-scrollbar-track {
|
||||
@@ -519,7 +651,8 @@
|
||||
}
|
||||
|
||||
.alphabet {
|
||||
width: 100px;
|
||||
width: 40px;
|
||||
flex-shrink: 0;
|
||||
margin-right: 10px;
|
||||
padding-top: 24px;
|
||||
display: flex;
|
||||
@@ -579,8 +712,9 @@
|
||||
}
|
||||
|
||||
.alphabet-position {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.transfer-button-container {
|
||||
@@ -607,14 +741,14 @@
|
||||
|
||||
position: absolute;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #006f3a;
|
||||
border: 1px solid var(--carrier-main, #006f3a);
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.2) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
),
|
||||
rgba(0, 111, 58, 0.4);
|
||||
rgba(var(--carrier-main-rgb, 0, 111, 58), 0.4);
|
||||
|
||||
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
||||
backdrop-filter: blur(10px);
|
||||
@@ -747,7 +881,7 @@
|
||||
border-radius: 32px;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
background: #006f3a;
|
||||
background: var(--carrier-right, #806c59);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@@ -26,10 +26,12 @@
|
||||
position: fixed;
|
||||
display: inline-flex;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3) inset,
|
||||
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(
|
||||
background:
|
||||
linear-gradient(
|
||||
to bottom right,
|
||||
rgba(255, 255, 255, 0.2) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
@@ -50,7 +52,8 @@
|
||||
height: 96px;
|
||||
background-color: #fcd500;
|
||||
color: black;
|
||||
border-radius: 10px;
|
||||
border-top-left-radius: 10px;
|
||||
border-bottom-left-radius: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -59,6 +62,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;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
rgba(255, 255, 255, 0.2) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
),
|
||||
#006f3a;
|
||||
var(--carrier-left, #006f3a);
|
||||
}
|
||||
|
||||
.side-menu-label {
|
||||
@@ -35,11 +35,13 @@
|
||||
font-size: 16px;
|
||||
margin-top: 120px;
|
||||
font-weight: 500;
|
||||
width: 220px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.side-menu-buttons {
|
||||
width: 220px;
|
||||
margin-top: 260px;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.side-menu-button {
|
||||
@@ -51,10 +53,6 @@
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.side-menu-button--sights {
|
||||
background-color: #fcd500;
|
||||
}
|
||||
|
||||
.side-menu-button--active {
|
||||
background-color: #fcd500;
|
||||
color: #000;
|
||||
@@ -138,10 +136,10 @@
|
||||
}
|
||||
|
||||
3.33% {
|
||||
fill: rgb(76, 175, 75);
|
||||
fill: var(--carrier-left, rgb(76, 175, 75));
|
||||
}
|
||||
50% {
|
||||
fill: rgb(76, 175, 75);
|
||||
fill: var(--carrier-left, rgb(76, 175, 75));
|
||||
}
|
||||
53.33% {
|
||||
fill: #ffffff;
|
||||
@@ -191,7 +189,7 @@
|
||||
top: -2px;
|
||||
width: 100px;
|
||||
height: 7px;
|
||||
background-color: #0e8953;
|
||||
background-color: color-mix(in srgb, var(--carrier-left, #006f3a) 80%, black);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
@@ -207,7 +205,7 @@
|
||||
rgba(255, 255, 255, 0) 8.71%,
|
||||
rgba(255, 255, 255, 0.16) 69.69%
|
||||
),
|
||||
#006f3a;
|
||||
var(--carrier-left, #006f3a);
|
||||
position: absolute;
|
||||
width: 288px;
|
||||
transform: translateY(100%);
|
||||
@@ -231,12 +229,10 @@
|
||||
.side-menu-sights-block {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-left: 20px;
|
||||
margin-top: 8px;
|
||||
touch-action: none;
|
||||
overscroll-behavior: contain;
|
||||
width: auto;
|
||||
max-width: calc(100% - 20px);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
@@ -244,10 +240,10 @@
|
||||
|
||||
.side-menu-sight {
|
||||
padding-bottom: 2px;
|
||||
margin-right: 20px;
|
||||
margin-bottom: 6px;
|
||||
margin-top: 6px;
|
||||
border-bottom: 1px solid #0e8953;
|
||||
border-bottom: 1px solid
|
||||
color-mix(in srgb, var(--carrier-left, #006f3a) 80%, black);
|
||||
font-family: "Roboto";
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
@@ -257,6 +253,12 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.side-menu-sight-selected-wrapper {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
margin-left: -20px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.side-menu-sight > span {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.custom-scrollbar-thumb {
|
||||
@@ -62,11 +63,12 @@
|
||||
}
|
||||
|
||||
.side-menu-sights-block .scrollable-viewport {
|
||||
height: calc(92%);
|
||||
height: calc(98%);
|
||||
}
|
||||
|
||||
.side-menu-sights-block .scrollable {
|
||||
height: 100%;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.list-of-sights-content .scrollable-viewport {
|
||||
|
||||
@@ -17,6 +17,11 @@ const isItemVisible = (item: (typeof NAVIGATION_ITEMS.primary)[number]): boolean
|
||||
);
|
||||
}
|
||||
|
||||
// Пользователь с ролью ТО всегда видит раздел устройств
|
||||
if (item.path === "/devices" && authStore.hasRole("devices_maintenance_rw")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const routePermissions = item.path ? ROUTE_REQUIRED_RESOURCES[item.path] ?? [] : [];
|
||||
const canAccessRoute = routePermissions.every((permission) =>
|
||||
authStore.canAccess(permission),
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { articlesStore } from "@shared";
|
||||
import { articlesStore, selectedCityStore } from "@shared";
|
||||
|
||||
const ArticleCreatePage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
const { articleData } = articlesStore;
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
import { articlesStore, languageStore } from "@shared";
|
||||
import { articlesStore, languageStore, selectedCityStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
const ArticleEditPage: React.FC = observer(() => {
|
||||
@@ -11,6 +11,11 @@ const ArticleEditPage: React.FC = observer(() => {
|
||||
|
||||
const { articleData, getArticle } = articlesStore;
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
|
||||
@@ -31,7 +31,7 @@ export const ArticleListPage = observer(() => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchArticles();
|
||||
}, [language]);
|
||||
}, [language, selectedCityStore.cityVersion]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
@@ -55,11 +55,12 @@ export const ArticleListPage = observer(() => {
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
<button onClick={() => navigate(`/article/${params.row.id}`)}>
|
||||
<button title="Просмотр" onClick={() => navigate(`/article/${params.row.id}`)}>
|
||||
<Eye size={20} className="text-green-500" />
|
||||
</button>
|
||||
{canWriteArticles && (
|
||||
<button
|
||||
title="Удалить"
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
@@ -107,7 +108,9 @@ export const ArticleListPage = observer(() => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
{(rows.length > 0 || searchQuery) && (
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
)}
|
||||
|
||||
<div className="w-full">
|
||||
<DataGrid
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
languageStore,
|
||||
isMediaIdEmpty,
|
||||
useSelectedCity,
|
||||
selectedCityStore,
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
@@ -27,6 +28,56 @@ import {
|
||||
import { useState, useEffect } from "react";
|
||||
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
|
||||
|
||||
type ColorFields = { main_color: string; left_color: string; right_color: string };
|
||||
|
||||
const colorFields = (data: ColorFields) => ({
|
||||
main_color: data.main_color,
|
||||
left_color: data.left_color,
|
||||
right_color: data.right_color,
|
||||
});
|
||||
|
||||
const ColorPickerField = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
}) => (
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<div
|
||||
className="w-10 h-10 rounded border border-gray-300 flex-shrink-0 cursor-pointer overflow-hidden relative"
|
||||
style={{ backgroundColor: value || "#ffffff" }}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
value={value || "#ffffff"}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
value={value}
|
||||
placeholder="#000000"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
InputProps={{
|
||||
endAdornment: value ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("")}
|
||||
className="text-gray-400 hover:text-gray-600 text-xs px-1"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
) : undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CarrierCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const { createCarrierData, setCreateCarrierData } = carrierStore;
|
||||
@@ -43,6 +94,11 @@ export const CarrierCreatePage = observer(() => {
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCities = async () => {
|
||||
if (!authStore.me) {
|
||||
@@ -220,6 +276,69 @@ export const CarrierCreatePage = observer(() => {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="w-full flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<ColorPickerField
|
||||
label="Основной цвет"
|
||||
value={createCarrierData.main_color}
|
||||
onChange={(val) =>
|
||||
setCreateCarrierData(
|
||||
createCarrierData[language].full_name,
|
||||
createCarrierData[language].short_name,
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language,
|
||||
{ ...colorFields(createCarrierData), main_color: val }
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 pl-1">
|
||||
Используется в: виджет обращений, значки на карте, скопление достопримечательностей на карте, информационный виджет
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<ColorPickerField
|
||||
label="Левый цвет"
|
||||
value={createCarrierData.left_color}
|
||||
onChange={(val) =>
|
||||
setCreateCarrierData(
|
||||
createCarrierData[language].full_name,
|
||||
createCarrierData[language].short_name,
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language,
|
||||
{ ...colorFields(createCarrierData), left_color: val }
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 pl-1">
|
||||
Используется в: боковое меню, левый виджет достопримечательности
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<ColorPickerField
|
||||
label="Правый цвет"
|
||||
value={createCarrierData.right_color}
|
||||
onChange={(val) =>
|
||||
setCreateCarrierData(
|
||||
createCarrierData[language].full_name,
|
||||
createCarrierData[language].short_name,
|
||||
createCarrierData.city_id,
|
||||
createCarrierData[language].slogan,
|
||||
selectedMediaId || "",
|
||||
language,
|
||||
{ ...colorFields(createCarrierData), right_color: val }
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 pl-1">
|
||||
Используется в: список достопримечательностей, страница достопримечательности
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||
<ImageUploadCard
|
||||
title="Логотип перевозчика"
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
languageStore,
|
||||
isMediaIdEmpty,
|
||||
LoadingSpinner,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets";
|
||||
@@ -30,6 +31,60 @@ import {
|
||||
UploadMediaDialog,
|
||||
} from "@shared";
|
||||
|
||||
type ColorFields = {
|
||||
main_color: string;
|
||||
left_color: string;
|
||||
right_color: string;
|
||||
};
|
||||
|
||||
const colorFields = (data: ColorFields) => ({
|
||||
main_color: data.main_color,
|
||||
left_color: data.left_color,
|
||||
right_color: data.right_color,
|
||||
});
|
||||
|
||||
const ColorPickerField = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
}) => (
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<div
|
||||
className="w-10 h-10 rounded border border-gray-300 flex-shrink-0 cursor-pointer overflow-hidden relative"
|
||||
style={{ backgroundColor: value || "#ffffff" }}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
value={value || "#ffffff"}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
value={value}
|
||||
placeholder="#000000"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
InputProps={{
|
||||
endAdornment: value ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("")}
|
||||
className="text-gray-400 hover:text-gray-600 text-xs px-1"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
) : undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CarrierEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
@@ -49,6 +104,11 @@ export const CarrierEditPage = observer(() => {
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!id) {
|
||||
@@ -68,13 +128,19 @@ export const CarrierEditPage = observer(() => {
|
||||
const carrierData = await getCarrier(Number(id));
|
||||
|
||||
if (carrierData) {
|
||||
const colors = {
|
||||
main_color: carrierData.ru?.main_color || "",
|
||||
left_color: carrierData.ru?.left_color || "",
|
||||
right_color: carrierData.ru?.right_color || "",
|
||||
};
|
||||
setEditCarrierData(
|
||||
carrierData.ru?.full_name || "",
|
||||
carrierData.ru?.short_name || "",
|
||||
carrierData.ru?.city_id || 0,
|
||||
carrierData.ru?.slogan || "",
|
||||
carrierData.ru?.logo || "",
|
||||
"ru"
|
||||
"ru",
|
||||
colors,
|
||||
);
|
||||
setEditCarrierData(
|
||||
carrierData.en?.full_name || "",
|
||||
@@ -82,7 +148,7 @@ export const CarrierEditPage = observer(() => {
|
||||
carrierData.en?.city_id || 0,
|
||||
carrierData.en?.slogan || "",
|
||||
carrierData.en?.logo || "",
|
||||
"en"
|
||||
"en",
|
||||
);
|
||||
setEditCarrierData(
|
||||
carrierData.zh?.full_name || "",
|
||||
@@ -90,7 +156,7 @@ export const CarrierEditPage = observer(() => {
|
||||
carrierData.zh?.city_id || 0,
|
||||
carrierData.zh?.slogan || "",
|
||||
carrierData.zh?.logo || "",
|
||||
"zh"
|
||||
"zh",
|
||||
);
|
||||
setInitialCityName(carrierData.ru?.city || "");
|
||||
}
|
||||
@@ -129,7 +195,7 @@ export const CarrierEditPage = observer(() => {
|
||||
editCarrierData.city_id,
|
||||
editCarrierData[language].slogan,
|
||||
media.id,
|
||||
language
|
||||
language,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -211,7 +277,7 @@ export const CarrierEditPage = observer(() => {
|
||||
Number(e.target.value),
|
||||
editCarrierData[language].slogan,
|
||||
editCarrierData.logo,
|
||||
language
|
||||
language,
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -235,7 +301,7 @@ export const CarrierEditPage = observer(() => {
|
||||
editCarrierData.city_id,
|
||||
editCarrierData[language].slogan,
|
||||
editCarrierData.logo,
|
||||
language
|
||||
language,
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -252,7 +318,7 @@ export const CarrierEditPage = observer(() => {
|
||||
editCarrierData.city_id,
|
||||
editCarrierData[language].slogan,
|
||||
editCarrierData.logo,
|
||||
language
|
||||
language,
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -268,11 +334,77 @@ export const CarrierEditPage = observer(() => {
|
||||
editCarrierData.city_id,
|
||||
e.target.value,
|
||||
editCarrierData.logo,
|
||||
language
|
||||
language,
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="w-full flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<ColorPickerField
|
||||
label="Основной цвет"
|
||||
value={editCarrierData.main_color}
|
||||
onChange={(val) =>
|
||||
setEditCarrierData(
|
||||
editCarrierData[language].full_name,
|
||||
editCarrierData[language].short_name,
|
||||
editCarrierData.city_id,
|
||||
editCarrierData[language].slogan,
|
||||
editCarrierData.logo,
|
||||
language,
|
||||
{ ...colorFields(editCarrierData), main_color: val },
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 pl-1">
|
||||
Используется в: значки на карте, скопление достопримечательностей
|
||||
на карте, информационный виджет
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<ColorPickerField
|
||||
label="Левый цвет"
|
||||
value={editCarrierData.left_color}
|
||||
onChange={(val) =>
|
||||
setEditCarrierData(
|
||||
editCarrierData[language].full_name,
|
||||
editCarrierData[language].short_name,
|
||||
editCarrierData.city_id,
|
||||
editCarrierData[language].slogan,
|
||||
editCarrierData.logo,
|
||||
language,
|
||||
{ ...colorFields(editCarrierData), left_color: val },
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 pl-1">
|
||||
Используется в: виджет обращений, боковое меню (фон, список
|
||||
остановок), левый виджет достопримечательности
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<ColorPickerField
|
||||
label="Правый цвет"
|
||||
value={editCarrierData.right_color}
|
||||
onChange={(val) =>
|
||||
setEditCarrierData(
|
||||
editCarrierData[language].full_name,
|
||||
editCarrierData[language].short_name,
|
||||
editCarrierData.city_id,
|
||||
editCarrierData[language].slogan,
|
||||
editCarrierData.logo,
|
||||
language,
|
||||
{ ...colorFields(editCarrierData), right_color: val },
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 pl-1">
|
||||
Используется в: список достопримечательностей (фон, карточки),
|
||||
правый виджет достопримечательности
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||
<ImageUploadCard
|
||||
title="Логотип перевозчика"
|
||||
@@ -346,7 +478,7 @@ export const CarrierEditPage = observer(() => {
|
||||
editCarrierData.city_id,
|
||||
editCarrierData[language].slogan,
|
||||
"",
|
||||
language
|
||||
language,
|
||||
);
|
||||
setIsDeleteLogoModalOpen(false);
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { authStore, carrierStore, cityStore, languageStore, SearchInput } from "@shared";
|
||||
import { authStore, carrierStore, cityStore, languageStore, selectedCityStore, SearchInput } from "@shared";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||
@@ -39,7 +39,7 @@ export const CarrierListPage = observer(() => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchData();
|
||||
}, [language]);
|
||||
}, [language, selectedCityStore.cityVersion]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
@@ -98,10 +98,11 @@ export const CarrierListPage = observer(() => {
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
<button onClick={() => navigate(`/carrier/${params.row.id}/edit`)}>
|
||||
<button title="Редактировать" onClick={() => navigate(`/carrier/${params.row.id}/edit`)}>
|
||||
<Pencil size={20} className="text-blue-500" />
|
||||
</button>
|
||||
<button
|
||||
title="Удалить"
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
@@ -161,7 +162,9 @@ export const CarrierListPage = observer(() => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
{(rows.length > 0 || searchQuery) && (
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
@@ -169,6 +172,11 @@ export const CarrierListPage = observer(() => {
|
||||
checkboxSelection={canWriteCarriers}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
onRowDoubleClick={(params) => {
|
||||
if (canWriteCarriers) {
|
||||
navigate(`/carrier/${params.id}/edit`);
|
||||
}
|
||||
}}
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -17,16 +17,24 @@ import {
|
||||
countryStore,
|
||||
languageStore,
|
||||
mediaStore,
|
||||
snapshotStore,
|
||||
isMediaIdEmpty,
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
||||
|
||||
export const CityCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
const { language } = languageStore;
|
||||
const { createCityData, setCreateCityData, setCreateCityWeatherCode } =
|
||||
cityStore;
|
||||
@@ -53,7 +61,13 @@ export const CityCreatePage = observer(() => {
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const ruCityName = createCityData.ru.name.trim();
|
||||
await cityStore.createCity();
|
||||
try {
|
||||
await snapshotStore.createEmptySnapshot(`${ruCityName}_Пустой_Экспорт`);
|
||||
} catch (e) {
|
||||
console.warn("Failed to create empty snapshot for city:", e);
|
||||
}
|
||||
toast.success("Город успешно создан");
|
||||
navigate("/city");
|
||||
} catch (error) {
|
||||
@@ -143,9 +157,9 @@ export const CityCreatePage = observer(() => {
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Код города для погоды"
|
||||
type="number"
|
||||
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">
|
||||
|
||||
@@ -23,12 +23,19 @@ import {
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
||||
|
||||
export const CityEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||
@@ -189,9 +196,9 @@ export const CityEditPage = observer(() => {
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Код города для погоды"
|
||||
type="number"
|
||||
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">
|
||||
|
||||
@@ -114,10 +114,11 @@ export const CityListPage = observer(() => {
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
<button onClick={() => navigate(`/city/${params.row.id}/edit`)}>
|
||||
<button title="Редактировать" onClick={() => navigate(`/city/${params.row.id}/edit`)}>
|
||||
<Pencil size={20} className="text-blue-500" />
|
||||
</button>
|
||||
<button
|
||||
title="Удалить"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleteModalOpen(true);
|
||||
@@ -139,7 +140,10 @@ export const CityListPage = observer(() => {
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl">Города</h1>
|
||||
{canWriteCities && (
|
||||
<CreateButton label="Создать город" path="/city/create" />
|
||||
<CreateButton
|
||||
label="Создать город"
|
||||
path="/city/create"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -155,7 +159,9 @@ export const CityListPage = observer(() => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
{(rows.length > 0 || searchQuery) && (
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={filteredRows}
|
||||
@@ -163,6 +169,11 @@ export const CityListPage = observer(() => {
|
||||
checkboxSelection={canWriteCities}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
onRowDoubleClick={(params) => {
|
||||
if (canWriteCities) {
|
||||
navigate(`/city/${params.id}/edit`);
|
||||
}
|
||||
}}
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
@@ -195,7 +206,11 @@ export const CityListPage = observer(() => {
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? <CircularProgress size={20} /> : "Нет городов"}
|
||||
{isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
"Нет городов"
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -15,11 +15,18 @@ import {
|
||||
RU_COUNTRIES,
|
||||
EN_COUNTRIES,
|
||||
ZH_COUNTRIES,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export const CountryAddPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { createCountryData, setCountryData, createCountry } = countryStore;
|
||||
|
||||
|
||||
@@ -4,12 +4,18 @@ import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { countryStore, languageStore } from "@shared";
|
||||
import { useState } from "react";
|
||||
import { countryStore, languageStore, selectedCityStore } from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
|
||||
export const CountryCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
const { createCountryData, setCountryData, createCountry } = countryStore;
|
||||
|
||||
@@ -4,12 +4,18 @@ import { ArrowLeft, Save } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { countryStore, languageStore, LoadingSpinner } from "@shared";
|
||||
import { countryStore, languageStore, LoadingSpinner, selectedCityStore } from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LanguageSwitcher } from "@widgets";
|
||||
|
||||
export const CountryEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||
const { language } = languageStore;
|
||||
|
||||
@@ -92,7 +92,10 @@ export const CountryListPage = observer(() => {
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl">Страны</h1>
|
||||
{canWriteCountries && (
|
||||
<CreateButton label="Добавить страну" path="/country/add" />
|
||||
<CreateButton
|
||||
label="Добавить страну"
|
||||
path="/country/add"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -108,7 +111,9 @@ export const CountryListPage = observer(() => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
{(rows.length > 0 || searchQuery) && (
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
@@ -148,7 +153,11 @@ export const CountryListPage = observer(() => {
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? <CircularProgress size={20} /> : "Нет стран"}
|
||||
{isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
"Нет стран"
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
cityStore,
|
||||
createSightStore,
|
||||
languageStore,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import {
|
||||
CreateInformationTab,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
} from "@widgets";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { runInAction } from "mobx";
|
||||
|
||||
function a11yProps(index: number) {
|
||||
return {
|
||||
@@ -30,6 +32,11 @@ export const CreateSightPage = observer(() => {
|
||||
const { getArticles } = articlesStore;
|
||||
const needLeave = createSightStore.needLeaveAgree;
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
const handleChange = (_: React.SyntheticEvent, newValue: number) => {
|
||||
setValue(newValue);
|
||||
};
|
||||
@@ -56,6 +63,14 @@ export const CreateSightPage = observer(() => {
|
||||
await authStore.fetchMeCities().catch(() => undefined);
|
||||
}
|
||||
await getArticles(languageStore.language);
|
||||
|
||||
const { selectedCityId, selectedCity } = selectedCityStore;
|
||||
if (selectedCityId && selectedCity && !createSightStore.sight.city_id) {
|
||||
runInAction(() => {
|
||||
createSightStore.sight.city_id = selectedCityId;
|
||||
createSightStore.sight.city = selectedCity.name;
|
||||
});
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
cityStore,
|
||||
editSightStore,
|
||||
LoadingSpinner,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { useBlocker, useParams } from "react-router-dom";
|
||||
|
||||
@@ -25,6 +26,11 @@ export const EditSightPage = observer(() => {
|
||||
const { sight, getSightInfo, needLeaveAgree, getRightArticles } = editSightStore;
|
||||
const { getArticles } = articlesStore;
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
const { id } = useParams();
|
||||
const { getCities } = cityStore;
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
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";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
@@ -20,6 +20,11 @@ export const MediaCreatePage = observer(() => {
|
||||
const [type, setType] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
MEDIA_TYPE_LABELS,
|
||||
languageStore,
|
||||
LoadingSpinner,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { MediaViewer } from "@widgets";
|
||||
|
||||
@@ -42,6 +43,11 @@ export const MediaEditPage = observer(() => {
|
||||
const [mediaType, setMediaType] = useState(media?.media_type ?? 1);
|
||||
const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
mediaStore.getOneMedia(id);
|
||||
|
||||
@@ -78,11 +78,12 @@ export const MediaListPage = observer(() => {
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
<button onClick={() => navigate(`/media/${params.row.id}`)}>
|
||||
<button title="Просмотр" onClick={() => navigate(`/media/${params.row.id}`)}>
|
||||
<Eye size={20} className="text-green-500" />
|
||||
</button>
|
||||
{canWriteMedia && (
|
||||
<button
|
||||
title="Удалить"
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
@@ -112,7 +113,9 @@ export const MediaListPage = observer(() => {
|
||||
return (
|
||||
<>
|
||||
<div className="w-full">
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
{(rows.length > 0 || searchQuery) && (
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
)}
|
||||
|
||||
{canWriteMedia && ids.length > 0 && (
|
||||
<div className="flex justify-end mb-5 duration-300">
|
||||
|
||||
@@ -39,11 +39,18 @@ import type { Route } from "@shared";
|
||||
|
||||
export const RouteCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
const [carrier, setCarrier] = useState<string>("");
|
||||
const [routeNumber, setRouteNumber] = useState("");
|
||||
const [routeCoords, setRouteCoords] = useState("");
|
||||
const [govRouteNumber, setGovRouteNumber] = useState("");
|
||||
const [governorAppeal, setGovernorAppeal] = useState<string>("");
|
||||
const [buttonText, setButtonText] = useState("");
|
||||
const [direction, setDirection] = useState("backward");
|
||||
const [scaleMin, setScaleMin] = useState("10");
|
||||
const [scaleMax, setScaleMax] = useState("100");
|
||||
@@ -51,7 +58,7 @@ export const RouteCreatePage = observer(() => {
|
||||
const [turn, setTurn] = useState("");
|
||||
const [centerLat, setCenterLat] = useState("");
|
||||
const [centerLng, setCenterLng] = useState("");
|
||||
const [videoTimer, setVideoTimer] = useState(60);
|
||||
const [videoTimer, setVideoTimer] = useState(420);
|
||||
const [videoPreview, setVideoPreview] = useState<string>("");
|
||||
const [icon, setIcon] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -286,6 +293,10 @@ export const RouteCreatePage = observer(() => {
|
||||
newRoute.governor_appeal = governor_appeal;
|
||||
}
|
||||
|
||||
if (buttonText.trim()) {
|
||||
newRoute.button_text = buttonText.trim();
|
||||
}
|
||||
|
||||
const newId = await routeStore.createRoute(newRoute);
|
||||
toast.success("Маршрут успешно создан");
|
||||
navigate(`/route/${newId}/edit`);
|
||||
@@ -401,6 +412,18 @@ export const RouteCreatePage = observer(() => {
|
||||
onChange={(e) => setGovRouteNumber(e.target.value)}
|
||||
/>
|
||||
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
Текст кнопки обращения
|
||||
</Typography>
|
||||
<TextField
|
||||
value={buttonText}
|
||||
onChange={(e) => setButtonText(e.target.value)}
|
||||
placeholder="Обращение губернатора"
|
||||
fullWidth
|
||||
size="small"
|
||||
helperText="Если пусто, будет использован текст по умолчанию"
|
||||
/>
|
||||
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
Обращение к пассажирам
|
||||
</Typography>
|
||||
@@ -555,7 +578,7 @@ export const RouteCreatePage = observer(() => {
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Таймер видео (сек)"
|
||||
label="Таймер видео заставки (сек)"
|
||||
type="number"
|
||||
value={videoTimer}
|
||||
onChange={(e) => {
|
||||
|
||||
@@ -36,11 +36,18 @@ import {
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
LoadingSpinner,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { LinkedItems } from "../LinekedStations";
|
||||
|
||||
export const RouteEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
const { id } = useParams();
|
||||
const { editRouteData, copyRouteAction } = routeStore;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -548,9 +555,9 @@ export const RouteEditPage = observer(() => {
|
||||
/>
|
||||
<TextField
|
||||
className="w-full"
|
||||
label="Таймер видео (сек)"
|
||||
label="Таймер видео заставки (сек)"
|
||||
type="number"
|
||||
value={editRouteData.video_timer ?? 60}
|
||||
value={editRouteData.video_timer ?? 420}
|
||||
onChange={(e) => {
|
||||
const val = Math.max(1, Math.round(Number(e.target.value)));
|
||||
if (Number.isFinite(val)) {
|
||||
@@ -559,6 +566,22 @@ export const RouteEditPage = observer(() => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
Текст кнопки обращения
|
||||
</Typography>
|
||||
<TextField
|
||||
value={editRouteData.button_text || ""}
|
||||
onChange={(e) =>
|
||||
routeStore.setEditRouteData({
|
||||
button_text: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Обращение губернатора"
|
||||
fullWidth
|
||||
size="small"
|
||||
helperText="Если пусто, будет использован текст по умолчанию"
|
||||
/>
|
||||
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
Обращение к пассажирам
|
||||
</Typography>
|
||||
|
||||
@@ -43,7 +43,7 @@ export const RouteListPage = observer(() => {
|
||||
loadCounts(routeIds);
|
||||
};
|
||||
fetchData();
|
||||
}, [language]);
|
||||
}, [language, selectedCityStore.cityVersion]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
@@ -139,7 +139,7 @@ export const RouteListPage = observer(() => {
|
||||
headerAlign: "center" as const,
|
||||
sortable: true,
|
||||
renderHeader: (params: any) => (
|
||||
<Tooltip title="Количество привязанных достопримечательностей">
|
||||
<Tooltip title="Отображает количество привязанных достопримечательностей">
|
||||
<span>{params.colDef.headerName}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
@@ -157,7 +157,7 @@ export const RouteListPage = observer(() => {
|
||||
headerAlign: "center" as const,
|
||||
sortable: true,
|
||||
renderHeader: (params: any) => (
|
||||
<Tooltip title="Количество привязанных остановок">
|
||||
<Tooltip title="Отображает количество привязанных остановок">
|
||||
<span>{params.colDef.headerName}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
@@ -178,22 +178,23 @@ export const RouteListPage = observer(() => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
{canWriteRoutes && (
|
||||
<button onClick={() => navigate(`/route/${params.row.id}/edit`)}>
|
||||
<button title="Редактировать" onClick={() => navigate(`/route/${params.row.id}/edit`)}>
|
||||
<Pencil size={20} className="text-blue-500" />
|
||||
</button>
|
||||
)}
|
||||
{canShowRoutePreview && (
|
||||
<button onClick={() => navigate(`/route-preview/${params.row.id}`)}>
|
||||
<button title="Предпросмотр на карте" onClick={() => navigate(`/route-preview/${params.row.id}`)}>
|
||||
<Map size={20} className="text-purple-500" />
|
||||
</button>
|
||||
)}
|
||||
{canShowRoutePreview && (
|
||||
<button onClick={() => window.open(`/demo/${params.row.id}`, "_blank")}>
|
||||
<button title="Демо" onClick={() => window.open(`/demo/${params.row.id}`, "_blank")}>
|
||||
<Monitor size={20} className="text-green-500" />
|
||||
</button>
|
||||
)}
|
||||
{canWriteRoutes && (
|
||||
<button
|
||||
title="Удалить"
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
@@ -210,6 +211,9 @@ export const RouteListPage = observer(() => {
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const { selectedCityId } = selectedCityStore;
|
||||
if (!selectedCityId) {
|
||||
return [];
|
||||
}
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
let filtered = routes.data;
|
||||
if (selectedCityId) {
|
||||
@@ -247,7 +251,10 @@ export const RouteListPage = observer(() => {
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl">Маршруты</h1>
|
||||
{canWriteRoutes && (
|
||||
<CreateButton label="Создать маршрут" path="/route/create" />
|
||||
<CreateButton
|
||||
label="Создать маршрут"
|
||||
path="/route/create"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -263,7 +270,9 @@ export const RouteListPage = observer(() => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
{(rows.length > 0 || searchQuery) && (
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
@@ -304,7 +313,13 @@ export const RouteListPage = observer(() => {
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? <CircularProgress size={20} /> : "Нет маршрутов"}
|
||||
{isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : !selectedCityStore.selectedCityId ? (
|
||||
"Выберите город"
|
||||
) : (
|
||||
"Нет маршрутов"
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Stack, Typography, Button } from "@mui/material";
|
||||
import { Button } from "@mui/material";
|
||||
import { useNavigate, useNavigationType } from "react-router";
|
||||
import { MediaViewer } from "@widgets";
|
||||
import { useMapData } from "./MapDataContext";
|
||||
@@ -15,22 +15,24 @@ type LeftSidebarProps = {
|
||||
|
||||
export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
|
||||
const navigate = useNavigate();
|
||||
const navigationType = useNavigationType(); // PUSH, POP, REPLACE
|
||||
const navigationType = useNavigationType();
|
||||
const { routeData } = useMapData();
|
||||
const [carrierThumbnail, setCarrierThumbnail] = useState<string | null>(null);
|
||||
const [carrierLogo, setCarrierLogo] = useState<string | null>(null);
|
||||
const [carrierSlogan, setCarrierSlogan] = useState<string | null>(null);
|
||||
const [carrierShortName, setCarrierShortName] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchCarrierThumbnail() {
|
||||
async function fetchCarrierData() {
|
||||
if (routeData?.carrier_id) {
|
||||
const { city_id, logo } = (
|
||||
const carrier = (
|
||||
await authInstance.get(`/carrier/${routeData.carrier_id}`)
|
||||
).data;
|
||||
const { arms } = (await authInstance.get(`/city/${city_id}`)).data;
|
||||
setCarrierThumbnail(arms);
|
||||
setCarrierLogo(logo);
|
||||
setCarrierLogo(carrier.logo);
|
||||
setCarrierSlogan(carrier.slogan ?? null);
|
||||
setCarrierShortName(carrier.short_name ?? null);
|
||||
}
|
||||
}
|
||||
fetchCarrierThumbnail();
|
||||
fetchCarrierData();
|
||||
}, [routeData?.carrier_id]);
|
||||
|
||||
const handleBack = () => {
|
||||
@@ -42,131 +44,190 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: 288,
|
||||
height: "100%",
|
||||
color: "#fff",
|
||||
transition: "padding 0.3s ease",
|
||||
p: open ? 2 : 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "stretch",
|
||||
justifyContent: "flex-start",
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="column"
|
||||
height="100%"
|
||||
width="100%"
|
||||
spacing={4}
|
||||
alignItems="stretch"
|
||||
justifyContent="space-between"
|
||||
sx={{
|
||||
{/* Кнопка назад — вне основного меню */}
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 12px 0",
|
||||
opacity: open ? 1 : 0,
|
||||
pointerEvents: open ? "auto" : "none",
|
||||
transition: "opacity 0.25s ease",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={handleBack}
|
||||
variant="contained"
|
||||
sx={{
|
||||
backgroundColor: "#222",
|
||||
color: "#fff",
|
||||
borderRadius: 1.5,
|
||||
px: 2,
|
||||
py: 1,
|
||||
"&:hover": { backgroundColor: "#2d2d2d" },
|
||||
}}
|
||||
fullWidth
|
||||
startIcon={<ArrowBackIcon />}
|
||||
>
|
||||
Назад
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Основное меню — повторяет .side-menu */}
|
||||
<div
|
||||
style={{
|
||||
boxSizing: "border-box",
|
||||
paddingTop: 46,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
height: "calc(100% - 56px)",
|
||||
position: "relative",
|
||||
opacity: open ? 1 : 0,
|
||||
transition: "opacity 0.25s ease",
|
||||
pointerEvents: open ? "auto" : "none",
|
||||
display: open ? "flex" : "none",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Button
|
||||
onClick={handleBack}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{
|
||||
backgroundColor: "#222",
|
||||
color: "#fff",
|
||||
borderRadius: 1.5,
|
||||
px: 2,
|
||||
py: 1,
|
||||
marginBottom: 10,
|
||||
"&:hover": {
|
||||
backgroundColor: "#2d2d2d",
|
||||
},
|
||||
{/* Герб — .side-menu-crest */}
|
||||
<div
|
||||
style={{
|
||||
width: 170,
|
||||
height: 170,
|
||||
alignSelf: "flex-start",
|
||||
marginLeft: 20,
|
||||
backgroundColor: "rgba(255,255,255,0.15)",
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Герб
|
||||
</div>
|
||||
|
||||
{/* Слоган — .side-menu-label */}
|
||||
{carrierSlogan && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 10,
|
||||
textAlign: "left",
|
||||
fontSize: 15,
|
||||
padding: "0 20px",
|
||||
alignSelf: "flex-start",
|
||||
fontWeight: 400,
|
||||
lineHeight: "150%",
|
||||
}}
|
||||
fullWidth
|
||||
startIcon={<ArrowBackIcon />}
|
||||
>
|
||||
Назад
|
||||
</Button>
|
||||
{carrierSlogan}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
spacing={3}
|
||||
{/* Кнопки — .side-menu-buttons */}
|
||||
<div
|
||||
style={{
|
||||
width: 220,
|
||||
marginTop: routeData?.governor_appeal || 0 > 0 ? 40 : 260,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "#fff",
|
||||
color: "#000",
|
||||
textAlign: "center",
|
||||
padding: "8px 16px",
|
||||
marginBottom: 16,
|
||||
borderRadius: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 150,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{carrierThumbnail && !isMediaIdEmpty(carrierThumbnail) && (
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: carrierThumbnail,
|
||||
media_type: 1, // Тип "Фото" для логотипа
|
||||
filename: "route_thumbnail",
|
||||
}}
|
||||
fullWidth
|
||||
fullHeight
|
||||
/>
|
||||
)}
|
||||
<Typography sx={{ color: "#fff" }} textAlign="center">
|
||||
При поддержке Правительства
|
||||
</Typography>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
<div className="flex flex-col items-center justify-center gap-2 mt-10">
|
||||
<button className="bg-[#fcd500] text-black px-4 py-2 rounded-md w-full font-medium my-10">
|
||||
Обращение губернатора
|
||||
</button>
|
||||
<button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
|
||||
Достопримечательности
|
||||
</button>
|
||||
<button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
|
||||
Остановки
|
||||
</button>
|
||||
Достопримечательности
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "#fff",
|
||||
color: "#000",
|
||||
textAlign: "center",
|
||||
padding: "8px 16px",
|
||||
marginBottom: 16,
|
||||
borderRadius: 10,
|
||||
}}
|
||||
>
|
||||
Остановки
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Stack
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
maxHeight={150}
|
||||
justifyContent="center"
|
||||
flexGrow={1}
|
||||
{/* Нижняя секция — .side-menu-bottom-section */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{carrierLogo && !isMediaIdEmpty(carrierLogo) && (
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: carrierLogo,
|
||||
media_type: 1, // Тип "Фото" для логотипа
|
||||
filename: "route_thumbnail_logo",
|
||||
}}
|
||||
fullHeight
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
{/* .side-menu-carrier-block */}
|
||||
<div style={{ padding: "0 20px" }}>
|
||||
{carrierLogo && !isMediaIdEmpty(carrierLogo) && (
|
||||
<div style={{ width: 170 }}>
|
||||
<MediaViewer
|
||||
media={{
|
||||
id: carrierLogo,
|
||||
media_type: 1,
|
||||
filename: "carrier_logo",
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{carrierShortName && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 4,
|
||||
textAlign: "left",
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
lineHeight: "150%",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
{carrierShortName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Typography
|
||||
variant="h6"
|
||||
textAlign="center"
|
||||
sx={{ color: "#fff", marginTop: "auto" }}
|
||||
>
|
||||
#ВсемПоПути
|
||||
</Typography>
|
||||
</Stack>
|
||||
{/* .side-menu-bottom-photo */}
|
||||
<img
|
||||
src="/side-menu-photo.png"
|
||||
alt=""
|
||||
style={{
|
||||
width: "288px",
|
||||
marginTop: 32,
|
||||
display: "block",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-[20px] -right-[520px] z-10">
|
||||
<div
|
||||
className="absolute bottom-[20px] z-10"
|
||||
style={{
|
||||
right: open ? -520 : -312,
|
||||
transition: "right 0.3s ease",
|
||||
}}
|
||||
>
|
||||
<LanguageSelector onBack={onToggle} isSidebarOpen={open} />
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -448,6 +448,22 @@ const StationLabel = observer(
|
||||
anchor={dynamicAnchor}
|
||||
zIndex={isHovered || isControlHovered ? 1000 : 0}
|
||||
>
|
||||
{ruLabelWidth > 0 && (
|
||||
<pixiGraphics
|
||||
draw={(g: Graphics) => {
|
||||
g.clear();
|
||||
const hasSecondLabel = !!(station.name && language !== "ru" && ruLabel);
|
||||
const pad = 10 / scale;
|
||||
const w = ruLabelWidth + pad * 2;
|
||||
const top = -compensatedRuFontSize / 2 - pad;
|
||||
const bottom = hasSecondLabel
|
||||
? compensatedRuFontSize * 1.1 + compensatedNameFontSize / 2 + pad
|
||||
: compensatedRuFontSize / 2 + pad;
|
||||
g.rect(-w / 2, top, w, bottom - top);
|
||||
g.fill({ color: 0x000000, alpha: 0.001 });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{ruLabel && (
|
||||
<pixiText
|
||||
ref={ruLabelRef}
|
||||
|
||||
@@ -54,7 +54,8 @@ export const RoutePreview = () => {
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
width: isLeftSidebarOpen ? 300 : 0,
|
||||
zIndex: 20,
|
||||
width: isLeftSidebarOpen ? 288 : 0,
|
||||
transition: "width 0.3s ease",
|
||||
overflow: "visible",
|
||||
height: "100%",
|
||||
@@ -145,14 +146,14 @@ export const RouteMap = observer(() => {
|
||||
) {
|
||||
const coordinates = coordinatesToLocal(
|
||||
originalRouteData?.center_latitude,
|
||||
originalRouteData?.center_longitude
|
||||
originalRouteData?.center_longitude,
|
||||
);
|
||||
|
||||
setTransform(
|
||||
coordinates.x,
|
||||
coordinates.y,
|
||||
originalRouteData?.rotate,
|
||||
originalRouteData?.scale_min
|
||||
originalRouteData?.scale_min,
|
||||
);
|
||||
setIsSetup(true);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface RouteData {
|
||||
icon_size?: number;
|
||||
font_size: number;
|
||||
governor_appeal: number;
|
||||
button_text?: string;
|
||||
id: number;
|
||||
path: [number, number][];
|
||||
rotate: number;
|
||||
|
||||
@@ -26,10 +26,12 @@
|
||||
position: fixed;
|
||||
display: inline-flex;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3) inset,
|
||||
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(
|
||||
background:
|
||||
linear-gradient(
|
||||
to bottom right,
|
||||
rgba(255, 255, 255, 0.2) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
@@ -50,7 +52,8 @@
|
||||
height: 96px;
|
||||
background-color: #fcd500;
|
||||
color: black;
|
||||
border-radius: 10px;
|
||||
border-top-left-radius: 10px;
|
||||
border-bottom-left-radius: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -59,6 +62,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;
|
||||
|
||||
@@ -6,6 +6,14 @@ 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 "";
|
||||
@@ -33,7 +41,7 @@ export const RouteWidget = observer(() => {
|
||||
|
||||
return (
|
||||
<div className={styles["route-widget"]} style={{ position: "relative" }}>
|
||||
<div className={styles["route-widget-number"]}>
|
||||
<div className={[styles["route-widget-number"], getNumberSizeClass(routeData?.route_sys_number)].join(" ")}>
|
||||
{routeData?.route_sys_number || ""}
|
||||
</div>
|
||||
<div className={styles["route-widget-content"]}>
|
||||
|
||||
@@ -2327,9 +2327,6 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
const stationScreenY =
|
||||
rotatedY * camera.scale + camera.translation.y;
|
||||
|
||||
const labelX = stationScreenX + offsetX;
|
||||
const labelY = stationScreenY + offsetY;
|
||||
|
||||
const backendAlign = station.align;
|
||||
|
||||
const anchor = getAnchorFromOffset(backendAlign ?? 2);
|
||||
@@ -2339,8 +2336,6 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
|
||||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
|
||||
const cssX = labelX / dpr;
|
||||
const cssY = labelY / dpr;
|
||||
const rotationCss = `${rotationAngle}rad`;
|
||||
const counterRotationCss = `${-rotationAngle}rad`;
|
||||
|
||||
@@ -2359,6 +2354,13 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
const scaleFactor = 1 + (zoomClampedScale - 1) * 0.4;
|
||||
|
||||
const primaryFontSize = 16 * fontScale * scaleFactor;
|
||||
|
||||
const mainLabelHeight = primaryFontSize * 1.2;
|
||||
const labelX = stationScreenX + offsetX;
|
||||
const labelY = stationScreenY + offsetY + mainLabelHeight / 2;
|
||||
|
||||
const cssX = labelX / dpr;
|
||||
const cssY = labelY / dpr;
|
||||
const secondaryFontSize = 13 * fontScale * scaleFactor;
|
||||
|
||||
const secondaryMarginTop = 5 * fontScale * scaleFactor;
|
||||
@@ -2404,13 +2406,7 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
hoveredStationIconId === station.id ||
|
||||
resizingStationIconId === station.id;
|
||||
|
||||
const secondaryLineHeight = 1.2;
|
||||
const secondaryHeight = showSecondary
|
||||
? secondaryFontSize * secondaryLineHeight
|
||||
: 0;
|
||||
const menuPaddingTop = showSecondary
|
||||
? Math.max(0, secondaryHeight - secondaryMarginTop) + 3
|
||||
: 3;
|
||||
const secondaryLineHeight = 1.2 * scaleFactor;
|
||||
|
||||
return (
|
||||
<div key={station.id}>
|
||||
@@ -2440,7 +2436,7 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
color: "#fff",
|
||||
fontFamily: "Roboto, sans-serif",
|
||||
textAlign: "left",
|
||||
pointerEvents: "auto",
|
||||
pointerEvents: "none",
|
||||
cursor: "grab",
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
@@ -2448,14 +2444,16 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
pointerEvents: "auto",
|
||||
display: "inline-block",
|
||||
pointerEvents: "none",
|
||||
transformOrigin: "left center",
|
||||
transform: `rotate(${rotationCss})`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
pointerEvents: "auto",
|
||||
display: "inline-block",
|
||||
pointerEvents: "none",
|
||||
transformOrigin: "left center",
|
||||
transform: `rotate(${counterRotationCss})`,
|
||||
}}
|
||||
@@ -2549,6 +2547,7 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
) : null}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
fontWeight: 700,
|
||||
fontSize: primaryFontSize,
|
||||
textShadow: "0 0 4px rgba(0,0,0,0.6)",
|
||||
@@ -2557,26 +2556,26 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
}}
|
||||
>
|
||||
{station.name}
|
||||
{showSecondary ? (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "100%",
|
||||
marginTop: -1 * secondaryMarginTop,
|
||||
fontWeight: 400,
|
||||
fontSize: secondaryFontSize,
|
||||
lineHeight: secondaryLineHeight,
|
||||
color: "#CBCBCB",
|
||||
textShadow: "0 0 3px rgba(0,0,0,0.4)",
|
||||
whiteSpace: "nowrap",
|
||||
...secondaryPositionStyle,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{secondaryStation?.name}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{showSecondary ? (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "100%",
|
||||
marginTop: -1 * secondaryMarginTop,
|
||||
fontWeight: 400,
|
||||
fontSize: secondaryFontSize,
|
||||
lineHeight: secondaryLineHeight,
|
||||
color: "#CBCBCB",
|
||||
textShadow: "0 0 3px rgba(0,0,0,0.4)",
|
||||
whiteSpace: "nowrap",
|
||||
...secondaryPositionStyle,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{secondaryStation?.name}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2587,9 +2586,9 @@ export const WebGLRouteMapPrototype = observer(() => {
|
||||
top: "100%",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
paddingTop: menuPaddingTop,
|
||||
paddingTop: "8px",
|
||||
pointerEvents: "auto",
|
||||
zIndex: 10,
|
||||
zIndex: 1000000,
|
||||
cursor: "default",
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -46,7 +46,7 @@ export const SightListPage = observer(() => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchSights();
|
||||
}, [language]);
|
||||
}, [language, selectedCityStore.cityVersion]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
@@ -105,10 +105,11 @@ export const SightListPage = observer(() => {
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
<button onClick={() => navigate(`/sight/${params.row.id}/edit`)}>
|
||||
<button title="Редактировать" onClick={() => navigate(`/sight/${params.row.id}/edit`)}>
|
||||
<Pencil size={20} className="text-blue-500" />
|
||||
</button>
|
||||
<button
|
||||
title="Удалить"
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
@@ -121,8 +122,11 @@ export const SightListPage = observer(() => {
|
||||
}] : []),
|
||||
];
|
||||
|
||||
const { selectedCityId } = selectedCityStore;
|
||||
const filteredSights = useMemo(() => {
|
||||
const { selectedCityId } = selectedCityStore;
|
||||
if (!selectedCityId) {
|
||||
return [];
|
||||
}
|
||||
const allowedCityIds = canReadCities
|
||||
? null
|
||||
: authStore.meCities["ru"].map((c) => c.city_id);
|
||||
@@ -131,12 +135,12 @@ export const SightListPage = observer(() => {
|
||||
if (allowedCityIds && !allowedCityIds.includes(sight.city_id)) {
|
||||
return false;
|
||||
}
|
||||
if (selectedCityId && sight.city_id !== selectedCityId) {
|
||||
if (sight.city_id !== selectedCityId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [sights, selectedCityStore.selectedCityId, canReadCities, authStore.meCities]);
|
||||
}, [sights, selectedCityId, canReadCities, authStore.meCities]);
|
||||
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
const rows = filteredSights
|
||||
@@ -177,7 +181,9 @@ export const SightListPage = observer(() => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
{(rows.length > 0 || searchQuery) && (
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
@@ -216,6 +222,8 @@ export const SightListPage = observer(() => {
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : !selectedCityId ? (
|
||||
"Выберите город"
|
||||
) : (
|
||||
"Нет достопримечательностей"
|
||||
)}
|
||||
|
||||
@@ -1,17 +1,155 @@
|
||||
import { Button, TextField } from "@mui/material";
|
||||
import { snapshotStore } from "@shared";
|
||||
import {
|
||||
Button,
|
||||
TextField,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from "@mui/material";
|
||||
import { snapshotStore, authStore, routeStore, selectedCityStore, cityStore, carrierStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "react-toastify";
|
||||
import { runInAction } from "mobx";
|
||||
|
||||
function escapeRegex(s: string) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function buildExportNameRegex(cityNames: string[]): RegExp {
|
||||
if (!cityNames.length) return /.+/;
|
||||
const pattern = cityNames.map(escapeRegex).join("|");
|
||||
return new RegExp(`^(${pattern})_.+$`);
|
||||
}
|
||||
|
||||
export const SnapshotCreatePage = observer(() => {
|
||||
const { createSnapshot, getSnapshotStatus, getStorageInfo, snapshotStatus } = snapshotStore;
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [duplicateWarningOpen, setDuplicateWarningOpen] = useState(false);
|
||||
const [duplicateRouteNumbers, setDuplicateRouteNumbers] = useState<string[]>([]);
|
||||
|
||||
const exportNameRegex = useMemo(() => {
|
||||
const names = cityStore.cities["ru"].data.map((c) => c.name.trim());
|
||||
return buildExportNameRegex(names);
|
||||
}, [cityStore.cities["ru"].data.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cityStore.cities["ru"].loaded) {
|
||||
cityStore.getCities("ru");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const canReadRoutes = authStore.canRead("routes");
|
||||
|
||||
const startExport = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const id = await createSnapshot(name);
|
||||
|
||||
await getSnapshotStatus(id);
|
||||
|
||||
while (snapshotStore.snapshotStatus?.Status != "done") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
await getSnapshotStatus(id);
|
||||
}
|
||||
|
||||
if (snapshotStore.snapshotStatus?.Status === "done") {
|
||||
toast.success("Экспорт медиа успешно создан");
|
||||
|
||||
runInAction(() => {
|
||||
snapshotStore.snapshotStatus = null;
|
||||
});
|
||||
|
||||
await getStorageInfo();
|
||||
navigate(-1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Ошибка при создании экспорта медиа");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!canReadRoutes) {
|
||||
await startExport();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
runInAction(() => {
|
||||
routeStore.routes.loaded = false;
|
||||
});
|
||||
await routeStore.getRoutes();
|
||||
await carrierStore.getCarriers("ru");
|
||||
|
||||
const routes = routeStore.routes.data;
|
||||
const carriers = carrierStore.carriers.ru.data;
|
||||
const carrierCityMap = new Map<number, number>();
|
||||
for (const c of carriers) {
|
||||
carrierCityMap.set(c.id, c.city_id);
|
||||
}
|
||||
|
||||
const duplicateMessages: string[] = [];
|
||||
|
||||
const directionKey = new Map<string, number>();
|
||||
for (const route of routes) {
|
||||
const num = (route.route_sys_number ?? "").trim();
|
||||
if (!num) continue;
|
||||
const cityId = carrierCityMap.get(route.carrier_id) ?? 0;
|
||||
const key = `${num}|${route.route_direction}|${cityId}`;
|
||||
directionKey.set(key, (directionKey.get(key) ?? 0) + 1);
|
||||
}
|
||||
for (const [key, count] of directionKey) {
|
||||
if (count > 1) {
|
||||
const [num, dir] = key.split("|");
|
||||
const dirLabel = dir === "true" ? "прямой" : "обратный";
|
||||
duplicateMessages.push(
|
||||
`Дублируется маршрут №${num} (${dirLabel})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cityPerNumber = new Map<string, Set<number>>();
|
||||
for (const route of routes) {
|
||||
const num = (route.route_sys_number ?? "").trim();
|
||||
if (!num) continue;
|
||||
const cityId = carrierCityMap.get(route.carrier_id) ?? 0;
|
||||
if (!cityPerNumber.has(num)) {
|
||||
cityPerNumber.set(num, new Set());
|
||||
}
|
||||
cityPerNumber.get(num)!.add(cityId);
|
||||
}
|
||||
for (const [num, cities] of cityPerNumber) {
|
||||
if (cities.size > 1) {
|
||||
duplicateMessages.push(
|
||||
`Маршрут №${num} присутствует в нескольких городах`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (duplicateMessages.length > 0) {
|
||||
setDuplicateRouteNumbers(duplicateMessages);
|
||||
setDuplicateWarningOpen(true);
|
||||
} else {
|
||||
await startExport();
|
||||
}
|
||||
} catch {
|
||||
await startExport();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-[400px] flex justify-center items-center">
|
||||
@@ -32,7 +170,19 @@ export const SnapshotCreatePage = observer(() => {
|
||||
label="Название"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
error={!!nameError}
|
||||
helperText={nameError ?? " "}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setName(val);
|
||||
const trimmed = val.trim();
|
||||
const hasFullFormat = trimmed.includes("_") && trimmed.split("_").slice(1).join("_").length > 0;
|
||||
if (hasFullFormat && !exportNameRegex.test(trimmed)) {
|
||||
setNameError("Название должно начинаться с названия существующего города");
|
||||
} else {
|
||||
setNameError(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
@@ -40,36 +190,8 @@ export const SnapshotCreatePage = observer(() => {
|
||||
color="primary"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const id = await createSnapshot(name);
|
||||
|
||||
await getSnapshotStatus(id);
|
||||
|
||||
while (snapshotStore.snapshotStatus?.Status != "done") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
await getSnapshotStatus(id);
|
||||
}
|
||||
|
||||
if (snapshotStore.snapshotStatus?.Status === "done") {
|
||||
toast.success("Экспорт медиа успешно создан");
|
||||
|
||||
runInAction(() => {
|
||||
snapshotStore.snapshotStatus = null;
|
||||
});
|
||||
|
||||
await getStorageInfo();
|
||||
navigate(-1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Ошибка при создании экспорта медиа");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading || !name.trim()}
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || !exportNameRegex.test(name.trim())}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -87,6 +209,45 @@ export const SnapshotCreatePage = observer(() => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={duplicateWarningOpen}
|
||||
onClose={() => !isLoading && setDuplicateWarningOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Найдены повторяющиеся маршруты</DialogTitle>
|
||||
<DialogContent>
|
||||
<p className="mb-3">
|
||||
Обнаружены маршруты с одинаковыми номерами трассы. Это может привести к
|
||||
некорректным данным в экспорте.
|
||||
</p>
|
||||
<ul className="list-disc pl-5">
|
||||
{duplicateRouteNumbers.map((msg, i) => (
|
||||
<li key={i}>{msg}</li>
|
||||
))}
|
||||
</ul>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => setDuplicateWarningOpen(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{ backgroundColor: "#795548", "&:hover": { backgroundColor: "#5D4037" } }}
|
||||
disabled={isLoading}
|
||||
onClick={async () => {
|
||||
setDuplicateWarningOpen(false);
|
||||
await startExport();
|
||||
}}
|
||||
>
|
||||
Продолжить экспорт
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { authStore, languageStore, snapshotStore, SearchInput } from "@shared";
|
||||
import { authStore, languageStore, snapshotStore, cityStore, vehicleStore, SearchInput } from "@shared";
|
||||
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, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from "@mui/material";
|
||||
import { Alert, Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, TextField, Typography } from "@mui/material";
|
||||
|
||||
const LOW_STORAGE_THRESHOLD_GB = 10;
|
||||
|
||||
function escapeRegex(s: string) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function buildExportNameRegex(cityNames: string[]): RegExp {
|
||||
if (!cityNames.length) return /.+/;
|
||||
const pattern = cityNames.map(escapeRegex).join("|");
|
||||
return new RegExp(`^(${pattern})_.+$`);
|
||||
}
|
||||
|
||||
const SEGMENT_COLORS = [
|
||||
"#FF3B30",
|
||||
"#FF9500",
|
||||
@@ -33,11 +43,14 @@ export const SnapshotListPage = observer(() => {
|
||||
createEmptySnapshot,
|
||||
} = snapshotStore;
|
||||
const canWriteDevices = authStore.canWrite("devices");
|
||||
const canReadDevices = authStore.canRead("devices");
|
||||
const canCreateSnapshot =
|
||||
authStore.hasRole("snapshot_create") && canWriteDevices;
|
||||
const canManageSnapshots = authStore.canWrite("snapshot") && canWriteDevices;
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isSnapshotOnDeviceWarning, setIsSnapshotOnDeviceWarning] = useState(false);
|
||||
const [devicesWithSnapshot, setDevicesWithSnapshot] = useState<string[]>([]);
|
||||
const [rowId, setRowId] = useState<string | null>(null);
|
||||
const { language } = languageStore;
|
||||
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
|
||||
@@ -45,6 +58,12 @@ export const SnapshotListPage = observer(() => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isEmptySnapshotModalOpen, setIsEmptySnapshotModalOpen] = useState(false);
|
||||
const [emptySnapshotName, setEmptySnapshotName] = useState("");
|
||||
const [emptySnapshotNameError, setEmptySnapshotNameError] = useState<string | null>(null);
|
||||
|
||||
const exportNameRegex = useMemo(() => {
|
||||
const names = cityStore.cities["ru"].data.map((c) => c.name.trim());
|
||||
return buildExportNameRegex(names);
|
||||
}, [cityStore.cities["ru"].data.length]);
|
||||
const [isCreatingEmpty, setIsCreatingEmpty] = useState(false);
|
||||
const [paginationModel, setPaginationModel] = useState({
|
||||
page: 0,
|
||||
@@ -61,7 +80,11 @@ export const SnapshotListPage = observer(() => {
|
||||
useEffect(() => {
|
||||
const fetchSnapshots = async () => {
|
||||
setIsLoading(true);
|
||||
await Promise.all([getSnapshots(), getStorageInfo()]);
|
||||
const promises: Promise<void>[] = [getSnapshots(), getStorageInfo()];
|
||||
if (canReadDevices && !vehicleStore.vehicles.loaded) {
|
||||
promises.push(vehicleStore.getVehicles());
|
||||
}
|
||||
await Promise.all(promises);
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchSnapshots();
|
||||
@@ -76,6 +99,26 @@ export const SnapshotListPage = observer(() => {
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "color",
|
||||
headerName: "",
|
||||
width: 28,
|
||||
sortable: false,
|
||||
disableColumnMenu: true,
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 12,
|
||||
height: 12,
|
||||
backgroundColor: params.value,
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: "name",
|
||||
headerName: "Название",
|
||||
@@ -117,6 +160,7 @@ export const SnapshotListPage = observer(() => {
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
<button
|
||||
title="Восстановить"
|
||||
onClick={() => {
|
||||
setIsRestoreModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
@@ -125,9 +169,22 @@ export const SnapshotListPage = observer(() => {
|
||||
<DatabaseBackup size={20} className="text-blue-500" />
|
||||
</button>
|
||||
<button
|
||||
title="Удалить"
|
||||
onClick={() => {
|
||||
const snapshotId = params.row.id;
|
||||
if (canReadDevices) {
|
||||
const devicesUsing = vehicleStore.vehicles.data
|
||||
.filter(v => v.vehicle.current_snapshot_uuid === snapshotId)
|
||||
.map(v => v.vehicle.tail_number || v.vehicle.uuid || `ID ${v.vehicle.id}`);
|
||||
if (devicesUsing.length > 0) {
|
||||
setDevicesWithSnapshot(devicesUsing);
|
||||
setIsSnapshotOnDeviceWarning(true);
|
||||
setRowId(snapshotId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
setRowId(snapshotId);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={20} className="text-red-500" />
|
||||
@@ -150,12 +207,13 @@ export const SnapshotListPage = observer(() => {
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
)
|
||||
.map((snapshot) => ({
|
||||
.map((snapshot, index) => ({
|
||||
id: snapshot.ID,
|
||||
name: snapshot.Name,
|
||||
parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name,
|
||||
created_at: formatCreationTime(snapshot.CreationTime),
|
||||
occupied_disk_space_gb: snapshot.occupied_disk_space_gb,
|
||||
color: SEGMENT_COLORS[index % SEGMENT_COLORS.length],
|
||||
}));
|
||||
}, [snapshots, searchQuery]);
|
||||
|
||||
@@ -178,10 +236,11 @@ export const SnapshotListPage = observer(() => {
|
||||
disabled={isLowStorage}
|
||||
onClick={() => {
|
||||
setEmptySnapshotName("");
|
||||
setEmptySnapshotNameError(null);
|
||||
setIsEmptySnapshotModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Создать пустой снапшот
|
||||
Создать пустой экспорт
|
||||
</Button>
|
||||
)}
|
||||
{canCreateSnapshot && (
|
||||
@@ -203,7 +262,7 @@ export const SnapshotListPage = observer(() => {
|
||||
</div>
|
||||
|
||||
<div className="flex w-full h-3 rounded-lg overflow-hidden bg-gray-100">
|
||||
{rows.map((row, i) => {
|
||||
{rows.map((row) => {
|
||||
const pct =
|
||||
row.occupied_disk_space_gb != null && totalGB > 0
|
||||
? (row.occupied_disk_space_gb / totalGB) * 100
|
||||
@@ -214,8 +273,7 @@ export const SnapshotListPage = observer(() => {
|
||||
key={row.id}
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
backgroundColor:
|
||||
SEGMENT_COLORS[i % SEGMENT_COLORS.length],
|
||||
backgroundColor: row.color,
|
||||
}}
|
||||
title={`${row.name}: ${row.occupied_disk_space_gb?.toFixed(1)} ГБ`}
|
||||
/>
|
||||
@@ -233,7 +291,7 @@ export const SnapshotListPage = observer(() => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-1 mt-3">
|
||||
{rows.map((row, i) => {
|
||||
{rows.map((row) => {
|
||||
if (row.occupied_disk_space_gb == null || row.occupied_disk_space_gb <= 0)
|
||||
return null;
|
||||
return (
|
||||
@@ -243,10 +301,7 @@ export const SnapshotListPage = observer(() => {
|
||||
>
|
||||
<span
|
||||
className="inline-block w-2.5 h-2.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
SEGMENT_COLORS[i % SEGMENT_COLORS.length],
|
||||
}}
|
||||
style={{ backgroundColor: row.color }}
|
||||
/>
|
||||
{row.name}
|
||||
</div>
|
||||
@@ -280,7 +335,9 @@ export const SnapshotListPage = observer(() => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
{(rows.length > 0 || searchQuery) && (
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
@@ -325,14 +382,26 @@ export const SnapshotListPage = observer(() => {
|
||||
fullWidth
|
||||
maxWidth="xs"
|
||||
>
|
||||
<DialogTitle>Создать пустой снапшот</DialogTitle>
|
||||
<DialogTitle>Создать пустой экспорт</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
label="Название"
|
||||
value={emptySnapshotName}
|
||||
onChange={(e) => setEmptySnapshotName(e.target.value)}
|
||||
error={!!emptySnapshotNameError}
|
||||
helperText={emptySnapshotNameError ?? " "}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setEmptySnapshotName(val);
|
||||
const trimmed = val.trim();
|
||||
const hasFullFormat = trimmed.includes("_") && trimmed.split("_").slice(1).join("_").length > 0;
|
||||
if (hasFullFormat && !exportNameRegex.test(trimmed)) {
|
||||
setEmptySnapshotNameError("Название должно начинаться с названия существующего города");
|
||||
} else {
|
||||
setEmptySnapshotNameError(null);
|
||||
}
|
||||
}}
|
||||
margin="normal"
|
||||
/>
|
||||
</DialogContent>
|
||||
@@ -342,7 +411,7 @@ export const SnapshotListPage = observer(() => {
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!emptySnapshotName.trim() || isCreatingEmpty}
|
||||
disabled={!exportNameRegex.test(emptySnapshotName.trim()) || isCreatingEmpty}
|
||||
onClick={async () => {
|
||||
setIsCreatingEmpty(true);
|
||||
try {
|
||||
@@ -359,6 +428,36 @@ export const SnapshotListPage = observer(() => {
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={isSnapshotOnDeviceWarning}
|
||||
onClose={() => setIsSnapshotOnDeviceWarning(false)}
|
||||
fullWidth
|
||||
maxWidth="xs"
|
||||
>
|
||||
<DialogTitle>Удаление невозможно</DialogTitle>
|
||||
<DialogContent>
|
||||
<Alert severity="warning" sx={{ mt: 1 }}>
|
||||
Этот экспорт загружен на устройства. Удалите или замените экспорт на
|
||||
устройствах перед удалением.
|
||||
</Alert>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="body2" fontWeight={600} gutterBottom>
|
||||
Устройства:
|
||||
</Typography>
|
||||
{devicesWithSnapshot.map((name, i) => (
|
||||
<Typography key={i} variant="body2">
|
||||
• {name}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setIsSnapshotOnDeviceWarning(false)}>
|
||||
Закрыть
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<SnapshotRestore
|
||||
open={isRestoreModalOpen}
|
||||
loading={isLoading}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
mediaStore,
|
||||
isMediaIdEmpty,
|
||||
useSelectedCity,
|
||||
selectedCityStore,
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
@@ -32,6 +33,12 @@ import {
|
||||
|
||||
export const StationCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
const {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
mediaStore,
|
||||
isMediaIdEmpty,
|
||||
LoadingSpinner,
|
||||
selectedCityStore,
|
||||
SelectMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
UploadMediaDialog,
|
||||
@@ -34,6 +35,12 @@ import { LinkedSights } from "../LinkedSights";
|
||||
|
||||
export const StationEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||
const { language } = languageStore;
|
||||
|
||||
@@ -49,7 +49,7 @@ export const StationListPage = observer(() => {
|
||||
loadSightCounts(stationIds);
|
||||
};
|
||||
fetchStations();
|
||||
}, [language]);
|
||||
}, [language, selectedCityStore.cityVersion]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
@@ -86,13 +86,13 @@ export const StationListPage = observer(() => {
|
||||
},
|
||||
{
|
||||
field: "sightCount",
|
||||
headerName: "Достопримечательности",
|
||||
headerName: "Привязки",
|
||||
width: 180,
|
||||
align: "center" as const,
|
||||
headerAlign: "center" as const,
|
||||
sortable: true,
|
||||
renderHeader: (params) => (
|
||||
<Tooltip title="Количество привязанных достопримечательностей">
|
||||
<Tooltip title="Отображает количество привязок">
|
||||
<span>{params.colDef.headerName}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
@@ -114,7 +114,7 @@ export const StationListPage = observer(() => {
|
||||
headerAlign: "center" as const,
|
||||
sortable: true,
|
||||
renderHeader: (params) => (
|
||||
<Tooltip title="Подтверждение добавленных пересадок">
|
||||
<Tooltip title="Отображает подтверждение добавленных пересадок">
|
||||
<span>{params.colDef.headerName}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
@@ -141,7 +141,7 @@ export const StationListPage = observer(() => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
{canWriteStations && (
|
||||
<button onClick={() => navigate(`/station/${params.row.id}/edit`)}>
|
||||
<button title="Редактировать" onClick={() => navigate(`/station/${params.row.id}/edit`)}>
|
||||
<Pencil size={20} className="text-blue-500" />
|
||||
</button>
|
||||
)}
|
||||
@@ -151,13 +151,14 @@ export const StationListPage = observer(() => {
|
||||
setSelectedStationId(params.row.id);
|
||||
setIsTransfersModalOpen(true);
|
||||
}}
|
||||
title="Редактировать пересадки"
|
||||
title="Управление пересадками"
|
||||
>
|
||||
<Route size={20} className="text-purple-500" />
|
||||
</button>
|
||||
)}
|
||||
{canWriteStations && (
|
||||
<button
|
||||
title="Удалить"
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
@@ -174,9 +175,12 @@ export const StationListPage = observer(() => {
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const { selectedCityId } = selectedCityStore;
|
||||
if (!selectedCityId) {
|
||||
return [];
|
||||
}
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
return stationLists[language].data
|
||||
.filter((station: any) => !selectedCityId || station.city_id === selectedCityId)
|
||||
.filter((station: any) => station.city_id === selectedCityId)
|
||||
.filter(
|
||||
(station: any) =>
|
||||
!query ||
|
||||
@@ -202,7 +206,10 @@ export const StationListPage = observer(() => {
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h1 className="text-2xl">Остановки</h1>
|
||||
{canWriteStations && (
|
||||
<CreateButton label="Создать остановку" path="/station/create" />
|
||||
<CreateButton
|
||||
label="Создать остановку"
|
||||
path="/station/create"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -218,7 +225,9 @@ export const StationListPage = observer(() => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
{(rows.length > 0 || searchQuery) && (
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
@@ -277,7 +286,13 @@ export const StationListPage = observer(() => {
|
||||
slots={{
|
||||
noRowsOverlay: () => (
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? <CircularProgress size={20} /> : "Нет остановок"}
|
||||
{isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : !selectedCityStore.selectedCityId ? (
|
||||
"Выберите город"
|
||||
) : (
|
||||
"Нет остановок"
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Button, Paper, TextField } from "@mui/material";
|
||||
import {
|
||||
Button,
|
||||
Paper,
|
||||
TextField,
|
||||
Typography,
|
||||
Box,
|
||||
Divider,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -10,9 +17,10 @@ import {
|
||||
SelectMediaDialog,
|
||||
UploadMediaDialog,
|
||||
PreviewMediaDialog,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ImageUploadCard } from "@widgets";
|
||||
import { ImageUploadCard, PermissionsTable, RolesHintTable, ROLE_RESOURCES } from "@widgets";
|
||||
|
||||
export const UserCreatePage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
@@ -26,13 +34,38 @@ export const UserCreatePage = observer(() => {
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||
>(null);
|
||||
|
||||
const [localRoles, setLocalRoles] = useState<string[]>(
|
||||
createUserData.roles ?? ["articles_ro", "articles_rw", "media_ro", "media_rw"]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mediaStore.getMedia();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const allRw = ROLE_RESOURCES.every(({ key }) => localRoles.includes(`${key}_rw`));
|
||||
const isAdmin = allRw && !localRoles.includes("devices_maintenance_rw");
|
||||
if (isAdmin !== createUserData.is_admin) {
|
||||
setCreateUserData(
|
||||
createUserData.name || "",
|
||||
createUserData.email || "",
|
||||
createUserData.password || "",
|
||||
isAdmin,
|
||||
createUserData.icon
|
||||
);
|
||||
}
|
||||
}, [localRoles]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
// Убеждаемся, что роли в сторе обновлены перед созданием
|
||||
userStore.createUserData.roles = localRoles;
|
||||
await createUser();
|
||||
toast.success("Пользователь успешно создан");
|
||||
navigate("/user");
|
||||
@@ -67,18 +100,15 @@ export const UserCreatePage = observer(() => {
|
||||
: selectedMedia?.id ?? createUserData.icon ?? null;
|
||||
|
||||
return (
|
||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
<Paper className="w-full p-6 flex flex-col gap-8">
|
||||
<button className="flex items-center gap-2 self-start" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft size={20} />
|
||||
Назад
|
||||
</button>
|
||||
|
||||
<section className="flex flex-col gap-6">
|
||||
<Typography variant="h6">Основные данные</Typography>
|
||||
|
||||
<div className="flex flex-col gap-10 w-full items-end">
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Имя"
|
||||
@@ -116,6 +146,7 @@ export const UserCreatePage = observer(() => {
|
||||
label="Пароль"
|
||||
value={createUserData.password || ""}
|
||||
required
|
||||
type="password"
|
||||
onChange={(e) =>
|
||||
setCreateUserData(
|
||||
createUserData.name || "",
|
||||
@@ -127,7 +158,7 @@ export const UserCreatePage = observer(() => {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||
<div className="w-full flex flex-col gap-4 max-w-[300px]">
|
||||
<ImageUploadCard
|
||||
title="Аватар"
|
||||
imageKey="thumbnail"
|
||||
@@ -156,23 +187,72 @@ export const UserCreatePage = observer(() => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleCreate}
|
||||
disabled={
|
||||
isLoading || !createUserData.name || !createUserData.password
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Создать"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<Typography variant="h6">Права доступа</Typography>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setCreateUserData(
|
||||
createUserData.name || "",
|
||||
createUserData.email || "",
|
||||
createUserData.password || "",
|
||||
true,
|
||||
createUserData.icon
|
||||
);
|
||||
const next: string[] = [];
|
||||
for (const { key } of ROLE_RESOURCES) {
|
||||
next.push(`${key}_rw`);
|
||||
}
|
||||
next.push("snapshot_create");
|
||||
setLocalRoles(next);
|
||||
}}
|
||||
>
|
||||
Полный доступ (admin)
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setCreateUserData(
|
||||
createUserData.name || "",
|
||||
createUserData.email || "",
|
||||
createUserData.password || "",
|
||||
false,
|
||||
createUserData.icon
|
||||
);
|
||||
setLocalRoles(["devices_ro", "vehicles_ro", "devices_maintenance_rw"]);
|
||||
}}
|
||||
>
|
||||
Администратор ТО
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<PermissionsTable localRoles={localRoles} setLocalRoles={setLocalRoles} />
|
||||
<RolesHintTable />
|
||||
</section>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="self-end w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleCreate}
|
||||
disabled={
|
||||
isLoading || !createUserData.name || !createUserData.password || !createUserData.email
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : (
|
||||
"Создать"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<SelectMediaDialog
|
||||
open={isSelectMediaOpen}
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import {
|
||||
Button,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Paper,
|
||||
TextField,
|
||||
Box,
|
||||
Typography,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Divider,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
@@ -31,45 +22,12 @@ import {
|
||||
authStore,
|
||||
cityStore,
|
||||
MultiSelect,
|
||||
selectedCityStore,
|
||||
type User,
|
||||
type UserCity,
|
||||
} from "@shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ImageUploadCard, DeleteModal } from "@widgets";
|
||||
|
||||
const ROLE_RESOURCES = [
|
||||
{ key: "snapshot", label: "Экспорт" },
|
||||
{ key: "devices", label: "Устройства" },
|
||||
{ key: "vehicles", label: "Транспорт" },
|
||||
{ key: "users", label: "Пользователи" },
|
||||
{ key: "sights", label: "Достопримечательности" },
|
||||
{ key: "stations", label: "Остановки" },
|
||||
{ key: "routes", label: "Маршруты" },
|
||||
{ key: "countries", label: "Страны" },
|
||||
{ key: "cities", label: "Города" },
|
||||
{ key: "carriers", label: "Перевозчики" },
|
||||
] as const;
|
||||
|
||||
type PermissionLevel = "none" | "ro" | "rw";
|
||||
|
||||
function getPermissionLevel(roles: string[], resource: string): PermissionLevel {
|
||||
if (roles.includes(`${resource}_rw`)) return "rw";
|
||||
if (roles.includes(`${resource}_ro`)) return "ro";
|
||||
return "none";
|
||||
}
|
||||
|
||||
function applyPermissionChange(
|
||||
roles: string[],
|
||||
resource: string,
|
||||
level: PermissionLevel,
|
||||
): string[] {
|
||||
const filtered = roles.filter(
|
||||
(r) => r !== `${resource}_ro` && r !== `${resource}_rw`,
|
||||
);
|
||||
if (level === "ro") return [...filtered, `${resource}_ro`];
|
||||
if (level === "rw") return [...filtered, `${resource}_rw`];
|
||||
return filtered;
|
||||
}
|
||||
import { ImageUploadCard, DeleteModal, PermissionsTable, RolesHintTable, ROLE_RESOURCES } from "@widgets";
|
||||
|
||||
export const UserEditPage = observer(() => {
|
||||
const navigate = useNavigate();
|
||||
@@ -93,10 +51,29 @@ export const UserEditPage = observer(() => {
|
||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
languageStore.setLanguage("ru");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const allRw = ROLE_RESOURCES.every(({ key }) => localRoles.includes(`${key}_rw`));
|
||||
const isAdmin = allRw && !localRoles.includes("devices_maintenance_rw");
|
||||
if (isAdmin !== editUserData.is_admin) {
|
||||
setEditUserData(
|
||||
editUserData.name || "",
|
||||
editUserData.email || "",
|
||||
editUserData.password || "",
|
||||
isAdmin,
|
||||
editUserData.icon || ""
|
||||
);
|
||||
}
|
||||
}, [localRoles]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (id) {
|
||||
@@ -311,175 +288,36 @@ export const UserEditPage = observer(() => {
|
||||
<section className="flex flex-col gap-4">
|
||||
<Typography variant="h6">Права доступа</Typography>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={localRoles.includes("admin")}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setLocalRoles((prev) => {
|
||||
let next = prev.filter((r) => r !== "admin");
|
||||
for (const { key } of ROLE_RESOURCES) {
|
||||
next = next.filter((r) => r !== `${key}_ro` && r !== `${key}_rw`);
|
||||
next.push(`${key}_rw`);
|
||||
}
|
||||
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;
|
||||
});
|
||||
} else {
|
||||
setLocalRoles((prev) => prev.filter((r) => r !== "admin"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Полный доступ (admin)"
|
||||
/>
|
||||
|
||||
<Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: "action.hover" }}>
|
||||
<TableCell sx={{ fontWeight: 600, width: 220 }}>Ресурс</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>Нет доступа</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение/Запись</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>
|
||||
Доп. права
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{ROLE_RESOURCES.map(({ key, label }) => {
|
||||
const level = getPermissionLevel(localRoles, key);
|
||||
const isSnapshotResource = key === "snapshot";
|
||||
|
||||
const handleChange = (val: string) => {
|
||||
setLocalRoles((prev) => {
|
||||
let updated = applyPermissionChange(prev, key, val as PermissionLevel);
|
||||
|
||||
if (key === "devices") {
|
||||
updated = applyPermissionChange(
|
||||
updated,
|
||||
"vehicles",
|
||||
val as PermissionLevel,
|
||||
);
|
||||
}
|
||||
|
||||
const allRw = ROLE_RESOURCES.every(({ key: k }) =>
|
||||
updated.includes(`${k}_rw`),
|
||||
);
|
||||
if (allRw && !updated.includes("admin")) {
|
||||
const next = [...updated];
|
||||
if (!next.includes("snapshot_create")) {
|
||||
next.push("snapshot_create");
|
||||
}
|
||||
next.push("admin");
|
||||
return next;
|
||||
}
|
||||
if (!allRw) {
|
||||
return updated.filter((r) => r !== "admin");
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const isDevicesResource = key === "devices";
|
||||
|
||||
const handleSnapshotCreateChange = (checked: boolean) => {
|
||||
if (!isSnapshotResource) {
|
||||
return;
|
||||
}
|
||||
setLocalRoles((prev) => {
|
||||
const withoutSnapshotCreate = prev.filter(
|
||||
(role) => role !== "snapshot_create"
|
||||
);
|
||||
return checked
|
||||
? [...withoutSnapshotCreate, "snapshot_create"]
|
||||
: withoutSnapshotCreate;
|
||||
});
|
||||
};
|
||||
|
||||
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>
|
||||
<TableCell align="center" padding="checkbox">
|
||||
<RadioGroup
|
||||
row
|
||||
value={level}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
|
||||
>
|
||||
<Radio value="none" size="small" />
|
||||
</RadioGroup>
|
||||
</TableCell>
|
||||
<TableCell align="center" padding="checkbox">
|
||||
{isSnapshotResource ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
-
|
||||
</Typography>
|
||||
) : (
|
||||
<RadioGroup
|
||||
row
|
||||
value={level}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
|
||||
>
|
||||
<Radio value="ro" size="small" />
|
||||
</RadioGroup>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center" padding="checkbox">
|
||||
<RadioGroup
|
||||
row
|
||||
value={level}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
|
||||
>
|
||||
<Radio value="rw" size="small" />
|
||||
</RadioGroup>
|
||||
</TableCell>
|
||||
<TableCell align="center" padding="checkbox">
|
||||
{isSnapshotResource ? (
|
||||
<Checkbox
|
||||
checked={localRoles.includes("snapshot_create")}
|
||||
onChange={(e) =>
|
||||
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">
|
||||
-
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditUserData(editUserData.name || "", editUserData.email || "", editUserData.password || "", true, editUserData.icon || "");
|
||||
const next: string[] = [];
|
||||
for (const { key } of ROLE_RESOURCES) {
|
||||
next.push(`${key}_rw`);
|
||||
}
|
||||
next.push("snapshot_create");
|
||||
setLocalRoles(next);
|
||||
}}
|
||||
>
|
||||
Полный доступ (admin)
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditUserData(editUserData.name || "", editUserData.email || "", editUserData.password || "", false, editUserData.icon || "");
|
||||
setLocalRoles(["devices_ro", "vehicles_ro", "devices_maintenance_rw"]);
|
||||
}}
|
||||
>
|
||||
Администратор ТО
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<PermissionsTable localRoles={localRoles} setLocalRoles={setLocalRoles} />
|
||||
<RolesHintTable />
|
||||
</section>
|
||||
|
||||
<Divider />
|
||||
|
||||
@@ -92,10 +92,11 @@ export const UserListPage = observer(() => {
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams) => (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
<button onClick={() => navigate(`/user/${params.row.id}/edit`)}>
|
||||
<button title="Редактировать" onClick={() => navigate(`/user/${params.row.id}/edit`)}>
|
||||
<Pencil size={20} className="text-blue-500" />
|
||||
</button>
|
||||
<button
|
||||
title="Удалить"
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
@@ -146,7 +147,9 @@ export const UserListPage = observer(() => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
{(rows.length > 0 || searchQuery) && (
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
)}
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
@@ -154,6 +157,11 @@ export const UserListPage = observer(() => {
|
||||
checkboxSelection={canWriteUsers}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
onRowDoubleClick={(params) => {
|
||||
if (canWriteUsers) {
|
||||
navigate(`/user/${params.row.id}/edit`);
|
||||
}
|
||||
}}
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
VEHICLE_TYPES,
|
||||
carrierStore,
|
||||
languageStore,
|
||||
cityStore,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { ArrowLeft, Save } from "lucide-react";
|
||||
@@ -26,11 +28,18 @@ export const VehicleCreatePage = observer(() => {
|
||||
const [type, setType] = useState("");
|
||||
const [carrierId, setCarrierId] = useState<number | null>(null);
|
||||
const [model, setModel] = useState("");
|
||||
const [cityId, setCityId] = useState<number | null>(selectedCityStore.selectedCityId);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { language } = languageStore;
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
carrierStore.getCarriers(language);
|
||||
cityStore.getCities("ru");
|
||||
}, [language]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
@@ -43,6 +52,7 @@ export const VehicleCreatePage = observer(() => {
|
||||
?.full_name as string,
|
||||
carrierId!,
|
||||
model || undefined,
|
||||
cityId ?? undefined,
|
||||
);
|
||||
toast.success("Транспорт успешно создан");
|
||||
} catch (error) {
|
||||
@@ -73,12 +83,11 @@ export const VehicleCreatePage = observer(() => {
|
||||
onChange={(e) => setTailNumber(e.target.value)}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Тип</InputLabel>
|
||||
<Select
|
||||
value={type}
|
||||
label="Тип"
|
||||
required
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
>
|
||||
{VEHICLE_TYPES.map((type) => (
|
||||
@@ -89,12 +98,11 @@ export const VehicleCreatePage = observer(() => {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Перевозчик</InputLabel>
|
||||
<Select
|
||||
value={carrierId || ""}
|
||||
label="Перевозчик"
|
||||
required
|
||||
onChange={(e) => setCarrierId(e.target.value as number)}
|
||||
>
|
||||
{carrierStore.carriers[language].data?.map((carrier) => (
|
||||
@@ -113,12 +121,27 @@ export const VehicleCreatePage = observer(() => {
|
||||
placeholder="Произвольное название модели"
|
||||
/>
|
||||
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Город</InputLabel>
|
||||
<Select
|
||||
value={cityId ?? ""}
|
||||
label="Город"
|
||||
onChange={(e) => setCityId(e.target.value ? Number(e.target.value) : null)}
|
||||
>
|
||||
{cityStore.cities.ru.data.map((city) => (
|
||||
<MenuItem key={city.id} value={city.id}>
|
||||
{city.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
className="w-min flex gap-2 items-center"
|
||||
startIcon={<Save size={20} />}
|
||||
onClick={handleCreate}
|
||||
disabled={isLoading || !tailNumber || !type || !carrierId}
|
||||
disabled={isLoading || !tailNumber || !type || !carrierId || !cityId}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
VEHICLE_TYPES,
|
||||
vehicleStore,
|
||||
LoadingSpinner,
|
||||
cityStore,
|
||||
selectedCityStore,
|
||||
} from "@shared";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
@@ -38,6 +40,11 @@ export const VehicleEditPage = observer(() => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
selectedCityStore.setIsLocked(true);
|
||||
return () => selectedCityStore.setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Устанавливаем русский язык при загрузке страницы
|
||||
languageStore.setLanguage("ru");
|
||||
@@ -54,6 +61,7 @@ export const VehicleEditPage = observer(() => {
|
||||
try {
|
||||
await getVehicle(Number(id));
|
||||
await getCarriers(language);
|
||||
await cityStore.getCities("ru");
|
||||
|
||||
setEditVehicleData({
|
||||
tail_number: vehicle[Number(id)]?.vehicle.tail_number ?? "",
|
||||
@@ -63,6 +71,7 @@ export const VehicleEditPage = observer(() => {
|
||||
model: vehicle[Number(id)]?.vehicle.model ?? "",
|
||||
snapshot_update_blocked:
|
||||
vehicle[Number(id)]?.vehicle.snapshot_update_blocked ?? false,
|
||||
city_id: vehicle[Number(id)]?.vehicle.city_id ?? selectedCityStore.selectedCityId ?? undefined,
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
@@ -125,12 +134,11 @@ export const VehicleEditPage = observer(() => {
|
||||
}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Тип</InputLabel>
|
||||
<Select
|
||||
value={editVehicleData.type}
|
||||
label="Тип"
|
||||
required
|
||||
onChange={(e) =>
|
||||
setEditVehicleData({ ...editVehicleData, type: e.target.value })
|
||||
}
|
||||
@@ -143,12 +151,11 @@ export const VehicleEditPage = observer(() => {
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Перевозчик</InputLabel>
|
||||
<Select
|
||||
value={editVehicleData.carrier_id}
|
||||
label="Перевозчик"
|
||||
required
|
||||
onChange={(e) =>
|
||||
setEditVehicleData({
|
||||
...editVehicleData,
|
||||
@@ -177,6 +184,26 @@ export const VehicleEditPage = observer(() => {
|
||||
placeholder="Произвольное название модели"
|
||||
/>
|
||||
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Город</InputLabel>
|
||||
<Select
|
||||
value={editVehicleData.city_id ?? ""}
|
||||
label="Город"
|
||||
onChange={(e) =>
|
||||
setEditVehicleData({
|
||||
...editVehicleData,
|
||||
city_id: e.target.value ? Number(e.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
{cityStore.cities.ru.data.map((city) => (
|
||||
<MenuItem key={city.id} value={city.id}>
|
||||
{city.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
@@ -202,7 +229,8 @@ export const VehicleEditPage = observer(() => {
|
||||
isLoading ||
|
||||
!editVehicleData.tail_number ||
|
||||
!editVehicleData.type ||
|
||||
!editVehicleData.carrier_id
|
||||
!editVehicleData.carrier_id ||
|
||||
!editVehicleData.city_id
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import { ruRU } from "@mui/x-data-grid/locales";
|
||||
import { authStore, carrierStore, languageStore, vehicleStore, SearchInput } from "@shared";
|
||||
import { authStore, carrierStore, languageStore, vehicleStore, SearchInput, selectedCityStore, cityStore } from "@shared";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Eye, Pencil, Trash2, Minus } from "lucide-react";
|
||||
@@ -11,7 +11,7 @@ import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
export const VehicleListPage = observer(() => {
|
||||
const { vehicles, getVehicles, deleteVehicle } = vehicleStore;
|
||||
const { carriers, getCarriers } = carrierStore;
|
||||
const { getCarriers } = carrierStore;
|
||||
const navigate = useNavigate();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
@@ -31,10 +31,11 @@ export const VehicleListPage = observer(() => {
|
||||
setIsLoading(true);
|
||||
await getVehicles();
|
||||
await getCarriers(language);
|
||||
await cityStore.getCities("ru");
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchData();
|
||||
}, [language]);
|
||||
}, [language, selectedCityStore.cityVersion]);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
@@ -114,15 +115,16 @@ export const VehicleListPage = observer(() => {
|
||||
return (
|
||||
<div className="flex h-full gap-7 justify-center items-center">
|
||||
{canWrite && (
|
||||
<button onClick={() => navigate(`/vehicle/${params.row.id}/edit`)}>
|
||||
<button title="Редактировать" onClick={() => navigate(`/vehicle/${params.row.id}/edit`)}>
|
||||
<Pencil size={20} className="text-blue-500" />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => navigate(`/vehicle/${params.row.id}`)}>
|
||||
<button title="Просмотр" onClick={() => navigate(`/vehicle/${params.row.id}`)}>
|
||||
<Eye size={20} className="text-green-500" />
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button
|
||||
title="Удалить"
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
setRowId(params.row.id);
|
||||
@@ -137,9 +139,13 @@ export const VehicleListPage = observer(() => {
|
||||
},
|
||||
];
|
||||
|
||||
const { selectedCityId } = selectedCityStore;
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!selectedCityId) return [];
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
return (vehicles.data ?? [])
|
||||
.filter((vehicle) => vehicle.vehicle.city_id === selectedCityId)
|
||||
.filter(
|
||||
(vehicle) =>
|
||||
!query ||
|
||||
@@ -151,11 +157,9 @@ export const VehicleListPage = observer(() => {
|
||||
tail_number: vehicle.vehicle.tail_number,
|
||||
type: vehicle.vehicle.type,
|
||||
carrier: vehicle.vehicle.carrier,
|
||||
city: carriers[language].data?.find(
|
||||
(carrier) => carrier.id === vehicle.vehicle.carrier_id
|
||||
)?.city,
|
||||
city: cityStore.cities.ru.data.find((c) => c.id === vehicle.vehicle.city_id)?.name,
|
||||
}));
|
||||
}, [vehicles.data, carriers[language].data, searchQuery]);
|
||||
}, [vehicles.data, selectedCityId, searchQuery]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -169,7 +173,9 @@ export const VehicleListPage = observer(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
{(rows.length > 0 || searchQuery) && (
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
)}
|
||||
|
||||
{canWriteVehicles && ids.length > 0 && (
|
||||
<div className="flex justify-end mb-5 duration-300">
|
||||
@@ -223,6 +229,8 @@ export const VehicleListPage = observer(() => {
|
||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||
{isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : !selectedCityId ? (
|
||||
"Выберите город"
|
||||
) : (
|
||||
"Нет транспортных средств"
|
||||
)}
|
||||
|
||||
@@ -103,8 +103,26 @@ export const UploadMediaDialog = observer(
|
||||
|
||||
setMediaFile(initialFile);
|
||||
setMediaFilename(initialFile.name);
|
||||
setAvailableMediaTypes([2]);
|
||||
setMediaType(2);
|
||||
|
||||
const extension = initialFile.name.split(".").pop()?.toLowerCase();
|
||||
if (extension) {
|
||||
if (["glb", "gltf"].includes(extension)) {
|
||||
setAvailableMediaTypes([6]);
|
||||
setMediaType(6);
|
||||
}
|
||||
if (
|
||||
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
|
||||
extension
|
||||
)
|
||||
) {
|
||||
setAvailableMediaTypes([1, 3, 4, 5]);
|
||||
setMediaType(1);
|
||||
} else if (["mp4", "webm", "mov"].includes(extension)) {
|
||||
setAvailableMediaTypes([2]);
|
||||
setMediaType(2);
|
||||
}
|
||||
}
|
||||
|
||||
const newBlobUrl = URL.createObjectURL(initialFile);
|
||||
setMediaUrl(newBlobUrl);
|
||||
previousMediaUrlRef.current = newBlobUrl;
|
||||
|
||||
@@ -16,9 +16,9 @@ export type Carrier = {
|
||||
city: string;
|
||||
city_id: number;
|
||||
logo: string;
|
||||
// main_color: string;
|
||||
// left_color: string;
|
||||
// right_color: string;
|
||||
main_color: string;
|
||||
left_color: string;
|
||||
right_color: string;
|
||||
};
|
||||
|
||||
type CarrierData = {
|
||||
@@ -112,6 +112,9 @@ class CarrierStore {
|
||||
createCarrierData = {
|
||||
city_id: 0,
|
||||
logo: "",
|
||||
main_color: "",
|
||||
left_color: "",
|
||||
right_color: "",
|
||||
ru: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
@@ -135,10 +138,16 @@ class CarrierStore {
|
||||
cityId: number,
|
||||
slogan: string,
|
||||
logoId: string,
|
||||
language: Language
|
||||
language: Language,
|
||||
colors?: { main_color?: string; left_color?: string; right_color?: string }
|
||||
) => {
|
||||
this.createCarrierData.city_id = cityId;
|
||||
this.createCarrierData.logo = logoId;
|
||||
if (colors) {
|
||||
if (colors.main_color !== undefined) this.createCarrierData.main_color = colors.main_color;
|
||||
if (colors.left_color !== undefined) this.createCarrierData.left_color = colors.left_color;
|
||||
if (colors.right_color !== undefined) this.createCarrierData.right_color = colors.right_color;
|
||||
}
|
||||
this.createCarrierData[language] = {
|
||||
full_name: fullName,
|
||||
short_name: shortName,
|
||||
@@ -198,9 +207,10 @@ class CarrierStore {
|
||||
city: cityName,
|
||||
city_id: this.createCarrierData.city_id,
|
||||
slogan: (this.createCarrierData[language].slogan || "").trim(),
|
||||
...(this.createCarrierData.logo
|
||||
? { logo: this.createCarrierData.logo }
|
||||
: {}),
|
||||
...(this.createCarrierData.logo ? { logo: this.createCarrierData.logo } : {}),
|
||||
...(this.createCarrierData.main_color ? { main_color: this.createCarrierData.main_color } : {}),
|
||||
...(this.createCarrierData.left_color ? { left_color: this.createCarrierData.left_color } : {}),
|
||||
...(this.createCarrierData.right_color ? { right_color: this.createCarrierData.right_color } : {}),
|
||||
};
|
||||
|
||||
const response = await languageInstance(language).post("/carrier", payload);
|
||||
@@ -243,6 +253,9 @@ class CarrierStore {
|
||||
this.createCarrierData = {
|
||||
city_id: 0,
|
||||
logo: "",
|
||||
main_color: "",
|
||||
left_color: "",
|
||||
right_color: "",
|
||||
ru: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
@@ -265,53 +278,44 @@ class CarrierStore {
|
||||
ru: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
|
||||
// main_color: "",
|
||||
// left_color: "",
|
||||
// right_color: "",
|
||||
slogan: "",
|
||||
},
|
||||
en: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
|
||||
// main_color: "",
|
||||
// left_color: "",
|
||||
// right_color: "",
|
||||
slogan: "",
|
||||
},
|
||||
zh: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
slogan: "",
|
||||
},
|
||||
city_id: 0,
|
||||
logo: "",
|
||||
zh: {
|
||||
full_name: "",
|
||||
short_name: "",
|
||||
|
||||
// main_color: "",
|
||||
// left_color: "",
|
||||
// right_color: "",
|
||||
slogan: "",
|
||||
},
|
||||
main_color: "",
|
||||
left_color: "",
|
||||
right_color: "",
|
||||
};
|
||||
|
||||
setEditCarrierData = (
|
||||
fullName: string,
|
||||
shortName: string,
|
||||
cityId: number,
|
||||
// main_color: string,
|
||||
// left_color: string,
|
||||
// right_color: string,
|
||||
slogan: string,
|
||||
logoId: string,
|
||||
language: Language
|
||||
language: Language,
|
||||
colors?: { main_color?: string; left_color?: string; right_color?: string }
|
||||
) => {
|
||||
this.editCarrierData.city_id = cityId;
|
||||
this.editCarrierData.logo = logoId;
|
||||
if (colors) {
|
||||
if (colors.main_color !== undefined) this.editCarrierData.main_color = colors.main_color;
|
||||
if (colors.left_color !== undefined) this.editCarrierData.left_color = colors.left_color;
|
||||
if (colors.right_color !== undefined) this.editCarrierData.right_color = colors.right_color;
|
||||
}
|
||||
this.editCarrierData[language] = {
|
||||
full_name: fullName,
|
||||
short_name: shortName,
|
||||
// main_color: main_color,
|
||||
// left_color: left_color,
|
||||
// right_color: right_color,
|
||||
slogan: slogan,
|
||||
};
|
||||
};
|
||||
@@ -326,9 +330,10 @@ class CarrierStore {
|
||||
slogan: (this.editCarrierData[lang].slogan || "").trim(),
|
||||
city: cityName,
|
||||
city_id: this.editCarrierData.city_id,
|
||||
...(this.editCarrierData.logo
|
||||
? { logo: this.editCarrierData.logo }
|
||||
: {}),
|
||||
...(this.editCarrierData.logo ? { logo: this.editCarrierData.logo } : {}),
|
||||
...(this.editCarrierData.main_color ? { main_color: this.editCarrierData.main_color } : {}),
|
||||
...(this.editCarrierData.left_color ? { left_color: this.editCarrierData.left_color } : {}),
|
||||
...(this.editCarrierData.right_color ? { right_color: this.editCarrierData.right_color } : {}),
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
|
||||
@@ -13,6 +13,7 @@ export type Route = {
|
||||
center_latitude: number;
|
||||
center_longitude: number;
|
||||
governor_appeal: number;
|
||||
button_text?: string;
|
||||
id: number;
|
||||
icon: string;
|
||||
path: number[][];
|
||||
@@ -143,6 +144,7 @@ class RouteStore {
|
||||
center_latitude: "",
|
||||
center_longitude: "",
|
||||
governor_appeal: 0,
|
||||
button_text: "" as string | undefined,
|
||||
id: 0,
|
||||
icon: "",
|
||||
path: [] as number[][],
|
||||
@@ -153,7 +155,7 @@ class RouteStore {
|
||||
scale_max: 0,
|
||||
scale_min: 0,
|
||||
video_preview: "" as string | undefined,
|
||||
video_timer: 60,
|
||||
video_timer: 420,
|
||||
};
|
||||
|
||||
setEditRouteData = (data: any) => {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { City } from "../CityStore";
|
||||
|
||||
class SelectedCityStore {
|
||||
selectedCity: City | null = null;
|
||||
isLocked: boolean = false;
|
||||
cityVersion: number = 0;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
@@ -24,6 +26,7 @@ class SelectedCityStore {
|
||||
setSelectedCity = (city: City | null) => {
|
||||
runInAction(() => {
|
||||
this.selectedCity = city;
|
||||
this.cityVersion += 1;
|
||||
if (city) {
|
||||
localStorage.setItem("selectedCity", JSON.stringify(city));
|
||||
} else {
|
||||
@@ -32,6 +35,12 @@ class SelectedCityStore {
|
||||
});
|
||||
};
|
||||
|
||||
setIsLocked = (locked: boolean) => {
|
||||
runInAction(() => {
|
||||
this.isLocked = locked;
|
||||
});
|
||||
};
|
||||
|
||||
clearSelectedCity = () => {
|
||||
this.setSelectedCity(null);
|
||||
};
|
||||
|
||||
@@ -60,12 +60,6 @@ class SnapshotStore {
|
||||
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 },
|
||||
|
||||
@@ -120,6 +120,7 @@ class VehicleStore {
|
||||
carrier: string,
|
||||
carrierId: number,
|
||||
model?: string,
|
||||
cityId?: number,
|
||||
) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
tail_number: tailNumber.trim(),
|
||||
@@ -129,6 +130,7 @@ class VehicleStore {
|
||||
};
|
||||
// TODO: когда будет бекенд — добавить model в payload и в ответ
|
||||
if (model != null && model !== "") payload.model = model;
|
||||
if (cityId != null) payload.city_id = cityId;
|
||||
const response = await languageInstance("ru").post("/vehicle", payload);
|
||||
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
||||
|
||||
@@ -147,6 +149,7 @@ class VehicleStore {
|
||||
carrier_id: number;
|
||||
model: string;
|
||||
snapshot_update_blocked: boolean;
|
||||
city_id?: number;
|
||||
} = {
|
||||
tail_number: "",
|
||||
type: 0,
|
||||
@@ -154,6 +157,7 @@ class VehicleStore {
|
||||
carrier_id: 0,
|
||||
model: "",
|
||||
snapshot_update_blocked: false,
|
||||
city_id: undefined,
|
||||
};
|
||||
|
||||
setEditVehicleData = (data: {
|
||||
@@ -163,6 +167,7 @@ class VehicleStore {
|
||||
carrier_id: number;
|
||||
model?: string;
|
||||
snapshot_update_blocked?: boolean;
|
||||
city_id?: number;
|
||||
}) => {
|
||||
this.editVehicleData = {
|
||||
...this.editVehicleData,
|
||||
@@ -179,6 +184,7 @@ class VehicleStore {
|
||||
carrier_id: number;
|
||||
model?: string;
|
||||
snapshot_update_blocked?: boolean;
|
||||
city_id?: number;
|
||||
},
|
||||
) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
@@ -190,6 +196,7 @@ class VehicleStore {
|
||||
if (data.model != null && data.model !== "") payload.model = data.model;
|
||||
if (data.snapshot_update_blocked != null)
|
||||
payload.snapshot_update_blocked = data.snapshot_update_blocked;
|
||||
if (data.city_id != null) payload.city_id = data.city_id;
|
||||
const response = await languageInstance("ru").patch(
|
||||
`/vehicle/${id}`,
|
||||
payload,
|
||||
|
||||
@@ -6,13 +6,23 @@ import {
|
||||
SelectChangeEvent,
|
||||
Typography,
|
||||
Box,
|
||||
keyframes,
|
||||
} from "@mui/material";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { authStore, cityStore, selectedCityStore, type City } from "@shared";
|
||||
import { authStore, cityStore, selectedCityStore, snapshotStore, type City } from "@shared";
|
||||
import { MapPin } from "lucide-react";
|
||||
|
||||
const borderSpin = keyframes`
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 50%;
|
||||
}
|
||||
`;
|
||||
|
||||
export const CitySelector: React.FC = observer(() => {
|
||||
const { selectedCity, setSelectedCity } = selectedCityStore;
|
||||
const { selectedCity, setSelectedCity, isLocked } = selectedCityStore;
|
||||
const canLoadAllCities = authStore.isAdmin && authStore.canRead("cities");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -43,53 +53,93 @@ export const CitySelector: React.FC = observer(() => {
|
||||
})()
|
||||
: baseCities;
|
||||
|
||||
const noCitySelected = !selectedCity?.id;
|
||||
|
||||
const handleCityChange = (event: SelectChangeEvent<string>) => {
|
||||
const cityId = event.target.value;
|
||||
if (cityId === "") {
|
||||
snapshotStore.clearStoreCache();
|
||||
setSelectedCity(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const city = currentCities.find((c) => c.id === Number(cityId));
|
||||
if (city) {
|
||||
snapshotStore.clearStoreCache();
|
||||
setSelectedCity(city);
|
||||
}
|
||||
};
|
||||
|
||||
const selectElement = (
|
||||
<Select
|
||||
value={selectedCity?.id?.toString() || ""}
|
||||
onChange={handleCityChange}
|
||||
displayEmpty
|
||||
disabled={isLocked}
|
||||
sx={{
|
||||
height: "40px",
|
||||
color: "white",
|
||||
borderRadius: "4px",
|
||||
...(noCitySelected && !isLocked
|
||||
? {
|
||||
backgroundColor: "#48989f",
|
||||
"& .MuiOutlinedInput-notchedOutline": { border: "none" },
|
||||
}
|
||||
: {
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: isLocked
|
||||
? "rgba(255, 255, 255, 0.1)"
|
||||
: "rgba(255, 255, 255, 0.3)",
|
||||
},
|
||||
}),
|
||||
"&.Mui-disabled": {
|
||||
color: "rgba(255, 255, 255, 0.5)",
|
||||
WebkitTextFillColor: "rgba(255, 255, 255, 0.5)",
|
||||
},
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: isLocked
|
||||
? "rgba(255, 255, 255, 0.1)"
|
||||
: "rgba(255, 255, 255, 0.5)",
|
||||
},
|
||||
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: "white",
|
||||
},
|
||||
"& .MuiSvgIcon-root": {
|
||||
color: isLocked ? "rgba(255, 255, 255, 0.3)" : "white",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<Typography variant="body2">Выберите город</Typography>
|
||||
</MenuItem>
|
||||
{currentCities.map((city) => (
|
||||
<MenuItem key={city.id} value={city.id?.toString()}>
|
||||
<Typography variant="body2">{city.name}</Typography>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box className="flex items-center gap-2">
|
||||
<MapPin size={16} className="text-white" />
|
||||
<MapPin size={16} className={isLocked ? "text-gray-400" : "text-white"} />
|
||||
<FormControl size="medium" sx={{ minWidth: 120 }}>
|
||||
<Select
|
||||
value={selectedCity?.id?.toString() || ""}
|
||||
onChange={handleCityChange}
|
||||
displayEmpty
|
||||
sx={{
|
||||
height: "40px",
|
||||
color: "white",
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||
},
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: "rgba(255, 255, 255, 0.5)",
|
||||
},
|
||||
"&.Mui.focused .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: "white",
|
||||
},
|
||||
"& .MuiSvgIcon-root": {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<Typography variant="body2">Выберите город</Typography>
|
||||
</MenuItem>
|
||||
{currentCities.map((city) => (
|
||||
<MenuItem key={city.id} value={city.id?.toString()}>
|
||||
<Typography variant="body2">{city.name}</Typography>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{noCitySelected && !isLocked ? (
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
borderRadius: "4px",
|
||||
padding: "2px",
|
||||
background: "linear-gradient(90deg, rgba(255,255,255,0.1), rgba(255,255,255,0.7), rgba(255,255,255,0.1), rgba(255,255,255,0.7), rgba(255,255,255,0.1))",
|
||||
backgroundSize: "200% 100%",
|
||||
animation: `${borderSpin} 2.5s linear infinite`,
|
||||
}}
|
||||
>
|
||||
{selectElement}
|
||||
</Box>
|
||||
) : (
|
||||
selectElement
|
||||
)}
|
||||
</FormControl>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -24,30 +24,51 @@ const shiftYYYYMMDD = (value: string, days: number) => {
|
||||
|
||||
type LogLevel = "info" | "warn" | "error" | "debug" | "fatal" | "unknown";
|
||||
|
||||
const LOG_LEVEL_STYLES: Record<LogLevel, { badge: string; text: string }> = {
|
||||
const LOG_LEVEL_STYLES: Record<
|
||||
LogLevel,
|
||||
{ badge: string; text: string; bg: string; color: string; borderColor: string }
|
||||
> = {
|
||||
info: {
|
||||
badge: "bg-blue-100 text-blue-700",
|
||||
text: "text-[#000000BF]",
|
||||
bg: "#DBEAFE",
|
||||
color: "#1D4ED8",
|
||||
borderColor: "#93C5FD",
|
||||
},
|
||||
debug: {
|
||||
badge: "bg-gray-100 text-gray-600",
|
||||
text: "text-gray-600",
|
||||
bg: "#F3F4F6",
|
||||
color: "#4B5563",
|
||||
borderColor: "#D1D5DB",
|
||||
},
|
||||
warn: {
|
||||
badge: "bg-amber-100 text-amber-700",
|
||||
text: "text-amber-800",
|
||||
bg: "#FEF3C7",
|
||||
color: "#B45309",
|
||||
borderColor: "#FCD34D",
|
||||
},
|
||||
error: {
|
||||
badge: "bg-red-100 text-red-700",
|
||||
text: "text-red-700",
|
||||
bg: "#FEE2E2",
|
||||
color: "#B91C1C",
|
||||
borderColor: "#FCA5A5",
|
||||
},
|
||||
fatal: {
|
||||
badge: "bg-red-200 text-red-900",
|
||||
text: "text-red-900 font-semibold",
|
||||
bg: "#FECACA",
|
||||
color: "#7F1D1D",
|
||||
borderColor: "#F87171",
|
||||
},
|
||||
unknown: {
|
||||
badge: "bg-gray-100 text-gray-500",
|
||||
text: "text-[#000000BF]",
|
||||
bg: "#F3F4F6",
|
||||
color: "#6B7280",
|
||||
borderColor: "#D1D5DB",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -139,6 +160,23 @@ export const DeviceLogsModal = ({
|
||||
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
|
||||
const [dateFrom, setDateFrom] = useState(toYYYYMMDD(yesterday));
|
||||
const [dateTo, setDateTo] = useState(toYYYYMMDD(today));
|
||||
|
||||
const ALL_LEVELS: LogLevel[] = ["debug", "info", "warn", "error", "fatal"];
|
||||
const [activeLevels, setActiveLevels] = useState<Set<LogLevel>>(
|
||||
new Set(ALL_LEVELS)
|
||||
);
|
||||
|
||||
const toggleLevel = (level: LogLevel) => {
|
||||
setActiveLevels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(level)) {
|
||||
next.delete(level);
|
||||
} else {
|
||||
next.add(level);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const dateToMin = shiftYYYYMMDD(dateFrom, 1);
|
||||
const dateFromMax = shiftYYYYMMDD(dateTo, -1);
|
||||
|
||||
@@ -205,16 +243,21 @@ export const DeviceLogsModal = ({
|
||||
return parsed;
|
||||
}, [chunks]);
|
||||
|
||||
const filteredLogs = useMemo(
|
||||
() => logs.filter((log) => activeLevels.has(log.level)),
|
||||
[logs, activeLevels]
|
||||
);
|
||||
|
||||
const logsText = useMemo(
|
||||
() =>
|
||||
logs
|
||||
filteredLogs
|
||||
.map((log) => {
|
||||
const level = log.level === "unknown" ? "LOG" : log.level.toUpperCase();
|
||||
const time = log.time ? `[${log.time}] ` : "";
|
||||
return `${time}${level}: ${log.text}`;
|
||||
})
|
||||
.join("\n"),
|
||||
[logs]
|
||||
[filteredLogs]
|
||||
);
|
||||
|
||||
const handleDownloadLogs = () => {
|
||||
@@ -253,6 +296,28 @@ export const DeviceLogsModal = ({
|
||||
<div className="flex flex-col gap-6 h-[85vh]">
|
||||
<div className="flex gap-4 items-center justify-between w-full flex-wrap">
|
||||
<h2 className="text-2xl font-semibold text-[#000000BF]">Логи</h2>
|
||||
<div className="flex gap-1.5 items-center">
|
||||
{ALL_LEVELS.map((level) => {
|
||||
const active = activeLevels.has(level);
|
||||
const s = LOG_LEVEL_STYLES[level];
|
||||
return (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
onClick={() => toggleLevel(level)}
|
||||
className="cursor-pointer select-none rounded-md px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide transition-all duration-150"
|
||||
style={{
|
||||
backgroundColor: active ? s.bg : "transparent",
|
||||
color: active ? s.color : "#9CA3AF",
|
||||
border: `1.5px solid ${active ? s.borderColor : "#E5E7EB"}`,
|
||||
opacity: active ? 1 : 0.55,
|
||||
}}
|
||||
>
|
||||
{level}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
<TextField
|
||||
type="date"
|
||||
@@ -280,7 +345,7 @@ export const DeviceLogsModal = ({
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={handleDownloadLogs}
|
||||
disabled={isLoading || Boolean(error) || logs.length === 0}
|
||||
disabled={isLoading || Boolean(error) || filteredLogs.length === 0}
|
||||
>
|
||||
Скачать .txt
|
||||
</Button>
|
||||
@@ -303,8 +368,8 @@ export const DeviceLogsModal = ({
|
||||
{!isLoading && !error && (
|
||||
<div className="w-full h-full overflow-y-auto rounded-xl">
|
||||
<div className="flex flex-col gap-0.5 font-mono text-[13px]">
|
||||
{logs.length > 0 ? (
|
||||
logs.map((log) => {
|
||||
{filteredLogs.length > 0 ? (
|
||||
filteredLogs.map((log) => {
|
||||
const style = LOG_LEVEL_STYLES[log.level];
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
vehicleStore,
|
||||
routeStore,
|
||||
Vehicle,
|
||||
carrierStore,
|
||||
selectedCityStore,
|
||||
menuStore,
|
||||
VEHICLE_TYPES,
|
||||
@@ -184,31 +183,14 @@ export const DevicesTable = observer(() => {
|
||||
pageSize: 50,
|
||||
});
|
||||
|
||||
const filterVehiclesBySelectedCity = (vehiclesList: Vehicle[]): Vehicle[] => {
|
||||
const selectedCityId = selectedCityStore.selectedCityId;
|
||||
const { selectedCityId } = selectedCityStore;
|
||||
|
||||
if (!selectedCityId) {
|
||||
return vehiclesList;
|
||||
}
|
||||
|
||||
const carriersInSelectedCityIds = new Set(
|
||||
carrierStore.carriers.ru.data
|
||||
.filter((carrier) => carrier.city_id === selectedCityId)
|
||||
.map((carrier) => carrier.id),
|
||||
const filteredVehicles = useMemo((): Vehicle[] => {
|
||||
if (!selectedCityId) return [];
|
||||
return (vehicles.data as Vehicle[]).filter(
|
||||
(vehicle) => vehicle.vehicle.city_id === selectedCityId,
|
||||
);
|
||||
|
||||
if (carriersInSelectedCityIds.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return vehiclesList.filter((vehicle) =>
|
||||
carriersInSelectedCityIds.has(vehicle.vehicle.carrier_id),
|
||||
);
|
||||
};
|
||||
|
||||
const filteredVehicles = filterVehiclesBySelectedCity(
|
||||
vehicles.data as Vehicle[],
|
||||
);
|
||||
}, [selectedCityId, vehicles.data]);
|
||||
|
||||
const rows = useMemo(
|
||||
() => transformToRows(filteredVehicles),
|
||||
@@ -628,41 +610,57 @@ export const DevicesTable = observer(() => {
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{canWriteDevices && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/vehicle/${row.vehicle_id}/edit`);
|
||||
}}
|
||||
title="Редактировать транспорт"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
{!isMaintenanceOnly && (
|
||||
<>
|
||||
{canWriteDevices && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/vehicle/${row.vehicle_id}/edit`);
|
||||
}}
|
||||
title="Редактировать"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleReloadStatus();
|
||||
}}
|
||||
title="Обновить статус"
|
||||
disabled={
|
||||
!row.device_uuid || !devices.includes(row.device_uuid)
|
||||
}
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (row.device_uuid) {
|
||||
navigator.clipboard.writeText(row.device_uuid);
|
||||
toast.success("UUID скопирован");
|
||||
}
|
||||
}}
|
||||
title="Копировать UUID"
|
||||
>
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (row.device_uuid) {
|
||||
setLogsModalDeviceUuid(row.device_uuid);
|
||||
setLogsModalOpen(true);
|
||||
}
|
||||
}}
|
||||
title="Логи"
|
||||
>
|
||||
<ScrollText size={16} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleReloadStatus();
|
||||
}}
|
||||
title="Перезапросить статус"
|
||||
disabled={
|
||||
!row.device_uuid || !devices.includes(row.device_uuid)
|
||||
}
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (row.device_uuid) {
|
||||
navigator.clipboard.writeText(row.device_uuid);
|
||||
toast.success("UUID скопирован");
|
||||
}
|
||||
}}
|
||||
title="Копировать UUID"
|
||||
>
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -670,22 +668,10 @@ export const DevicesTable = observer(() => {
|
||||
setSessionsModalVehicleTailNumber(row.tail_number);
|
||||
setSessionsModalOpen(true);
|
||||
}}
|
||||
title="Сессии ТО"
|
||||
title="Сессии обслуживания"
|
||||
>
|
||||
<Wrench size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (row.device_uuid) {
|
||||
setLogsModalDeviceUuid(row.device_uuid);
|
||||
setLogsModalOpen(true);
|
||||
}
|
||||
}}
|
||||
title="Логи устройства"
|
||||
>
|
||||
<ScrollText size={16} />
|
||||
</button>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
@@ -714,9 +700,11 @@ export const DevicesTable = observer(() => {
|
||||
|
||||
const visibleColumns = useMemo(() => {
|
||||
if (isMaintenanceOnly) {
|
||||
return columns.filter((c) =>
|
||||
["model", "tail_number", "maintenance_mode_on"].includes(c.field),
|
||||
);
|
||||
return columns
|
||||
.filter((c) =>
|
||||
["model", "tail_number", "maintenance_mode_on", "actions"].includes(c.field),
|
||||
)
|
||||
.map((c) => ({ ...c, flex: 1, width: undefined, minWidth: undefined }));
|
||||
}
|
||||
if (!canWriteDevices) {
|
||||
return columns.filter(
|
||||
@@ -729,20 +717,21 @@ export const DevicesTable = observer(() => {
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
await Promise.all([
|
||||
getVehicles(),
|
||||
getDevices(),
|
||||
getSnapshots(),
|
||||
getRoutes(),
|
||||
]);
|
||||
if (isMaintenanceOnly) {
|
||||
await Promise.all([getVehicles(), getDevices()]);
|
||||
} else {
|
||||
await Promise.all([
|
||||
getVehicles(),
|
||||
getDevices(),
|
||||
getSnapshots(),
|
||||
getRoutes(),
|
||||
]);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
fetchData();
|
||||
}, [getDevices, getSnapshots, getVehicles, getRoutes]);
|
||||
}, [getDevices, getSnapshots, getVehicles, getRoutes, isMaintenanceOnly, selectedCityStore.cityVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
carrierStore.getCarriers("ru");
|
||||
}, []);
|
||||
|
||||
const handleOpenSendSnapshotModal = () => {
|
||||
if (!canWriteDevices) {
|
||||
@@ -876,6 +865,8 @@ export const DevicesTable = observer(() => {
|
||||
>
|
||||
{isLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : !selectedCityId ? (
|
||||
"Выберите город"
|
||||
) : (
|
||||
"Нет устройств для отображения"
|
||||
)}
|
||||
@@ -916,13 +907,17 @@ export const DevicesTable = observer(() => {
|
||||
{!isCollapsed && (
|
||||
<Box sx={{ p: 0 }}>
|
||||
<DataGrid
|
||||
rows={groupRows}
|
||||
columns={visibleColumns}
|
||||
checkboxSelection={canWriteDevices}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
loading={isLoading}
|
||||
paginationModel={paginationModel}
|
||||
rows={groupRows}
|
||||
columns={visibleColumns}
|
||||
checkboxSelection={canWriteDevices}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
onRowDoubleClick={(params) => {
|
||||
if (canWriteDevices) {
|
||||
navigate(`/vehicle/${params.row.vehicle_id}/edit`);
|
||||
}
|
||||
}}
|
||||
loading={isLoading} paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
pageSizeOptions={[50]}
|
||||
onRowSelectionModelChange={
|
||||
|
||||
128
src/widgets/PermissionsTable/PermissionsTable.tsx
Normal file
128
src/widgets/PermissionsTable/PermissionsTable.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
Checkbox,
|
||||
Typography,
|
||||
Box,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
} from "@mui/material";
|
||||
import { ROLE_RESOURCES, getPermissionLevel, applyPermissionChange, type PermissionLevel } from "./constants";
|
||||
|
||||
interface PermissionsTableProps {
|
||||
localRoles: string[];
|
||||
setLocalRoles: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
}
|
||||
|
||||
export function PermissionsTable({ localRoles, setLocalRoles }: PermissionsTableProps) {
|
||||
return (
|
||||
<Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: "action.hover" }}>
|
||||
<TableCell sx={{ fontWeight: 600, width: 220 }}>Ресурс</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>Нет доступа</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение/Запись</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>Доп. права</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{ROLE_RESOURCES.map(({ key, label }) => {
|
||||
const level = getPermissionLevel(localRoles, key);
|
||||
const isSnapshotResource = key === "snapshot";
|
||||
const isDevicesResource = key === "devices";
|
||||
|
||||
const handleChange = (val: string) => {
|
||||
setLocalRoles((prev) => {
|
||||
let updated = applyPermissionChange(prev, key, val as PermissionLevel);
|
||||
if (key === "devices") {
|
||||
updated = applyPermissionChange(updated, "vehicles", val as PermissionLevel);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSnapshotCreateChange = (checked: boolean) => {
|
||||
setLocalRoles((prev) => {
|
||||
const without = prev.filter((role) => role !== "snapshot_create");
|
||||
return checked ? [...without, "snapshot_create"] : without;
|
||||
});
|
||||
};
|
||||
|
||||
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>
|
||||
<TableCell align="center" padding="checkbox">
|
||||
<RadioGroup
|
||||
row
|
||||
value={level}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
|
||||
>
|
||||
<Radio value="none" size="small" />
|
||||
</RadioGroup>
|
||||
</TableCell>
|
||||
<TableCell align="center" padding="checkbox">
|
||||
{isSnapshotResource ? (
|
||||
<Typography variant="body2" color="text.secondary">-</Typography>
|
||||
) : (
|
||||
<RadioGroup
|
||||
row
|
||||
value={level}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
|
||||
>
|
||||
<Radio value="ro" size="small" />
|
||||
</RadioGroup>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center" padding="checkbox">
|
||||
<RadioGroup
|
||||
row
|
||||
value={level}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
|
||||
>
|
||||
<Radio value="rw" size="small" />
|
||||
</RadioGroup>
|
||||
</TableCell>
|
||||
<TableCell align="center" padding="checkbox">
|
||||
{isSnapshotResource ? (
|
||||
<Checkbox
|
||||
checked={localRoles.includes("snapshot_create")}
|
||||
onChange={(e) => handleSnapshotCreateChange(e.target.checked)}
|
||||
size="small"
|
||||
title="Разрешает создавать новые снапшоты"
|
||||
/>
|
||||
) : isDevicesResource ? (
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "center" }}>
|
||||
<Checkbox
|
||||
checked={localRoles.includes("devices_maintenance_rw")}
|
||||
onChange={(e) => handleMaintenanceChange(e.target.checked)}
|
||||
size="small"
|
||||
title="Техническое обслуживание (ТО)"
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">-</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
49
src/widgets/PermissionsTable/RolesHintTable.tsx
Normal file
49
src/widgets/PermissionsTable/RolesHintTable.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
} from "@mui/material";
|
||||
|
||||
const ROLE_HINTS = [
|
||||
{ tab: "Экспорт", roles: "Экспорт (Ч/З)" },
|
||||
{ tab: "Создание экспорта", roles: "Экспорт (доп. права) + Устройства (Ч/З)" },
|
||||
{ tab: "Устройства", roles: "Устройства + Транспорт + Маршруты + Перевозчики + Экспорт (Ч/З)" },
|
||||
{ tab: "Карта", roles: "Маршруты (Ч/З) или Остановки (Ч/З) или Достопримечательности (Ч/З)" },
|
||||
{ tab: "Пользователи", roles: "Пользователи" },
|
||||
{ tab: "Достопримечательности", roles: "Достопримечательности" },
|
||||
{ tab: "Остановки", roles: "Остановки" },
|
||||
{ tab: "Маршруты", roles: "Маршруты + Перевозчики" },
|
||||
{ tab: "Страны", roles: "Страны" },
|
||||
{ tab: "Города", roles: "Города + Страны" },
|
||||
{ tab: "Перевозчики", roles: "Перевозчики" },
|
||||
];
|
||||
|
||||
export function RolesHintTable() {
|
||||
return (
|
||||
<Box sx={{ mt: 2, p: 2, bgcolor: "grey.50", borderRadius: 1, border: "1px solid", borderColor: "divider" }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Какие роли нужны для вкладок
|
||||
</Typography>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Вкладка</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Необходимые роли</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{ROLE_HINTS.map(({ tab, roles }) => (
|
||||
<TableRow key={tab}>
|
||||
<TableCell sx={{ py: 0.5 }}>{tab}</TableCell>
|
||||
<TableCell sx={{ py: 0.5 }}>{roles}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
33
src/widgets/PermissionsTable/constants.ts
Normal file
33
src/widgets/PermissionsTable/constants.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export const ROLE_RESOURCES = [
|
||||
{ key: "snapshot", label: "Экспорт" },
|
||||
{ key: "devices", label: "Устройства" },
|
||||
{ key: "vehicles", label: "Транспорт" },
|
||||
{ key: "users", label: "Пользователи" },
|
||||
{ key: "sights", label: "Достопримечательности" },
|
||||
{ key: "stations", label: "Остановки" },
|
||||
{ key: "routes", label: "Маршруты" },
|
||||
{ key: "countries", label: "Страны" },
|
||||
{ key: "cities", label: "Города" },
|
||||
{ key: "carriers", label: "Перевозчики" },
|
||||
] as const;
|
||||
|
||||
export type PermissionLevel = "none" | "ro" | "rw";
|
||||
|
||||
export function getPermissionLevel(roles: string[], resource: string): PermissionLevel {
|
||||
if (roles.includes(`${resource}_rw`)) return "rw";
|
||||
if (roles.includes(`${resource}_ro`)) return "ro";
|
||||
return "none";
|
||||
}
|
||||
|
||||
export function applyPermissionChange(
|
||||
roles: string[],
|
||||
resource: string,
|
||||
level: PermissionLevel,
|
||||
): string[] {
|
||||
const filtered = roles.filter(
|
||||
(r) => r !== `${resource}_ro` && r !== `${resource}_rw`,
|
||||
);
|
||||
if (level === "ro") return [...filtered, `${resource}_ro`];
|
||||
if (level === "rw") return [...filtered, `${resource}_rw`];
|
||||
return filtered;
|
||||
}
|
||||
3
src/widgets/PermissionsTable/index.ts
Normal file
3
src/widgets/PermissionsTable/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { PermissionsTable } from "./PermissionsTable";
|
||||
export { RolesHintTable } from "./RolesHintTable";
|
||||
export { ROLE_RESOURCES, type PermissionLevel, getPermissionLevel, applyPermissionChange } from "./constants";
|
||||
@@ -120,7 +120,6 @@ export const ReactMarkdownEditor = ({
|
||||
"table",
|
||||
"horizontal-rule",
|
||||
"preview",
|
||||
"fullscreen",
|
||||
"guide",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -205,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
|
||||
|
||||
@@ -94,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}
|
||||
|
||||
@@ -231,7 +231,7 @@ 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 }}>
|
||||
@@ -508,36 +508,44 @@ export const CreateRightTab = observer(
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
<Stack direction="row" spacing={1} alignItems="flex-start">
|
||||
<TextField
|
||||
label="Полное название (поддерживает перенос ↵)"
|
||||
multiline
|
||||
minRows={1}
|
||||
maxRows={4}
|
||||
size="small"
|
||||
value={sight[language].name}
|
||||
onChange={(e) =>
|
||||
updateSightInfo({ name: e.target.value }, language)
|
||||
}
|
||||
inputRef={shortNameRef}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={insertNewline}
|
||||
title="Вставить перенос строки"
|
||||
sx={{
|
||||
minWidth: 40,
|
||||
height: 40,
|
||||
fontSize: 18,
|
||||
p: 0,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
↵
|
||||
</Button>
|
||||
</Stack>
|
||||
{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={{
|
||||
minWidth: 40,
|
||||
height: 40,
|
||||
fontSize: 18,
|
||||
p: 0,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
↵
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
<SightFramePreview
|
||||
sightName={sight[language].name}
|
||||
previewMedia={previewMedia}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user