Compare commits

2 Commits

Author SHA1 Message Date
d758dbffa6 feat: big update 07.05.26 2026-05-07 13:08:33 +03:00
6af95bb449 feat: update color carrier 2026-05-05 15:07:18 +03:00
54 changed files with 1638 additions and 679 deletions

14
.env
View File

@@ -1,8 +1,8 @@
VITE_API_URL='https://wn.st.unprism.ru' # VITE_API_URL='https://wn.st.unprism.ru'
VITE_REACT_APP ='https://wn.st.unprism.ru/' # VITE_REACT_APP ='https://wn.st.unprism.ru/'
VITE_KRBL_MEDIA='https://wn.st.unprism.ru/media/' # 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' # 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'

View File

@@ -1,7 +1,7 @@
{ {
"name": "white-nights", "name": "white-nights",
"private": true, "private": true,
"version": "1.0.6", "version": "1.0.7",
"type": "module", "type": "module",
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {

View File

@@ -92,6 +92,9 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
requiredPermissions.length > 0 && requiredPermissions.length > 0 &&
!requiredPermissions.every((permission) => authStore.canAccess(permission)) !requiredPermissions.every((permission) => authStore.canAccess(permission))
) { ) {
if (location.pathname === "/devices" && authStore.hasRole("devices_maintenance_rw")) {
return <>{children}</>;
}
return <Navigate to="/" replace />; return <Navigate to="/" replace />;
} }

View File

@@ -24,6 +24,43 @@ import {
// @ts-ignore // @ts-ignore
import { orderStationsByRoute } from "../../utils/routeStationsUtils"; import { orderStationsByRoute } from "../../utils/routeStationsUtils";
import { resamplePath } from "../../utils/animationUtils"; 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 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");
}
class ApiStore { class ApiStore {
isLoading = true; isLoading = true;
@@ -115,6 +152,10 @@ class ApiStore {
getCarrier = async () => { getCarrier = async () => {
this.carrier = await getCarrier(this.route!.carrier_id!); this.carrier = await getCarrier(this.route!.carrier_id!);
applyCarrierColors(this.carrier);
if (this.carrier.main_color) {
colorStore.setMainColor(this.carrier.main_color);
}
}; };
getCity = async () => { getCity = async () => {

View File

@@ -78,6 +78,9 @@ export type GetCarrierResponse = {
logo: string; logo: string;
short_name: string; short_name: string;
slogan: string; slogan: string;
main_color?: string;
left_color?: string;
right_color?: string;
}; };
export type GetCityResponse = { export type GetCityResponse = {

View File

@@ -417,7 +417,7 @@ const ListOfSights = observer(() => {
key={currentSelectedSight.id} key={currentSelectedSight.id}
media={sightFrameMedia} media={sightFrameMedia}
sight_id={currentSelectedSight.id} sight_id={currentSelectedSight.id}
sight_name={currentSelectedSight.short_name || currentSelectedSight.name} sight_name={currentSelectedSight.name}
selectedLanguageRight={selectedLanguageRight} selectedLanguageRight={selectedLanguageRight}
/> />
)} )}

View File

@@ -43,9 +43,58 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
const [threeViewResetKey, setThreeViewResetKey] = useState(0); const [threeViewResetKey, setThreeViewResetKey] = useState(0);
const threeViewControlRef = useRef(null); const threeViewControlRef = useRef(null);
const mediaCache = useRef({}); const mediaCache = useRef({});
const idleTimerRef = useRef(null);
const textWrapperRef = useRef(null); const textWrapperRef = useRef(null);
// Автозакрытие fullscreen 3D при бездействии (45 сек)
useEffect(() => {
if (!isFullscreen3D) {
if (idleTimerRef.current) {
clearInterval(idleTimerRef.current);
idleTimerRef.current = null;
}
return;
}
let idleSeconds = 0;
const checkIdle = () => {
idleSeconds += 1;
if (idleSeconds >= 45) {
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 { const {
routeSights, routeSights,
routeSightsEn, routeSightsEn,
@@ -166,7 +215,10 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
const introSection = { const introSection = {
id: media?.id || "intro-title", id: media?.id || "intro-title",
heading: heading:
sight?.short_name || sight?.name || sight_name || "Название достопримечательности", sight?.short_name ||
sight?.name ||
sight_name ||
"Название достопримечательности",
body: "", body: "",
}; };
const allSections = [introSection, ...rightArticles]; const allSections = [introSection, ...rightArticles];
@@ -244,9 +296,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
alt="" alt=""
className={className} className={className}
onError={(e) => { onError={(e) => {
console.warn( console.warn(`Failed to load image: ${currentMediaData.path}`);
`Failed to load image: ${currentMediaData.path}`,
);
e.target.style.display = "none"; e.target.style.display = "none";
}} }}
/> />
@@ -261,9 +311,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
playsInline playsInline
className={className} className={className}
onError={(e) => { onError={(e) => {
console.warn( console.warn(`Failed to load video: ${currentMediaData.path}`);
`Failed to load video: ${currentMediaData.path}`,
);
e.target.style.display = "none"; e.target.style.display = "none";
}} }}
> >
@@ -314,9 +362,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
type="button" type="button"
className="three-d-control-btn" className="three-d-control-btn"
title="Уменьшить" title="Уменьшить"
onPointerUp={() => onPointerUp={() => threeViewControlRef.current?.zoomOut?.()}
threeViewControlRef.current?.zoomOut?.()
}
> >
<MinusIcon /> <MinusIcon />
</button> </button>
@@ -324,9 +370,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
type="button" type="button"
className="three-d-control-btn" className="three-d-control-btn"
title="Увеличить" title="Увеличить"
onPointerUp={() => onPointerUp={() => threeViewControlRef.current?.zoomIn?.()}
threeViewControlRef.current?.zoomIn?.()
}
> >
<PlusIcon /> <PlusIcon />
</button> </button>
@@ -368,87 +412,100 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
/> />
</svg> </svg>
</button> </button>
{isFullscreen3D && <div {isFullscreen3D && (
style={{
position: "absolute",
top: 94,
right: 10,
zIndex: 10,
pointerEvents: "none",
}}
>
<div <div
className="cluster-sights-list"
style={{ 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)`, position: "absolute",
backdropFilter: "blur(10px)", top: 94,
borderRadius: "8px", right: 10,
width: 200, zIndex: 10,
boxShadow: pointerEvents: "none",
"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",
}} }}
> >
{[ <div
{ className="cluster-sights-list"
label: "Вращать", style={{
icon: <img src={rotate3DIcon} alt="" width="14" height="14" />, 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",
label: "Приблизить / Отдалить", width: 200,
icon: <img src={zoom3DIcon} alt="" width="14" height="14" />, 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",
label: "Переместить", flexDirection: "column",
icon: <img src={pan3DIcon} alt="" width="14" height="14" />, padding: "8px 13px",
}, }}
].map((item, index, arr) => ( >
<div {[
key={index} {
style={{ label: "Вращать",
display: "flex", icon: (
alignItems: "center", <img
height: "30px", src={rotate3DIcon}
userSelect: "none", alt=""
touchAction: "none", width="14"
padding: "0 4px", height="14"
borderBottom: />
index < arr.length - 1 ),
? "1px solid rgba(255, 255, 255, 0.1)" },
: "none", {
transition: "background-color 0.2s", label: "Приблизить / Отдалить",
}} icon: (
> <img src={zoom3DIcon} alt="" width="14" height="14" />
<span ),
},
{
label: "Переместить",
icon: (
<img src={pan3DIcon} alt="" width="14" height="14" />
),
},
].map((item, index, arr) => (
<div
key={index}
style={{ style={{
display: "block", display: "flex",
marginRight: "8px", alignItems: "center",
flexShrink: 0, height: "30px",
lineHeight: 0, 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",
}} }}
> >
{item.icon} <span
</span> style={{
<span display: "block",
style={{ marginRight: "8px",
color: "white", flexShrink: 0,
fontSize: "12px", lineHeight: 0,
lineHeight: "1.5", }}
fontWeight: "400", >
overflow: "hidden", {item.icon}
textOverflow: "ellipsis", </span>
whiteSpace: "nowrap", <span
flex: 1, style={{
}} color: "white",
> fontSize: "12px",
{item.label} lineHeight: "1.5",
</span> fontWeight: "400",
</div> overflow: "hidden",
))} textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
}}
>
{item.label}
</span>
</div>
))}
</div>
</div> </div>
</div>} )}
</div> </div>
); );
default: default:
@@ -605,7 +662,9 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
overflowWrap: "break-word", overflowWrap: "break-word",
}} }}
> >
{selectedSection === 0 ? processedSightName : (sightData?.short_name || sight_name)} {selectedSection === 0
? processedSightName
: sightData?.short_name || sight_name}
</p> </p>
</div> </div>
)} )}
@@ -637,9 +696,18 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
paddingBottom: "4.5px", paddingBottom: "4.5px",
cursor: "pointer", cursor: "pointer",
}} }}
onPointerUp={() => { setSelectedSection(0); setIsFullscreen3D(false); }} onPointerUp={() => {
setSelectedSection(0);
setIsFullscreen3D(false);
}}
> >
<img src={subtractHomeIcon} alt="" width="24" height="21" style={{ display: "block" }} /> <img
src={subtractHomeIcon}
alt=""
width="24"
height="21"
style={{ display: "block" }}
/>
</div> </div>
)} )}
{contentError ? ( {contentError ? (
@@ -649,7 +717,10 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
articleSections.length > 1 && articleSections.length > 1 &&
articleSections.slice(1).map((section, index) => ( articleSections.slice(1).map((section, index) => (
<div <div
onPointerUp={() => { setSelectedSection(index + 1); setIsFullscreen3D(false); }} onPointerUp={() => {
setSelectedSection(index + 1);
setIsFullscreen3D(false);
}}
key={section.id || section.heading || index} key={section.id || section.heading || index}
className={`sight-frame-menu-point ${ className={`sight-frame-menu-point ${
index + 1 === selectedSection ? "active" : "" index + 1 === selectedSection ? "active" : ""

View File

@@ -95,21 +95,36 @@ const TransferWidget = observer(function TransferWidget({
} }
const getTransferLabel = () => { const getTransferLabel = () => {
if (selectedLanguageRight === "ru") { if (!stationName) {
return stationName if (selectedLanguageRight === "en") return "Nearest station not found";
? `Пересадки остановки ${stationName}:` if (selectedLanguageRight === "zh") return "最近的站点未找到";
: "Ближайшая остановка не обнаружена"; return "Ближайшая остановка не обнаружена";
} }
if (selectedLanguageRight === "en") { if (selectedLanguageRight === "en") {
return stationName return (
? `Available transfers at station ${stationName}` <>
: "Nearest station not found"; Transfer at stop<br />
«{stationName}»:
</>
);
} }
return stationName if (selectedLanguageRight === "zh") {
? `在车站可用的换乘:${stationName}` return (
: "最近的站点未找到"; <>
换乘站<br />
«{stationName}»:
</>
);
}
return (
<>
Пересадка на остановке<br />
«{stationName}»:
</>
);
}; };
const getNoTransfersMessage = () => { const getNoTransfersMessage = () => {

View File

@@ -71,7 +71,7 @@
} }
.react-markdown-container blockquote { .react-markdown-container blockquote {
border-left: 4px solid #006F3A; border-left: 4px solid var(--carrier-main, #006F3A);
padding-left: 16px; padding-left: 16px;
margin-top: 16px; margin-top: 16px;
margin-bottom: 16px; margin-bottom: 16px;

View File

@@ -17,7 +17,7 @@
114deg, 114deg,
rgba(255, 255, 255, 0.1) 8.71%, rgba(255, 255, 255, 0.1) 8.71%,
rgba(255, 255, 255, 0.05) 69.69% rgba(255, 255, 255, 0.05) 69.69%
), #006F3A; ), var(--carrier-main, #006F3A);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
color: white; color: white;

View File

@@ -881,25 +881,36 @@ export const WebGLMap = observer(() => {
// Сегментный индекс каждой упорядоченной станции на routePath (canvas-пространство) // Сегментный индекс каждой упорядоченной станции на routePath (canvas-пространство)
const orderedStationSegs = useMemo(() => { 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) => { 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; if (stIdx < 0) return -1;
const sx = stationPoints[stIdx * 2]; const sx = stationPoints[stIdx * 2];
const sy = stationPoints[stIdx * 2 + 1]; const sy = stationPoints[stIdx * 2 + 1];
if (sx === undefined || sy === undefined) return -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) { for (let i = 0; i < routePath.length - 2; i += 2) {
const p1x = routePath[i], p1y = routePath[i + 1]; const p1x = routePath[i],
const p2x = routePath[i + 2], p2y = routePath[i + 3]; p1y = routePath[i + 1];
const dx = p2x - p1x, dy = p2y - p1y; const p2x = routePath[i + 2],
p2y = routePath[i + 3];
const dx = p2x - p1x,
dy = p2y - p1y;
const len2 = dx * dx + dy * dy; const len2 = dx * dx + dy * dy;
if (!len2) continue; if (!len2) continue;
const t = ((sx - p1x) * dx + (sy - p1y) * dy) / len2; const t = ((sx - p1x) * dx + (sy - p1y) * dy) / len2;
const cl = Math.max(0, Math.min(1, t)); 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); 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; return best;
}); });
@@ -1144,7 +1155,9 @@ export const WebGLMap = observer(() => {
const curIdx = apiStore.positionIndex; const curIdx = apiStore.positionIndex;
const prevIdx = prevPositionIndexRef.current; const prevIdx = prevPositionIndexRef.current;
const pathLen = apiStore.route?.path?.length ?? 0; 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; Math.abs(curIdx - prevIdx) > pathLen / 4;
prevPositionIndexRef.current = curIdx; prevPositionIndexRef.current = curIdx;
@@ -1414,32 +1427,72 @@ export const WebGLMap = observer(() => {
gl.uniform1f(u_pointSize, pointInnerSizePx); 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 passedPts1: number[] = [];
const unpassedPts1: number[] = []; const unpassedPts1: number[] = [];
for (let i = 0; i < orderedRouteStations.length; i++) { for (let i = 0; i < orderedRouteStations.length; i++) {
const orderedStation = (orderedRouteStations as any[])[i]; const orderedStation = (orderedRouteStations as any[])[i];
const stationSeg = orderedStationSegs[i] ?? -1; const stationSeg = orderedStationSegs[i] ?? -1;
if (!orderedStation || stationSeg < 0) continue; if (!orderedStation || stationSeg < 0) continue;
const isPassed = simulationDirection === 1 ? stationSeg < tramSegIndex : stationSeg > tramSegIndex; const isPassed =
const stIdx = stationData.findIndex((s: any) => String(s.id) === String(orderedStation.id)); simulationDirection === 1
? stationSeg < tramSegIndex
: stationSeg > tramSegIndex;
const stIdx = stationData.findIndex(
(s: any) => String(s.id) === String(orderedStation.id),
);
if (stIdx < 0) continue; if (stIdx < 0) continue;
const sx = stationPoints[stIdx * 2] as number; const sx = stationPoints[stIdx * 2] as number;
const sy = stationPoints[stIdx * 2 + 1] 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) { 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.uniform4f(
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(passedPts1), gl.STATIC_DRAW); 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); gl.drawArrays(gl.POINTS, 0, passedPts1.length / 2);
} }
if (unpassedPts1.length > 0) { 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.uniform4f(
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(unpassedPts1), gl.STATIC_DRAW); 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); gl.drawArrays(gl.POINTS, 0, unpassedPts1.length / 2);
} }
} else { } 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.bufferData(gl.ARRAY_BUFFER, stationPoints, gl.STATIC_DRAW);
gl.drawArrays(gl.POINTS, 0, stationPoints.length / 2); gl.drawArrays(gl.POINTS, 0, stationPoints.length / 2);
} }
@@ -1513,12 +1566,17 @@ export const WebGLMap = observer(() => {
const passedStationIds = new Set<string>(); const passedStationIds = new Set<string>();
const unpassedStationIds = 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++) { for (let i = 0; i < orderedRouteStations.length; i++) {
const station = (orderedRouteStations as any[])[i]; const station = (orderedRouteStations as any[])[i];
const seg = orderedStationSegs[i] ?? -1; const seg = orderedStationSegs[i] ?? -1;
if (!station || seg < 0) continue; 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)); if (isPassed) passedStationIds.add(String(station.id));
else unpassedStationIds.add(String(station.id)); else unpassedStationIds.add(String(station.id));
} }
@@ -1662,11 +1720,26 @@ export const WebGLMap = observer(() => {
const sin = Math.sin(rotationAngle); const sin = Math.sin(rotationAngle);
const startStationData = orderedRouteStations?.[0] const startStationData = orderedRouteStations?.[0]
? stationData.find((station: any) => station.id.toString() === String(orderedRouteStations[0].id)) ? stationData.find(
: stationData.find((station: any) => station.id.toString() === apiStore.context?.startStopId); (station: any) =>
station.id.toString() === String(orderedRouteStations[0].id),
)
: stationData.find(
(station: any) =>
station.id.toString() === apiStore.context?.startStopId,
);
const endStationData = orderedRouteStations?.length const endStationData = orderedRouteStations?.length
? stationData.find((station: any) => station.id.toString() === String(orderedRouteStations[orderedRouteStations.length - 1].id)) ? stationData.find(
: stationData.find((station: any) => station.id.toString() === apiStore.context?.endStopId); (station: any) =>
station.id.toString() ===
String(
orderedRouteStations[orderedRouteStations.length - 1].id,
),
)
: stationData.find(
(station: any) =>
station.id.toString() === apiStore.context?.endStopId,
);
const terminalStations: number[] = []; const terminalStations: number[] = [];
@@ -1766,7 +1839,13 @@ export const WebGLMap = observer(() => {
} }
return best; 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; : false;
@@ -1799,7 +1878,13 @@ export const WebGLMap = observer(() => {
} }
return best; 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; : false;
@@ -1825,11 +1910,24 @@ export const WebGLMap = observer(() => {
const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255; const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255;
if (startStationData && endStationData) { if (startStationData && endStationData) {
const startIsPassed = simulationDirection === 1 ? true : isStartPassed; const startIsPassed =
simulationDirection === 1 ? true : isStartPassed;
const endIsPassed = simulationDirection === -1 ? true : isEndPassed; 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.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); gl.drawArrays(gl.POINTS, 1, 1);
} else { } else {
const isStartStation = startStationData !== undefined; const isStartStation = startStationData !== undefined;
@@ -2529,7 +2627,7 @@ export const WebGLMap = observer(() => {
) )
: false; : false;
const badgeColor = "#006F3A"; const badgeColor = "var(--carrier-main, #006F3A)";
const listPanelWidth = 200; const listPanelWidth = 200;
const listItemHeight = 30; const listItemHeight = 30;
const listMaxHeight = 250; const listMaxHeight = 250;
@@ -2607,7 +2705,6 @@ export const WebGLMap = observer(() => {
<div <div
data-expanded-cluster={cluster.id} data-expanded-cluster={cluster.id}
onTouchStart={handleCircleInteraction} onTouchStart={handleCircleInteraction}
onTouchMove={handleCircleInteraction}
onMouseMove={handleCircleInteraction} onMouseMove={handleCircleInteraction}
style={{ style={{
position: "absolute", position: "absolute",
@@ -2673,15 +2770,14 @@ export const WebGLMap = observer(() => {
<div <div
className="cluster-sights-list" className="cluster-sights-list"
style={{ 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)", backdropFilter: "blur(10px)",
borderRadius: "8px", borderRadius: "8px",
width: listPanelWidth, width: listPanelWidth,
maxHeight: hasMoreThanTwo ? listMaxHeight : undefined, maxHeight: hasMoreThanTwo ? listMaxHeight : undefined,
boxShadow: 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", display: "flex",
flexDirection: "column", flexDirection: "column",
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}

View File

@@ -120,8 +120,8 @@ const LeftWidget = observer(
selectedLanguage === "ru" selectedLanguage === "ru"
? routeSights.find((sight) => sight.id === selectedSightId) ? routeSights.find((sight) => sight.id === selectedSightId)
: selectedLanguage === "en" : selectedLanguage === "en"
? routeSightsEn.find((sight) => sight.id === selectedSightId) ? routeSightsEn.find((sight) => sight.id === selectedSightId)
: routeSightsZh.find((sight) => sight.id === selectedSightId); : routeSightsZh.find((sight) => sight.id === selectedSightId);
const leftArticle = sight.left_article; const leftArticle = sight.left_article;
@@ -129,18 +129,18 @@ const LeftWidget = observer(
selectedLanguage === "ru" selectedLanguage === "ru"
? sightArticles.get(leftArticle + "_" + selectedLanguage) ? sightArticles.get(leftArticle + "_" + selectedLanguage)
: selectedLanguage === "en" : selectedLanguage === "en"
? sightArticlesEn.get(leftArticle + "_" + selectedLanguage) ? sightArticlesEn.get(leftArticle + "_" + selectedLanguage)
: sightArticlesZh.get(leftArticle + "_" + selectedLanguage); : sightArticlesZh.get(leftArticle + "_" + selectedLanguage);
const media = await ContentAPI.getMediaPreview( const media = await ContentAPI.getMediaPreview(
leftArticleData.media[0].id, leftArticleData.media[0].id,
selectedLanguage selectedLanguage,
); );
const response = { const response = {
mediaPath: media.path, mediaPath: media.path,
mediaType: media.type, mediaType: media.type,
title: sight.short_name || sight.name || leftArticleData.heading, title: leftArticleData.heading,
text: leftArticleData.body, text: leftArticleData.body,
address: sight.address, address: sight.address,
}; };
@@ -178,7 +178,7 @@ const LeftWidget = observer(
setIsImageLoaded(false); setIsImageLoaded(false);
console.error( console.error(
"Ошибка загрузки изображения для достопримечательности:", "Ошибка загрузки изображения для достопримечательности:",
selectedSightId selectedSightId,
); );
if (isVisible) { if (isVisible) {
setTimeout(() => { setTimeout(() => {
@@ -244,13 +244,13 @@ const LeftWidget = observer(
{selectedLanguage === "ru" {selectedLanguage === "ru"
? "Выберите достопримечательность для просмотра деталей." ? "Выберите достопримечательность для просмотра деталей."
: selectedLanguage === "zh" : selectedLanguage === "zh"
? "选择一个地标来查看详细信息。" ? "选择一个地标来查看详细信息。"
: "Select a landmark to view details."} : "Select a landmark to view details."}
</div> </div>
) : null} ) : null}
</div> </div>
); );
} },
); );
export default LeftWidget; export default LeftWidget;

View File

@@ -13,6 +13,7 @@ import StationsList from "./StationsList";
import LeftWidget from "./LeftWidget"; import LeftWidget from "./LeftWidget";
import { apiStore } from "../../api/ApiStore/store"; import { apiStore } from "../../api/ApiStore/store";
import { getMediaUrl } from "../../api/apiConfig"; import { getMediaUrl } from "../../api/apiConfig";
import defaultCrest from "../../assets/images/Герб.png";
const SideMenu = observer(({ onMenuToggle }) => { const SideMenu = observer(({ onMenuToggle }) => {
const { const {
@@ -357,7 +358,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
pointerEvents: "auto", pointerEvents: "auto",
background: background:
isSightsOpen || isStationOpen 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, : undefined,
backdropFilter: backdropFilter:
isSightsOpen || isStationOpen ? "blur(10px)" : undefined, isSightsOpen || isStationOpen ? "blur(10px)" : undefined,
@@ -369,13 +370,11 @@ const SideMenu = observer(({ onMenuToggle }) => {
"background 0.3s ease, backdrop-filter 0.3s ease, box-shadow 0.3s ease", "background 0.3s ease, backdrop-filter 0.3s ease, box-shadow 0.3s ease",
}} }}
> >
{designData?.creastPath && ( <img
<img className="side-menu-crest"
className="side-menu-crest" src={designData?.creastPath || defaultCrest}
src={designData?.creastPath} alt="Герб"
alt="Герб" />
/>
)}
{carrier?.slogan && ( {carrier?.slogan && (
<div className="side-menu-label">{carrier.slogan}</div> <div className="side-menu-label">{carrier.slogan}</div>
)} )}
@@ -493,7 +492,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
}, 300); }, 300);
} }
}} }}
className={`side-menu-button side-menu-button--sights ${ className={`side-menu-button ${
isSightsOpen ? "side-menu-button--active" : "" isSightsOpen ? "side-menu-button--active" : ""
}`} }`}
> >

View File

@@ -11,6 +11,42 @@ import { apiStore } from "../../api/ApiStore/store";
import { useClickDetection } from "../../hooks/useClickDetection"; import { useClickDetection } from "../../hooks/useClickDetection";
import { TouchableLayout } from "../TouchableLayout"; 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 = ({ const StationItem = ({
station, station,
handlePointerDown, handlePointerDown,
@@ -101,9 +137,9 @@ const StationItem = ({
> >
{sights.length > 0 ? ( {sights.length > 0 ? (
sights.map((sight, index) => ( sights.map((sight, index) => (
<div <SightTransferItem
key={sight.id} key={sight.id}
className="side-menu-sight-transfer pointer" name={getSightName(sight)}
style={{ style={{
borderBottom: borderBottom:
index < sights.length - 1 index < sights.length - 1
@@ -115,19 +151,11 @@ const StationItem = ({
onPointerUp={(e) => { onPointerUp={(e) => {
e.stopPropagation(); e.stopPropagation();
if (onSightClick) { if (onSightClick) {
// Вычисляем позицию элемента для правильного позиционирования левого виджета const elementRect = e.currentTarget.getBoundingClientRect();
const element = e.currentTarget; onSightClick(sight.id, elementRect.top);
const elementRect = element.getBoundingClientRect();
// Используем позицию элемента относительно viewport (elementRect.top)
// чтобы верхняя граница виджета совпадала с верхней границей элемента
const elementTop = elementRect.top;
onSightClick(sight.id, elementTop);
} }
}} }}
> />
{getSightName(sight)}
</div>
)) ))
) : ( ) : (
<div className="side-menu-sight-transfer-empty"> <div className="side-menu-sight-transfer-empty">

View File

@@ -1,21 +1,45 @@
import { makeAutoObservable, runInAction } from "mobx"; 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 TRANSITION_DURATION = 60000;
const TICK_INTERVAL = 100; const TICK_INTERVAL = 100;
const TICK_STEP = TICK_INTERVAL / TRANSITION_DURATION; 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 { interface ColorStore {
currentColor: string; currentColor: string;
setCurrentColor: (color: string) => void; setCurrentColor: (color: string) => void;
setMainColor: (hex: string) => void;
startColorAnimation: () => void; startColorAnimation: () => void;
stopColorAnimation: () => void; stopColorAnimation: () => void;
} }
class ColorStore implements ColorStore { class ColorStore implements ColorStore {
currentColor: string = "#fff"; currentColor: string = "#fff";
private mainColor: { r: number; g: number; b: number } = DEFAULT_MAIN;
private progress: number = 0; private progress: number = 0;
private direction: number = 1; private direction: number = 1;
private tickInterval: ReturnType<typeof setInterval> | null = null; private tickInterval: ReturnType<typeof setInterval> | null = null;
@@ -28,12 +52,12 @@ class ColorStore implements ColorStore {
this.currentColor = color; this.currentColor = color;
}; };
private interpolateColor(progress: number): string { setMainColor = (hex: string) => {
const h = Math.round(COLOR_WHITE.h + (COLOR_GREEN.h - COLOR_WHITE.h) * progress); const parsed = hexToRgb(hex);
const s = Math.round(COLOR_WHITE.s + (COLOR_GREEN.s - COLOR_WHITE.s) * progress); if (parsed) {
const l = Math.round(COLOR_WHITE.l + (COLOR_GREEN.l - COLOR_WHITE.l) * progress); this.mainColor = parsed;
return `hsl(${h}, ${s}%, ${l}%)`; }
} };
startColorAnimation = () => { startColorAnimation = () => {
if (this.tickInterval) return; if (this.tickInterval) return;
@@ -50,7 +74,7 @@ class ColorStore implements ColorStore {
this.direction = 1; this.direction = 1;
} }
this.currentColor = this.interpolateColor(this.progress); this.currentColor = interpolateRgb(WHITE, this.mainColor, this.progress);
}); });
}, TICK_INTERVAL); }, TICK_INTERVAL);
}; };
@@ -68,4 +92,4 @@ class ColorStore implements ColorStore {
} }
export const colorStore = new ColorStore(); export const colorStore = new ColorStore();
export { ColorStore }; export { ColorStore };

View File

@@ -11,7 +11,7 @@
rgba(255, 255, 255, 0) 8.71%, rgba(255, 255, 255, 0) 8.71%,
rgba(255, 255, 255, 0.16) 69.69% rgba(255, 255, 255, 0.16) 69.69%
), ),
#006F3A; var(--carrier-main, #006F3A);
box-sizing: border-box; box-sizing: border-box;
} }

View File

@@ -14,7 +14,7 @@
rgba(255, 255, 255, 0) 8.71%, rgba(255, 255, 255, 0) 8.71%,
rgba(255, 255, 255, 0.16) 69.69% rgba(255, 255, 255, 0.16) 69.69%
), ),
#006F3A; var(--carrier-left, #006F3A);
will-change: transform, opacity; will-change: transform, opacity;
backface-visibility: hidden; backface-visibility: hidden;
} }
@@ -93,6 +93,16 @@
padding-left: 10px; padding-left: 10px;
padding-bottom: 6px; padding-bottom: 6px;
width: 100%; 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;
} }
/* Анимация для списка пересадок */ /* Анимация для списка пересадок */

View File

@@ -17,7 +17,7 @@
rgba(255, 255, 255, 0) 8.71%, rgba(255, 255, 255, 0) 8.71%,
rgba(255, 255, 255, 0.16) 69.69% rgba(255, 255, 255, 0.16) 69.69%
), ),
#006f3a; var(--carrier-right, #006f3a);
color: white; color: white;
max-height: 68px; max-height: 68px;
@@ -63,7 +63,11 @@
border-radius: 10px; border-radius: 10px;
width: 128px; width: 128px;
background-color: #0e8953; background-color: color-mix(
in srgb,
var(--carrier-right, #006f3a) 80%,
black
);
} }
.list-of-sights-title { .list-of-sights-title {
@@ -194,7 +198,7 @@
rgba(255, 255, 255, 0) 8.71%, rgba(255, 255, 255, 0) 8.71%,
rgba(255, 255, 255, 0.16) 69.69% rgba(255, 255, 255, 0.16) 69.69%
), ),
#006f3a; var(--carrier-right, #006f3a);
max-height: calc(100vh - 128px); max-height: calc(100vh - 128px);
} }
@@ -237,7 +241,7 @@
rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0.22) 0%,
rgba(255, 255, 255, 0.04) 100% rgba(255, 255, 255, 0.04) 100%
), ),
rgba(0, 111, 58, 0.72); rgba(var(--carrier-right-rgb, 0, 111, 58), 0.72);
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset; box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
box-sizing: border-box; box-sizing: border-box;
color: white; color: white;
@@ -304,7 +308,7 @@
background: linear-gradient( background: linear-gradient(
to right, to right,
transparent 35%, transparent 35%,
#0e8953 50%, color-mix(in srgb, var(--carrier-right, #006f3a) 80%, black) 50%,
transparent 65% transparent 65%
); );
border-radius: 3px; border-radius: 3px;
@@ -341,7 +345,7 @@
rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0) 100% rgba(255, 255, 255, 0) 100%
), ),
rgba(0, 111, 58, 0.4); rgba(var(--carrier-right-rgb, 0, 111, 58), 0.4);
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset; box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
box-sizing: border-box; box-sizing: border-box;
@@ -607,14 +611,14 @@
position: absolute; position: absolute;
border-radius: 10px; border-radius: 10px;
border: 1px solid #006f3a; border: 1px solid var(--carrier-main, #006f3a);
background: background:
linear-gradient( linear-gradient(
180deg, 180deg,
rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0) 100% 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; box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
@@ -747,7 +751,7 @@
border-radius: 32px; border-radius: 32px;
right: 20px; right: 20px;
bottom: 20px; bottom: 20px;
background: #006f3a; background: var(--carrier-right, #006f3a);
z-index: 9999; z-index: 9999;
display: flex; display: flex;
} }

View File

@@ -34,7 +34,7 @@
rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0) 100% rgba(255, 255, 255, 0) 100%
), ),
rgba(0, 111, 58, 0.4); rgba(var(--carrier-main-rgb, 0, 111, 58), 0.4);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
pointer-events: auto; pointer-events: auto;
z-index: 10000001; z-index: 10000001;

View File

@@ -13,7 +13,7 @@
rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0) 100% rgba(255, 255, 255, 0) 100%
), ),
#006f3a; var(--carrier-left, #006f3a);
} }
.side-menu-label { .side-menu-label {
@@ -51,10 +51,6 @@
border-radius: 10px; border-radius: 10px;
} }
.side-menu-button--sights {
background-color: #fcd500;
}
.side-menu-button--active { .side-menu-button--active {
background-color: #fcd500; background-color: #fcd500;
color: #000; color: #000;
@@ -138,10 +134,10 @@
} }
3.33% { 3.33% {
fill: rgb(76, 175, 75); fill: var(--carrier-left, rgb(76, 175, 75));
} }
50% { 50% {
fill: rgb(76, 175, 75); fill: var(--carrier-left, rgb(76, 175, 75));
} }
53.33% { 53.33% {
fill: #ffffff; fill: #ffffff;
@@ -191,7 +187,7 @@
top: -2px; top: -2px;
width: 100px; width: 100px;
height: 7px; height: 7px;
background-color: #0e8953; background-color: color-mix(in srgb, var(--carrier-left, #006f3a) 80%, black);
border-radius: 10px; border-radius: 10px;
} }
@@ -207,7 +203,7 @@
rgba(255, 255, 255, 0) 8.71%, rgba(255, 255, 255, 0) 8.71%,
rgba(255, 255, 255, 0.16) 69.69% rgba(255, 255, 255, 0.16) 69.69%
), ),
#006f3a; var(--carrier-left, #006f3a);
position: absolute; position: absolute;
width: 288px; width: 288px;
transform: translateY(100%); transform: translateY(100%);
@@ -247,7 +243,8 @@
margin-right: 20px; margin-right: 20px;
margin-bottom: 6px; margin-bottom: 6px;
margin-top: 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-family: "Roboto";
font-size: 16px; font-size: 16px;
font-weight: 300; font-weight: 300;

View File

@@ -19,7 +19,7 @@
rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0) 100% rgba(255, 255, 255, 0) 100%
), ),
rgba(0, 111, 58, 0.4); rgba(179, 165, 152, 0.4);
} }
.weather-widget-time { .weather-widget-time {

View File

@@ -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 routePermissions = item.path ? ROUTE_REQUIRED_RESOURCES[item.path] ?? [] : [];
const canAccessRoute = routePermissions.every((permission) => const canAccessRoute = routePermissions.every((permission) =>
authStore.canAccess(permission), authStore.canAccess(permission),

View File

@@ -27,6 +27,56 @@ import {
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher } from "@widgets"; 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(() => { export const CarrierCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const { createCarrierData, setCreateCarrierData } = carrierStore; const { createCarrierData, setCreateCarrierData } = carrierStore;
@@ -220,6 +270,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"> <div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard <ImageUploadCard
title="Логотип перевозчика" title="Логотип перевозчика"

View File

@@ -30,6 +30,56 @@ import {
UploadMediaDialog, UploadMediaDialog,
} from "@shared"; } 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(() => { export const CarrierEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams(); const { id } = useParams();
@@ -68,13 +118,19 @@ export const CarrierEditPage = observer(() => {
const carrierData = await getCarrier(Number(id)); const carrierData = await getCarrier(Number(id));
if (carrierData) { if (carrierData) {
const colors = {
main_color: carrierData.ru?.main_color || "",
left_color: carrierData.ru?.left_color || "",
right_color: carrierData.ru?.right_color || "",
};
setEditCarrierData( setEditCarrierData(
carrierData.ru?.full_name || "", carrierData.ru?.full_name || "",
carrierData.ru?.short_name || "", carrierData.ru?.short_name || "",
carrierData.ru?.city_id || 0, carrierData.ru?.city_id || 0,
carrierData.ru?.slogan || "", carrierData.ru?.slogan || "",
carrierData.ru?.logo || "", carrierData.ru?.logo || "",
"ru" "ru",
colors
); );
setEditCarrierData( setEditCarrierData(
carrierData.en?.full_name || "", carrierData.en?.full_name || "",
@@ -273,6 +329,69 @@ export const CarrierEditPage = observer(() => {
} }
/> />
<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"> <div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard <ImageUploadCard
title="Логотип перевозчика" title="Логотип перевозчика"

View File

@@ -21,12 +21,19 @@ import {
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
selectedCityStore,
} from "@shared"; } from "@shared";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { LanguageSwitcher, ImageUploadCard } from "@widgets"; import { LanguageSwitcher, ImageUploadCard } from "@widgets";
export const CityCreatePage = observer(() => { export const CityCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const { language } = languageStore; const { language } = languageStore;
const { createCityData, setCreateCityData, setCreateCityWeatherCode } = const { createCityData, setCreateCityData, setCreateCityWeatherCode } =
cityStore; cityStore;

View File

@@ -23,12 +23,19 @@ import {
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
selectedCityStore,
} from "@shared"; } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LanguageSwitcher, ImageUploadCard } from "@widgets"; import { LanguageSwitcher, ImageUploadCard } from "@widgets";
export const CityEditPage = observer(() => { export const CityEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true); const [isLoadingData, setIsLoadingData] = useState(true);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);

View File

@@ -1,6 +1,6 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales"; import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, languageStore, cityStore, countryStore, SearchInput } from "@shared"; import { authStore, languageStore, cityStore, countryStore, selectedCityStore, SearchInput } from "@shared";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Pencil, Trash2, Minus } from "lucide-react"; import { Pencil, Trash2, Minus } from "lucide-react";
@@ -59,16 +59,21 @@ export const CityListPage = observer(() => {
}, [cities, countryStore.countries, language, isLoading]); }, [cities, countryStore.countries, language, isLoading]);
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
const { selectedCityId } = selectedCityStore;
if (!selectedCityId) return [];
const query = searchQuery.trim().toLowerCase(); const query = searchQuery.trim().toLowerCase();
if (!query) return rows; const result = rows.filter((row) => row.id === selectedCityId);
return rows.filter((row) => {
if (!query) return result;
return result.filter((row) => {
const cityName = (row.name ?? "").toLowerCase(); const cityName = (row.name ?? "").toLowerCase();
const countryName = ( const countryName = (
countryStore.countries[language]?.data?.find((c) => c.code === row.country)?.name ?? "" countryStore.countries[language]?.data?.find((c) => c.code === row.country)?.name ?? ""
).toLowerCase(); ).toLowerCase();
return cityName.includes(query) || countryName.includes(query); return cityName.includes(query) || countryName.includes(query);
}); });
}, [rows, searchQuery, countryStore.countries, language]); }, [rows, searchQuery, countryStore.countries, language, selectedCityStore.selectedCityId]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
@@ -139,7 +144,11 @@ export const CityListPage = observer(() => {
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Города</h1> <h1 className="text-2xl">Города</h1>
{canWriteCities && ( {canWriteCities && (
<CreateButton label="Создать город" path="/city/create" /> <CreateButton
label="Создать город"
path="/city/create"
disabled={!selectedCityStore.selectedCityId}
/>
)} )}
</div> </div>
@@ -195,7 +204,13 @@ export const CityListPage = observer(() => {
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет городов"} {isLoading ? (
<CircularProgress size={20} />
) : !selectedCityStore.selectedCityId ? (
"Выберите город"
) : (
"Нет городов"
)}
</Box> </Box>
), ),
}} }}

View File

@@ -15,11 +15,18 @@ import {
RU_COUNTRIES, RU_COUNTRIES,
EN_COUNTRIES, EN_COUNTRIES,
ZH_COUNTRIES, ZH_COUNTRIES,
selectedCityStore,
} from "@shared"; } from "@shared";
import { useState } from "react"; import { useState, useEffect } from "react";
export const CountryAddPage = observer(() => { export const CountryAddPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { createCountryData, setCountryData, createCountry } = countryStore; const { createCountryData, setCountryData, createCountry } = countryStore;

View File

@@ -4,12 +4,18 @@ import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { countryStore, languageStore } from "@shared"; import { countryStore, languageStore, selectedCityStore } from "@shared";
import { useState } from "react"; import { useState, useEffect } from "react";
import { LanguageSwitcher } from "@widgets"; import { LanguageSwitcher } from "@widgets";
export const CountryCreatePage = observer(() => { export const CountryCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore; const { language } = languageStore;
const { createCountryData, setCountryData, createCountry } = countryStore; const { createCountryData, setCountryData, createCountry } = countryStore;

View File

@@ -4,12 +4,18 @@ import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { countryStore, languageStore, LoadingSpinner } from "@shared"; import { countryStore, languageStore, LoadingSpinner, selectedCityStore } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets"; import { LanguageSwitcher } from "@widgets";
export const CountryEditPage = observer(() => { export const CountryEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true); const [isLoadingData, setIsLoadingData] = useState(true);
const { language } = languageStore; const { language } = languageStore;

View File

@@ -1,6 +1,6 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales"; import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, countryStore, languageStore, SearchInput } from "@shared"; import { authStore, countryStore, languageStore, selectedCityStore, SearchInput } from "@shared";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Trash2, Minus } from "lucide-react"; import { Trash2, Minus } from "lucide-react";
@@ -74,15 +74,20 @@ export const CountryListPage = observer(() => {
]; ];
const rows = useMemo(() => { const rows = useMemo(() => {
const { selectedCity } = selectedCityStore;
if (!selectedCity) {
return [];
}
const query = searchQuery.trim().toLowerCase(); const query = searchQuery.trim().toLowerCase();
return (countries[language]?.data ?? []) return (countries[language]?.data ?? [])
.filter((country) => country.code === selectedCity.country_code)
.filter((country) => !query || (country.name ?? "").toLowerCase().includes(query)) .filter((country) => !query || (country.name ?? "").toLowerCase().includes(query))
.map((country) => ({ .map((country) => ({
id: country.code, id: country.code,
code: country.code, code: country.code,
name: country.name, name: country.name,
})); }));
}, [countries[language]?.data, searchQuery]); }, [countries[language]?.data, searchQuery, selectedCityStore.selectedCity]);
return ( return (
<> <>
@@ -92,7 +97,11 @@ export const CountryListPage = observer(() => {
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Страны</h1> <h1 className="text-2xl">Страны</h1>
{canWriteCountries && ( {canWriteCountries && (
<CreateButton label="Добавить страну" path="/country/add" /> <CreateButton
label="Добавить страну"
path="/country/add"
disabled={!selectedCityStore.selectedCityId}
/>
)} )}
</div> </div>
@@ -148,7 +157,13 @@ export const CountryListPage = observer(() => {
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет стран"} {isLoading ? (
<CircularProgress size={20} />
) : !selectedCityStore.selectedCityId ? (
"Выберите город"
) : (
"Нет стран"
)}
</Box> </Box>
), ),
}} }}

View File

@@ -5,6 +5,7 @@ import {
cityStore, cityStore,
createSightStore, createSightStore,
languageStore, languageStore,
selectedCityStore,
} from "@shared"; } from "@shared";
import { import {
CreateInformationTab, CreateInformationTab,
@@ -30,6 +31,11 @@ export const CreateSightPage = observer(() => {
const { getArticles } = articlesStore; const { getArticles } = articlesStore;
const needLeave = createSightStore.needLeaveAgree; const needLeave = createSightStore.needLeaveAgree;
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const handleChange = (_: React.SyntheticEvent, newValue: number) => { const handleChange = (_: React.SyntheticEvent, newValue: number) => {
setValue(newValue); setValue(newValue);
}; };

View File

@@ -9,6 +9,7 @@ import {
cityStore, cityStore,
editSightStore, editSightStore,
LoadingSpinner, LoadingSpinner,
selectedCityStore,
} from "@shared"; } from "@shared";
import { useBlocker, useParams } from "react-router-dom"; import { useBlocker, useParams } from "react-router-dom";
@@ -25,6 +26,11 @@ export const EditSightPage = observer(() => {
const { sight, getSightInfo, needLeaveAgree, getRightArticles } = editSightStore; const { sight, getSightInfo, needLeaveAgree, getRightArticles } = editSightStore;
const { getArticles } = articlesStore; const { getArticles } = articlesStore;
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const { id } = useParams(); const { id } = useParams();
const { getCities } = cityStore; const { getCities } = cityStore;

View File

@@ -39,6 +39,12 @@ import type { Route } from "@shared";
export const RouteCreatePage = observer(() => { export const RouteCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const [carrier, setCarrier] = useState<string>(""); const [carrier, setCarrier] = useState<string>("");
const [routeNumber, setRouteNumber] = useState(""); const [routeNumber, setRouteNumber] = useState("");
const [routeCoords, setRouteCoords] = useState(""); const [routeCoords, setRouteCoords] = useState("");
@@ -51,7 +57,7 @@ export const RouteCreatePage = observer(() => {
const [turn, setTurn] = useState(""); const [turn, setTurn] = useState("");
const [centerLat, setCenterLat] = useState(""); const [centerLat, setCenterLat] = useState("");
const [centerLng, setCenterLng] = useState(""); const [centerLng, setCenterLng] = useState("");
const [videoTimer, setVideoTimer] = useState(60); const [videoTimer, setVideoTimer] = useState(420);
const [videoPreview, setVideoPreview] = useState<string>(""); const [videoPreview, setVideoPreview] = useState<string>("");
const [icon, setIcon] = useState<string>(""); const [icon, setIcon] = useState<string>("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -555,7 +561,7 @@ export const RouteCreatePage = observer(() => {
/> />
<TextField <TextField
className="w-full" className="w-full"
label="Таймер видео (сек)" label="Таймер видео заставки (сек)"
type="number" type="number"
value={videoTimer} value={videoTimer}
onChange={(e) => { onChange={(e) => {

View File

@@ -36,11 +36,18 @@ import {
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
LoadingSpinner, LoadingSpinner,
selectedCityStore,
} from "@shared"; } from "@shared";
import { LinkedItems } from "../LinekedStations"; import { LinkedItems } from "../LinekedStations";
export const RouteEditPage = observer(() => { export const RouteEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const { id } = useParams(); const { id } = useParams();
const { editRouteData, copyRouteAction } = routeStore; const { editRouteData, copyRouteAction } = routeStore;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -548,9 +555,9 @@ export const RouteEditPage = observer(() => {
/> />
<TextField <TextField
className="w-full" className="w-full"
label="Таймер видео (сек)" label="Таймер видео заставки (сек)"
type="number" type="number"
value={editRouteData.video_timer ?? 60} value={editRouteData.video_timer ?? 420}
onChange={(e) => { onChange={(e) => {
const val = Math.max(1, Math.round(Number(e.target.value))); const val = Math.max(1, Math.round(Number(e.target.value)));
if (Number.isFinite(val)) { if (Number.isFinite(val)) {

View File

@@ -139,7 +139,7 @@ export const RouteListPage = observer(() => {
headerAlign: "center" as const, headerAlign: "center" as const,
sortable: true, sortable: true,
renderHeader: (params: any) => ( renderHeader: (params: any) => (
<Tooltip title="Количество привязанных достопримечательностей"> <Tooltip title="Отображает количество привязанных достопримечательностей">
<span>{params.colDef.headerName}</span> <span>{params.colDef.headerName}</span>
</Tooltip> </Tooltip>
), ),
@@ -157,7 +157,7 @@ export const RouteListPage = observer(() => {
headerAlign: "center" as const, headerAlign: "center" as const,
sortable: true, sortable: true,
renderHeader: (params: any) => ( renderHeader: (params: any) => (
<Tooltip title="Количество привязанных остановок"> <Tooltip title="Отображает количество привязанных остановок">
<span>{params.colDef.headerName}</span> <span>{params.colDef.headerName}</span>
</Tooltip> </Tooltip>
), ),
@@ -210,6 +210,9 @@ export const RouteListPage = observer(() => {
const rows = useMemo(() => { const rows = useMemo(() => {
const { selectedCityId } = selectedCityStore; const { selectedCityId } = selectedCityStore;
if (!selectedCityId) {
return [];
}
const query = searchQuery.trim().toLowerCase(); const query = searchQuery.trim().toLowerCase();
let filtered = routes.data; let filtered = routes.data;
if (selectedCityId) { if (selectedCityId) {
@@ -247,7 +250,11 @@ export const RouteListPage = observer(() => {
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Маршруты</h1> <h1 className="text-2xl">Маршруты</h1>
{canWriteRoutes && ( {canWriteRoutes && (
<CreateButton label="Создать маршрут" path="/route/create" /> <CreateButton
label="Создать маршрут"
path="/route/create"
disabled={!selectedCityStore.selectedCityId}
/>
)} )}
</div> </div>
@@ -304,7 +311,13 @@ export const RouteListPage = observer(() => {
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет маршрутов"} {isLoading ? (
<CircularProgress size={20} />
) : !selectedCityStore.selectedCityId ? (
"Выберите город"
) : (
"Нет маршрутов"
)}
</Box> </Box>
), ),
}} }}

View File

@@ -1,4 +1,4 @@
import { Box, Stack, Typography, Button } from "@mui/material"; import { Button } from "@mui/material";
import { useNavigate, useNavigationType } from "react-router"; import { useNavigate, useNavigationType } from "react-router";
import { MediaViewer } from "@widgets"; import { MediaViewer } from "@widgets";
import { useMapData } from "./MapDataContext"; import { useMapData } from "./MapDataContext";
@@ -15,22 +15,22 @@ type LeftSidebarProps = {
export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => { export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const navigationType = useNavigationType(); // PUSH, POP, REPLACE const navigationType = useNavigationType();
const { routeData } = useMapData(); const { routeData } = useMapData();
const [carrierThumbnail, setCarrierThumbnail] = useState<string | null>(null);
const [carrierLogo, setCarrierLogo] = 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(() => { useEffect(() => {
async function fetchCarrierThumbnail() { async function fetchCarrierData() {
if (routeData?.carrier_id) { if (routeData?.carrier_id) {
const { city_id, logo } = ( const carrier = (await authInstance.get(`/carrier/${routeData.carrier_id}`)).data;
await authInstance.get(`/carrier/${routeData.carrier_id}`) setCarrierLogo(carrier.logo);
).data; setCarrierSlogan(carrier.slogan ?? null);
const { arms } = (await authInstance.get(`/city/${city_id}`)).data; setCarrierShortName(carrier.short_name ?? null);
setCarrierThumbnail(arms);
setCarrierLogo(logo);
} }
} }
fetchCarrierThumbnail(); fetchCarrierData();
}, [routeData?.carrier_id]); }, [routeData?.carrier_id]);
const handleBack = () => { const handleBack = () => {
@@ -42,131 +42,162 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
}; };
return ( return (
<Box <div
sx={{ style={{
position: "relative", position: "relative",
height: "100%", height: "100%",
color: "#fff", color: "#fff",
transition: "padding 0.3s ease",
p: open ? 2 : 0,
display: "flex",
flexDirection: "column",
alignItems: "stretch",
justifyContent: "flex-start",
}} }}
> >
<Stack {/* Кнопка назад — вне основного меню */}
direction="column" <div style={{ padding: "12px 12px 0" }}>
height="100%" <Button
width="100%" onClick={handleBack}
spacing={4} variant="contained"
alignItems="stretch" sx={{
justifyContent="space-between" backgroundColor: "#222",
sx={{ 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, opacity: open ? 1 : 0,
transition: "opacity 0.25s ease", transition: "opacity 0.25s ease",
pointerEvents: open ? "auto" : "none", pointerEvents: open ? "auto" : "none",
display: open ? "flex" : "none",
}} }}
> >
<div> {/* Герб — .side-menu-crest */}
<Button <div
onClick={handleBack} style={{
variant="contained" width: 170,
color="primary" height: 170,
sx={{ alignSelf: "flex-start",
backgroundColor: "#222", marginLeft: 20,
color: "#fff", backgroundColor: "rgba(255,255,255,0.15)",
borderRadius: 1.5, borderRadius: 8,
px: 2, display: "flex",
py: 1, alignItems: "center",
marginBottom: 10, justifyContent: "center",
"&:hover": { color: "rgba(255,255,255,0.5)",
backgroundColor: "#2d2d2d", 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 />}
> >
Назад {carrierSlogan}
</Button> </div>
)}
<Stack {/* Кнопки — .side-menu-buttons */}
direction="column" <div style={{ width: 220, marginTop: 260 }}>
alignItems="center" <div
justifyContent="center" style={{
spacing={3} backgroundColor: "#fff",
color: "#000",
textAlign: "center",
padding: "8px 16px",
marginBottom: 16,
borderRadius: 10,
}}
> >
<div Достопримечательности
style={{ </div>
maxWidth: 150, <div
display: "flex", style={{
flexDirection: "column", backgroundColor: "#fff",
alignItems: "center", color: "#000",
gap: 10, textAlign: "center",
}} padding: "8px 16px",
> marginBottom: 16,
{carrierThumbnail && !isMediaIdEmpty(carrierThumbnail) && ( borderRadius: 10,
<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>
</div> </div>
<Stack {/* Нижняя секция — .side-menu-bottom-section */}
direction="column" <div
alignItems="center" style={{
maxHeight={150} position: "absolute",
justifyContent="center" bottom: 0,
flexGrow={1} left: 0,
width: "100%",
display: "flex",
flexDirection: "column",
}}
> >
{carrierLogo && !isMediaIdEmpty(carrierLogo) && ( {/* .side-menu-carrier-block */}
<MediaViewer <div style={{ padding: "0 20px" }}>
media={{ {carrierLogo && !isMediaIdEmpty(carrierLogo) && (
id: carrierLogo, <div style={{ width: 170 }}>
media_type: 1, // Тип "Фото" для логотипа <MediaViewer
filename: "route_thumbnail_logo", media={{ id: carrierLogo, media_type: 1, filename: "carrier_logo" }}
}} fullWidth
fullHeight />
/> </div>
)} )}
</Stack> {carrierShortName && (
<div
style={{
marginTop: 4,
textAlign: "left",
fontSize: 16,
fontWeight: 700,
lineHeight: "150%",
color: "#fff",
}}
>
{carrierShortName}
</div>
)}
</div>
<Typography {/* .side-menu-bottom-photo */}
variant="h6" <img
textAlign="center" src="/side-menu-photo.png"
sx={{ color: "#fff", marginTop: "auto" }} alt=""
> style={{ width: "100%", marginTop: 32, display: "block", pointerEvents: "none" }}
#ВсемПоПути />
</Typography> </div>
</Stack> </div>
<div className="absolute bottom-[20px] -right-[520px] z-10"> <div className="absolute bottom-[20px] -right-[520px] z-10">
<LanguageSelector onBack={onToggle} isSidebarOpen={open} /> <LanguageSelector onBack={onToggle} isSidebarOpen={open} />
</div> </div>
</Box> </div>
); );
}); });

View File

@@ -34,7 +34,7 @@
rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0) 100% rgba(255, 255, 255, 0) 100%
), ),
rgba(179, 165, 152, 0.4); rgba(var(--carrier-main-rgb, 0, 111, 58), 0.4);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
pointer-events: auto; pointer-events: auto;
z-index: 10000001; z-index: 10000001;

View File

@@ -121,8 +121,11 @@ export const SightListPage = observer(() => {
}] : []), }] : []),
]; ];
const { selectedCityId } = selectedCityStore;
const filteredSights = useMemo(() => { const filteredSights = useMemo(() => {
const { selectedCityId } = selectedCityStore; if (!selectedCityId) {
return [];
}
const allowedCityIds = canReadCities const allowedCityIds = canReadCities
? null ? null
: authStore.meCities["ru"].map((c) => c.city_id); : authStore.meCities["ru"].map((c) => c.city_id);
@@ -131,12 +134,12 @@ export const SightListPage = observer(() => {
if (allowedCityIds && !allowedCityIds.includes(sight.city_id)) { if (allowedCityIds && !allowedCityIds.includes(sight.city_id)) {
return false; return false;
} }
if (selectedCityId && sight.city_id !== selectedCityId) { if (sight.city_id !== selectedCityId) {
return false; return false;
} }
return true; return true;
}); });
}, [sights, selectedCityStore.selectedCityId, canReadCities, authStore.meCities]); }, [sights, selectedCityId, canReadCities, authStore.meCities]);
const query = searchQuery.trim().toLowerCase(); const query = searchQuery.trim().toLowerCase();
const rows = filteredSights const rows = filteredSights
@@ -161,6 +164,7 @@ export const SightListPage = observer(() => {
<CreateButton <CreateButton
label="Создать достопримечательность" label="Создать достопримечательность"
path="/sight/create" path="/sight/create"
disabled={!selectedCityStore.selectedCityId}
/> />
)} )}
</div> </div>
@@ -216,6 +220,8 @@ export const SightListPage = observer(() => {
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? ( {isLoading ? (
<CircularProgress size={20} /> <CircularProgress size={20} />
) : !selectedCityId ? (
"Выберите город"
) : ( ) : (
"Нет достопримечательностей" "Нет достопримечательностей"
)} )}

View File

@@ -76,6 +76,26 @@ export const SnapshotListPage = observer(() => {
}; };
const columns: GridColDef[] = [ 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", field: "name",
headerName: "Название", headerName: "Название",
@@ -150,12 +170,13 @@ export const SnapshotListPage = observer(() => {
.toLowerCase() .toLowerCase()
.includes(query), .includes(query),
) )
.map((snapshot) => ({ .map((snapshot, index) => ({
id: snapshot.ID, id: snapshot.ID,
name: snapshot.Name, name: snapshot.Name,
parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name, parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name,
created_at: formatCreationTime(snapshot.CreationTime), created_at: formatCreationTime(snapshot.CreationTime),
occupied_disk_space_gb: snapshot.occupied_disk_space_gb, occupied_disk_space_gb: snapshot.occupied_disk_space_gb,
color: SEGMENT_COLORS[index % SEGMENT_COLORS.length],
})); }));
}, [snapshots, searchQuery]); }, [snapshots, searchQuery]);
@@ -181,7 +202,7 @@ export const SnapshotListPage = observer(() => {
setIsEmptySnapshotModalOpen(true); setIsEmptySnapshotModalOpen(true);
}} }}
> >
Создать пустой снапшот Создать пустой экспорт
</Button> </Button>
)} )}
{canCreateSnapshot && ( {canCreateSnapshot && (
@@ -203,7 +224,7 @@ export const SnapshotListPage = observer(() => {
</div> </div>
<div className="flex w-full h-3 rounded-lg overflow-hidden bg-gray-100"> <div className="flex w-full h-3 rounded-lg overflow-hidden bg-gray-100">
{rows.map((row, i) => { {rows.map((row) => {
const pct = const pct =
row.occupied_disk_space_gb != null && totalGB > 0 row.occupied_disk_space_gb != null && totalGB > 0
? (row.occupied_disk_space_gb / totalGB) * 100 ? (row.occupied_disk_space_gb / totalGB) * 100
@@ -214,8 +235,7 @@ export const SnapshotListPage = observer(() => {
key={row.id} key={row.id}
style={{ style={{
width: `${pct}%`, width: `${pct}%`,
backgroundColor: backgroundColor: row.color,
SEGMENT_COLORS[i % SEGMENT_COLORS.length],
}} }}
title={`${row.name}: ${row.occupied_disk_space_gb?.toFixed(1)} ГБ`} title={`${row.name}: ${row.occupied_disk_space_gb?.toFixed(1)} ГБ`}
/> />
@@ -233,7 +253,7 @@ export const SnapshotListPage = observer(() => {
</div> </div>
<div className="flex flex-wrap gap-x-5 gap-y-1 mt-3"> <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) if (row.occupied_disk_space_gb == null || row.occupied_disk_space_gb <= 0)
return null; return null;
return ( return (
@@ -243,10 +263,7 @@ export const SnapshotListPage = observer(() => {
> >
<span <span
className="inline-block w-2.5 h-2.5 rounded-full" className="inline-block w-2.5 h-2.5 rounded-full"
style={{ style={{ backgroundColor: row.color }}
backgroundColor:
SEGMENT_COLORS[i % SEGMENT_COLORS.length],
}}
/> />
{row.name} {row.name}
</div> </div>
@@ -325,7 +342,7 @@ export const SnapshotListPage = observer(() => {
fullWidth fullWidth
maxWidth="xs" maxWidth="xs"
> >
<DialogTitle>Создать пустой снапшот</DialogTitle> <DialogTitle>Создать пустой экспорт</DialogTitle>
<DialogContent> <DialogContent>
<TextField <TextField
autoFocus autoFocus

View File

@@ -19,6 +19,7 @@ import {
mediaStore, mediaStore,
isMediaIdEmpty, isMediaIdEmpty,
useSelectedCity, useSelectedCity,
selectedCityStore,
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
@@ -32,6 +33,12 @@ import {
export const StationCreatePage = observer(() => { export const StationCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore; const { language } = languageStore;
const { const {

View File

@@ -19,6 +19,7 @@ import {
mediaStore, mediaStore,
isMediaIdEmpty, isMediaIdEmpty,
LoadingSpinner, LoadingSpinner,
selectedCityStore,
SelectMediaDialog, SelectMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
UploadMediaDialog, UploadMediaDialog,
@@ -34,6 +35,12 @@ import { LinkedSights } from "../LinkedSights";
export const StationEditPage = observer(() => { export const StationEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true); const [isLoadingData, setIsLoadingData] = useState(true);
const { language } = languageStore; const { language } = languageStore;

View File

@@ -86,13 +86,13 @@ export const StationListPage = observer(() => {
}, },
{ {
field: "sightCount", field: "sightCount",
headerName: "Достопримечательности", headerName: "Привязки",
width: 180, width: 180,
align: "center" as const, align: "center" as const,
headerAlign: "center" as const, headerAlign: "center" as const,
sortable: true, sortable: true,
renderHeader: (params) => ( renderHeader: (params) => (
<Tooltip title="Количество привязанных достопримечательностей"> <Tooltip title="Отображает количество привязок">
<span>{params.colDef.headerName}</span> <span>{params.colDef.headerName}</span>
</Tooltip> </Tooltip>
), ),
@@ -114,7 +114,7 @@ export const StationListPage = observer(() => {
headerAlign: "center" as const, headerAlign: "center" as const,
sortable: true, sortable: true,
renderHeader: (params) => ( renderHeader: (params) => (
<Tooltip title="Подтверждение добавленных пересадок"> <Tooltip title="Отображает подтверждение добавленных пересадок">
<span>{params.colDef.headerName}</span> <span>{params.colDef.headerName}</span>
</Tooltip> </Tooltip>
), ),
@@ -174,9 +174,12 @@ export const StationListPage = observer(() => {
const rows = useMemo(() => { const rows = useMemo(() => {
const { selectedCityId } = selectedCityStore; const { selectedCityId } = selectedCityStore;
if (!selectedCityId) {
return [];
}
const query = searchQuery.trim().toLowerCase(); const query = searchQuery.trim().toLowerCase();
return stationLists[language].data return stationLists[language].data
.filter((station: any) => !selectedCityId || station.city_id === selectedCityId) .filter((station: any) => station.city_id === selectedCityId)
.filter( .filter(
(station: any) => (station: any) =>
!query || !query ||
@@ -202,7 +205,11 @@ export const StationListPage = observer(() => {
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Остановки</h1> <h1 className="text-2xl">Остановки</h1>
{canWriteStations && ( {canWriteStations && (
<CreateButton label="Создать остановку" path="/station/create" /> <CreateButton
label="Создать остановку"
path="/station/create"
disabled={!selectedCityStore.selectedCityId}
/>
)} )}
</div> </div>
@@ -277,7 +284,13 @@ export const StationListPage = observer(() => {
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет остановок"} {isLoading ? (
<CircularProgress size={20} />
) : !selectedCityStore.selectedCityId ? (
"Выберите город"
) : (
"Нет остановок"
)}
</Box> </Box>
), ),
}} }}

View File

@@ -1,4 +1,19 @@
import { Button, Paper, TextField } from "@mui/material"; import {
Button,
Paper,
TextField,
Checkbox,
Typography,
Box,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Radio,
RadioGroup,
Divider,
} from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react"; import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -14,6 +29,40 @@ import {
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { ImageUploadCard } from "@widgets"; import { ImageUploadCard } 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;
}
export const UserCreatePage = observer(() => { export const UserCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const { createUserData, setCreateUserData, createUser } = userStore; const { createUserData, setCreateUserData, createUser } = userStore;
@@ -26,13 +75,33 @@ export const UserCreatePage = observer(() => {
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null); >(null);
const [localRoles, setLocalRoles] = useState<string[]>(
createUserData.roles ?? ["articles_ro", "articles_rw", "media_ro", "media_rw"]
);
useEffect(() => { useEffect(() => {
mediaStore.getMedia(); 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 () => { const handleCreate = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
// Убеждаемся, что роли в сторе обновлены перед созданием
userStore.createUserData.roles = localRoles;
await createUser(); await createUser();
toast.success("Пользователь успешно создан"); toast.success("Пользователь успешно создан");
navigate("/user"); navigate("/user");
@@ -67,18 +136,15 @@ export const UserCreatePage = observer(() => {
: selectedMedia?.id ?? createUserData.icon ?? null; : selectedMedia?.id ?? createUserData.icon ?? null;
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full p-6 flex flex-col gap-8">
<div className="flex items-center gap-4"> <button className="flex items-center gap-2 self-start" onClick={() => navigate(-1)}>
<button <ArrowLeft size={20} />
className="flex items-center gap-2" Назад
onClick={() => navigate(-1)} </button>
>
<ArrowLeft size={20} /> <section className="flex flex-col gap-6">
Назад <Typography variant="h6">Основные данные</Typography>
</button>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<TextField <TextField
fullWidth fullWidth
label="Имя" label="Имя"
@@ -116,6 +182,7 @@ export const UserCreatePage = observer(() => {
label="Пароль" label="Пароль"
value={createUserData.password || ""} value={createUserData.password || ""}
required required
type="password"
onChange={(e) => onChange={(e) =>
setCreateUserData( setCreateUserData(
createUserData.name || "", createUserData.name || "",
@@ -127,7 +194,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 <ImageUploadCard
title="Аватар" title="Аватар"
imageKey="thumbnail" imageKey="thumbnail"
@@ -156,23 +223,197 @@ export const UserCreatePage = observer(() => {
}} }}
/> />
</div> </div>
</section>
<Button <Divider />
variant="contained"
className="w-min flex gap-2 items-center" <section className="flex flex-col gap-4">
startIcon={<Save size={20} />} <Typography variant="h6">Права доступа</Typography>
onClick={handleCreate}
disabled={ <Box sx={{ display: "flex", gap: 1 }}>
isLoading || !createUserData.name || !createUserData.password <Button
} variant="outlined"
> size="small"
{isLoading ? ( onClick={() => {
<Loader2 size={20} className="animate-spin" /> setCreateUserData(
) : ( createUserData.name || "",
"Создать" createUserData.email || "",
)} createUserData.password || "",
</Button> true,
</div> 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>
<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,
);
}
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 ? (
<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>
</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 <SelectMediaDialog
open={isSelectMediaOpen} open={isSelectMediaOpen}

View File

@@ -1,6 +1,5 @@
import { import {
Button, Button,
FormControlLabel,
Checkbox, Checkbox,
Paper, Paper,
TextField, TextField,
@@ -97,6 +96,20 @@ export const UserEditPage = observer(() => {
languageStore.setLanguage("ru"); 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(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
@@ -311,35 +324,33 @@ export const UserEditPage = observer(() => {
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">
<Typography variant="h6">Права доступа</Typography> <Typography variant="h6">Права доступа</Typography>
<FormControlLabel <Box sx={{ display: "flex", gap: 1 }}>
control={ <Button
<Checkbox variant="outlined"
checked={localRoles.includes("admin")} size="small"
onChange={(e) => { onClick={() => {
if (e.target.checked) { setEditUserData(editUserData.name || "", editUserData.email || "", editUserData.password || "", true, editUserData.icon || "");
setLocalRoles((prev) => { const next: string[] = [];
let next = prev.filter((r) => r !== "admin"); for (const { key } of ROLE_RESOURCES) {
for (const { key } of ROLE_RESOURCES) { next.push(`${key}_rw`);
next = next.filter((r) => r !== `${key}_ro` && r !== `${key}_rw`); }
next.push(`${key}_rw`); next.push("snapshot_create");
} setLocalRoles(next);
if (!next.includes("snapshot_create")) { }}
next.push("snapshot_create"); >
} Полный доступ (admin)
if (!next.includes("devices_maintenance_rw")) { </Button>
next.push("devices_maintenance_rw"); <Button
} variant="outlined"
next.push("admin"); size="small"
return next; onClick={() => {
}); setEditUserData(editUserData.name || "", editUserData.email || "", editUserData.password || "", false, editUserData.icon || "");
} else { setLocalRoles(["devices_ro", "vehicles_ro", "devices_maintenance_rw"]);
setLocalRoles((prev) => prev.filter((r) => r !== "admin")); }}
} >
}} Администратор ТО
/> </Button>
} </Box>
label="Полный доступ (admin)"
/>
<Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1 }}> <Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
<Table size="small"> <Table size="small">
@@ -371,20 +382,6 @@ export const UserEditPage = observer(() => {
); );
} }
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; return updated;
}); });
}; };
@@ -462,12 +459,14 @@ export const UserEditPage = observer(() => {
title="Разрешает создавать новые снапшоты" title="Разрешает создавать новые снапшоты"
/> />
) : isDevicesResource ? ( ) : isDevicesResource ? (
<Checkbox <Box sx={{ display: "flex", gap: 0.5, justifyContent: "center" }}>
checked={localRoles.includes("devices_maintenance_rw")} <Checkbox
onChange={(e) => handleMaintenanceChange(e.target.checked)} checked={localRoles.includes("devices_maintenance_rw")}
size="small" onChange={(e) => handleMaintenanceChange(e.target.checked)}
title="Разрешает переводить устройства в режим технического обслуживания" size="small"
/> title="Техническое обслуживание (ТО)"
/>
</Box>
) : ( ) : (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
- -

View File

@@ -16,9 +16,9 @@ export type Carrier = {
city: string; city: string;
city_id: number; city_id: number;
logo: string; logo: string;
// main_color: string; main_color: string;
// left_color: string; left_color: string;
// right_color: string; right_color: string;
}; };
type CarrierData = { type CarrierData = {
@@ -112,6 +112,9 @@ class CarrierStore {
createCarrierData = { createCarrierData = {
city_id: 0, city_id: 0,
logo: "", logo: "",
main_color: "",
left_color: "",
right_color: "",
ru: { ru: {
full_name: "", full_name: "",
short_name: "", short_name: "",
@@ -135,10 +138,16 @@ class CarrierStore {
cityId: number, cityId: number,
slogan: string, slogan: string,
logoId: string, logoId: string,
language: Language language: Language,
colors?: { main_color?: string; left_color?: string; right_color?: string }
) => { ) => {
this.createCarrierData.city_id = cityId; this.createCarrierData.city_id = cityId;
this.createCarrierData.logo = logoId; 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] = { this.createCarrierData[language] = {
full_name: fullName, full_name: fullName,
short_name: shortName, short_name: shortName,
@@ -198,9 +207,10 @@ class CarrierStore {
city: cityName, city: cityName,
city_id: this.createCarrierData.city_id, city_id: this.createCarrierData.city_id,
slogan: (this.createCarrierData[language].slogan || "").trim(), slogan: (this.createCarrierData[language].slogan || "").trim(),
...(this.createCarrierData.logo ...(this.createCarrierData.logo ? { 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); const response = await languageInstance(language).post("/carrier", payload);
@@ -243,6 +253,9 @@ class CarrierStore {
this.createCarrierData = { this.createCarrierData = {
city_id: 0, city_id: 0,
logo: "", logo: "",
main_color: "",
left_color: "",
right_color: "",
ru: { ru: {
full_name: "", full_name: "",
short_name: "", short_name: "",
@@ -265,53 +278,44 @@ class CarrierStore {
ru: { ru: {
full_name: "", full_name: "",
short_name: "", short_name: "",
// main_color: "",
// left_color: "",
// right_color: "",
slogan: "", slogan: "",
}, },
en: { en: {
full_name: "", full_name: "",
short_name: "", short_name: "",
slogan: "",
// main_color: "", },
// left_color: "", zh: {
// right_color: "", full_name: "",
short_name: "",
slogan: "", slogan: "",
}, },
city_id: 0, city_id: 0,
logo: "", logo: "",
zh: { main_color: "",
full_name: "", left_color: "",
short_name: "", right_color: "",
// main_color: "",
// left_color: "",
// right_color: "",
slogan: "",
},
}; };
setEditCarrierData = ( setEditCarrierData = (
fullName: string, fullName: string,
shortName: string, shortName: string,
cityId: number, cityId: number,
// main_color: string,
// left_color: string,
// right_color: string,
slogan: string, slogan: string,
logoId: string, logoId: string,
language: Language language: Language,
colors?: { main_color?: string; left_color?: string; right_color?: string }
) => { ) => {
this.editCarrierData.city_id = cityId; this.editCarrierData.city_id = cityId;
this.editCarrierData.logo = logoId; 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] = { this.editCarrierData[language] = {
full_name: fullName, full_name: fullName,
short_name: shortName, short_name: shortName,
// main_color: main_color,
// left_color: left_color,
// right_color: right_color,
slogan: slogan, slogan: slogan,
}; };
}; };
@@ -326,9 +330,10 @@ class CarrierStore {
slogan: (this.editCarrierData[lang].slogan || "").trim(), slogan: (this.editCarrierData[lang].slogan || "").trim(),
city: cityName, city: cityName,
city_id: this.editCarrierData.city_id, city_id: this.editCarrierData.city_id,
...(this.editCarrierData.logo ...(this.editCarrierData.logo ? { 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(() => { runInAction(() => {

View File

@@ -153,7 +153,7 @@ class RouteStore {
scale_max: 0, scale_max: 0,
scale_min: 0, scale_min: 0,
video_preview: "" as string | undefined, video_preview: "" as string | undefined,
video_timer: 60, video_timer: 420,
}; };
setEditRouteData = (data: any) => { setEditRouteData = (data: any) => {

View File

@@ -3,6 +3,7 @@ import { City } from "../CityStore";
class SelectedCityStore { class SelectedCityStore {
selectedCity: City | null = null; selectedCity: City | null = null;
isLocked: boolean = false;
constructor() { constructor() {
makeAutoObservable(this); makeAutoObservable(this);
@@ -32,6 +33,12 @@ class SelectedCityStore {
}); });
}; };
setIsLocked = (locked: boolean) => {
runInAction(() => {
this.isLocked = locked;
});
};
clearSelectedCity = () => { clearSelectedCity = () => {
this.setSelectedCity(null); this.setSelectedCity(null);
}; };

View File

@@ -12,7 +12,7 @@ import { authStore, cityStore, selectedCityStore, type City } from "@shared";
import { MapPin } from "lucide-react"; import { MapPin } from "lucide-react";
export const CitySelector: React.FC = observer(() => { export const CitySelector: React.FC = observer(() => {
const { selectedCity, setSelectedCity } = selectedCityStore; const { selectedCity, setSelectedCity, isLocked } = selectedCityStore;
const canLoadAllCities = authStore.isAdmin && authStore.canRead("cities"); const canLoadAllCities = authStore.isAdmin && authStore.canRead("cities");
useEffect(() => { useEffect(() => {
@@ -58,26 +58,35 @@ export const CitySelector: React.FC = observer(() => {
return ( return (
<Box className="flex items-center gap-2"> <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 }}> <FormControl size="medium" sx={{ minWidth: 120 }}>
<Select <Select
value={selectedCity?.id?.toString() || ""} value={selectedCity?.id?.toString() || ""}
onChange={handleCityChange} onChange={handleCityChange}
displayEmpty displayEmpty
disabled={isLocked}
sx={{ sx={{
height: "40px", height: "40px",
color: "white", color: "white",
"&.Mui-disabled": {
color: "rgba(255, 255, 255, 0.5)",
WebkitTextFillColor: "rgba(255, 255, 255, 0.5)",
},
"& .MuiOutlinedInput-notchedOutline": { "& .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255, 255, 255, 0.3)", borderColor: isLocked
? "rgba(255, 255, 255, 0.1)"
: "rgba(255, 255, 255, 0.3)",
}, },
"&:hover .MuiOutlinedInput-notchedOutline": { "&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(255, 255, 255, 0.5)", borderColor: isLocked
? "rgba(255, 255, 255, 0.1)"
: "rgba(255, 255, 255, 0.5)",
}, },
"&.Mui.focused .MuiOutlinedInput-notchedOutline": { "&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: "white", borderColor: "white",
}, },
"& .MuiSvgIcon-root": { "& .MuiSvgIcon-root": {
color: "white", color: isLocked ? "rgba(255, 255, 255, 0.3)" : "white",
}, },
}} }}
> >

View File

@@ -628,41 +628,57 @@ export const DevicesTable = observer(() => {
justifyContent: "center", justifyContent: "center",
}} }}
> >
{canWriteDevices && ( {!isMaintenanceOnly && (
<button <>
onClick={(e) => { {canWriteDevices && (
e.stopPropagation(); <button
navigate(`/vehicle/${row.vehicle_id}/edit`); onClick={(e) => {
}} e.stopPropagation();
title="Редактировать транспорт" navigate(`/vehicle/${row.vehicle_id}/edit`);
> }}
<Pencil size={16} /> title="Редактировать транспорт"
</button> >
<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 <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -674,18 +690,6 @@ export const DevicesTable = observer(() => {
> >
<Wrench size={16} /> <Wrench size={16} />
</button> </button>
<button
onClick={(e) => {
e.stopPropagation();
if (row.device_uuid) {
setLogsModalDeviceUuid(row.device_uuid);
setLogsModalOpen(true);
}
}}
title="Логи устройства"
>
<ScrollText size={16} />
</button>
</Box> </Box>
); );
}, },
@@ -714,9 +718,11 @@ export const DevicesTable = observer(() => {
const visibleColumns = useMemo(() => { const visibleColumns = useMemo(() => {
if (isMaintenanceOnly) { if (isMaintenanceOnly) {
return columns.filter((c) => return columns
["model", "tail_number", "maintenance_mode_on"].includes(c.field), .filter((c) =>
); ["model", "tail_number", "maintenance_mode_on", "actions"].includes(c.field),
)
.map((c) => ({ ...c, flex: 1, width: undefined, minWidth: undefined }));
} }
if (!canWriteDevices) { if (!canWriteDevices) {
return columns.filter( return columns.filter(
@@ -729,20 +735,26 @@ export const DevicesTable = observer(() => {
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
setIsLoading(true); setIsLoading(true);
await Promise.all([ if (isMaintenanceOnly) {
getVehicles(), await Promise.all([getVehicles(), getDevices()]);
getDevices(), } else {
getSnapshots(), await Promise.all([
getRoutes(), getVehicles(),
]); getDevices(),
getSnapshots(),
getRoutes(),
]);
}
setIsLoading(false); setIsLoading(false);
}; };
fetchData(); fetchData();
}, [getDevices, getSnapshots, getVehicles, getRoutes]); }, [getDevices, getSnapshots, getVehicles, getRoutes, isMaintenanceOnly]);
useEffect(() => { useEffect(() => {
carrierStore.getCarriers("ru"); if (!isMaintenanceOnly) {
}, []); carrierStore.getCarriers("ru");
}
}, [isMaintenanceOnly]);
const handleOpenSendSnapshotModal = () => { const handleOpenSendSnapshotModal = () => {
if (!canWriteDevices) { if (!canWriteDevices) {

View File

@@ -120,7 +120,6 @@ export const ReactMarkdownEditor = ({
"table", "table",
"horizontal-rule", "horizontal-rule",
"preview", "preview",
"fullscreen",
"guide", "guide",
], ],
}; };

View File

@@ -243,149 +243,123 @@ export const LeftWidgetTab = observer(
flex: 1, flex: 1,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
maxWidth: "320px", maxWidth: "316px",
gap: 0.5, gap: 0.5,
}} }}
> >
<Paper <Box
elevation={3}
sx={{ sx={{
width: "100%", width: "316px",
minWidth: 320,
background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
overflowY: "auto",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: "center",
borderRadius: "10px", borderRadius: "10px",
background:
"linear-gradient(114deg, rgba(255,255,255,0) 8.71%, rgba(255,255,255,0.16) 69.69%), #006F3A",
}} }}
> >
<Box {data.left.media.length > 0 ? (
sx={{ <Box
overflow: "hidden",
width: "100%",
minHeight: 100,
padding: "3px",
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
"& img": {
borderTopLeftRadius: "10px",
borderTopRightRadius: "10px",
width: "100%",
height: "auto",
objectFit: "contain",
},
}}
>
{data.left.media.length > 0 ? (
<>
<MediaViewer
media={{
id: data.left.media[0].id,
media_type: data.left.media[0].media_type,
filename: data.left.media[0].filename,
}}
fullWidth
/>
{sight.common.watermark_lu && (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
sight.common.watermark_lu
}/download?token=${token}`}
alt="preview"
className="absolute top-4 left-4 z-10"
style={{
width: "30px",
height: "30px",
objectFit: "contain",
}}
/>
)}
{sight.common.watermark_rd && (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
sight.common.watermark_rd
}/download?token=${token}`}
alt="preview"
className="absolute bottom-4 right-4 z-10"
style={{
width: "30px",
height: "30px",
objectFit: "contain",
}}
/>
)}
</>
) : (
<ImagePlus size={48} color="white" />
)}
</Box>
<Box
sx={{
background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
color: "white",
margin: "5px 0px 5px 0px",
display: "flex",
flexDirection: "column",
gap: 1,
padding: 1,
}}
>
<Typography
variant="h5"
component="h2"
sx={{ sx={{
position: "relative",
width: "312px",
height: "175px",
margin: "2px 0px 2px 0px",
borderRadius: "10px 10px 0 0",
overflow: "hidden",
flexShrink: 0,
"& img, & video": {
width: "100%",
height: "100%",
objectFit: "cover",
borderRadius: "10px 10px 0 0",
},
}}
>
<MediaViewer
media={{
id: data.left.media[0].id,
media_type: data.left.media[0].media_type,
filename: data.left.media[0].filename,
}}
fullWidth
/>
{sight.common.watermark_lu && (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${sight.common.watermark_lu}/download?token=${token}`}
alt="preview"
className="absolute top-4 left-4 z-10"
style={{ width: "30px", height: "30px", objectFit: "contain" }}
/>
)}
{sight.common.watermark_rd && (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${sight.common.watermark_rd}/download?token=${token}`}
alt="preview"
className="absolute bottom-4 right-4 z-10"
style={{ width: "30px", height: "30px", objectFit: "contain" }}
/>
)}
</Box>
) : (
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", height: "175px" }}>
<ImagePlus size={48} color="white" />
</Box>
)}
<Box sx={{ padding: "0px 10px 20px 10px", width: "100%" }}>
<Box
component="div"
sx={{
color: "#fff",
fontFamily: "Roboto",
fontSize: "20px",
fontWeight: 600,
lineHeight: "150%",
wordBreak: "break-word", wordBreak: "break-word",
fontSize: "24px",
fontWeight: 700,
lineHeight: "120%",
}} }}
> >
{data?.left?.heading || "Название информации"} {data?.left?.heading || "Название информации"}
</Typography> </Box>
<Typography <Box
variant="h6" component="div"
component="h2"
sx={{ sx={{
marginTop: "2px",
color: "#fff",
fontFamily: "Roboto",
fontSize: "16px",
fontWeight: 400,
lineHeight: "150%",
wordBreak: "break-word", wordBreak: "break-word",
fontSize: "18px",
lineHeight: "120%",
}} }}
> >
{sight[language as Language].address} {sight[language as Language].address}
</Typography>
</Box>
{data?.left?.body && (
<Box
sx={{
padding: 1,
maxHeight: "300px",
overflowY: "auto",
width: "100%",
"&::-webkit-scrollbar": {
display: "none",
},
"&": {
scrollbarWidth: "none",
},
background:
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
flexGrow: 1,
}}
>
<ReactMarkdownComponent value={data?.left?.body} />
</Box> </Box>
)}
</Paper> {data?.left?.body && (
<Box
sx={{
marginTop: "15px",
maxHeight: "200px",
overflowY: "auto",
"&::-webkit-scrollbar": { display: "none" },
scrollbarWidth: "none",
"& p, & li, & h1, & h2, & h3": {
color: "#fff !important",
fontFamily: "Roboto, sans-serif !important",
fontSize: "16px !important",
fontWeight: "300 !important",
lineHeight: "135% !important",
marginTop: "0 !important",
marginBottom: "0 !important",
},
}}
>
<ReactMarkdownComponent value={data?.left?.body} />
</Box>
)}
</Box>
</Box>
</Box> </Box>
</Box> </Box>
)} )}

View File

@@ -12,7 +12,7 @@
rgba(255, 255, 255, 0) 8.71%, rgba(255, 255, 255, 0) 8.71%,
rgba(255, 255, 255, 0.16) 69.69% rgba(255, 255, 255, 0.16) 69.69%
), ),
#806c59; #006f3a;
} }
.sfp-sight-frame-media-stack { .sfp-sight-frame-media-stack {
@@ -22,7 +22,6 @@
width: calc(100% - 4px); width: calc(100% - 4px);
height: 300px; height: 300px;
overflow: hidden; overflow: hidden;
background: #111;
} }
.sfp-sight-frame-media-item { .sfp-sight-frame-media-item {
@@ -67,21 +66,21 @@
.sfp-sight-frame-title { .sfp-sight-frame-title {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 7px 16px; padding: 10px 20px;
width: 100%; width: 100%;
text-align: left; text-align: left;
font-family: "Roboto"; font-family: "Roboto";
font-size: 24px; font-size: 24px;
font-weight: 600; font-weight: 600;
line-height: 120%; line-height: 120%;
border-bottom: 1px solid rgba(255, 255, 255, 0.8); border-bottom: 1px solid var(--Glass-stroke, rgba(255, 255, 255, 0.8));
background: background:
linear-gradient( linear-gradient(
180deg, 180deg,
rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0.22) 0%,
rgba(255, 255, 255, 0.04) 100% rgba(255, 255, 255, 0.04) 100%
), ),
rgba(179, 165, 152, 0.72); rgba(0, 111, 58, 0.72);
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset; box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
box-sizing: border-box; box-sizing: border-box;
color: white; color: white;
@@ -118,7 +117,7 @@
padding: 16px; padding: 16px;
box-sizing: border-box; box-sizing: border-box;
max-height: calc(80vh - 354px); max-height: calc(80vh - 354px);
min-height: 80px; min-height: 0;
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -156,6 +155,9 @@
.sfp-sight-frame-text h3, .sfp-sight-frame-text h3,
.sfp-sight-frame-text li { .sfp-sight-frame-text li {
color: #fff; color: #fff;
font-size: 18px;
line-height: 150%;
font-family: "Roboto";
} }
.sfp-sight-frame-menu { .sfp-sight-frame-menu {
@@ -173,7 +175,7 @@
rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0) 100% rgba(255, 255, 255, 0) 100%
), ),
rgba(179, 165, 152, 0.4); rgba(0, 111, 58, 0.4);
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset; box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
box-sizing: border-box; box-sizing: border-box;
@@ -265,3 +267,4 @@
.sfp-sight-frame-media-stack.three-d-view { .sfp-sight-frame-media-stack.three-d-view {
background-color: #111 !important; background-color: #111 !important;
} }

File diff suppressed because one or more lines are too long