14 Commits

Author SHA1 Message Date
cc38f2e66c feat: webgl preview improvements, permissions refactor and snapshot safeguards 2026-06-14 23:13:51 +03:00
d4c5db61ea feat: add markdown, lang atribute, button_text 2026-05-25 12:46:53 +03:00
55cdea17ea fix: stabilize right widget layout and sight frame menu height with lang 2026-05-23 13:42:11 +03:00
3725c7f569 fix: change route_number to route_sys_number 2026-05-23 10:34:12 +03:00
2659c6a5b8 feat: add city name in snaphost name 2026-05-23 10:30:36 +03:00
1bb3f43979 feat: add scrollable menu with fade hints and home button transition 2026-05-20 12:48:00 +03:00
7e539f550b feat: hide search input when list is empty and add animated city selector highlight
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-19 14:14:04 +03:00
fbf6b0dc9d feat: add annotation for action buttons and double click for user list page 2026-05-19 13:47:36 +03:00
a997cdb198 feat: delete title atribute from the client page 2026-05-19 13:16:17 +03:00
bf45dcdbfc feat: update appeal widget and check double routes for create snapshot 2026-05-19 13:16:02 +03:00
83ccdef790 feat: update media in edit article and remove big hit boxes from clusters 2026-05-13 11:09:12 +03:00
51d1870198 feat: appeal widget and right widget update 2026-05-12 00:04:02 +03:00
193f53c029 feat: update map, admin to and cache 2026-05-08 13:33:41 +03:00
4bda233b63 feat: update route widget and leftsidebar in route-preview 2026-05-08 01:12:28 +03:00
78 changed files with 1916 additions and 1322 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'

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "white-nights", "name": "white-nights",
"version": "1.0.6", "version": "1.0.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "white-nights", "name": "white-nights",
"version": "1.0.6", "version": "1.0.8",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",

View File

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

View File

@@ -1,13 +1,19 @@
import * as React from "react"; import * as React from "react";
import { Router } from "./router"; import { Router } from "./router";
import { CustomTheme } from "@shared"; import { CustomTheme, languageStore } from "@shared";
import { ThemeProvider } from "@mui/material/styles"; import { ThemeProvider } from "@mui/material/styles";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { GlobalErrorBoundary } from "./GlobalErrorBoundary"; import { GlobalErrorBoundary } from "./GlobalErrorBoundary";
import { TestingModeBanner } from "@widgets"; import { TestingModeBanner } from "@widgets";
import { observer } from "mobx-react-lite";
export const App: React.FC = () => ( export const App: React.FC = observer(() => {
React.useEffect(() => {
document.documentElement.lang = languageStore.language;
}, [languageStore.language]);
return (
<GlobalErrorBoundary> <GlobalErrorBoundary>
<ThemeProvider theme={CustomTheme.Light}> <ThemeProvider theme={CustomTheme.Light}>
<TestingModeBanner /> <TestingModeBanner />
@@ -16,3 +22,4 @@ export const App: React.FC = () => (
</ThemeProvider> </ThemeProvider>
</GlobalErrorBoundary> </GlobalErrorBoundary>
); );
});

View File

@@ -47,6 +47,16 @@ function darkenHex(hex: string, amount: number): string {
return `#${dr.toString(16).padStart(2, "0")}${dg.toString(16).padStart(2, "0")}${db.toString(16).padStart(2, "0")}`; return `#${dr.toString(16).padStart(2, "0")}${dg.toString(16).padStart(2, "0")}${db.toString(16).padStart(2, "0")}`;
} }
function lightenRgbString(hex: string, amount: number): string | null {
const rgb = hexToRgbString(hex);
if (!rgb) return null;
const [r, g, b] = rgb.split(",").map(Number);
const lr = Math.round(r + (255 - r) * amount);
const lg = Math.round(g + (255 - g) * amount);
const lb = Math.round(b + (255 - b) * amount);
return `${lr}, ${lg}, ${lb}`;
}
function applyCarrierColors(carrier: { main_color?: string; left_color?: string; right_color?: string }) { function applyCarrierColors(carrier: { main_color?: string; left_color?: string; right_color?: string }) {
const mainColor = carrier.main_color || "#006f3a"; const mainColor = carrier.main_color || "#006f3a";
const leftColor = carrier.left_color || "#006f3a"; const leftColor = carrier.left_color || "#006f3a";
@@ -60,6 +70,7 @@ function applyCarrierColors(carrier: { main_color?: string; left_color?: string;
document.documentElement.style.setProperty("--carrier-left-rgb", hexToRgbString(leftColor) ?? "0, 111, 58"); 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", rightColor);
document.documentElement.style.setProperty("--carrier-right-rgb", hexToRgbString(rightColor) ?? "0, 111, 58"); document.documentElement.style.setProperty("--carrier-right-rgb", hexToRgbString(rightColor) ?? "0, 111, 58");
document.documentElement.style.setProperty("--carrier-right-menu-rgb", lightenRgbString(rightColor, 0.38) ?? "179, 165, 152");
} }
class ApiStore { class ApiStore {

View File

@@ -55,6 +55,7 @@ export type GetRouteResponse = {
center_latitude: number; center_latitude: number;
center_longitude: number; center_longitude: number;
governor_appeal: number; governor_appeal: number;
button_text?: string;
id: number; id: number;
path: [number, number][]; path: [number, number][];
rotate: number; rotate: number;

View File

@@ -37,14 +37,14 @@ const Fullscreen3DModal = ({ isOpen, onClose, fileUrl }) => {
</div> </div>
</div> </div>
<div className="fullscreen-3d-actions"> <div className="fullscreen-3d-actions">
<button title="Увеличить масштаб" disabled={scale >= 1}> <button disabled={scale >= 1}>
<img src={scale_plus} alt="Увеличить" /> <img src={scale_plus} alt="Увеличить" />
</button> </button>
<button title="Уменьшить масштаб" disabled={scale <= 0.1}> <button disabled={scale <= 0.1}>
<img src={scale_minus} alt="Уменьшить" /> <img src={scale_minus} alt="Уменьшить" />
</button> </button>
<button onPointerUp={onClose} title="Закрыть"> <button onPointerUp={onClose}>
<img src={closeIcon} alt="Закрыть" /> <img src={closeIcon} alt="Закрыть" />
</button> </button>
</div> </div>

View File

@@ -411,7 +411,7 @@ const ListOfSights = observer(() => {
}, [currentSelectedSight]); }, [currentSelectedSight]);
return ( return (
<div className="right-widget"> <div className="right-widget" lang={selectedLanguageRight}>
{currentSelectedSight && ( {currentSelectedSight && (
<SightFrame <SightFrame
key={currentSelectedSight.id} key={currentSelectedSight.id}

View File

@@ -1,4 +1,4 @@
import React from "react"; import React, { useState, useEffect, useRef } from "react";
const LANGUAGES = { const LANGUAGES = {
EN: "en", EN: "en",
@@ -18,6 +18,26 @@ const ListHeader = function ListHeader({
isTransferWidgetOpen, isTransferWidgetOpen,
onBackToNearest, onBackToNearest,
}) { }) {
const [isIdle, setIsIdle] = useState(false);
const timerRef = useRef(null);
useEffect(() => {
const resetTimer = () => {
setIsIdle(false);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setIsIdle(true), 15000);
};
const events = ["pointermove", "pointerdown", "touchstart", "keydown"];
events.forEach((e) => window.addEventListener(e, resetTimer));
resetTimer();
return () => {
clearTimeout(timerRef.current);
events.forEach((e) => window.removeEventListener(e, resetTimer));
};
}, []);
const getTitle = () => { const getTitle = () => {
return selectedLanguageRight === LANGUAGES.RU return selectedLanguageRight === LANGUAGES.RU
? "Достопримечательности" ? "Достопримечательности"
@@ -41,14 +61,11 @@ const ListHeader = function ListHeader({
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="21" width="20"
height="12" height="20"
viewBox="0 0 21 12" viewBox="0 0 21 12"
fill="none" fill="none"
style={{ className={`chevron-svg${isOpen ? " is-open" : ""}${isIdle ? " is-idle" : ""}`}
transform: isOpen ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.15s ease-in-out",
}}
> >
<g clipPath="url(#clip0_658_91932)"> <g clipPath="url(#clip0_658_91932)">
<path <path

View File

@@ -36,7 +36,7 @@ const SightComponent = function SightComponent({
aria-label={`Выбрать достопримечательность ${title}`} aria-label={`Выбрать достопримечательность ${title}`}
> >
<div className="sight-image">{renderThumbnail()}</div> <div className="sight-image">{renderThumbnail()}</div>
<div className="sight-title" title={title}> <div className="sight-title">
{title} {title}
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useMemo } from "react"; import React, { useState, useEffect, useRef, useMemo, useLayoutEffect, useCallback } from "react";
import axios from "axios"; import axios from "axios";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useGeolocationStore } from "../../stores"; import { useGeolocationStore } from "../../stores";
@@ -46,8 +46,46 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
const idleTimerRef = useRef(null); const idleTimerRef = useRef(null);
const textWrapperRef = useRef(null); const textWrapperRef = useRef(null);
const menuRef = useRef(null);
const [menuNeedsScroll, setMenuNeedsScroll] = useState(false);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
// Автозакрытие fullscreen 3D при бездействии (45 сек) const updateScrollState = useCallback(() => {
const menu = menuRef.current;
if (!menu) return;
setCanScrollLeft(menu.scrollLeft > 2);
setCanScrollRight(menu.scrollLeft + menu.clientWidth < menu.scrollWidth - 2);
}, []);
useEffect(() => {
const menu = menuRef.current;
if (!menu || !menuNeedsScroll) {
setCanScrollLeft(false);
setCanScrollRight(false);
return;
}
updateScrollState();
menu.addEventListener('scroll', updateScrollState);
return () => menu.removeEventListener('scroll', updateScrollState);
}, [menuNeedsScroll, updateScrollState]);
useLayoutEffect(() => {
const menu = menuRef.current;
if (!menu) return;
const children = Array.from(menu.querySelectorAll('.sight-frame-menu-point'));
if (children.length < 2) {
setMenuNeedsScroll(false);
return;
}
const style = getComputedStyle(menu);
const availableWidth = menu.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
const totalChildrenWidth = children.reduce((sum, el) => sum + el.offsetWidth, 0);
const evenGap = (availableWidth - totalChildrenWidth) / (children.length + 1);
setMenuNeedsScroll(evenGap < 10);
}, [articleSections, selectedSection]);
// Автозакрытие fullscreen 3D при бездействии (60 сек)
useEffect(() => { useEffect(() => {
if (!isFullscreen3D) { if (!isFullscreen3D) {
if (idleTimerRef.current) { if (idleTimerRef.current) {
@@ -61,7 +99,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
const checkIdle = () => { const checkIdle = () => {
idleSeconds += 1; idleSeconds += 1;
if (idleSeconds >= 45) { if (idleSeconds >= 60) {
setIsFullscreen3D(false); setIsFullscreen3D(false);
} }
}; };
@@ -361,7 +399,6 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
<button <button
type="button" type="button"
className="three-d-control-btn" className="three-d-control-btn"
title="Уменьшить"
onPointerUp={() => threeViewControlRef.current?.zoomOut?.()} onPointerUp={() => threeViewControlRef.current?.zoomOut?.()}
> >
<MinusIcon /> <MinusIcon />
@@ -369,7 +406,6 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
<button <button
type="button" type="button"
className="three-d-control-btn" className="three-d-control-btn"
title="Увеличить"
onPointerUp={() => threeViewControlRef.current?.zoomIn?.()} onPointerUp={() => threeViewControlRef.current?.zoomIn?.()}
> >
<PlusIcon /> <PlusIcon />
@@ -377,7 +413,6 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
<button <button
type="button" type="button"
className="three-d-control-btn" className="three-d-control-btn"
title={isFullscreen3D ? "Свернуть" : "Развернуть"}
onPointerUp={() => { onPointerUp={() => {
if (isFullscreen3D) { if (isFullscreen3D) {
setIsFullscreen3D(false); setIsFullscreen3D(false);
@@ -392,11 +427,6 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
</div> </div>
<button <button
className={"fullscreen-3d-button"} className={"fullscreen-3d-button"}
title={
isFullscreen3D
? "Закрыть полноэкранный режим"
: "Открыть в полноэкранном режиме"
}
aria-hidden="true" aria-hidden="true"
> >
<svg <svg
@@ -682,8 +712,14 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
</> </>
)} )}
</div> </div>
<div className="sight-frame-menu"> <div className="sight-frame-menu-wrapper">
{selectedSection !== 0 && ( <div className="sight-frame-menu-fade left" style={{ opacity: canScrollLeft ? 1 : 0 }} />
<div className="sight-frame-menu-fade right" style={{ opacity: canScrollRight ? 1 : 0 }} />
<div
className="sight-frame-menu"
ref={menuRef}
style={menuNeedsScroll ? { justifyContent: 'space-between' } : undefined}
>
<div <div
style={{ style={{
position: "absolute", position: "absolute",
@@ -695,6 +731,10 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
paddingTop: "4.5px", paddingTop: "4.5px",
paddingBottom: "4.5px", paddingBottom: "4.5px",
cursor: "pointer", cursor: "pointer",
opacity: selectedSection !== 0 ? 1 : 0,
transform: selectedSection !== 0 ? "scale(1)" : "scale(0.5)",
transition: "opacity 0.3s ease, transform 0.3s ease",
pointerEvents: selectedSection !== 0 ? "auto" : "none",
}} }}
onPointerUp={() => { onPointerUp={() => {
setSelectedSection(0); setSelectedSection(0);
@@ -709,7 +749,6 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
style={{ display: "block" }} style={{ display: "block" }}
/> />
</div> </div>
)}
{contentError ? ( {contentError ? (
<p className="error-message">{contentError}</p> <p className="error-message">{contentError}</p>
) : ( ) : (
@@ -734,6 +773,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
)} )}
</div> </div>
</div> </div>
</div>
); );
}); });

View File

@@ -19,7 +19,11 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
height: 60, height: 60,
top: 0, top: 0,
hasScroll: false, hasScroll: false,
isAtTop: true,
isAtBottom: false,
}); });
const [visible, setVisible] = useState(false);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const rafRef = useRef<number | null>(null); const rafRef = useRef<number | null>(null);
const update = useCallback(() => { const update = useCallback(() => {
@@ -31,8 +35,11 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
const st = el.scrollTop; const st = el.scrollTop;
const th = ch; const th = ch;
const isAtTop = st <= 0;
const isAtBottom = st + ch >= sh - 1;
if (sh <= ch) { if (sh <= ch) {
setState({ height: th, top: 0, hasScroll: false }); setState((prev) => ({ ...prev, hasScroll: false, isAtTop: true, isAtBottom: true }));
return; return;
} }
@@ -41,7 +48,7 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
const scrollRange = sh - ch; const scrollRange = sh - ch;
const top = range <= 0 ? 0 : (st / scrollRange) * range; const top = range <= 0 ? 0 : (st / scrollRange) * range;
setState({ height: thumbHeight, top, hasScroll: true }); setState({ height: thumbHeight, top, hasScroll: true, isAtTop, isAtBottom });
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -68,7 +75,24 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
}; };
}, [update]); }, [update]);
return state; useEffect(() => {
if (state.hasScroll) {
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
setVisible(true);
} else {
hideTimerRef.current = setTimeout(() => {
setVisible(false);
}, 200);
}
return () => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
};
}, [state.hasScroll]);
return { ...state, visible };
} }
export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>( export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
@@ -234,9 +258,12 @@ export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
}; };
}, [thumb.hasScroll]); }, [thumb.hasScroll]);
const containerClassName = className const containerClassName = [
? `scrollable-container ${className}` "scrollable-container",
: "scrollable-container"; className,
thumb.isAtTop ? "is-at-top" : "",
thumb.isAtBottom ? "is-at-bottom" : "",
].filter(Boolean).join(" ");
const viewportStyle: React.CSSProperties = maxHeight const viewportStyle: React.CSSProperties = maxHeight
? { ? {
@@ -251,17 +278,21 @@ export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
<div ref={scrollableRef} className="scrollable"> <div ref={scrollableRef} className="scrollable">
{children} {children}
</div> </div>
{thumb.hasScroll && ( <div
<div ref={trackRef} className="custom-scrollbar-track"> ref={trackRef}
className="custom-scrollbar-track"
style={{ opacity: thumb.hasScroll ? 1 : 0 }}
>
{thumb.visible && (
<div <div
ref={thumbRef} ref={thumbRef}
className="custom-scrollbar-thumb" className="custom-scrollbar-thumb"
style={{ height: thumb.height, top: thumb.top }} style={{ height: thumb.height, top: thumb.top }}
/> />
</div>
)} )}
</div> </div>
</div> </div>
</div>
); );
}, },
); );

View File

@@ -17,7 +17,6 @@ import {
UNPASSED_STATION_COLOR, UNPASSED_STATION_COLOR,
BUS_COLOR, BUS_COLOR,
BASE_ICON_SIZE, BASE_ICON_SIZE,
CLUSTER_RADIUS_BASE,
} from "./Constants"; } from "./Constants";
import { SCALE_FACTOR } from "../../assets/Constants"; import { SCALE_FACTOR } from "../../assets/Constants";
import { apiStore } from "../../api/ApiStore/store"; import { apiStore } from "../../api/ApiStore/store";
@@ -156,6 +155,19 @@ const useSightClustering = (
continue; continue;
} }
const hasCustomIcon =
sight.is_default_icon === false && !isMediaIdEmpty(sight.icon ?? null);
if (hasCustomIcon) {
sight.visited = true;
clusteredResult.push({
type: "point",
id: String(sight.id),
data: sight,
});
continue;
}
const clusterSights: SightData[] = []; const clusterSights: SightData[] = [];
const queue = [sight]; const queue = [sight];
sight.visited = true; sight.visited = true;
@@ -165,8 +177,12 @@ const useSightClustering = (
clusterSights.push(current); clusterSights.push(current);
for (const potentialNeighbor of unclusteredSights) { for (const potentialNeighbor of unclusteredSights) {
const neighborHasCustomIcon =
potentialNeighbor.is_default_icon === false &&
!isMediaIdEmpty(potentialNeighbor.icon ?? null);
if ( if (
!potentialNeighbor.visited && !potentialNeighbor.visited &&
!neighborHasCustomIcon &&
clusterSights.length < 4 && clusterSights.length < 4 &&
getDistance(current, potentialNeighbor) < distanceThreshold getDistance(current, potentialNeighbor) < distanceThreshold
) { ) {
@@ -176,6 +192,10 @@ const useSightClustering = (
} }
} }
for (const leftover of queue) {
leftover.visited = false;
}
if (clusterSights.length > 1) { if (clusterSights.length > 1) {
let furthestSight: SightData | null = null; let furthestSight: SightData | null = null;
let maxDistanceToPath = -1; let maxDistanceToPath = -1;
@@ -382,13 +402,15 @@ export const WebGLMap = observer(() => {
return livePercent; return livePercent;
} }
if (sight?.is_default_icon === false) {
if ( if (
sight != null &&
typeof sight.icon_size === "number" && typeof sight.icon_size === "number" &&
Number.isFinite(sight.icon_size) Number.isFinite(sight.icon_size)
) { ) {
return sight.icon_size; return sight.icon_size;
} }
return 100;
}
if ( if (
typeof routeData?.icon_size === "number" && typeof routeData?.icon_size === "number" &&
@@ -2551,14 +2573,8 @@ export const WebGLMap = observer(() => {
const rx = cluster.longitude; const rx = cluster.longitude;
const ry = cluster.latitude; const ry = cluster.latitude;
const iconSizePercent = resolveSightIconSizePercent();
const iconSize =
SIGHT_ICON_BASE_SIZE * clamp(iconSizePercent / 100, 0.1, 10);
const screenX = (rx * scale + position.x) / dpr; const screenX = (rx * scale + position.x) / dpr;
const screenY = (ry * scale + position.y) / dpr; const screenY = (ry * scale + position.y) / dpr;
const iconLeft = screenX - iconSize / 2;
const iconTop = screenY - iconSize / 2;
const isExpanded = activeClusterId === cluster.id; const isExpanded = activeClusterId === cluster.id;
const selectedSightInCluster = cluster.sights.find( const selectedSightInCluster = cluster.sights.find(
@@ -2570,6 +2586,20 @@ export const WebGLMap = observer(() => {
const selectedIsCustomInCluster = const selectedIsCustomInCluster =
selectedSightInCluster?.is_default_icon === false && selectedSightInCluster?.is_default_icon === false &&
selectedHasIconInCluster; selectedHasIconInCluster;
const iconSizePercent = resolveSightIconSizePercent(
cluster.sights[0],
);
const clusterCustomScaleFactor = selectedIsCustomInCluster
? scale / Math.max(customSightIconBaseScaleRef.current ?? 1, 1e-6)
: 1;
const iconSize =
SIGHT_ICON_BASE_SIZE *
clamp(iconSizePercent / 100, 0.1, 10) *
clusterCustomScaleFactor;
const iconLeft = screenX - iconSize;
const iconTop = screenY - iconSize;
const hasSelectedAltIconInCluster = const hasSelectedAltIconInCluster =
selectedSightInCluster != null && selectedSightInCluster != null &&
selectedIsCustomInCluster && selectedIsCustomInCluster &&
@@ -2643,13 +2673,10 @@ export const WebGLMap = observer(() => {
onTouchEnd={handleClusterClick} onTouchEnd={handleClusterClick}
style={{ style={{
position: "absolute", position: "absolute",
left: iconLeft - CLUSTER_RADIUS_BASE - 10, left: iconLeft,
top: iconTop - CLUSTER_RADIUS_BASE - 10, top: iconTop,
width: iconSize + CLUSTER_RADIUS_BASE * 2 + 20, width: iconSize,
height: iconSize + CLUSTER_RADIUS_BASE * 2 + 20, height: iconSize,
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "auto", pointerEvents: "auto",
cursor: "pointer", cursor: "pointer",
userSelect: "none", userSelect: "none",
@@ -2662,15 +2689,22 @@ export const WebGLMap = observer(() => {
}), }),
}} }}
> >
<div style={{ position: "relative" }}> <div
style={{
position: "absolute",
width: iconSize,
height: iconSize,
flexShrink: 0,
}}
>
<img <img
src={clusterIconUrl} src={clusterIconUrl}
alt="" alt=""
width={iconSize}
height={iconSize}
style={{ style={{
display: "block", display: "block",
pointerEvents: "none", pointerEvents: "none",
width: "100%",
height: "100%",
filter: hasSelectedSight filter: hasSelectedSight
? hasSelectedAltIconInCluster ? hasSelectedAltIconInCluster
? "none" ? "none"
@@ -2683,6 +2717,7 @@ export const WebGLMap = observer(() => {
position: "absolute", position: "absolute",
top: -6, top: -6,
right: -6, right: -6,
zIndex: 1,
width: 15, width: 15,
height: 15, height: 15,
borderRadius: "10px", borderRadius: "10px",
@@ -2708,8 +2743,8 @@ export const WebGLMap = observer(() => {
onMouseMove={handleCircleInteraction} onMouseMove={handleCircleInteraction}
style={{ style={{
position: "absolute", position: "absolute",
left: screenX - iconSize / 2, left: screenX - iconSize,
top: screenY - iconSize / 2, top: screenY - iconSize,
display: "flex", display: "flex",
alignItems: "flex-start", alignItems: "flex-start",
pointerEvents: "auto", pointerEvents: "auto",
@@ -2920,7 +2955,6 @@ export const WebGLMap = observer(() => {
whiteSpace: "nowrap", whiteSpace: "nowrap",
flex: 1, flex: 1,
}} }}
title={sightName}
> >
{sightName} {sightName}
</span> </span>

View File

@@ -5,6 +5,8 @@ import { useGeolocationStore } from "../../stores";
import "../../styles/LeftWidget.css"; import "../../styles/LeftWidget.css";
import { apiStore } from "../../api/ApiStore/store"; import { apiStore } from "../../api/ApiStore/store";
import { apiBaseURL } from "../../api/apiConfig"; import { apiBaseURL } from "../../api/apiConfig";
import { ReactMarkdownComponent } from "../ReactMarkdown";
import { TouchableLayout } from "../TouchableLayout";
const LeftWidget = observer( const LeftWidget = observer(
({ selectedSightId, onClose, isVisible, sightTop }) => { ({ selectedSightId, onClose, isVisible, sightTop }) => {
@@ -15,8 +17,7 @@ const LeftWidget = observer(
const [isImageLoaded, setIsImageLoaded] = useState(false); const [isImageLoaded, setIsImageLoaded] = useState(false);
const [widgetHeight, setWidgetHeight] = useState(0); const [widgetHeight, setWidgetHeight] = useState(0);
const textRef = useRef(null); const layoutRef = useRef(null);
const activeTouch = useRef(null);
const widgetRef = useRef(null); const widgetRef = useRef(null);
const store = useGeolocationStore(); const store = useGeolocationStore();
@@ -37,64 +38,10 @@ const LeftWidget = observer(
}, [selectedSightData, isImageLoaded, isVisible, isLoading, error]); }, [selectedSightData, isImageLoaded, isVisible, isLoading, error]);
useEffect(() => { useEffect(() => {
const scrollContainer = textRef.current; if (layoutRef.current) {
if (!scrollContainer) return; const scrollable = layoutRef.current.querySelector(".scrollable");
if (scrollable) scrollable.scrollTop = 0;
const handleTouchStart = (e) => {
e.stopPropagation();
if (e.touches.length === 1) {
activeTouch.current = {
identifier: e.touches[0].identifier,
lastY: e.touches[0].clientY,
};
} }
};
const handleTouchMove = (e) => {
e.preventDefault();
if (activeTouch.current) {
for (const touch of e.changedTouches) {
if (touch.identifier === activeTouch.current.identifier) {
const deltaY = touch.clientY - activeTouch.current.lastY;
scrollContainer.scrollTop -= deltaY;
activeTouch.current.lastY = touch.clientY;
break;
}
}
}
};
const handleTouchEnd = (e) => {
for (const touch of e.changedTouches) {
if (
activeTouch.current &&
touch.identifier === activeTouch.current.identifier
) {
activeTouch.current = null;
break;
}
}
};
scrollContainer.addEventListener("touchstart", handleTouchStart, {
passive: true,
});
scrollContainer.addEventListener("touchmove", handleTouchMove, {
passive: false,
});
scrollContainer.addEventListener("touchend", handleTouchEnd, {
passive: true,
});
scrollContainer.addEventListener("touchcancel", handleTouchEnd, {
passive: true,
});
return () => {
scrollContainer.removeEventListener("touchstart", handleTouchStart);
scrollContainer.removeEventListener("touchmove", handleTouchMove);
scrollContainer.removeEventListener("touchend", handleTouchEnd);
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
};
}, [selectedSightData]); }, [selectedSightData]);
useEffect(() => { useEffect(() => {
@@ -123,6 +70,8 @@ const LeftWidget = observer(
? routeSightsEn.find((sight) => sight.id === selectedSightId) ? routeSightsEn.find((sight) => sight.id === selectedSightId)
: routeSightsZh.find((sight) => sight.id === selectedSightId); : routeSightsZh.find((sight) => sight.id === selectedSightId);
if (!sight) return;
const leftArticle = sight.left_article; const leftArticle = sight.left_article;
const leftArticleData = const leftArticleData =
@@ -132,6 +81,8 @@ const LeftWidget = observer(
? sightArticlesEn.get(leftArticle + "_" + selectedLanguage) ? sightArticlesEn.get(leftArticle + "_" + selectedLanguage)
: sightArticlesZh.get(leftArticle + "_" + selectedLanguage); : sightArticlesZh.get(leftArticle + "_" + selectedLanguage);
if (!leftArticleData?.media?.length) return;
const media = await ContentAPI.getMediaPreview( const media = await ContentAPI.getMediaPreview(
leftArticleData.media[0].id, leftArticleData.media[0].id,
selectedLanguage, selectedLanguage,
@@ -208,7 +159,7 @@ const LeftWidget = observer(
}; };
return ( return (
<div ref={widgetRef} style={widgetTransformStyle} className="left-widget"> <div ref={widgetRef} style={widgetTransformStyle} className="left-widget" lang={selectedLanguage}>
{isLoading ? ( {isLoading ? (
<div>Загрузка информации...</div> <div>Загрузка информации...</div>
) : error ? ( ) : error ? (
@@ -234,9 +185,11 @@ const LeftWidget = observer(
<div className="left-widget-address"> <div className="left-widget-address">
{selectedSightData.address} {selectedSightData.address}
</div> </div>
<div ref={textRef} className="left-widget-text"> <TouchableLayout ref={layoutRef} className="left-widget-text-scroll">
{selectedSightData.text} <div className="left-widget-text">
<ReactMarkdownComponent value={selectedSightData.text} />
</div> </div>
</TouchableLayout>
</div> </div>
</> </>
) : (isVisible || selectedSightData) && !isLoading ? ( ) : (isVisible || selectedSightData) && !isLoading ? (

View File

@@ -2,7 +2,6 @@ import "../../styles/SideMenu.css";
import AppealWidget from "../widgets/AppealWidget"; import AppealWidget from "../widgets/AppealWidget";
import { useEffect, useState, useCallback, useRef } from "react"; import { useEffect, useState, useCallback, useRef } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import gouvermentImage from "../../assets/images/test-image.png";
import sideMenuPhoto from "/side-menu-photo.png"; import sideMenuPhoto from "/side-menu-photo.png";
import RouteWidget from "../widgets/RouteWidget"; import RouteWidget from "../widgets/RouteWidget";
import ContentAPI from "../../api/content/content.api"; import ContentAPI from "../../api/content/content.api";
@@ -264,15 +263,20 @@ const SideMenu = observer(({ onMenuToggle }) => {
} }
}; };
const isMenuOpenRef = useRef(isMenuOpen);
const handleMenuToggleRef = useRef(handleMenuToggle);
useEffect(() => { isMenuOpenRef.current = isMenuOpen; }, [isMenuOpen]);
useEffect(() => { handleMenuToggleRef.current = handleMenuToggle; });
useEffect(() => { useEffect(() => {
// Автоматическое закрытие сайд-меню после 45 секунд бездействия // Автоматическое закрытие сайд-меню после 60 секунд бездействия
let idleSeconds = 0; let idleSeconds = 0;
const checkIdle = () => { const checkIdle = () => {
idleSeconds += 1; idleSeconds += 1;
if (idleSeconds >= 45 && isMenuOpen) { if (idleSeconds >= 60 && isMenuOpenRef.current) {
handleMenuToggle(false); handleMenuToggleRef.current(false);
} }
}; };
@@ -301,7 +305,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
window.removeEventListener(event, resetIdle); window.removeEventListener(event, resetIdle);
}); });
}; };
}, [isMenuOpen, handleMenuToggle]); }, []);
// Закрываем и открываем список достопримечательностей при изменении сортировки // Закрываем и открываем список достопримечательностей при изменении сортировки
const prevSortingByRef = useRef(sortingBy); const prevSortingByRef = useRef(sortingBy);
@@ -443,14 +447,16 @@ const SideMenu = observer(({ onMenuToggle }) => {
}} }}
className="appeal-button" className="appeal-button"
> >
{selectedLanguage == "ru" {route?.button_text
? route.button_text
: selectedLanguage == "ru"
? "Обращение губернатора" ? "Обращение губернатора"
: selectedLanguage == "zh" : selectedLanguage == "zh"
? "州长致辞" ? "州长致辞"
: "Governor's appeal"} : "Governor's appeal"}
</div> </div>
)} )}
<div className="side-menu-buttons"> <div className="side-menu-buttons" style={{ marginTop: route?.governor_appeal > 0 ? '40px' : '260px' }}>
<div <div
onPointerUp={() => { onPointerUp={() => {
if (!isSightsOpen) { if (!isSightsOpen) {
@@ -578,11 +584,17 @@ const SideMenu = observer(({ onMenuToggle }) => {
<RouteWidget /> <RouteWidget />
<AppealWidget <AppealWidget
widgetImgPath={gouvermentImage} widgetImgPath={(() => {
const m = sightArticles.get(route?.governor_appeal + "_ru")?.media;
const mediaId = Array.isArray(m) ? m[0]?.id : m?.id;
return mediaId ? getMediaUrl(mediaId) : undefined;
})()}
isOpen={isWidgetOpen}
style={{ style={{
transform: isWidgetOpen ? "translateX(0)" : "translateX(-200%)", transform: isWidgetOpen ? "translateX(0)" : "translateX(-200%)",
transition: "transform 0.5s ease", transition: "transform 0.5s ease",
zIndex: -1, zIndex: -1,
pointerEvents: isWidgetOpen ? "auto" : "none",
}} }}
widgetLabel={ widgetLabel={
selectedLanguage == "ru" selectedLanguage == "ru"

View File

@@ -64,7 +64,6 @@ const StationItem = ({
className="side-menu-sight" className="side-menu-sight"
onPointerDown={(e) => handlePointerDown(e, station.id)} onPointerDown={(e) => handlePointerDown(e, station.id)}
onPointerUp={(e) => handlePointerUp(e, station.id, handleStationClick)} onPointerUp={(e) => handlePointerUp(e, station.id, handleStationClick)}
title={station.name}
> >
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}> <span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
{station.name} {station.name}

View File

@@ -84,6 +84,12 @@ const SightItem = ({
return () => window.removeEventListener("resize", checkWidth); return () => window.removeEventListener("resize", checkWidth);
}, [sightName]); }, [sightName]);
useEffect(() => {
if (localSelectedSightId !== sight.id) {
setIsExpanded(false);
}
}, [localSelectedSightId, sight.id]);
const handleClick = (e) => { const handleClick = (e) => {
const newExpanded = !isExpanded; const newExpanded = !isExpanded;
setIsExpanded(newExpanded); setIsExpanded(newExpanded);
@@ -96,16 +102,19 @@ const SightItem = ({
const stations = sightStationsCache.get(cacheKey) || []; const stations = sightStationsCache.get(cacheKey) || [];
return ( return (
<div> <div
className={
localSelectedSightId === sight.id
? "side-menu-sight-selected-wrapper"
: ""
}
>
<div <div
ref={containerRef} ref={containerRef}
id={`sight-${sight.id}`} id={`sight-${sight.id}`}
onPointerDown={(e) => handlePointerDown(e, sight.id)} onPointerDown={(e) => handlePointerDown(e, sight.id)}
onPointerUp={(e) => handlePointerUp(e, sight.id, handleClick)} onPointerUp={(e) => handlePointerUp(e, sight.id, handleClick)}
className={`side-menu-sight pointer ${ className="side-menu-sight pointer"
localSelectedSightId === sight.id ? "selected" : ""
}`}
title={sightName}
> >
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}> <span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
{sightName} {sightName}

View File

@@ -70,7 +70,6 @@ const SightItem = ({
className={`side-menu-sight pointer ${ className={`side-menu-sight pointer ${
localSelectedSightId === sight.id ? "selected" : "" localSelectedSightId === sight.id ? "selected" : ""
}`} }`}
title={sightName}
> >
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}> <span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
{sightName} {sightName}

View File

@@ -111,7 +111,13 @@ const StationItem = ({
}; };
return ( return (
<div> <div
className={
selectedStationId === station.id
? "side-menu-sight-selected-wrapper"
: ""
}
>
<div <div
ref={containerRef} ref={containerRef}
className="side-menu-sight" className="side-menu-sight"
@@ -124,7 +130,6 @@ const StationItem = ({
); );
} }
}} }}
title={station.name}
> >
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}> <span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
{station.name} {station.name}

View File

@@ -1,13 +1,50 @@
import '../../styles/AppealWidget.css' import { useRef, useEffect } from "react";
import "../../styles/AppealWidget.css";
import { TouchableLayout } from "../TouchableLayout";
import { ReactMarkdownComponent } from "../ReactMarkdown";
function AppealWidget({
widgetImgPath,
widgetLabel,
widgetText,
style,
isOpen,
}) {
const stopProp = (e) => {
e.stopPropagation();
e.preventDefault();
};
const layoutRef = useRef(null);
useEffect(() => {
if (isOpen && layoutRef.current) {
const scrollable = layoutRef.current.querySelector(".scrollable");
if (scrollable) scrollable.scrollTop = 0;
}
}, [isOpen]);
function AppealWidget({widgetImgPath, widgetLabel, widgetText, style}) {
return ( return (
<div style={style} className='dynamic-widget'> <div
<img className='dynamic-widget-image' src={widgetImgPath} /> style={style}
<div className='dynamic-widget-label'>{widgetLabel}</div> className="dynamic-widget"
<div className='dynamic-widget-text'>{widgetText}</div> onPointerDown={stopProp}
onPointerMove={stopProp}
onPointerUp={stopProp}
>
{widgetImgPath && (
<img className="dynamic-widget-image" src={widgetImgPath} />
)}
<div className="dynamic-widget-label">{widgetLabel}</div>
<TouchableLayout
ref={layoutRef}
className="dynamic-widget-text-scroll"
>
<div className="dynamic-widget-text">
<ReactMarkdownComponent value={widgetText} />
</div>
</TouchableLayout>
</div> </div>
); );
} }
export default AppealWidget export default AppealWidget;

View File

@@ -92,7 +92,6 @@ const RouteWidget = observer(() => {
className={`route-widget-label ${ className={`route-widget-label ${
shouldAnimate(startStation?.name, 18) ? "marquee" : "" shouldAnimate(startStation?.name, 18) ? "marquee" : ""
} ${getLabelSizeClass(startStation?.name)}`} } ${getLabelSizeClass(startStation?.name)}`}
title={startStation?.name}
> >
{startStation?.name} {startStation?.name}
</div> </div>
@@ -100,7 +99,6 @@ const RouteWidget = observer(() => {
className={`route-widget-label ${ className={`route-widget-label ${
shouldAnimate(endStation?.name, 18) ? "marquee" : "" shouldAnimate(endStation?.name, 18) ? "marquee" : ""
} ${getLabelSizeClass(endStation?.name)}`} } ${getLabelSizeClass(endStation?.name)}`}
title={endStation?.name}
> >
{endStation?.name} {endStation?.name}
</div> </div>
@@ -109,7 +107,6 @@ const RouteWidget = observer(() => {
className={`route-widget-subtitle ${ className={`route-widget-subtitle ${
shouldAnimate(routeEnSubtitle, 50) ? "marquee" : "" shouldAnimate(routeEnSubtitle, 50) ? "marquee" : ""
}`} }`}
title={routeEnSubtitle}
> >
{routeEnSubtitle} {routeEnSubtitle}
</div> </div>
@@ -119,7 +116,6 @@ const RouteWidget = observer(() => {
className={`route-widget-subtitle ${ className={`route-widget-subtitle ${
shouldAnimate(routeZhSubtitle, 50) ? "marquee" : "" shouldAnimate(routeZhSubtitle, 50) ? "marquee" : ""
}`} }`}
title={routeZhSubtitle}
> >
{routeZhSubtitle} {routeZhSubtitle}
</div> </div>

View File

@@ -1,6 +1,6 @@
import { Canvas, useThree } from "@react-three/fiber"; import { Canvas, useThree, useFrame } from "@react-three/fiber";
import { OrbitControls, Stage, useGLTF } from "@react-three/drei"; import { OrbitControls, Center, useGLTF } from "@react-three/drei";
import React, { useEffect, Suspense } from "react"; import React, { useEffect, useRef, Suspense, useCallback } from "react";
import { BACKGROUND_COLOR } from "../../assets/Constants"; import { BACKGROUND_COLOR } from "../../assets/Constants";
import * as THREE from "three"; import * as THREE from "three";
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib"; import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
@@ -23,6 +23,7 @@ interface ThreeViewProps {
const ZOOM_FACTOR = 1.2; const ZOOM_FACTOR = 1.2;
const MIN_DISTANCE = 1; const MIN_DISTANCE = 1;
const MAX_DISTANCE = 100; const MAX_DISTANCE = 100;
const CAMERA_FOV = 40;
const TouchController = () => { const TouchController = () => {
const { camera, controls, gl } = useThree(); const { camera, controls, gl } = useThree();
@@ -197,6 +198,47 @@ const AutoResize = () => {
return null; return null;
}; };
const FitCamera = ({
groupRef,
onReady,
}: {
groupRef: React.RefObject<THREE.Group>;
onReady: () => void;
}) => {
const { camera, controls } = useThree();
const fitted = useRef(false);
useFrame(() => {
if (fitted.current) return;
const group = groupRef.current;
if (!group || group.children.length === 0) return;
const box = new THREE.Box3().setFromObject(group);
const sphere = new THREE.Sphere();
box.getBoundingSphere(sphere);
if (sphere.radius === 0) return;
const fov = THREE.MathUtils.degToRad(CAMERA_FOV);
const dist = sphere.radius / Math.sin(fov / 2);
camera.position.set(0, 0, dist);
camera.lookAt(0, 0, 0);
camera.updateProjectionMatrix();
if (controls) {
const orbit = controls as unknown as OrbitControlsImpl;
orbit.target.set(0, 0, 0);
orbit.update();
}
fitted.current = true;
onReady();
});
return null;
};
const Model = ({ const Model = ({
fileUrl, fileUrl,
onLoad, onLoad,
@@ -233,21 +275,37 @@ export const ThreeView: React.FC<ThreeViewProps> = ({
onError, onError,
controlRef, controlRef,
}) => { }) => {
const [isReady, setIsReady] = React.useState(false);
const groupRef = useRef<THREE.Group>(null!);
const handleReady = useCallback(() => {
setIsReady(true);
onLoad?.();
}, [onLoad]);
return ( return (
<div style={{ width, height, position: "relative", overflow: "hidden" }}> <div style={{ width, height, position: "relative", overflow: "hidden" }}>
{!isReady && (
<div style={{
position: "absolute", inset: 0,
backgroundColor: `#${BACKGROUND_COLOR.toString(16).padStart(6, "0")}`,
zIndex: 1,
}} />
)}
<Canvas <Canvas
gl={{ gl={{
antialias: true, antialias: true,
toneMappingExposure: 1.5, toneMappingExposure: 1.5,
outputColorSpace: THREE.SRGBColorSpace, outputColorSpace: THREE.SRGBColorSpace,
}} }}
camera={{ position: [0, 0, 5], fov: 40 }} camera={{ position: [0, 0, 50], fov: CAMERA_FOV }}
style={{ width: "100%", height: "100%" }} style={{ width: "100%", height: "100%" }}
onError={(e: any) => onError?.(e.message)} onError={(e: any) => onError?.(e.message)}
> >
<AutoResize /> <AutoResize />
<TouchController /> <TouchController />
{controlRef && <ZoomController controlRef={controlRef} />} {controlRef && <ZoomController controlRef={controlRef} />}
<FitCamera groupRef={groupRef} onReady={handleReady} />
<color attach="background" args={[BACKGROUND_COLOR]} /> <color attach="background" args={[BACKGROUND_COLOR]} />
<ambientLight intensity={0.8} /> <ambientLight intensity={0.8} />
<directionalLight position={[30, 30, 30]} intensity={1.2} /> <directionalLight position={[30, 30, 30]} intensity={1.2} />
@@ -265,23 +323,18 @@ export const ThreeView: React.FC<ThreeViewProps> = ({
<pointLight position={[0, 30, 0]} intensity={0.6} /> <pointLight position={[0, 30, 0]} intensity={0.6} />
<Suspense fallback={null}> <Suspense fallback={null}>
<Stage <Center precise>
environment={null} <group ref={groupRef}>
intensity={1} <Model fileUrl={fileUrl} />
castShadow={false} </group>
shadows={false} </Center>
adjustCamera={true}
center={{ precise: true }}
>
<Model fileUrl={fileUrl} onLoad={onLoad} />
</Stage>
</Suspense> </Suspense>
<OrbitControls <OrbitControls
makeDefault makeDefault
enableZoom={true} enableZoom={true}
enablePan={true} enablePan={true}
target={[50, 50, 50]} target={[0, 0, 0]}
minDistance={1} minDistance={1}
maxDistance={100} maxDistance={100}
enableDamping={true} enableDamping={true}

View File

@@ -14,9 +14,9 @@
.side-menu-sights-block { .side-menu-sights-block {
height: calc(60%); height: calc(60%);
overflow-y: scroll; overflow-y: scroll;
margin-left: 20px; margin-left: 0;
margin-top: 8px; margin-top: 8px;
margin-right: 5px; padding-right: 5px;
touch-action: none; /* Отключаем стандартные действия */ touch-action: none; /* Отключаем стандартные действия */
overscroll-behavior: contain; /* Предотвращаем прокрутку родительских элементов */ overscroll-behavior: contain; /* Предотвращаем прокрутку родительских элементов */
} }

View File

@@ -5,37 +5,124 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
width: 420px; width: 420px;
max-height: calc(100vh - 150px - 98px);
border-radius: 10px; border-radius: 10px;
background: linear-gradient( background:
linear-gradient(
114deg, 114deg,
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%
), ),
var(--carrier-main, #006F3A); var(--carrier-left, #006f3a);
box-sizing: border-box; box-sizing: border-box;
overflow: hidden;
touch-action: none;
} }
.dynamic-widget-image { .dynamic-widget-image {
border-radius-top-left: 10px; padding-top: 2px;
border-radius-top-right: 10px; margin-left: 2px;
padding-top: 4px; margin-right: 2px;
margin-left: 4px; width: 416px;
margin-right: 4px; border-radius: 10px 10px 0 0;
width: 412px; object-fit: cover;
} }
.dynamic-widget-label { .dynamic-widget-label {
width: 380px; width: 100%;
margin-top: 29px; padding: 10px 20px;
box-sizing: border-box;
font-size: 20px; font-size: 20px;
font-weight: 500; font-weight: 500;
color: #fff;
border-bottom: 1px solid var(--Glass-stroke, rgba(255, 255, 255, 0.8));
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0) 100%
),
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.4);
} }
.dynamic-widget-text-scroll.scrollable-container {
flex: 1;
min-height: 0;
align-self: stretch;
margin: 15px 20px;
overflow: hidden;
}
.dynamic-widget-text-scroll .scrollable-viewport {
flex: 1;
min-height: 0;
overflow: hidden;
}
.dynamic-widget-text-scroll .scrollable {
flex: 1;
min-height: 0;
}
.dynamic-widget-text { .dynamic-widget-text {
margin-top: 16px;
margin-bottom: 25px;
width: 380px;
font-size: 16px; font-size: 16px;
font-weight: 300; font-weight: 300;
line-height: 150%; line-height: 135%;
padding-right: 5px;
}
.dynamic-widget-text .react-markdown-container {
font-size: 16px;
line-height: 135%;
font-weight: 300;
}
.dynamic-widget-text .react-markdown-container p {
font-size: 16px;
line-height: 135%;
margin-bottom: 8px;
}
.dynamic-widget-text .react-markdown-container p:last-child {
margin-bottom: 0;
}
.dynamic-widget-text .react-markdown-container h1,
.dynamic-widget-text .react-markdown-container h2,
.dynamic-widget-text .react-markdown-container h3,
.dynamic-widget-text .react-markdown-container h4,
.dynamic-widget-text .react-markdown-container h5,
.dynamic-widget-text .react-markdown-container h6 {
font-size: 18px;
margin-top: 10px;
margin-bottom: 4px;
font-weight: 600;
}
.dynamic-widget-text .react-markdown-container ul,
.dynamic-widget-text .react-markdown-container ol {
margin-bottom: 8px;
padding-left: 20px;
}
.dynamic-widget-text .react-markdown-container li {
margin-bottom: 4px;
}
.dynamic-widget-text .react-markdown-container blockquote {
margin-top: 8px;
margin-bottom: 8px;
padding-left: 12px;
border-left: 3px solid rgba(255, 255, 255, 0.4);
}
.dynamic-widget-text .react-markdown-container img {
max-width: 100%;
border-radius: 6px;
}
.dynamic-widget-text .react-markdown-container a {
color: rgba(255, 255, 255, 0.9);
text-decoration: underline;
} }

View File

@@ -67,17 +67,78 @@
line-height: 150%; line-height: 150%;
} }
.left-widget-text { .left-widget-text-scroll.scrollable-container {
margin-top: 15px; margin-top: 15px;
overflow: hidden;
width: 100%;
}
.left-widget-text-scroll .scrollable-viewport {
max-height: 200px;
}
.left-widget-text {
color: #fff; color: #fff;
font-family: "Roboto"; font-family: "Roboto";
font-size: 16px; font-size: 16px;
font-weight: 300; font-weight: 300;
line-height: 135%; line-height: 135%;
max-height: 200px; /* Пример ограничения высоты */ padding-right: 3px;
overflow-y: auto; }
touch-action: none;
overscroll-behavior: contain; .left-widget-text .react-markdown-container {
font-size: 16px;
line-height: 135%;
font-weight: 300;
}
.left-widget-text .react-markdown-container p {
font-size: 16px;
line-height: 135%;
margin-bottom: 8px;
}
.left-widget-text .react-markdown-container p:last-child {
margin-bottom: 0;
}
.left-widget-text .react-markdown-container h1,
.left-widget-text .react-markdown-container h2,
.left-widget-text .react-markdown-container h3,
.left-widget-text .react-markdown-container h4,
.left-widget-text .react-markdown-container h5,
.left-widget-text .react-markdown-container h6 {
font-size: 18px;
margin-top: 10px;
margin-bottom: 4px;
font-weight: 600;
}
.left-widget-text .react-markdown-container ul,
.left-widget-text .react-markdown-container ol {
margin-bottom: 8px;
padding-left: 20px;
}
.left-widget-text .react-markdown-container li {
margin-bottom: 4px;
}
.left-widget-text .react-markdown-container blockquote {
margin-top: 8px;
margin-bottom: 8px;
padding-left: 12px;
border-left: 3px solid rgba(255, 255, 255, 0.4);
}
.left-widget-text .react-markdown-container img {
max-width: 100%;
border-radius: 6px;
}
.left-widget-text .react-markdown-container a {
color: rgba(255, 255, 255, 0.9);
text-decoration: underline;
} }
.left-widget-image { .left-widget-image {

View File

@@ -1,3 +1,45 @@
@property --fade-top {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --fade-bottom {
syntax: "<length>";
inherits: false;
initial-value: 45px;
}
@keyframes pulse-chevron {
0% {
transform: rotate(var(--r, 0deg)) translateY(0px) scale(1);
}
40% {
transform: rotate(var(--r, 0deg)) translateY(-4px) scale(1.12);
}
60% {
transform: rotate(var(--r, 0deg)) translateY(-5px) scale(1.14);
}
100% {
transform: rotate(var(--r, 0deg)) translateY(0px) scale(1);
}
}
.chevron-svg {
font-size: 20px;
animation: pulse-chevron 1.2s ease-in-out infinite;
animation-play-state: paused;
will-change: transform;
}
.chevron-svg.is-idle {
animation-play-state: running;
}
.chevron-svg.is-open {
--r: 180deg;
}
.right-widget { .right-widget {
position: fixed; position: fixed;
right: 32px; right: 32px;
@@ -17,7 +59,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%
), ),
var(--carrier-right, #006f3a); var(--carrier-right, #806c59);
color: white; color: white;
max-height: 68px; max-height: 68px;
@@ -65,7 +107,7 @@
background-color: color-mix( background-color: color-mix(
in srgb, in srgb,
var(--carrier-right, #006f3a) 80%, var(--carrier-right, #806c59) 80%,
black black
); );
} }
@@ -94,6 +136,27 @@
backface-visibility: hidden; backface-visibility: hidden;
} }
.list-of-sights-content .scrollable {
--fade-top: 0px;
--fade-bottom: 45px;
mask-image: linear-gradient(
to bottom,
transparent 0px,
black var(--fade-top),
black calc(100% - var(--fade-bottom)),
transparent 100%
);
transition: --fade-top 0.5s ease, --fade-bottom 0.5s ease;
}
.list-of-sights-content:not(.is-at-top) .scrollable {
--fade-top: 15px;
}
.list-of-sights-content.is-at-bottom .scrollable {
--fade-bottom: 0px;
}
.list-of-sights-grid { .list-of-sights-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
@@ -107,6 +170,11 @@
pointer-events: auto; pointer-events: auto;
} }
.list-of-sights-content .custom-scrollbar-track {
margin-bottom: 10px;
overflow: hidden;
}
.sight-component { .sight-component {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -198,7 +266,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%
), ),
var(--carrier-right, #006f3a); var(--carrier-right, #806c59);
max-height: calc(100vh - 128px); max-height: calc(100vh - 128px);
} }
@@ -241,7 +309,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(var(--carrier-right-rgb, 0, 111, 58), 0.72); rgba(var(--carrier-right-rgb, 128, 108, 89), 0.72);
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset; box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
box-sizing: border-box; box-sizing: border-box;
color: white; color: white;
@@ -250,6 +318,16 @@
min-width: 0; min-width: 0;
} }
.sight-frame-title:not(.intro-title) {
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0) 100%
),
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.4);
}
.sight-frame-title p { .sight-frame-title p {
word-wrap: break-word; word-wrap: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
@@ -308,7 +386,7 @@
background: linear-gradient( background: linear-gradient(
to right, to right,
transparent 35%, transparent 35%,
color-mix(in srgb, var(--carrier-right, #006f3a) 80%, black) 50%, color-mix(in srgb, var(--carrier-right, #806c59) 80%, black) 50%,
transparent 65% transparent 65%
); );
border-radius: 3px; border-radius: 3px;
@@ -330,13 +408,51 @@
margin-bottom: 0; margin-bottom: 0;
} }
.sight-frame-menu { .sight-frame-menu-wrapper {
position: relative; position: relative;
padding: 7px;
width: 100%; width: 100%;
flex-shrink: 0;
}
.sight-frame-menu-fade {
position: absolute;
top: 0;
bottom: 0;
width: 120px;
z-index: 3;
pointer-events: none;
transition: opacity 0.4s ease;
}
.sight-frame-menu-fade.left {
left: 0;
background: linear-gradient(
to right,
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.95),
transparent
);
border-radius: 0 0 0 10px;
}
.sight-frame-menu-fade.right {
right: 0;
background: linear-gradient(
to left,
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.95),
transparent
);
border-radius: 0 0 10px 0;
}
.sight-frame-menu {
z-index: 10000;
position: relative;
padding: 7px 60px;
width: 100%;
height: 60px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-around; justify-content: space-evenly;
border-radius: 0px 0px 10px 10px; border-radius: 0px 0px 10px 10px;
border-top: 1px solid rgba(255, 255, 255, 0.8); border-top: 1px solid rgba(255, 255, 255, 0.8);
background: background:
@@ -345,11 +461,22 @@
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(var(--carrier-right-rgb, 0, 111, 58), 0.4); rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.4);
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset; 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;
flex-shrink: 0; flex-shrink: 0;
overflow-x: auto;
overflow-y: hidden;
}
.sight-frame-menu::-webkit-scrollbar {
display: none;
}
.sight-frame-menu {
-ms-overflow-style: none;
scrollbar-width: none;
} }
.sight-frame-menu-point { .sight-frame-menu-point {
@@ -360,16 +487,17 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
padding: 8px 12px; padding: 8px 12px;
white-space: nowrap; white-space: nowrap;
flex-shrink: 0;
border-bottom: 2px solid transparent;
transition: transition:
background-color 0.1s ease, background-color 0.1s ease,
color 0.1s ease; color 0.1s ease;
} }
.sight-frame-menu-point.active { .sight-frame-menu-point.active {
border-bottom: 2px solid #fff;
font-weight: 600; font-weight: 600;
border-bottom-color: #fff;
} }
.sight-frame-text-wrapper::-webkit-scrollbar-track { .sight-frame-text-wrapper::-webkit-scrollbar-track {
@@ -523,7 +651,8 @@
} }
.alphabet { .alphabet {
width: 100px; width: 40px;
flex-shrink: 0;
margin-right: 10px; margin-right: 10px;
padding-top: 24px; padding-top: 24px;
display: flex; display: flex;
@@ -583,8 +712,9 @@
} }
.alphabet-position { .alphabet-position {
display: inline-flex; display: flex;
justify-content: space-between; justify-content: space-between;
width: 100%;
} }
.transfer-button-container { .transfer-button-container {
@@ -751,7 +881,7 @@
border-radius: 32px; border-radius: 32px;
right: 20px; right: 20px;
bottom: 20px; bottom: 20px;
background: var(--carrier-right, #006f3a); background: var(--carrier-right, #806c59);
z-index: 9999; z-index: 9999;
display: flex; display: flex;
} }

View File

@@ -26,15 +26,17 @@
position: fixed; position: fixed;
display: inline-flex; display: inline-flex;
border-radius: 10px; border-radius: 10px;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3) inset, box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.3) inset,
/* Внутренняя рамка */ 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset; /* Ваш существующий внутренний shadow */ /* Внутренняя рамка */ 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset; /* Ваш существующий внутренний shadow */
padding: 1px; /* Чтобы контент не прилипал к рамке */ padding: 1px; /* Чтобы контент не прилипал к рамке */
background: linear-gradient( background:
linear-gradient(
to bottom right, to bottom right,
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(var(--carrier-main-rgb, 0, 111, 58), 0.4); rgba(179, 165, 152, 0.4);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
pointer-events: auto; pointer-events: auto;
z-index: 10000001; z-index: 10000001;
@@ -50,7 +52,8 @@
height: 96px; height: 96px;
background-color: #fcd500; background-color: #fcd500;
color: black; color: black;
border-radius: 10px; border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;

View File

@@ -35,11 +35,13 @@
font-size: 16px; font-size: 16px;
margin-top: 120px; margin-top: 120px;
font-weight: 500; font-weight: 500;
width: 220px;
text-align: center;
} }
.side-menu-buttons { .side-menu-buttons {
width: 220px; width: 220px;
margin-top: 260px; margin-top: 40px;
} }
.side-menu-button { .side-menu-button {
@@ -227,12 +229,10 @@
.side-menu-sights-block { .side-menu-sights-block {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
margin-left: 20px;
margin-top: 8px; margin-top: 8px;
touch-action: none; touch-action: none;
overscroll-behavior: contain; overscroll-behavior: contain;
width: auto; width: 100%;
max-width: calc(100% - 20px);
box-sizing: border-box; box-sizing: border-box;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
@@ -240,7 +240,6 @@
.side-menu-sight { .side-menu-sight {
padding-bottom: 2px; padding-bottom: 2px;
margin-right: 20px;
margin-bottom: 6px; margin-bottom: 6px;
margin-top: 6px; margin-top: 6px;
border-bottom: 1px solid border-bottom: 1px solid
@@ -254,6 +253,12 @@
position: relative; position: relative;
} }
.side-menu-sight-selected-wrapper {
background: rgba(0, 0, 0, 0.2);
margin-left: -20px;
padding-left: 20px;
}
.side-menu-sight > span { .side-menu-sight > span {
display: inline-block; display: inline-block;
white-space: nowrap; white-space: nowrap;

View File

@@ -43,6 +43,7 @@
position: relative; position: relative;
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
border-radius: 3px; border-radius: 3px;
transition: opacity 0.2s ease;
} }
.custom-scrollbar-thumb { .custom-scrollbar-thumb {
@@ -62,11 +63,12 @@
} }
.side-menu-sights-block .scrollable-viewport { .side-menu-sights-block .scrollable-viewport {
height: calc(92%); height: calc(98%);
} }
.side-menu-sights-block .scrollable { .side-menu-sights-block .scrollable {
height: 100%; height: 100%;
padding-left: 20px;
} }
.list-of-sights-content .scrollable-viewport { .list-of-sights-content .scrollable-viewport {

View File

@@ -1,12 +1,17 @@
import React from "react"; import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { LanguageSwitcher } from "@widgets"; import { LanguageSwitcher } from "@widgets";
import { articlesStore } from "@shared"; import { articlesStore, selectedCityStore } from "@shared";
const ArticleCreatePage: React.FC = () => { const ArticleCreatePage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const { articleData } = articlesStore; const { articleData } = articlesStore;
return ( return (

View File

@@ -2,7 +2,7 @@ import React, { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { LanguageSwitcher } from "@widgets"; import { LanguageSwitcher } from "@widgets";
import { articlesStore, languageStore } from "@shared"; import { articlesStore, languageStore, selectedCityStore } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
const ArticleEditPage: React.FC = observer(() => { const ArticleEditPage: React.FC = observer(() => {
@@ -11,6 +11,11 @@ const ArticleEditPage: React.FC = observer(() => {
const { articleData, getArticle } = articlesStore; const { articleData, getArticle } = articlesStore;
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
useEffect(() => { useEffect(() => {
// Устанавливаем русский язык при загрузке страницы // Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");

View File

@@ -31,7 +31,7 @@ export const ArticleListPage = observer(() => {
setIsLoading(false); setIsLoading(false);
}; };
fetchArticles(); fetchArticles();
}, [language]); }, [language, selectedCityStore.cityVersion]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
@@ -55,11 +55,12 @@ export const ArticleListPage = observer(() => {
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/article/${params.row.id}`)}> <button title="Просмотр" onClick={() => navigate(`/article/${params.row.id}`)}>
<Eye size={20} className="text-green-500" /> <Eye size={20} className="text-green-500" />
</button> </button>
{canWriteArticles && ( {canWriteArticles && (
<button <button
title="Удалить"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -107,7 +108,9 @@ export const ArticleListPage = observer(() => {
</div> </div>
)} )}
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} /> <SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<div className="w-full"> <div className="w-full">
<DataGrid <DataGrid

View File

@@ -20,6 +20,7 @@ import {
languageStore, languageStore,
isMediaIdEmpty, isMediaIdEmpty,
useSelectedCity, useSelectedCity,
selectedCityStore,
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
@@ -93,6 +94,11 @@ export const CarrierCreatePage = observer(() => {
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null); >(null);
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
useEffect(() => { useEffect(() => {
const fetchCities = async () => { const fetchCities = async () => {
if (!authStore.me) { if (!authStore.me) {
@@ -288,7 +294,7 @@ export const CarrierCreatePage = observer(() => {
} }
/> />
<p className="text-xs text-gray-500 pl-1"> <p className="text-xs text-gray-500 pl-1">
Используется в: виджет маршрута, виджет обращений, значки на карте, скопление достопримечательностей на карте, информационный виджет Используется в: виджет обращений, значки на карте, скопление достопримечательностей на карте, информационный виджет
</p> </p>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">

View File

@@ -21,6 +21,7 @@ import {
languageStore, languageStore,
isMediaIdEmpty, isMediaIdEmpty,
LoadingSpinner, LoadingSpinner,
selectedCityStore,
} from "@shared"; } from "@shared";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets"; import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets";
@@ -30,7 +31,11 @@ import {
UploadMediaDialog, UploadMediaDialog,
} from "@shared"; } from "@shared";
type ColorFields = { main_color: string; left_color: string; right_color: string }; type ColorFields = {
main_color: string;
left_color: string;
right_color: string;
};
const colorFields = (data: ColorFields) => ({ const colorFields = (data: ColorFields) => ({
main_color: data.main_color, main_color: data.main_color,
@@ -99,6 +104,11 @@ export const CarrierEditPage = observer(() => {
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null); >(null);
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (!id) { if (!id) {
@@ -130,7 +140,7 @@ export const CarrierEditPage = observer(() => {
carrierData.ru?.slogan || "", carrierData.ru?.slogan || "",
carrierData.ru?.logo || "", carrierData.ru?.logo || "",
"ru", "ru",
colors colors,
); );
setEditCarrierData( setEditCarrierData(
carrierData.en?.full_name || "", carrierData.en?.full_name || "",
@@ -138,7 +148,7 @@ export const CarrierEditPage = observer(() => {
carrierData.en?.city_id || 0, carrierData.en?.city_id || 0,
carrierData.en?.slogan || "", carrierData.en?.slogan || "",
carrierData.en?.logo || "", carrierData.en?.logo || "",
"en" "en",
); );
setEditCarrierData( setEditCarrierData(
carrierData.zh?.full_name || "", carrierData.zh?.full_name || "",
@@ -146,7 +156,7 @@ export const CarrierEditPage = observer(() => {
carrierData.zh?.city_id || 0, carrierData.zh?.city_id || 0,
carrierData.zh?.slogan || "", carrierData.zh?.slogan || "",
carrierData.zh?.logo || "", carrierData.zh?.logo || "",
"zh" "zh",
); );
setInitialCityName(carrierData.ru?.city || ""); setInitialCityName(carrierData.ru?.city || "");
} }
@@ -185,7 +195,7 @@ export const CarrierEditPage = observer(() => {
editCarrierData.city_id, editCarrierData.city_id,
editCarrierData[language].slogan, editCarrierData[language].slogan,
media.id, media.id,
language language,
); );
}; };
@@ -267,7 +277,7 @@ export const CarrierEditPage = observer(() => {
Number(e.target.value), Number(e.target.value),
editCarrierData[language].slogan, editCarrierData[language].slogan,
editCarrierData.logo, editCarrierData.logo,
language language,
) )
} }
> >
@@ -291,7 +301,7 @@ export const CarrierEditPage = observer(() => {
editCarrierData.city_id, editCarrierData.city_id,
editCarrierData[language].slogan, editCarrierData[language].slogan,
editCarrierData.logo, editCarrierData.logo,
language language,
) )
} }
/> />
@@ -308,7 +318,7 @@ export const CarrierEditPage = observer(() => {
editCarrierData.city_id, editCarrierData.city_id,
editCarrierData[language].slogan, editCarrierData[language].slogan,
editCarrierData.logo, editCarrierData.logo,
language language,
) )
} }
/> />
@@ -324,7 +334,7 @@ export const CarrierEditPage = observer(() => {
editCarrierData.city_id, editCarrierData.city_id,
e.target.value, e.target.value,
editCarrierData.logo, editCarrierData.logo,
language language,
) )
} }
/> />
@@ -342,12 +352,13 @@ export const CarrierEditPage = observer(() => {
editCarrierData[language].slogan, editCarrierData[language].slogan,
editCarrierData.logo, editCarrierData.logo,
language, language,
{ ...colorFields(editCarrierData), main_color: val } { ...colorFields(editCarrierData), main_color: val },
) )
} }
/> />
<p className="text-xs text-gray-500 pl-1"> <p className="text-xs text-gray-500 pl-1">
Используется в: виджет маршрута, виджет обращений, значки на карте, скопление достопримечательностей на карте, информационный виджет Используется в: значки на карте, скопление достопримечательностей
на карте, информационный виджет
</p> </p>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -362,12 +373,13 @@ export const CarrierEditPage = observer(() => {
editCarrierData[language].slogan, editCarrierData[language].slogan,
editCarrierData.logo, editCarrierData.logo,
language, language,
{ ...colorFields(editCarrierData), left_color: val } { ...colorFields(editCarrierData), left_color: val },
) )
} }
/> />
<p className="text-xs text-gray-500 pl-1"> <p className="text-xs text-gray-500 pl-1">
Используется в: боковое меню, левый виджет достопримечательности Используется в: виджет обращений, боковое меню (фон, список
остановок), левый виджет достопримечательности
</p> </p>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -382,12 +394,13 @@ export const CarrierEditPage = observer(() => {
editCarrierData[language].slogan, editCarrierData[language].slogan,
editCarrierData.logo, editCarrierData.logo,
language, language,
{ ...colorFields(editCarrierData), right_color: val } { ...colorFields(editCarrierData), right_color: val },
) )
} }
/> />
<p className="text-xs text-gray-500 pl-1"> <p className="text-xs text-gray-500 pl-1">
Используется в: список достопримечательностей, страница достопримечательности Используется в: список достопримечательностей (фон, карточки),
правый виджет достопримечательности
</p> </p>
</div> </div>
</div> </div>
@@ -465,7 +478,7 @@ export const CarrierEditPage = observer(() => {
editCarrierData.city_id, editCarrierData.city_id,
editCarrierData[language].slogan, editCarrierData[language].slogan,
"", "",
language language,
); );
setIsDeleteLogoModalOpen(false); setIsDeleteLogoModalOpen(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, carrierStore, cityStore, languageStore, SearchInput } from "@shared"; import { authStore, carrierStore, cityStore, 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 { Pencil, Trash2, Minus } from "lucide-react"; import { Pencil, Trash2, Minus } from "lucide-react";
@@ -39,7 +39,7 @@ export const CarrierListPage = observer(() => {
setIsLoading(false); setIsLoading(false);
}; };
fetchData(); fetchData();
}, [language]); }, [language, selectedCityStore.cityVersion]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
@@ -98,10 +98,11 @@ export const CarrierListPage = observer(() => {
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/carrier/${params.row.id}/edit`)}> <button title="Редактировать" onClick={() => navigate(`/carrier/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" /> <Pencil size={20} className="text-blue-500" />
</button> </button>
<button <button
title="Удалить"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -161,7 +162,9 @@ export const CarrierListPage = observer(() => {
</div> </div>
)} )}
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} /> <SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<DataGrid <DataGrid
rows={rows} rows={rows}
@@ -169,6 +172,11 @@ export const CarrierListPage = observer(() => {
checkboxSelection={canWriteCarriers} checkboxSelection={canWriteCarriers}
disableRowSelectionExcludeModel disableRowSelectionExcludeModel
disableRowSelectionOnClick disableRowSelectionOnClick
onRowDoubleClick={(params) => {
if (canWriteCarriers) {
navigate(`/carrier/${params.id}/edit`);
}
}}
loading={isLoading} loading={isLoading}
paginationModel={paginationModel} paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel} onPaginationModelChange={setPaginationModel}

View File

@@ -17,6 +17,7 @@ import {
countryStore, countryStore,
languageStore, languageStore,
mediaStore, mediaStore,
snapshotStore,
isMediaIdEmpty, isMediaIdEmpty,
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
@@ -60,7 +61,13 @@ export const CityCreatePage = observer(() => {
const handleCreate = async () => { const handleCreate = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const ruCityName = createCityData.ru.name.trim();
await cityStore.createCity(); await cityStore.createCity();
try {
await snapshotStore.createEmptySnapshot(`${ruCityName}устой_Экспорт`);
} catch (e) {
console.warn("Failed to create empty snapshot for city:", e);
}
toast.success("Город успешно создан"); toast.success("Город успешно создан");
navigate("/city"); navigate("/city");
} catch (error) { } catch (error) {

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, selectedCityStore, SearchInput } from "@shared"; import { authStore, languageStore, cityStore, countryStore, 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,21 +59,16 @@ 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();
const result = rows.filter((row) => row.id === selectedCityId); if (!query) return rows;
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, selectedCityStore.selectedCityId]); }, [rows, searchQuery, countryStore.countries, language]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
@@ -119,10 +114,11 @@ export const CityListPage = observer(() => {
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/city/${params.row.id}/edit`)}> <button title="Редактировать" onClick={() => navigate(`/city/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" /> <Pencil size={20} className="text-blue-500" />
</button> </button>
<button <button
title="Удалить"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
@@ -147,7 +143,6 @@ export const CityListPage = observer(() => {
<CreateButton <CreateButton
label="Создать город" label="Создать город"
path="/city/create" path="/city/create"
disabled={!selectedCityStore.selectedCityId}
/> />
)} )}
</div> </div>
@@ -164,7 +159,9 @@ export const CityListPage = observer(() => {
</div> </div>
)} )}
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} /> <SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<DataGrid <DataGrid
rows={filteredRows} rows={filteredRows}
@@ -172,6 +169,11 @@ export const CityListPage = observer(() => {
checkboxSelection={canWriteCities} checkboxSelection={canWriteCities}
disableRowSelectionExcludeModel disableRowSelectionExcludeModel
disableRowSelectionOnClick disableRowSelectionOnClick
onRowDoubleClick={(params) => {
if (canWriteCities) {
navigate(`/city/${params.id}/edit`);
}
}}
loading={isLoading} loading={isLoading}
paginationModel={paginationModel} paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel} onPaginationModelChange={setPaginationModel}
@@ -206,8 +208,6 @@ export const CityListPage = 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} />
) : !selectedCityStore.selectedCityId ? (
"Выберите город"
) : ( ) : (
"Нет городов" "Нет городов"
)} )}

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, selectedCityStore, SearchInput } from "@shared"; import { authStore, countryStore, languageStore, 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,20 +74,15 @@ 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, selectedCityStore.selectedCity]); }, [countries[language]?.data, searchQuery]);
return ( return (
<> <>
@@ -100,7 +95,6 @@ export const CountryListPage = observer(() => {
<CreateButton <CreateButton
label="Добавить страну" label="Добавить страну"
path="/country/add" path="/country/add"
disabled={!selectedCityStore.selectedCityId}
/> />
)} )}
</div> </div>
@@ -117,7 +111,9 @@ export const CountryListPage = observer(() => {
</div> </div>
)} )}
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} /> <SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<DataGrid <DataGrid
rows={rows} rows={rows}
@@ -159,8 +155,6 @@ export const CountryListPage = 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} />
) : !selectedCityStore.selectedCityId ? (
"Выберите город"
) : ( ) : (
"Нет стран" "Нет стран"
)} )}

View File

@@ -15,6 +15,7 @@ import {
} from "@widgets"; } from "@widgets";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { runInAction } from "mobx";
function a11yProps(index: number) { function a11yProps(index: number) {
return { return {
@@ -62,6 +63,14 @@ export const CreateSightPage = observer(() => {
await authStore.fetchMeCities().catch(() => undefined); await authStore.fetchMeCities().catch(() => undefined);
} }
await getArticles(languageStore.language); await getArticles(languageStore.language);
const { selectedCityId, selectedCity } = selectedCityStore;
if (selectedCityId && selectedCity && !createSightStore.sight.city_id) {
runInAction(() => {
createSightStore.sight.city_id = selectedCityId;
createSightStore.sight.city = selectedCity.name;
});
}
}; };
fetchData(); fetchData();
}, []); }, []);

View File

@@ -10,7 +10,7 @@ import {
import { mediaStore, MEDIA_TYPE_LABELS, selectedCityStore } from "@shared"; import { mediaStore, MEDIA_TYPE_LABELS, selectedCityStore } from "@shared";
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 { useState } from "react"; import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@@ -20,6 +20,11 @@ export const MediaCreatePage = observer(() => {
const [type, setType] = useState(""); const [type, setType] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const handleCreate = async () => { const handleCreate = async () => {
try { try {
setIsLoading(true); setIsLoading(true);

View File

@@ -22,6 +22,7 @@ import {
MEDIA_TYPE_LABELS, MEDIA_TYPE_LABELS,
languageStore, languageStore,
LoadingSpinner, LoadingSpinner,
selectedCityStore,
} from "@shared"; } from "@shared";
import { MediaViewer } from "@widgets"; import { MediaViewer } from "@widgets";
@@ -42,6 +43,11 @@ export const MediaEditPage = observer(() => {
const [mediaType, setMediaType] = useState(media?.media_type ?? 1); const [mediaType, setMediaType] = useState(media?.media_type ?? 1);
const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>([]); const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>([]);
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
useEffect(() => { useEffect(() => {
if (id) { if (id) {
mediaStore.getOneMedia(id); mediaStore.getOneMedia(id);

View File

@@ -78,11 +78,12 @@ export const MediaListPage = observer(() => {
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/media/${params.row.id}`)}> <button title="Просмотр" onClick={() => navigate(`/media/${params.row.id}`)}>
<Eye size={20} className="text-green-500" /> <Eye size={20} className="text-green-500" />
</button> </button>
{canWriteMedia && ( {canWriteMedia && (
<button <button
title="Удалить"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -112,7 +113,9 @@ export const MediaListPage = observer(() => {
return ( return (
<> <>
<div className="w-full"> <div className="w-full">
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} /> <SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
{canWriteMedia && ids.length > 0 && ( {canWriteMedia && ids.length > 0 && (
<div className="flex justify-end mb-5 duration-300"> <div className="flex justify-end mb-5 duration-300">

View File

@@ -50,6 +50,7 @@ export const RouteCreatePage = observer(() => {
const [routeCoords, setRouteCoords] = useState(""); const [routeCoords, setRouteCoords] = useState("");
const [govRouteNumber, setGovRouteNumber] = useState(""); const [govRouteNumber, setGovRouteNumber] = useState("");
const [governorAppeal, setGovernorAppeal] = useState<string>(""); const [governorAppeal, setGovernorAppeal] = useState<string>("");
const [buttonText, setButtonText] = useState("");
const [direction, setDirection] = useState("backward"); const [direction, setDirection] = useState("backward");
const [scaleMin, setScaleMin] = useState("10"); const [scaleMin, setScaleMin] = useState("10");
const [scaleMax, setScaleMax] = useState("100"); const [scaleMax, setScaleMax] = useState("100");
@@ -292,6 +293,10 @@ export const RouteCreatePage = observer(() => {
newRoute.governor_appeal = governor_appeal; newRoute.governor_appeal = governor_appeal;
} }
if (buttonText.trim()) {
newRoute.button_text = buttonText.trim();
}
const newId = await routeStore.createRoute(newRoute); const newId = await routeStore.createRoute(newRoute);
toast.success("Маршрут успешно создан"); toast.success("Маршрут успешно создан");
navigate(`/route/${newId}/edit`); navigate(`/route/${newId}/edit`);
@@ -407,6 +412,18 @@ export const RouteCreatePage = observer(() => {
onChange={(e) => setGovRouteNumber(e.target.value)} onChange={(e) => setGovRouteNumber(e.target.value)}
/> />
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Текст кнопки обращения
</Typography>
<TextField
value={buttonText}
onChange={(e) => setButtonText(e.target.value)}
placeholder="Обращение губернатора"
fullWidth
size="small"
helperText="Если пусто, будет использован текст по умолчанию"
/>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}> <Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Обращение к пассажирам Обращение к пассажирам
</Typography> </Typography>

View File

@@ -566,6 +566,22 @@ export const RouteEditPage = observer(() => {
}} }}
/> />
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Текст кнопки обращения
</Typography>
<TextField
value={editRouteData.button_text || ""}
onChange={(e) =>
routeStore.setEditRouteData({
button_text: e.target.value,
})
}
placeholder="Обращение губернатора"
fullWidth
size="small"
helperText="Если пусто, будет использован текст по умолчанию"
/>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}> <Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Обращение к пассажирам Обращение к пассажирам
</Typography> </Typography>

View File

@@ -43,7 +43,7 @@ export const RouteListPage = observer(() => {
loadCounts(routeIds); loadCounts(routeIds);
}; };
fetchData(); fetchData();
}, [language]); }, [language, selectedCityStore.cityVersion]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
@@ -178,22 +178,23 @@ export const RouteListPage = observer(() => {
return ( return (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
{canWriteRoutes && ( {canWriteRoutes && (
<button onClick={() => navigate(`/route/${params.row.id}/edit`)}> <button title="Редактировать" onClick={() => navigate(`/route/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" /> <Pencil size={20} className="text-blue-500" />
</button> </button>
)} )}
{canShowRoutePreview && ( {canShowRoutePreview && (
<button onClick={() => navigate(`/route-preview/${params.row.id}`)}> <button title="Предпросмотр на карте" onClick={() => navigate(`/route-preview/${params.row.id}`)}>
<Map size={20} className="text-purple-500" /> <Map size={20} className="text-purple-500" />
</button> </button>
)} )}
{canShowRoutePreview && ( {canShowRoutePreview && (
<button onClick={() => window.open(`/demo/${params.row.id}`, "_blank")}> <button title="Демо" onClick={() => window.open(`/demo/${params.row.id}`, "_blank")}>
<Monitor size={20} className="text-green-500" /> <Monitor size={20} className="text-green-500" />
</button> </button>
)} )}
{canWriteRoutes && ( {canWriteRoutes && (
<button <button
title="Удалить"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -253,7 +254,6 @@ export const RouteListPage = observer(() => {
<CreateButton <CreateButton
label="Создать маршрут" label="Создать маршрут"
path="/route/create" path="/route/create"
disabled={!selectedCityStore.selectedCityId}
/> />
)} )}
</div> </div>
@@ -270,7 +270,9 @@ export const RouteListPage = observer(() => {
</div> </div>
)} )}
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} /> <SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<DataGrid <DataGrid
rows={rows} rows={rows}

View File

@@ -24,7 +24,9 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
useEffect(() => { useEffect(() => {
async function fetchCarrierData() { async function fetchCarrierData() {
if (routeData?.carrier_id) { if (routeData?.carrier_id) {
const carrier = (await authInstance.get(`/carrier/${routeData.carrier_id}`)).data; const carrier = (
await authInstance.get(`/carrier/${routeData.carrier_id}`)
).data;
setCarrierLogo(carrier.logo); setCarrierLogo(carrier.logo);
setCarrierSlogan(carrier.slogan ?? null); setCarrierSlogan(carrier.slogan ?? null);
setCarrierShortName(carrier.short_name ?? null); setCarrierShortName(carrier.short_name ?? null);
@@ -45,12 +47,20 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
<div <div
style={{ style={{
position: "relative", position: "relative",
width: 288,
height: "100%", height: "100%",
color: "#fff", color: "#fff",
}} }}
> >
{/* Кнопка назад — вне основного меню */} {/* Кнопка назад — вне основного меню */}
<div style={{ padding: "12px 12px 0" }}> <div
style={{
padding: "12px 12px 0",
opacity: open ? 1 : 0,
pointerEvents: open ? "auto" : "none",
transition: "opacity 0.25s ease",
}}
>
<Button <Button
onClick={handleBack} onClick={handleBack}
variant="contained" variant="contained"
@@ -122,7 +132,12 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
)} )}
{/* Кнопки — .side-menu-buttons */} {/* Кнопки — .side-menu-buttons */}
<div style={{ width: 220, marginTop: 260 }}> <div
style={{
width: 220,
marginTop: routeData?.governor_appeal || 0 > 0 ? 40 : 260,
}}
>
<div <div
style={{ style={{
backgroundColor: "#fff", backgroundColor: "#fff",
@@ -165,7 +180,11 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
{carrierLogo && !isMediaIdEmpty(carrierLogo) && ( {carrierLogo && !isMediaIdEmpty(carrierLogo) && (
<div style={{ width: 170 }}> <div style={{ width: 170 }}>
<MediaViewer <MediaViewer
media={{ id: carrierLogo, media_type: 1, filename: "carrier_logo" }} media={{
id: carrierLogo,
media_type: 1,
filename: "carrier_logo",
}}
fullWidth fullWidth
/> />
</div> </div>
@@ -190,12 +209,23 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
<img <img
src="/side-menu-photo.png" src="/side-menu-photo.png"
alt="" alt=""
style={{ width: "100%", marginTop: 32, display: "block", pointerEvents: "none" }} style={{
width: "288px",
marginTop: 32,
display: "block",
pointerEvents: "none",
}}
/> />
</div> </div>
</div> </div>
<div className="absolute bottom-[20px] -right-[520px] z-10"> <div
className="absolute bottom-[20px] z-10"
style={{
right: open ? -520 : -312,
transition: "right 0.3s ease",
}}
>
<LanguageSelector onBack={onToggle} isSidebarOpen={open} /> <LanguageSelector onBack={onToggle} isSidebarOpen={open} />
</div> </div>
</div> </div>

View File

@@ -448,6 +448,22 @@ const StationLabel = observer(
anchor={dynamicAnchor} anchor={dynamicAnchor}
zIndex={isHovered || isControlHovered ? 1000 : 0} zIndex={isHovered || isControlHovered ? 1000 : 0}
> >
{ruLabelWidth > 0 && (
<pixiGraphics
draw={(g: Graphics) => {
g.clear();
const hasSecondLabel = !!(station.name && language !== "ru" && ruLabel);
const pad = 10 / scale;
const w = ruLabelWidth + pad * 2;
const top = -compensatedRuFontSize / 2 - pad;
const bottom = hasSecondLabel
? compensatedRuFontSize * 1.1 + compensatedNameFontSize / 2 + pad
: compensatedRuFontSize / 2 + pad;
g.rect(-w / 2, top, w, bottom - top);
g.fill({ color: 0x000000, alpha: 0.001 });
}}
/>
)}
{ruLabel && ( {ruLabel && (
<pixiText <pixiText
ref={ruLabelRef} ref={ruLabelRef}

View File

@@ -54,7 +54,8 @@ export const RoutePreview = () => {
<Box <Box
sx={{ sx={{
position: "relative", position: "relative",
width: isLeftSidebarOpen ? 300 : 0, zIndex: 20,
width: isLeftSidebarOpen ? 288 : 0,
transition: "width 0.3s ease", transition: "width 0.3s ease",
overflow: "visible", overflow: "visible",
height: "100%", height: "100%",
@@ -145,14 +146,14 @@ export const RouteMap = observer(() => {
) { ) {
const coordinates = coordinatesToLocal( const coordinates = coordinatesToLocal(
originalRouteData?.center_latitude, originalRouteData?.center_latitude,
originalRouteData?.center_longitude originalRouteData?.center_longitude,
); );
setTransform( setTransform(
coordinates.x, coordinates.x,
coordinates.y, coordinates.y,
originalRouteData?.rotate, originalRouteData?.rotate,
originalRouteData?.scale_min originalRouteData?.scale_min,
); );
setIsSetup(true); setIsSetup(true);
} }

View File

@@ -6,6 +6,7 @@ export interface RouteData {
icon_size?: number; icon_size?: number;
font_size: number; font_size: number;
governor_appeal: number; governor_appeal: number;
button_text?: string;
id: number; id: number;
path: [number, number][]; path: [number, number][];
rotate: number; rotate: number;

View File

@@ -26,15 +26,17 @@
position: fixed; position: fixed;
display: inline-flex; display: inline-flex;
border-radius: 10px; border-radius: 10px;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3) inset, box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.3) inset,
/* Внутренняя рамка */ 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset; /* Ваш существующий внутренний shadow */ /* Внутренняя рамка */ 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset; /* Ваш существующий внутренний shadow */
padding: 1px; /* Чтобы контент не прилипал к рамке */ padding: 1px; /* Чтобы контент не прилипал к рамке */
background: linear-gradient( background:
linear-gradient(
to bottom right, to bottom right,
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(var(--carrier-main-rgb, 0, 111, 58), 0.4); rgba(179, 165, 152, 0.4);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
pointer-events: auto; pointer-events: auto;
z-index: 10000001; z-index: 10000001;
@@ -50,7 +52,8 @@
height: 96px; height: 96px;
background-color: #fcd500; background-color: #fcd500;
color: black; color: black;
border-radius: 10px; border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;

View File

@@ -2327,9 +2327,6 @@ export const WebGLRouteMapPrototype = observer(() => {
const stationScreenY = const stationScreenY =
rotatedY * camera.scale + camera.translation.y; rotatedY * camera.scale + camera.translation.y;
const labelX = stationScreenX + offsetX;
const labelY = stationScreenY + offsetY;
const backendAlign = station.align; const backendAlign = station.align;
const anchor = getAnchorFromOffset(backendAlign ?? 2); const anchor = getAnchorFromOffset(backendAlign ?? 2);
@@ -2339,8 +2336,6 @@ export const WebGLRouteMapPrototype = observer(() => {
const dpr = Math.max(1, window.devicePixelRatio || 1); const dpr = Math.max(1, window.devicePixelRatio || 1);
const cssX = labelX / dpr;
const cssY = labelY / dpr;
const rotationCss = `${rotationAngle}rad`; const rotationCss = `${rotationAngle}rad`;
const counterRotationCss = `${-rotationAngle}rad`; const counterRotationCss = `${-rotationAngle}rad`;
@@ -2359,6 +2354,13 @@ export const WebGLRouteMapPrototype = observer(() => {
const scaleFactor = 1 + (zoomClampedScale - 1) * 0.4; const scaleFactor = 1 + (zoomClampedScale - 1) * 0.4;
const primaryFontSize = 16 * fontScale * scaleFactor; const primaryFontSize = 16 * fontScale * scaleFactor;
const mainLabelHeight = primaryFontSize * 1.2;
const labelX = stationScreenX + offsetX;
const labelY = stationScreenY + offsetY + mainLabelHeight / 2;
const cssX = labelX / dpr;
const cssY = labelY / dpr;
const secondaryFontSize = 13 * fontScale * scaleFactor; const secondaryFontSize = 13 * fontScale * scaleFactor;
const secondaryMarginTop = 5 * fontScale * scaleFactor; const secondaryMarginTop = 5 * fontScale * scaleFactor;
@@ -2404,13 +2406,7 @@ export const WebGLRouteMapPrototype = observer(() => {
hoveredStationIconId === station.id || hoveredStationIconId === station.id ||
resizingStationIconId === station.id; resizingStationIconId === station.id;
const secondaryLineHeight = 1.2; const secondaryLineHeight = 1.2 * scaleFactor;
const secondaryHeight = showSecondary
? secondaryFontSize * secondaryLineHeight
: 0;
const menuPaddingTop = showSecondary
? Math.max(0, secondaryHeight - secondaryMarginTop) + 3
: 3;
return ( return (
<div key={station.id}> <div key={station.id}>
@@ -2440,7 +2436,7 @@ export const WebGLRouteMapPrototype = observer(() => {
color: "#fff", color: "#fff",
fontFamily: "Roboto, sans-serif", fontFamily: "Roboto, sans-serif",
textAlign: "left", textAlign: "left",
pointerEvents: "auto", pointerEvents: "none",
cursor: "grab", cursor: "grab",
userSelect: "none", userSelect: "none",
touchAction: "none", touchAction: "none",
@@ -2448,14 +2444,16 @@ export const WebGLRouteMapPrototype = observer(() => {
> >
<div <div
style={{ style={{
pointerEvents: "auto", display: "inline-block",
pointerEvents: "none",
transformOrigin: "left center", transformOrigin: "left center",
transform: `rotate(${rotationCss})`, transform: `rotate(${rotationCss})`,
}} }}
> >
<div <div
style={{ style={{
pointerEvents: "auto", display: "inline-block",
pointerEvents: "none",
transformOrigin: "left center", transformOrigin: "left center",
transform: `rotate(${counterRotationCss})`, transform: `rotate(${counterRotationCss})`,
}} }}
@@ -2549,6 +2547,7 @@ export const WebGLRouteMapPrototype = observer(() => {
) : null} ) : null}
<div <div
style={{ style={{
position: "relative",
fontWeight: 700, fontWeight: 700,
fontSize: primaryFontSize, fontSize: primaryFontSize,
textShadow: "0 0 4px rgba(0,0,0,0.6)", textShadow: "0 0 4px rgba(0,0,0,0.6)",
@@ -2557,7 +2556,6 @@ export const WebGLRouteMapPrototype = observer(() => {
}} }}
> >
{station.name} {station.name}
</div>
{showSecondary ? ( {showSecondary ? (
<div <div
style={{ style={{
@@ -2580,6 +2578,7 @@ export const WebGLRouteMapPrototype = observer(() => {
</div> </div>
</div> </div>
</div> </div>
</div>
{hoveredStationId === station.id && ( {hoveredStationId === station.id && (
<div <div
style={{ style={{
@@ -2587,9 +2586,9 @@ export const WebGLRouteMapPrototype = observer(() => {
top: "100%", top: "100%",
left: "50%", left: "50%",
transform: "translateX(-50%)", transform: "translateX(-50%)",
paddingTop: menuPaddingTop, paddingTop: "8px",
pointerEvents: "auto", pointerEvents: "auto",
zIndex: 10, zIndex: 1000000,
cursor: "default", cursor: "default",
}} }}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}

View File

@@ -46,7 +46,7 @@ export const SightListPage = observer(() => {
setIsLoading(false); setIsLoading(false);
}; };
fetchSights(); fetchSights();
}, [language]); }, [language, selectedCityStore.cityVersion]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
@@ -105,10 +105,11 @@ export const SightListPage = observer(() => {
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/sight/${params.row.id}/edit`)}> <button title="Редактировать" onClick={() => navigate(`/sight/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" /> <Pencil size={20} className="text-blue-500" />
</button> </button>
<button <button
title="Удалить"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -164,7 +165,6 @@ export const SightListPage = observer(() => {
<CreateButton <CreateButton
label="Создать достопримечательность" label="Создать достопримечательность"
path="/sight/create" path="/sight/create"
disabled={!selectedCityStore.selectedCityId}
/> />
)} )}
</div> </div>
@@ -181,7 +181,9 @@ export const SightListPage = observer(() => {
</div> </div>
)} )}
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} /> <SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<DataGrid <DataGrid
rows={rows} rows={rows}

View File

@@ -1,46 +1,58 @@
import { Button, TextField } from "@mui/material"; import {
import { snapshotStore } from "@shared"; Button,
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { snapshotStore, authStore, routeStore, selectedCityStore, cityStore, carrierStore } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react"; import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useState } from "react"; import { useState, useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { runInAction } from "mobx"; import { runInAction } from "mobx";
function escapeRegex(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function buildExportNameRegex(cityNames: string[]): RegExp {
if (!cityNames.length) return /.+/;
const pattern = cityNames.map(escapeRegex).join("|");
return new RegExp(`^(${pattern})_.+$`);
}
export const SnapshotCreatePage = observer(() => { export const SnapshotCreatePage = observer(() => {
const { createSnapshot, getSnapshotStatus, getStorageInfo, snapshotStatus } = snapshotStore; const { createSnapshot, getSnapshotStatus, getStorageInfo, snapshotStatus } = snapshotStore;
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [nameError, setNameError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [duplicateWarningOpen, setDuplicateWarningOpen] = useState(false);
const [duplicateRouteNumbers, setDuplicateRouteNumbers] = useState<string[]>([]);
return ( const exportNameRegex = useMemo(() => {
<div className="w-full h-[400px] flex justify-center items-center"> const names = cityStore.cities["ru"].data.map((c) => c.name.trim());
<div className="w-full h-full p-3 flex flex-col gap-10"> return buildExportNameRegex(names);
<div className="flex justify-between items-center"> }, [cityStore.cities["ru"].data.length]);
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<h1 className="text-2xl font-bold">Создание экспорта медиа</h1>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
className="w-full"
label="Название"
required
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Button useEffect(() => {
variant="contained" if (!cityStore.cities["ru"].loaded) {
color="primary" cityStore.getCities("ru");
className="w-min flex gap-2 items-center" }
startIcon={<Save size={20} />} }, []);
onClick={async () => {
const canReadRoutes = authStore.canRead("routes");
const startExport = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const id = await createSnapshot(name); const id = await createSnapshot(name);
@@ -68,8 +80,118 @@ export const SnapshotCreatePage = observer(() => {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
};
const handleSave = async () => {
if (!canReadRoutes) {
await startExport();
return;
}
try {
runInAction(() => {
routeStore.routes.loaded = false;
});
await routeStore.getRoutes();
await carrierStore.getCarriers("ru");
const routes = routeStore.routes.data;
const carriers = carrierStore.carriers.ru.data;
const carrierCityMap = new Map<number, number>();
for (const c of carriers) {
carrierCityMap.set(c.id, c.city_id);
}
const duplicateMessages: string[] = [];
const directionKey = new Map<string, number>();
for (const route of routes) {
const num = (route.route_sys_number ?? "").trim();
if (!num) continue;
const cityId = carrierCityMap.get(route.carrier_id) ?? 0;
const key = `${num}|${route.route_direction}|${cityId}`;
directionKey.set(key, (directionKey.get(key) ?? 0) + 1);
}
for (const [key, count] of directionKey) {
if (count > 1) {
const [num, dir] = key.split("|");
const dirLabel = dir === "true" ? "прямой" : "обратный";
duplicateMessages.push(
`Дублируется маршрут №${num} (${dirLabel})`
);
}
}
const cityPerNumber = new Map<string, Set<number>>();
for (const route of routes) {
const num = (route.route_sys_number ?? "").trim();
if (!num) continue;
const cityId = carrierCityMap.get(route.carrier_id) ?? 0;
if (!cityPerNumber.has(num)) {
cityPerNumber.set(num, new Set());
}
cityPerNumber.get(num)!.add(cityId);
}
for (const [num, cities] of cityPerNumber) {
if (cities.size > 1) {
duplicateMessages.push(
`Маршрут №${num} присутствует в нескольких городах`
);
}
}
if (duplicateMessages.length > 0) {
setDuplicateRouteNumbers(duplicateMessages);
setDuplicateWarningOpen(true);
} else {
await startExport();
}
} catch {
await startExport();
}
};
return (
<div className="w-full h-[400px] flex justify-center items-center">
<div className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<h1 className="text-2xl font-bold">Создание экспорта медиа</h1>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
className="w-full"
label="Название"
required
value={name}
error={!!nameError}
helperText={nameError ?? " "}
onChange={(e) => {
const val = e.target.value;
setName(val);
const trimmed = val.trim();
const hasFullFormat = trimmed.includes("_") && trimmed.split("_").slice(1).join("_").length > 0;
if (hasFullFormat && !exportNameRegex.test(trimmed)) {
setNameError("Название должно начинаться с названия существующего города");
} else {
setNameError(null);
}
}} }}
disabled={isLoading || !name.trim()} />
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleSave}
disabled={isLoading || !exportNameRegex.test(name.trim())}
> >
{isLoading ? ( {isLoading ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -87,6 +209,45 @@ export const SnapshotCreatePage = observer(() => {
</Button> </Button>
</div> </div>
</div> </div>
<Dialog
open={duplicateWarningOpen}
onClose={() => !isLoading && setDuplicateWarningOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>Найдены повторяющиеся маршруты</DialogTitle>
<DialogContent>
<p className="mb-3">
Обнаружены маршруты с одинаковыми номерами трассы. Это может привести к
некорректным данным в экспорте.
</p>
<ul className="list-disc pl-5">
{duplicateRouteNumbers.map((msg, i) => (
<li key={i}>{msg}</li>
))}
</ul>
</DialogContent>
<DialogActions>
<Button
onClick={() => setDuplicateWarningOpen(false)}
disabled={isLoading}
>
Отмена
</Button>
<Button
variant="contained"
sx={{ backgroundColor: "#795548", "&:hover": { backgroundColor: "#5D4037" } }}
disabled={isLoading}
onClick={async () => {
setDuplicateWarningOpen(false);
await startExport();
}}
>
Продолжить экспорт
</Button>
</DialogActions>
</Dialog>
</div> </div>
); );
}); });

View File

@@ -1,14 +1,24 @@
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, snapshotStore, SearchInput } from "@shared"; import { authStore, languageStore, snapshotStore, cityStore, vehicleStore, 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 { DatabaseBackup, Trash2 } from "lucide-react"; import { DatabaseBackup, Trash2 } from "lucide-react";
import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets"; import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets";
import { Alert, Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from "@mui/material"; import { Alert, Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, TextField, Typography } from "@mui/material";
const LOW_STORAGE_THRESHOLD_GB = 10; const LOW_STORAGE_THRESHOLD_GB = 10;
function escapeRegex(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function buildExportNameRegex(cityNames: string[]): RegExp {
if (!cityNames.length) return /.+/;
const pattern = cityNames.map(escapeRegex).join("|");
return new RegExp(`^(${pattern})_.+$`);
}
const SEGMENT_COLORS = [ const SEGMENT_COLORS = [
"#FF3B30", "#FF3B30",
"#FF9500", "#FF9500",
@@ -33,11 +43,14 @@ export const SnapshotListPage = observer(() => {
createEmptySnapshot, createEmptySnapshot,
} = snapshotStore; } = snapshotStore;
const canWriteDevices = authStore.canWrite("devices"); const canWriteDevices = authStore.canWrite("devices");
const canReadDevices = authStore.canRead("devices");
const canCreateSnapshot = const canCreateSnapshot =
authStore.hasRole("snapshot_create") && canWriteDevices; authStore.hasRole("snapshot_create") && canWriteDevices;
const canManageSnapshots = authStore.canWrite("snapshot") && canWriteDevices; const canManageSnapshots = authStore.canWrite("snapshot") && canWriteDevices;
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isSnapshotOnDeviceWarning, setIsSnapshotOnDeviceWarning] = useState(false);
const [devicesWithSnapshot, setDevicesWithSnapshot] = useState<string[]>([]);
const [rowId, setRowId] = useState<string | null>(null); const [rowId, setRowId] = useState<string | null>(null);
const { language } = languageStore; const { language } = languageStore;
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false); const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
@@ -45,6 +58,12 @@ export const SnapshotListPage = observer(() => {
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [isEmptySnapshotModalOpen, setIsEmptySnapshotModalOpen] = useState(false); const [isEmptySnapshotModalOpen, setIsEmptySnapshotModalOpen] = useState(false);
const [emptySnapshotName, setEmptySnapshotName] = useState(""); const [emptySnapshotName, setEmptySnapshotName] = useState("");
const [emptySnapshotNameError, setEmptySnapshotNameError] = useState<string | null>(null);
const exportNameRegex = useMemo(() => {
const names = cityStore.cities["ru"].data.map((c) => c.name.trim());
return buildExportNameRegex(names);
}, [cityStore.cities["ru"].data.length]);
const [isCreatingEmpty, setIsCreatingEmpty] = useState(false); const [isCreatingEmpty, setIsCreatingEmpty] = useState(false);
const [paginationModel, setPaginationModel] = useState({ const [paginationModel, setPaginationModel] = useState({
page: 0, page: 0,
@@ -61,7 +80,11 @@ export const SnapshotListPage = observer(() => {
useEffect(() => { useEffect(() => {
const fetchSnapshots = async () => { const fetchSnapshots = async () => {
setIsLoading(true); setIsLoading(true);
await Promise.all([getSnapshots(), getStorageInfo()]); const promises: Promise<void>[] = [getSnapshots(), getStorageInfo()];
if (canReadDevices && !vehicleStore.vehicles.loaded) {
promises.push(vehicleStore.getVehicles());
}
await Promise.all(promises);
setIsLoading(false); setIsLoading(false);
}; };
fetchSnapshots(); fetchSnapshots();
@@ -137,6 +160,7 @@ export const SnapshotListPage = observer(() => {
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
<button <button
title="Восстановить"
onClick={() => { onClick={() => {
setIsRestoreModalOpen(true); setIsRestoreModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -145,9 +169,22 @@ export const SnapshotListPage = observer(() => {
<DatabaseBackup size={20} className="text-blue-500" /> <DatabaseBackup size={20} className="text-blue-500" />
</button> </button>
<button <button
title="Удалить"
onClick={() => { onClick={() => {
const snapshotId = params.row.id;
if (canReadDevices) {
const devicesUsing = vehicleStore.vehicles.data
.filter(v => v.vehicle.current_snapshot_uuid === snapshotId)
.map(v => v.vehicle.tail_number || v.vehicle.uuid || `ID ${v.vehicle.id}`);
if (devicesUsing.length > 0) {
setDevicesWithSnapshot(devicesUsing);
setIsSnapshotOnDeviceWarning(true);
setRowId(snapshotId);
return;
}
}
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(snapshotId);
}} }}
> >
<Trash2 size={20} className="text-red-500" /> <Trash2 size={20} className="text-red-500" />
@@ -199,6 +236,7 @@ export const SnapshotListPage = observer(() => {
disabled={isLowStorage} disabled={isLowStorage}
onClick={() => { onClick={() => {
setEmptySnapshotName(""); setEmptySnapshotName("");
setEmptySnapshotNameError(null);
setIsEmptySnapshotModalOpen(true); setIsEmptySnapshotModalOpen(true);
}} }}
> >
@@ -297,7 +335,9 @@ export const SnapshotListPage = observer(() => {
</Alert> </Alert>
)} )}
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} /> <SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<DataGrid <DataGrid
rows={rows} rows={rows}
@@ -349,7 +389,19 @@ export const SnapshotListPage = observer(() => {
fullWidth fullWidth
label="Название" label="Название"
value={emptySnapshotName} value={emptySnapshotName}
onChange={(e) => setEmptySnapshotName(e.target.value)} error={!!emptySnapshotNameError}
helperText={emptySnapshotNameError ?? " "}
onChange={(e) => {
const val = e.target.value;
setEmptySnapshotName(val);
const trimmed = val.trim();
const hasFullFormat = trimmed.includes("_") && trimmed.split("_").slice(1).join("_").length > 0;
if (hasFullFormat && !exportNameRegex.test(trimmed)) {
setEmptySnapshotNameError("Название должно начинаться с названия существующего города");
} else {
setEmptySnapshotNameError(null);
}
}}
margin="normal" margin="normal"
/> />
</DialogContent> </DialogContent>
@@ -359,7 +411,7 @@ export const SnapshotListPage = observer(() => {
</Button> </Button>
<Button <Button
variant="contained" variant="contained"
disabled={!emptySnapshotName.trim() || isCreatingEmpty} disabled={!exportNameRegex.test(emptySnapshotName.trim()) || isCreatingEmpty}
onClick={async () => { onClick={async () => {
setIsCreatingEmpty(true); setIsCreatingEmpty(true);
try { try {
@@ -376,6 +428,36 @@ export const SnapshotListPage = observer(() => {
</DialogActions> </DialogActions>
</Dialog> </Dialog>
<Dialog
open={isSnapshotOnDeviceWarning}
onClose={() => setIsSnapshotOnDeviceWarning(false)}
fullWidth
maxWidth="xs"
>
<DialogTitle>Удаление невозможно</DialogTitle>
<DialogContent>
<Alert severity="warning" sx={{ mt: 1 }}>
Этот экспорт загружен на устройства. Удалите или замените экспорт на
устройствах перед удалением.
</Alert>
<Box sx={{ mt: 2 }}>
<Typography variant="body2" fontWeight={600} gutterBottom>
Устройства:
</Typography>
{devicesWithSnapshot.map((name, i) => (
<Typography key={i} variant="body2">
{name}
</Typography>
))}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsSnapshotOnDeviceWarning(false)}>
Закрыть
</Button>
</DialogActions>
</Dialog>
<SnapshotRestore <SnapshotRestore
open={isRestoreModalOpen} open={isRestoreModalOpen}
loading={isLoading} loading={isLoading}

View File

@@ -49,7 +49,7 @@ export const StationListPage = observer(() => {
loadSightCounts(stationIds); loadSightCounts(stationIds);
}; };
fetchStations(); fetchStations();
}, [language]); }, [language, selectedCityStore.cityVersion]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
@@ -141,7 +141,7 @@ export const StationListPage = observer(() => {
return ( return (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
{canWriteStations && ( {canWriteStations && (
<button onClick={() => navigate(`/station/${params.row.id}/edit`)}> <button title="Редактировать" onClick={() => navigate(`/station/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" /> <Pencil size={20} className="text-blue-500" />
</button> </button>
)} )}
@@ -151,13 +151,14 @@ export const StationListPage = observer(() => {
setSelectedStationId(params.row.id); setSelectedStationId(params.row.id);
setIsTransfersModalOpen(true); setIsTransfersModalOpen(true);
}} }}
title="Редактировать пересадки" title="Управление пересадками"
> >
<Route size={20} className="text-purple-500" /> <Route size={20} className="text-purple-500" />
</button> </button>
)} )}
{canWriteStations && ( {canWriteStations && (
<button <button
title="Удалить"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -208,7 +209,6 @@ export const StationListPage = observer(() => {
<CreateButton <CreateButton
label="Создать остановку" label="Создать остановку"
path="/station/create" path="/station/create"
disabled={!selectedCityStore.selectedCityId}
/> />
)} )}
</div> </div>
@@ -225,7 +225,9 @@ export const StationListPage = observer(() => {
</div> </div>
)} )}
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} /> <SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<DataGrid <DataGrid
rows={rows} rows={rows}

View File

@@ -2,16 +2,8 @@ import {
Button, Button,
Paper, Paper,
TextField, TextField,
Checkbox,
Typography, Typography,
Box, Box,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Radio,
RadioGroup,
Divider, Divider,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
@@ -25,43 +17,10 @@ import {
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
selectedCityStore,
} from "@shared"; } from "@shared";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { ImageUploadCard } from "@widgets"; import { ImageUploadCard, PermissionsTable, RolesHintTable, ROLE_RESOURCES } 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();
@@ -79,6 +38,11 @@ export const UserCreatePage = observer(() => {
createUserData.roles ?? ["articles_ro", "articles_rw", "media_ro", "media_rw"] createUserData.roles ?? ["articles_ro", "articles_rw", "media_ro", "media_rw"]
); );
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
useEffect(() => { useEffect(() => {
mediaStore.getMedia(); mediaStore.getMedia();
}, []); }, []);
@@ -270,133 +234,8 @@ export const UserCreatePage = observer(() => {
</Button> </Button>
</Box> </Box>
<Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1 }}> <PermissionsTable localRoles={localRoles} setLocalRoles={setLocalRoles} />
<Table size="small"> <RolesHintTable />
<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> </section>
<Button <Button

View File

@@ -1,17 +1,9 @@
import { import {
Button, Button,
Checkbox,
Paper, Paper,
TextField, TextField,
Box, Box,
Typography, Typography,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Radio,
RadioGroup,
Divider, Divider,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
@@ -30,45 +22,12 @@ import {
authStore, authStore,
cityStore, cityStore,
MultiSelect, MultiSelect,
selectedCityStore,
type User, type User,
type UserCity, type UserCity,
} from "@shared"; } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ImageUploadCard, DeleteModal } from "@widgets"; import { ImageUploadCard, DeleteModal, PermissionsTable, RolesHintTable, ROLE_RESOURCES } 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 UserEditPage = observer(() => { export const UserEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -92,6 +51,11 @@ export const UserEditPage = observer(() => {
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null); >(null);
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
useEffect(() => { useEffect(() => {
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}, []); }, []);
@@ -352,133 +316,8 @@ export const UserEditPage = observer(() => {
</Button> </Button>
</Box> </Box>
<Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1 }}> <PermissionsTable localRoles={localRoles} setLocalRoles={setLocalRoles} />
<Table size="small"> <RolesHintTable />
<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> </section>
<Divider /> <Divider />

View File

@@ -92,10 +92,11 @@ export const UserListPage = observer(() => {
sortable: false, sortable: false,
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/user/${params.row.id}/edit`)}> <button title="Редактировать" onClick={() => navigate(`/user/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" /> <Pencil size={20} className="text-blue-500" />
</button> </button>
<button <button
title="Удалить"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -146,7 +147,9 @@ export const UserListPage = observer(() => {
</div> </div>
)} )}
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} /> <SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
<DataGrid <DataGrid
rows={rows} rows={rows}
@@ -154,6 +157,11 @@ export const UserListPage = observer(() => {
checkboxSelection={canWriteUsers} checkboxSelection={canWriteUsers}
disableRowSelectionExcludeModel disableRowSelectionExcludeModel
disableRowSelectionOnClick disableRowSelectionOnClick
onRowDoubleClick={(params) => {
if (canWriteUsers) {
navigate(`/user/${params.row.id}/edit`);
}
}}
loading={isLoading} loading={isLoading}
paginationModel={paginationModel} paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel} onPaginationModelChange={setPaginationModel}

View File

@@ -12,6 +12,8 @@ import {
VEHICLE_TYPES, VEHICLE_TYPES,
carrierStore, carrierStore,
languageStore, languageStore,
cityStore,
selectedCityStore,
} from "@shared"; } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Save } from "lucide-react";
@@ -26,11 +28,18 @@ export const VehicleCreatePage = observer(() => {
const [type, setType] = useState(""); const [type, setType] = useState("");
const [carrierId, setCarrierId] = useState<number | null>(null); const [carrierId, setCarrierId] = useState<number | null>(null);
const [model, setModel] = useState(""); const [model, setModel] = useState("");
const [cityId, setCityId] = useState<number | null>(selectedCityStore.selectedCityId);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore; const { language } = languageStore;
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
useEffect(() => { useEffect(() => {
carrierStore.getCarriers(language); carrierStore.getCarriers(language);
cityStore.getCities("ru");
}, [language]); }, [language]);
const handleCreate = async () => { const handleCreate = async () => {
@@ -43,6 +52,7 @@ export const VehicleCreatePage = observer(() => {
?.full_name as string, ?.full_name as string,
carrierId!, carrierId!,
model || undefined, model || undefined,
cityId ?? undefined,
); );
toast.success("Транспорт успешно создан"); toast.success("Транспорт успешно создан");
} catch (error) { } catch (error) {
@@ -73,12 +83,11 @@ export const VehicleCreatePage = observer(() => {
onChange={(e) => setTailNumber(e.target.value)} onChange={(e) => setTailNumber(e.target.value)}
/> />
<FormControl fullWidth> <FormControl fullWidth required>
<InputLabel>Тип</InputLabel> <InputLabel>Тип</InputLabel>
<Select <Select
value={type} value={type}
label="Тип" label="Тип"
required
onChange={(e) => setType(e.target.value)} onChange={(e) => setType(e.target.value)}
> >
{VEHICLE_TYPES.map((type) => ( {VEHICLE_TYPES.map((type) => (
@@ -89,12 +98,11 @@ export const VehicleCreatePage = observer(() => {
</Select> </Select>
</FormControl> </FormControl>
<FormControl fullWidth> <FormControl fullWidth required>
<InputLabel>Перевозчик</InputLabel> <InputLabel>Перевозчик</InputLabel>
<Select <Select
value={carrierId || ""} value={carrierId || ""}
label="Перевозчик" label="Перевозчик"
required
onChange={(e) => setCarrierId(e.target.value as number)} onChange={(e) => setCarrierId(e.target.value as number)}
> >
{carrierStore.carriers[language].data?.map((carrier) => ( {carrierStore.carriers[language].data?.map((carrier) => (
@@ -113,12 +121,27 @@ export const VehicleCreatePage = observer(() => {
placeholder="Произвольное название модели" placeholder="Произвольное название модели"
/> />
<FormControl fullWidth required>
<InputLabel>Город</InputLabel>
<Select
value={cityId ?? ""}
label="Город"
onChange={(e) => setCityId(e.target.value ? Number(e.target.value) : null)}
>
{cityStore.cities.ru.data.map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>
))}
</Select>
</FormControl>
<Button <Button
variant="contained" variant="contained"
className="w-min flex gap-2 items-center" className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />} startIcon={<Save size={20} />}
onClick={handleCreate} onClick={handleCreate}
disabled={isLoading || !tailNumber || !type || !carrierId} disabled={isLoading || !tailNumber || !type || !carrierId || !cityId}
> >
{isLoading ? ( {isLoading ? (
<Loader2 size={20} className="animate-spin" /> <Loader2 size={20} className="animate-spin" />

View File

@@ -20,6 +20,8 @@ import {
VEHICLE_TYPES, VEHICLE_TYPES,
vehicleStore, vehicleStore,
LoadingSpinner, LoadingSpinner,
cityStore,
selectedCityStore,
} from "@shared"; } from "@shared";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@@ -38,6 +40,11 @@ export const VehicleEditPage = observer(() => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true); const [isLoadingData, setIsLoadingData] = useState(true);
useEffect(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
useEffect(() => { useEffect(() => {
// Устанавливаем русский язык при загрузке страницы // Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
@@ -54,6 +61,7 @@ export const VehicleEditPage = observer(() => {
try { try {
await getVehicle(Number(id)); await getVehicle(Number(id));
await getCarriers(language); await getCarriers(language);
await cityStore.getCities("ru");
setEditVehicleData({ setEditVehicleData({
tail_number: vehicle[Number(id)]?.vehicle.tail_number ?? "", tail_number: vehicle[Number(id)]?.vehicle.tail_number ?? "",
@@ -63,6 +71,7 @@ export const VehicleEditPage = observer(() => {
model: vehicle[Number(id)]?.vehicle.model ?? "", model: vehicle[Number(id)]?.vehicle.model ?? "",
snapshot_update_blocked: snapshot_update_blocked:
vehicle[Number(id)]?.vehicle.snapshot_update_blocked ?? false, vehicle[Number(id)]?.vehicle.snapshot_update_blocked ?? false,
city_id: vehicle[Number(id)]?.vehicle.city_id ?? selectedCityStore.selectedCityId ?? undefined,
}); });
} finally { } finally {
setIsLoadingData(false); setIsLoadingData(false);
@@ -125,12 +134,11 @@ export const VehicleEditPage = observer(() => {
} }
/> />
<FormControl fullWidth> <FormControl fullWidth required>
<InputLabel>Тип</InputLabel> <InputLabel>Тип</InputLabel>
<Select <Select
value={editVehicleData.type} value={editVehicleData.type}
label="Тип" label="Тип"
required
onChange={(e) => onChange={(e) =>
setEditVehicleData({ ...editVehicleData, type: e.target.value }) setEditVehicleData({ ...editVehicleData, type: e.target.value })
} }
@@ -143,12 +151,11 @@ export const VehicleEditPage = observer(() => {
</Select> </Select>
</FormControl> </FormControl>
<FormControl fullWidth> <FormControl fullWidth required>
<InputLabel>Перевозчик</InputLabel> <InputLabel>Перевозчик</InputLabel>
<Select <Select
value={editVehicleData.carrier_id} value={editVehicleData.carrier_id}
label="Перевозчик" label="Перевозчик"
required
onChange={(e) => onChange={(e) =>
setEditVehicleData({ setEditVehicleData({
...editVehicleData, ...editVehicleData,
@@ -177,6 +184,26 @@ export const VehicleEditPage = observer(() => {
placeholder="Произвольное название модели" placeholder="Произвольное название модели"
/> />
<FormControl fullWidth required>
<InputLabel>Город</InputLabel>
<Select
value={editVehicleData.city_id ?? ""}
label="Город"
onChange={(e) =>
setEditVehicleData({
...editVehicleData,
city_id: e.target.value ? Number(e.target.value) : undefined,
})
}
>
{cityStore.cities.ru.data.map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControlLabel <FormControlLabel
control={ control={
<Checkbox <Checkbox
@@ -202,7 +229,8 @@ export const VehicleEditPage = observer(() => {
isLoading || isLoading ||
!editVehicleData.tail_number || !editVehicleData.tail_number ||
!editVehicleData.type || !editVehicleData.type ||
!editVehicleData.carrier_id !editVehicleData.carrier_id ||
!editVehicleData.city_id
} }
> >
{isLoading ? ( {isLoading ? (

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, carrierStore, languageStore, vehicleStore, SearchInput } from "@shared"; import { authStore, carrierStore, languageStore, vehicleStore, SearchInput, selectedCityStore, cityStore } 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 { Eye, Pencil, Trash2, Minus } from "lucide-react"; import { Eye, Pencil, Trash2, Minus } from "lucide-react";
@@ -11,7 +11,7 @@ import { Box, CircularProgress } from "@mui/material";
export const VehicleListPage = observer(() => { export const VehicleListPage = observer(() => {
const { vehicles, getVehicles, deleteVehicle } = vehicleStore; const { vehicles, getVehicles, deleteVehicle } = vehicleStore;
const { carriers, getCarriers } = carrierStore; const { getCarriers } = carrierStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
@@ -31,10 +31,11 @@ export const VehicleListPage = observer(() => {
setIsLoading(true); setIsLoading(true);
await getVehicles(); await getVehicles();
await getCarriers(language); await getCarriers(language);
await cityStore.getCities("ru");
setIsLoading(false); setIsLoading(false);
}; };
fetchData(); fetchData();
}, [language]); }, [language, selectedCityStore.cityVersion]);
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
@@ -114,15 +115,16 @@ export const VehicleListPage = observer(() => {
return ( return (
<div className="flex h-full gap-7 justify-center items-center"> <div className="flex h-full gap-7 justify-center items-center">
{canWrite && ( {canWrite && (
<button onClick={() => navigate(`/vehicle/${params.row.id}/edit`)}> <button title="Редактировать" onClick={() => navigate(`/vehicle/${params.row.id}/edit`)}>
<Pencil size={20} className="text-blue-500" /> <Pencil size={20} className="text-blue-500" />
</button> </button>
)} )}
<button onClick={() => navigate(`/vehicle/${params.row.id}`)}> <button title="Просмотр" onClick={() => navigate(`/vehicle/${params.row.id}`)}>
<Eye size={20} className="text-green-500" /> <Eye size={20} className="text-green-500" />
</button> </button>
{canWrite && ( {canWrite && (
<button <button
title="Удалить"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setRowId(params.row.id); setRowId(params.row.id);
@@ -137,9 +139,13 @@ export const VehicleListPage = observer(() => {
}, },
]; ];
const { selectedCityId } = selectedCityStore;
const rows = useMemo(() => { const rows = useMemo(() => {
if (!selectedCityId) return [];
const query = searchQuery.trim().toLowerCase(); const query = searchQuery.trim().toLowerCase();
return (vehicles.data ?? []) return (vehicles.data ?? [])
.filter((vehicle) => vehicle.vehicle.city_id === selectedCityId)
.filter( .filter(
(vehicle) => (vehicle) =>
!query || !query ||
@@ -151,11 +157,9 @@ export const VehicleListPage = observer(() => {
tail_number: vehicle.vehicle.tail_number, tail_number: vehicle.vehicle.tail_number,
type: vehicle.vehicle.type, type: vehicle.vehicle.type,
carrier: vehicle.vehicle.carrier, carrier: vehicle.vehicle.carrier,
city: carriers[language].data?.find( city: cityStore.cities.ru.data.find((c) => c.id === vehicle.vehicle.city_id)?.name,
(carrier) => carrier.id === vehicle.vehicle.carrier_id
)?.city,
})); }));
}, [vehicles.data, carriers[language].data, searchQuery]); }, [vehicles.data, selectedCityId, searchQuery]);
return ( return (
<> <>
@@ -169,7 +173,9 @@ export const VehicleListPage = observer(() => {
/> />
</div> </div>
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} /> <SearchInput value={searchQuery} onChange={setSearchQuery} />
)}
{canWriteVehicles && ids.length > 0 && ( {canWriteVehicles && ids.length > 0 && (
<div className="flex justify-end mb-5 duration-300"> <div className="flex justify-end mb-5 duration-300">
@@ -223,6 +229,8 @@ export const VehicleListPage = observer(() => {
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? ( {isLoading ? (
<CircularProgress size={20} /> <CircularProgress size={20} />
) : !selectedCityId ? (
"Выберите город"
) : ( ) : (
"Нет транспортных средств" "Нет транспортных средств"
)} )}

View File

@@ -103,8 +103,26 @@ export const UploadMediaDialog = observer(
setMediaFile(initialFile); setMediaFile(initialFile);
setMediaFilename(initialFile.name); setMediaFilename(initialFile.name);
const extension = initialFile.name.split(".").pop()?.toLowerCase();
if (extension) {
if (["glb", "gltf"].includes(extension)) {
setAvailableMediaTypes([6]);
setMediaType(6);
}
if (
["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes(
extension
)
) {
setAvailableMediaTypes([1, 3, 4, 5]);
setMediaType(1);
} else if (["mp4", "webm", "mov"].includes(extension)) {
setAvailableMediaTypes([2]); setAvailableMediaTypes([2]);
setMediaType(2); setMediaType(2);
}
}
const newBlobUrl = URL.createObjectURL(initialFile); const newBlobUrl = URL.createObjectURL(initialFile);
setMediaUrl(newBlobUrl); setMediaUrl(newBlobUrl);
previousMediaUrlRef.current = newBlobUrl; previousMediaUrlRef.current = newBlobUrl;

View File

@@ -13,6 +13,7 @@ export type Route = {
center_latitude: number; center_latitude: number;
center_longitude: number; center_longitude: number;
governor_appeal: number; governor_appeal: number;
button_text?: string;
id: number; id: number;
icon: string; icon: string;
path: number[][]; path: number[][];
@@ -143,6 +144,7 @@ class RouteStore {
center_latitude: "", center_latitude: "",
center_longitude: "", center_longitude: "",
governor_appeal: 0, governor_appeal: 0,
button_text: "" as string | undefined,
id: 0, id: 0,
icon: "", icon: "",
path: [] as number[][], path: [] as number[][],

View File

@@ -4,6 +4,7 @@ import { City } from "../CityStore";
class SelectedCityStore { class SelectedCityStore {
selectedCity: City | null = null; selectedCity: City | null = null;
isLocked: boolean = false; isLocked: boolean = false;
cityVersion: number = 0;
constructor() { constructor() {
makeAutoObservable(this); makeAutoObservable(this);
@@ -25,6 +26,7 @@ class SelectedCityStore {
setSelectedCity = (city: City | null) => { setSelectedCity = (city: City | null) => {
runInAction(() => { runInAction(() => {
this.selectedCity = city; this.selectedCity = city;
this.cityVersion += 1;
if (city) { if (city) {
localStorage.setItem("selectedCity", JSON.stringify(city)); localStorage.setItem("selectedCity", JSON.stringify(city));
} else { } else {

View File

@@ -60,12 +60,6 @@ class SnapshotStore {
articlesStore.articleData = null; articlesStore.articleData = null;
articlesStore.articleMedia = null; articlesStore.articleMedia = null;
countryStore.countries = {
ru: { data: [], loaded: false },
en: { data: [], loaded: false },
zh: { data: [], loaded: false },
};
carrierStore.carriers = { carrierStore.carriers = {
ru: { data: [], loaded: false }, ru: { data: [], loaded: false },
en: { data: [], loaded: false }, en: { data: [], loaded: false },

View File

@@ -120,6 +120,7 @@ class VehicleStore {
carrier: string, carrier: string,
carrierId: number, carrierId: number,
model?: string, model?: string,
cityId?: number,
) => { ) => {
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
tail_number: tailNumber.trim(), tail_number: tailNumber.trim(),
@@ -129,6 +130,7 @@ class VehicleStore {
}; };
// TODO: когда будет бекенд — добавить model в payload и в ответ // TODO: когда будет бекенд — добавить model в payload и в ответ
if (model != null && model !== "") payload.model = model; if (model != null && model !== "") payload.model = model;
if (cityId != null) payload.city_id = cityId;
const response = await languageInstance("ru").post("/vehicle", payload); const response = await languageInstance("ru").post("/vehicle", payload);
const normalizedVehicle = this.normalizeVehicleItem(response.data); const normalizedVehicle = this.normalizeVehicleItem(response.data);
@@ -147,6 +149,7 @@ class VehicleStore {
carrier_id: number; carrier_id: number;
model: string; model: string;
snapshot_update_blocked: boolean; snapshot_update_blocked: boolean;
city_id?: number;
} = { } = {
tail_number: "", tail_number: "",
type: 0, type: 0,
@@ -154,6 +157,7 @@ class VehicleStore {
carrier_id: 0, carrier_id: 0,
model: "", model: "",
snapshot_update_blocked: false, snapshot_update_blocked: false,
city_id: undefined,
}; };
setEditVehicleData = (data: { setEditVehicleData = (data: {
@@ -163,6 +167,7 @@ class VehicleStore {
carrier_id: number; carrier_id: number;
model?: string; model?: string;
snapshot_update_blocked?: boolean; snapshot_update_blocked?: boolean;
city_id?: number;
}) => { }) => {
this.editVehicleData = { this.editVehicleData = {
...this.editVehicleData, ...this.editVehicleData,
@@ -179,6 +184,7 @@ class VehicleStore {
carrier_id: number; carrier_id: number;
model?: string; model?: string;
snapshot_update_blocked?: boolean; snapshot_update_blocked?: boolean;
city_id?: number;
}, },
) => { ) => {
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
@@ -190,6 +196,7 @@ class VehicleStore {
if (data.model != null && data.model !== "") payload.model = data.model; if (data.model != null && data.model !== "") payload.model = data.model;
if (data.snapshot_update_blocked != null) if (data.snapshot_update_blocked != null)
payload.snapshot_update_blocked = data.snapshot_update_blocked; payload.snapshot_update_blocked = data.snapshot_update_blocked;
if (data.city_id != null) payload.city_id = data.city_id;
const response = await languageInstance("ru").patch( const response = await languageInstance("ru").patch(
`/vehicle/${id}`, `/vehicle/${id}`,
payload, payload,

View File

@@ -6,11 +6,21 @@ import {
SelectChangeEvent, SelectChangeEvent,
Typography, Typography,
Box, Box,
keyframes,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { authStore, cityStore, selectedCityStore, type City } from "@shared"; import { authStore, cityStore, selectedCityStore, snapshotStore, type City } from "@shared";
import { MapPin } from "lucide-react"; import { MapPin } from "lucide-react";
const borderSpin = keyframes`
0% {
background-position: 0% 50%;
}
100% {
background-position: 200% 50%;
}
`;
export const CitySelector: React.FC = observer(() => { export const CitySelector: React.FC = observer(() => {
const { selectedCity, setSelectedCity, isLocked } = selectedCityStore; const { selectedCity, setSelectedCity, isLocked } = selectedCityStore;
const canLoadAllCities = authStore.isAdmin && authStore.canRead("cities"); const canLoadAllCities = authStore.isAdmin && authStore.canRead("cities");
@@ -43,23 +53,24 @@ export const CitySelector: React.FC = observer(() => {
})() })()
: baseCities; : baseCities;
const noCitySelected = !selectedCity?.id;
const handleCityChange = (event: SelectChangeEvent<string>) => { const handleCityChange = (event: SelectChangeEvent<string>) => {
const cityId = event.target.value; const cityId = event.target.value;
if (cityId === "") { if (cityId === "") {
snapshotStore.clearStoreCache();
setSelectedCity(null); setSelectedCity(null);
return; return;
} }
const city = currentCities.find((c) => c.id === Number(cityId)); const city = currentCities.find((c) => c.id === Number(cityId));
if (city) { if (city) {
snapshotStore.clearStoreCache();
setSelectedCity(city); setSelectedCity(city);
} }
}; };
return ( const selectElement = (
<Box className="flex items-center gap-2">
<MapPin size={16} className={isLocked ? "text-gray-400" : "text-white"} />
<FormControl size="medium" sx={{ minWidth: 120 }}>
<Select <Select
value={selectedCity?.id?.toString() || ""} value={selectedCity?.id?.toString() || ""}
onChange={handleCityChange} onChange={handleCityChange}
@@ -68,15 +79,23 @@ export const CitySelector: React.FC = observer(() => {
sx={{ sx={{
height: "40px", height: "40px",
color: "white", color: "white",
"&.Mui-disabled": { borderRadius: "4px",
color: "rgba(255, 255, 255, 0.5)", ...(noCitySelected && !isLocked
WebkitTextFillColor: "rgba(255, 255, 255, 0.5)", ? {
}, backgroundColor: "#48989f",
"& .MuiOutlinedInput-notchedOutline": { border: "none" },
}
: {
"& .MuiOutlinedInput-notchedOutline": { "& .MuiOutlinedInput-notchedOutline": {
borderColor: isLocked borderColor: isLocked
? "rgba(255, 255, 255, 0.1)" ? "rgba(255, 255, 255, 0.1)"
: "rgba(255, 255, 255, 0.3)", : "rgba(255, 255, 255, 0.3)",
}, },
}),
"&.Mui-disabled": {
color: "rgba(255, 255, 255, 0.5)",
WebkitTextFillColor: "rgba(255, 255, 255, 0.5)",
},
"&:hover .MuiOutlinedInput-notchedOutline": { "&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: isLocked borderColor: isLocked
? "rgba(255, 255, 255, 0.1)" ? "rgba(255, 255, 255, 0.1)"
@@ -99,6 +118,28 @@ export const CitySelector: React.FC = observer(() => {
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>
);
return (
<Box className="flex items-center gap-2">
<MapPin size={16} className={isLocked ? "text-gray-400" : "text-white"} />
<FormControl size="medium" sx={{ minWidth: 120 }}>
{noCitySelected && !isLocked ? (
<Box
sx={{
position: "relative",
borderRadius: "4px",
padding: "2px",
background: "linear-gradient(90deg, rgba(255,255,255,0.1), rgba(255,255,255,0.7), rgba(255,255,255,0.1), rgba(255,255,255,0.7), rgba(255,255,255,0.1))",
backgroundSize: "200% 100%",
animation: `${borderSpin} 2.5s linear infinite`,
}}
>
{selectElement}
</Box>
) : (
selectElement
)}
</FormControl> </FormControl>
</Box> </Box>
); );

View File

@@ -24,30 +24,51 @@ const shiftYYYYMMDD = (value: string, days: number) => {
type LogLevel = "info" | "warn" | "error" | "debug" | "fatal" | "unknown"; type LogLevel = "info" | "warn" | "error" | "debug" | "fatal" | "unknown";
const LOG_LEVEL_STYLES: Record<LogLevel, { badge: string; text: string }> = { const LOG_LEVEL_STYLES: Record<
LogLevel,
{ badge: string; text: string; bg: string; color: string; borderColor: string }
> = {
info: { info: {
badge: "bg-blue-100 text-blue-700", badge: "bg-blue-100 text-blue-700",
text: "text-[#000000BF]", text: "text-[#000000BF]",
bg: "#DBEAFE",
color: "#1D4ED8",
borderColor: "#93C5FD",
}, },
debug: { debug: {
badge: "bg-gray-100 text-gray-600", badge: "bg-gray-100 text-gray-600",
text: "text-gray-600", text: "text-gray-600",
bg: "#F3F4F6",
color: "#4B5563",
borderColor: "#D1D5DB",
}, },
warn: { warn: {
badge: "bg-amber-100 text-amber-700", badge: "bg-amber-100 text-amber-700",
text: "text-amber-800", text: "text-amber-800",
bg: "#FEF3C7",
color: "#B45309",
borderColor: "#FCD34D",
}, },
error: { error: {
badge: "bg-red-100 text-red-700", badge: "bg-red-100 text-red-700",
text: "text-red-700", text: "text-red-700",
bg: "#FEE2E2",
color: "#B91C1C",
borderColor: "#FCA5A5",
}, },
fatal: { fatal: {
badge: "bg-red-200 text-red-900", badge: "bg-red-200 text-red-900",
text: "text-red-900 font-semibold", text: "text-red-900 font-semibold",
bg: "#FECACA",
color: "#7F1D1D",
borderColor: "#F87171",
}, },
unknown: { unknown: {
badge: "bg-gray-100 text-gray-500", badge: "bg-gray-100 text-gray-500",
text: "text-[#000000BF]", text: "text-[#000000BF]",
bg: "#F3F4F6",
color: "#6B7280",
borderColor: "#D1D5DB",
}, },
}; };
@@ -139,6 +160,23 @@ export const DeviceLogsModal = ({
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000); const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
const [dateFrom, setDateFrom] = useState(toYYYYMMDD(yesterday)); const [dateFrom, setDateFrom] = useState(toYYYYMMDD(yesterday));
const [dateTo, setDateTo] = useState(toYYYYMMDD(today)); const [dateTo, setDateTo] = useState(toYYYYMMDD(today));
const ALL_LEVELS: LogLevel[] = ["debug", "info", "warn", "error", "fatal"];
const [activeLevels, setActiveLevels] = useState<Set<LogLevel>>(
new Set(ALL_LEVELS)
);
const toggleLevel = (level: LogLevel) => {
setActiveLevels((prev) => {
const next = new Set(prev);
if (next.has(level)) {
next.delete(level);
} else {
next.add(level);
}
return next;
});
};
const dateToMin = shiftYYYYMMDD(dateFrom, 1); const dateToMin = shiftYYYYMMDD(dateFrom, 1);
const dateFromMax = shiftYYYYMMDD(dateTo, -1); const dateFromMax = shiftYYYYMMDD(dateTo, -1);
@@ -205,16 +243,21 @@ export const DeviceLogsModal = ({
return parsed; return parsed;
}, [chunks]); }, [chunks]);
const filteredLogs = useMemo(
() => logs.filter((log) => activeLevels.has(log.level)),
[logs, activeLevels]
);
const logsText = useMemo( const logsText = useMemo(
() => () =>
logs filteredLogs
.map((log) => { .map((log) => {
const level = log.level === "unknown" ? "LOG" : log.level.toUpperCase(); const level = log.level === "unknown" ? "LOG" : log.level.toUpperCase();
const time = log.time ? `[${log.time}] ` : ""; const time = log.time ? `[${log.time}] ` : "";
return `${time}${level}: ${log.text}`; return `${time}${level}: ${log.text}`;
}) })
.join("\n"), .join("\n"),
[logs] [filteredLogs]
); );
const handleDownloadLogs = () => { const handleDownloadLogs = () => {
@@ -253,6 +296,28 @@ export const DeviceLogsModal = ({
<div className="flex flex-col gap-6 h-[85vh]"> <div className="flex flex-col gap-6 h-[85vh]">
<div className="flex gap-4 items-center justify-between w-full flex-wrap"> <div className="flex gap-4 items-center justify-between w-full flex-wrap">
<h2 className="text-2xl font-semibold text-[#000000BF]">Логи</h2> <h2 className="text-2xl font-semibold text-[#000000BF]">Логи</h2>
<div className="flex gap-1.5 items-center">
{ALL_LEVELS.map((level) => {
const active = activeLevels.has(level);
const s = LOG_LEVEL_STYLES[level];
return (
<button
key={level}
type="button"
onClick={() => toggleLevel(level)}
className="cursor-pointer select-none rounded-md px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide transition-all duration-150"
style={{
backgroundColor: active ? s.bg : "transparent",
color: active ? s.color : "#9CA3AF",
border: `1.5px solid ${active ? s.borderColor : "#E5E7EB"}`,
opacity: active ? 1 : 0.55,
}}
>
{level}
</button>
);
})}
</div>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<TextField <TextField
type="date" type="date"
@@ -280,7 +345,7 @@ export const DeviceLogsModal = ({
variant="outlined" variant="outlined"
size="small" size="small"
onClick={handleDownloadLogs} onClick={handleDownloadLogs}
disabled={isLoading || Boolean(error) || logs.length === 0} disabled={isLoading || Boolean(error) || filteredLogs.length === 0}
> >
Скачать .txt Скачать .txt
</Button> </Button>
@@ -303,8 +368,8 @@ export const DeviceLogsModal = ({
{!isLoading && !error && ( {!isLoading && !error && (
<div className="w-full h-full overflow-y-auto rounded-xl"> <div className="w-full h-full overflow-y-auto rounded-xl">
<div className="flex flex-col gap-0.5 font-mono text-[13px]"> <div className="flex flex-col gap-0.5 font-mono text-[13px]">
{logs.length > 0 ? ( {filteredLogs.length > 0 ? (
logs.map((log) => { filteredLogs.map((log) => {
const style = LOG_LEVEL_STYLES[log.level]; const style = LOG_LEVEL_STYLES[log.level];
return ( return (
<div <div

View File

@@ -9,7 +9,6 @@ import {
vehicleStore, vehicleStore,
routeStore, routeStore,
Vehicle, Vehicle,
carrierStore,
selectedCityStore, selectedCityStore,
menuStore, menuStore,
VEHICLE_TYPES, VEHICLE_TYPES,
@@ -184,31 +183,14 @@ export const DevicesTable = observer(() => {
pageSize: 50, pageSize: 50,
}); });
const filterVehiclesBySelectedCity = (vehiclesList: Vehicle[]): Vehicle[] => { const { selectedCityId } = selectedCityStore;
const selectedCityId = selectedCityStore.selectedCityId;
if (!selectedCityId) { const filteredVehicles = useMemo((): Vehicle[] => {
return vehiclesList; if (!selectedCityId) return [];
} return (vehicles.data as Vehicle[]).filter(
(vehicle) => vehicle.vehicle.city_id === selectedCityId,
const carriersInSelectedCityIds = new Set(
carrierStore.carriers.ru.data
.filter((carrier) => carrier.city_id === selectedCityId)
.map((carrier) => carrier.id),
);
if (carriersInSelectedCityIds.size === 0) {
return [];
}
return vehiclesList.filter((vehicle) =>
carriersInSelectedCityIds.has(vehicle.vehicle.carrier_id),
);
};
const filteredVehicles = filterVehiclesBySelectedCity(
vehicles.data as Vehicle[],
); );
}, [selectedCityId, vehicles.data]);
const rows = useMemo( const rows = useMemo(
() => transformToRows(filteredVehicles), () => transformToRows(filteredVehicles),
@@ -636,7 +618,7 @@ export const DevicesTable = observer(() => {
e.stopPropagation(); e.stopPropagation();
navigate(`/vehicle/${row.vehicle_id}/edit`); navigate(`/vehicle/${row.vehicle_id}/edit`);
}} }}
title="Редактировать транспорт" title="Редактировать"
> >
<Pencil size={16} /> <Pencil size={16} />
</button> </button>
@@ -646,7 +628,7 @@ export const DevicesTable = observer(() => {
e.stopPropagation(); e.stopPropagation();
handleReloadStatus(); handleReloadStatus();
}} }}
title="Перезапросить статус" title="Обновить статус"
disabled={ disabled={
!row.device_uuid || !devices.includes(row.device_uuid) !row.device_uuid || !devices.includes(row.device_uuid)
} }
@@ -673,7 +655,7 @@ export const DevicesTable = observer(() => {
setLogsModalOpen(true); setLogsModalOpen(true);
} }
}} }}
title="Логи устройства" title="Логи"
> >
<ScrollText size={16} /> <ScrollText size={16} />
</button> </button>
@@ -686,7 +668,7 @@ export const DevicesTable = observer(() => {
setSessionsModalVehicleTailNumber(row.tail_number); setSessionsModalVehicleTailNumber(row.tail_number);
setSessionsModalOpen(true); setSessionsModalOpen(true);
}} }}
title="Сессии ТО" title="Сессии обслуживания"
> >
<Wrench size={16} /> <Wrench size={16} />
</button> </button>
@@ -748,13 +730,8 @@ export const DevicesTable = observer(() => {
setIsLoading(false); setIsLoading(false);
}; };
fetchData(); fetchData();
}, [getDevices, getSnapshots, getVehicles, getRoutes, isMaintenanceOnly]); }, [getDevices, getSnapshots, getVehicles, getRoutes, isMaintenanceOnly, selectedCityStore.cityVersion]);
useEffect(() => {
if (!isMaintenanceOnly) {
carrierStore.getCarriers("ru");
}
}, [isMaintenanceOnly]);
const handleOpenSendSnapshotModal = () => { const handleOpenSendSnapshotModal = () => {
if (!canWriteDevices) { if (!canWriteDevices) {
@@ -888,6 +865,8 @@ export const DevicesTable = observer(() => {
> >
{isLoading ? ( {isLoading ? (
<CircularProgress size={20} /> <CircularProgress size={20} />
) : !selectedCityId ? (
"Выберите город"
) : ( ) : (
"Нет устройств для отображения" "Нет устройств для отображения"
)} )}
@@ -933,8 +912,12 @@ export const DevicesTable = observer(() => {
checkboxSelection={canWriteDevices} checkboxSelection={canWriteDevices}
disableRowSelectionExcludeModel disableRowSelectionExcludeModel
disableRowSelectionOnClick disableRowSelectionOnClick
loading={isLoading} onRowDoubleClick={(params) => {
paginationModel={paginationModel} if (canWriteDevices) {
navigate(`/vehicle/${params.row.vehicle_id}/edit`);
}
}}
loading={isLoading} paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel} onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]} pageSizeOptions={[50]}
onRowSelectionModelChange={ onRowSelectionModelChange={

View File

@@ -0,0 +1,128 @@
import {
Checkbox,
Typography,
Box,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Radio,
RadioGroup,
} from "@mui/material";
import { ROLE_RESOURCES, getPermissionLevel, applyPermissionChange, type PermissionLevel } from "./constants";
interface PermissionsTableProps {
localRoles: string[];
setLocalRoles: React.Dispatch<React.SetStateAction<string[]>>;
}
export function PermissionsTable({ localRoles, setLocalRoles }: PermissionsTableProps) {
return (
<Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
<Table size="small">
<TableHead>
<TableRow sx={{ bgcolor: "action.hover" }}>
<TableCell sx={{ fontWeight: 600, width: 220 }}>Ресурс</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Нет доступа</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение/Запись</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Доп. права</TableCell>
</TableRow>
</TableHead>
<TableBody>
{ROLE_RESOURCES.map(({ key, label }) => {
const level = getPermissionLevel(localRoles, key);
const isSnapshotResource = key === "snapshot";
const isDevicesResource = key === "devices";
const handleChange = (val: string) => {
setLocalRoles((prev) => {
let updated = applyPermissionChange(prev, key, val as PermissionLevel);
if (key === "devices") {
updated = applyPermissionChange(updated, "vehicles", val as PermissionLevel);
}
return updated;
});
};
const handleSnapshotCreateChange = (checked: boolean) => {
setLocalRoles((prev) => {
const without = prev.filter((role) => role !== "snapshot_create");
return checked ? [...without, "snapshot_create"] : without;
});
};
const handleMaintenanceChange = (checked: boolean) => {
setLocalRoles((prev) => {
const without = prev.filter((r) => r !== "devices_maintenance_rw");
return checked ? [...without, "devices_maintenance_rw"] : without;
});
};
return (
<TableRow key={key} hover>
<TableCell>{label}</TableCell>
<TableCell align="center" padding="checkbox">
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="none" size="small" />
</RadioGroup>
</TableCell>
<TableCell align="center" padding="checkbox">
{isSnapshotResource ? (
<Typography variant="body2" color="text.secondary">-</Typography>
) : (
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="ro" size="small" />
</RadioGroup>
)}
</TableCell>
<TableCell align="center" padding="checkbox">
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="rw" size="small" />
</RadioGroup>
</TableCell>
<TableCell align="center" padding="checkbox">
{isSnapshotResource ? (
<Checkbox
checked={localRoles.includes("snapshot_create")}
onChange={(e) => handleSnapshotCreateChange(e.target.checked)}
size="small"
title="Разрешает создавать новые снапшоты"
/>
) : isDevicesResource ? (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "center" }}>
<Checkbox
checked={localRoles.includes("devices_maintenance_rw")}
onChange={(e) => handleMaintenanceChange(e.target.checked)}
size="small"
title="Техническое обслуживание (ТО)"
/>
</Box>
) : (
<Typography variant="body2" color="text.secondary">-</Typography>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Box>
);
}

View File

@@ -0,0 +1,49 @@
import {
Typography,
Box,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@mui/material";
const ROLE_HINTS = [
{ tab: "Экспорт", roles: "Экспорт (Ч/З)" },
{ tab: "Создание экспорта", roles: "Экспорт (доп. права) + Устройства (Ч/З)" },
{ tab: "Устройства", roles: "Устройства + Транспорт + Маршруты + Перевозчики + Экспорт (Ч/З)" },
{ tab: "Карта", roles: "Маршруты (Ч/З) или Остановки (Ч/З) или Достопримечательности (Ч/З)" },
{ tab: "Пользователи", roles: "Пользователи" },
{ tab: "Достопримечательности", roles: "Достопримечательности" },
{ tab: "Остановки", roles: "Остановки" },
{ tab: "Маршруты", roles: "Маршруты + Перевозчики" },
{ tab: "Страны", roles: "Страны" },
{ tab: "Города", roles: "Города + Страны" },
{ tab: "Перевозчики", roles: "Перевозчики" },
];
export function RolesHintTable() {
return (
<Box sx={{ mt: 2, p: 2, bgcolor: "grey.50", borderRadius: 1, border: "1px solid", borderColor: "divider" }}>
<Typography variant="subtitle2" gutterBottom>
Какие роли нужны для вкладок
</Typography>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Вкладка</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Необходимые роли</TableCell>
</TableRow>
</TableHead>
<TableBody>
{ROLE_HINTS.map(({ tab, roles }) => (
<TableRow key={tab}>
<TableCell sx={{ py: 0.5 }}>{tab}</TableCell>
<TableCell sx={{ py: 0.5 }}>{roles}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
);
}

View File

@@ -0,0 +1,33 @@
export const ROLE_RESOURCES = [
{ key: "snapshot", label: "Экспорт" },
{ key: "devices", label: "Устройства" },
{ key: "vehicles", label: "Транспорт" },
{ key: "users", label: "Пользователи" },
{ key: "sights", label: "Достопримечательности" },
{ key: "stations", label: "Остановки" },
{ key: "routes", label: "Маршруты" },
{ key: "countries", label: "Страны" },
{ key: "cities", label: "Города" },
{ key: "carriers", label: "Перевозчики" },
] as const;
export type PermissionLevel = "none" | "ro" | "rw";
export function getPermissionLevel(roles: string[], resource: string): PermissionLevel {
if (roles.includes(`${resource}_rw`)) return "rw";
if (roles.includes(`${resource}_ro`)) return "ro";
return "none";
}
export function applyPermissionChange(
roles: string[],
resource: string,
level: PermissionLevel,
): string[] {
const filtered = roles.filter(
(r) => r !== `${resource}_ro` && r !== `${resource}_rw`,
);
if (level === "ro") return [...filtered, `${resource}_ro`];
if (level === "rw") return [...filtered, `${resource}_rw`];
return filtered;
}

View File

@@ -0,0 +1,3 @@
export { PermissionsTable } from "./PermissionsTable";
export { RolesHintTable } from "./RolesHintTable";
export { ROLE_RESOURCES, type PermissionLevel, getPermissionLevel, applyPermissionChange } from "./constants";

View File

@@ -164,6 +164,7 @@
position: relative; position: relative;
padding: 7px; padding: 7px;
width: 100%; width: 100%;
height: 60px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-around; justify-content: space-around;

View File

@@ -87,12 +87,14 @@ export const SightsTable = observer(() => {
<TableCell align="center" className="py-3"> <TableCell align="center" className="py-3">
<div className="flex justify-center items-center gap-3"> <div className="flex justify-center items-center gap-3">
<button <button
title="Редактировать"
className="rounded-md px-3 py-1.5 transition-transform transform hover:scale-105" className="rounded-md px-3 py-1.5 transition-transform transform hover:scale-105"
onClick={() => navigate(`/sight/${row?.id}`)} onClick={() => navigate(`/sight/${row?.id}`)}
> >
<Pencil size={18} className="text-blue-500" /> <Pencil size={18} className="text-blue-500" />
</button> </button>
<button <button
title="Удалить"
className="rounded-md px-3 py-1.5 transition-transform transform hover:scale-105" className="rounded-md px-3 py-1.5 transition-transform transform hover:scale-105"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);

View File

@@ -21,3 +21,4 @@ export * from "./SaveWithoutCityAgree";
export * from "./CitySelector"; export * from "./CitySelector";
export * from "./modals"; export * from "./modals";
export * from "./TestingModeBanner"; export * from "./TestingModeBanner";
export * from "./PermissionsTable";

File diff suppressed because one or more lines are too long

474
yarn.lock
View File

@@ -16,7 +16,7 @@
resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz" resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz"
integrity sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA== integrity sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==
"@babel/core@^7.21.3", "@babel/core@^7.28.0": "@babel/core@^7.0.0", "@babel/core@^7.0.0-0", "@babel/core@^7.21.3", "@babel/core@^7.28.0":
version "7.28.5" version "7.28.5"
resolved "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz" resolved "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz"
integrity sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw== integrity sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==
@@ -170,28 +170,6 @@
resolved "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz" resolved "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz"
integrity sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow== integrity sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==
"@emnapi/core@^1.5.0":
version "1.10.0"
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.10.0.tgz#380ccc8f2412ea22d1d972df7f8ee23a3b9c7467"
integrity sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==
dependencies:
"@emnapi/wasi-threads" "1.2.1"
tslib "^2.4.0"
"@emnapi/runtime@^1.5.0":
version "1.10.0"
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.10.0.tgz#4b260c0d3534204e98c6110b8db1a987d26ec87c"
integrity sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==
dependencies:
tslib "^2.4.0"
"@emnapi/wasi-threads@1.2.1", "@emnapi/wasi-threads@^1.1.0":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz#28fed21a1ba1ce797c44a070abc94d42f3ae8548"
integrity sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==
dependencies:
tslib "^2.4.0"
"@emotion/babel-plugin@^11.13.5": "@emotion/babel-plugin@^11.13.5":
version "11.13.5" version "11.13.5"
resolved "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz" resolved "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz"
@@ -237,7 +215,7 @@
resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz" resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz"
integrity sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ== integrity sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==
"@emotion/react@^11.14.0": "@emotion/react@^11.0.0-rc.0", "@emotion/react@^11.14.0", "@emotion/react@^11.4.1", "@emotion/react@^11.5.0", "@emotion/react@^11.9.0":
version "11.14.0" version "11.14.0"
resolved "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz" resolved "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz"
integrity sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA== integrity sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==
@@ -267,7 +245,7 @@
resolved "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz" resolved "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz"
integrity sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg== integrity sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==
"@emotion/styled@^11.14.0": "@emotion/styled@^11.14.0", "@emotion/styled@^11.3.0", "@emotion/styled@^11.8.1":
version "11.14.1" version "11.14.1"
resolved "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz" resolved "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz"
integrity sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw== integrity sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==
@@ -299,136 +277,11 @@
resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz" resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz"
integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg== integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==
"@esbuild/aix-ppc64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz#2ae33300598132cc4cf580dbbb28d30fed3c5c49"
integrity sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==
"@esbuild/android-arm64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz#927708b3db5d739d6cb7709136924cc81bec9b03"
integrity sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==
"@esbuild/android-arm@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.11.tgz#571f94e7f4068957ec4c2cfb907deae3d01b55ae"
integrity sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==
"@esbuild/android-x64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.11.tgz#8a3bf5cae6c560c7ececa3150b2bde76e0fb81e6"
integrity sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==
"@esbuild/darwin-arm64@0.25.11": "@esbuild/darwin-arm64@0.25.11":
version "0.25.11" version "0.25.11"
resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz" resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz"
integrity sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w== integrity sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==
"@esbuild/darwin-x64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz#70f5e925a30c8309f1294d407a5e5e002e0315fe"
integrity sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==
"@esbuild/freebsd-arm64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz#4ec1db687c5b2b78b44148025da9632397553e8a"
integrity sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==
"@esbuild/freebsd-x64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz#4c81abd1b142f1e9acfef8c5153d438ca53f44bb"
integrity sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==
"@esbuild/linux-arm64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz#69517a111acfc2b93aa0fb5eaeb834c0202ccda5"
integrity sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==
"@esbuild/linux-arm@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz#58dac26eae2dba0fac5405052b9002dac088d38f"
integrity sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==
"@esbuild/linux-ia32@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz#b89d4efe9bdad46ba944f0f3b8ddd40834268c2b"
integrity sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==
"@esbuild/linux-loong64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz#11f603cb60ad14392c3f5c94d64b3cc8b630fbeb"
integrity sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==
"@esbuild/linux-mips64el@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz#b7d447ff0676b8ab247d69dac40a5cf08e5eeaf5"
integrity sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==
"@esbuild/linux-ppc64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz#b3a28ed7cc252a61b07ff7c8fd8a984ffd3a2f74"
integrity sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==
"@esbuild/linux-riscv64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz#ce75b08f7d871a75edcf4d2125f50b21dc9dc273"
integrity sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==
"@esbuild/linux-s390x@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz#cd08f6c73b6b6ff9ccdaabbd3ff6ad3dca99c263"
integrity sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==
"@esbuild/linux-x64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz#3c3718af31a95d8946ebd3c32bb1e699bdf74910"
integrity sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==
"@esbuild/netbsd-arm64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz#b4c767082401e3a4e8595fe53c47cd7f097c8077"
integrity sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==
"@esbuild/netbsd-x64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz#f2a930458ed2941d1f11ebc34b9c7d61f7a4d034"
integrity sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==
"@esbuild/openbsd-arm64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz#b4ae93c75aec48bc1e8a0154957a05f0641f2dad"
integrity sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==
"@esbuild/openbsd-x64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz#b42863959c8dcf9b01581522e40012d2c70045e2"
integrity sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==
"@esbuild/openharmony-arm64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz#b2e717141c8fdf6bddd4010f0912e6b39e1640f1"
integrity sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==
"@esbuild/sunos-x64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz#9fbea1febe8778927804828883ec0f6dd80eb244"
integrity sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==
"@esbuild/win32-arm64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz#501539cedb24468336073383989a7323005a8935"
integrity sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==
"@esbuild/win32-ia32@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz#8ac7229aa82cef8f16ffb58f1176a973a7a15343"
integrity sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==
"@esbuild/win32-x64@0.25.11":
version "0.25.11"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz#5ecda6f3fe138b7e456f4e429edde33c823f392f"
integrity sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==
"@eslint-community/eslint-utils@^4.7.0", "@eslint-community/eslint-utils@^4.8.0": "@eslint-community/eslint-utils@^4.7.0", "@eslint-community/eslint-utils@^4.8.0":
version "4.9.0" version "4.9.0"
resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz" resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz"
@@ -479,7 +332,7 @@
minimatch "^3.1.2" minimatch "^3.1.2"
strip-json-comments "^3.1.1" strip-json-comments "^3.1.1"
"@eslint/js@9.38.0", "@eslint/js@^9.25.0": "@eslint/js@^9.25.0", "@eslint/js@9.38.0":
version "9.38.0" version "9.38.0"
resolved "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz" resolved "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz"
integrity sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A== integrity sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==
@@ -589,7 +442,7 @@
dependencies: dependencies:
"@babel/runtime" "^7.28.4" "@babel/runtime" "^7.28.4"
"@mui/material@^7.1.0": "@mui/material@^5.15.14 || ^6.0.0 || ^7.0.0", "@mui/material@^7.1.0", "@mui/material@^7.3.4":
version "7.3.4" version "7.3.4"
resolved "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz" resolved "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz"
integrity sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw== integrity sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==
@@ -628,7 +481,7 @@
csstype "^3.1.3" csstype "^3.1.3"
prop-types "^15.8.1" prop-types "^15.8.1"
"@mui/system@^7.3.3": "@mui/system@^5.15.14 || ^6.0.0 || ^7.0.0", "@mui/system@^7.3.3":
version "7.3.3" version "7.3.3"
resolved "https://registry.npmjs.org/@mui/system/-/system-7.3.3.tgz" resolved "https://registry.npmjs.org/@mui/system/-/system-7.3.3.tgz"
integrity sha512-Lqq3emZr5IzRLKaHPuMaLBDVaGvxoh6z7HMWd1RPKawBM5uMRaQ4ImsmmgXWtwJdfZux5eugfDhXJUo2mliS8Q== integrity sha512-Lqq3emZr5IzRLKaHPuMaLBDVaGvxoh6z7HMWd1RPKawBM5uMRaQ4ImsmmgXWtwJdfZux5eugfDhXJUo2mliS8Q==
@@ -693,13 +546,6 @@
"@mui/utils" "^7.3.3" "@mui/utils" "^7.3.3"
"@mui/x-internals" "8.14.0" "@mui/x-internals" "8.14.0"
"@napi-rs/wasm-runtime@^1.0.7":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz#a46bbfedc29751b7170c5d23bc1d8ee8c7e3c1e1"
integrity sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==
dependencies:
"@tybys/wasm-util" "^0.10.1"
"@nodelib/fs.scandir@2.1.5": "@nodelib/fs.scandir@2.1.5":
version "2.1.5" version "2.1.5"
resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz"
@@ -708,7 +554,7 @@
"@nodelib/fs.stat" "2.0.5" "@nodelib/fs.stat" "2.0.5"
run-parallel "^1.1.9" run-parallel "^1.1.9"
"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": "@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5":
version "2.0.5" version "2.0.5"
resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz" resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
@@ -726,7 +572,7 @@
resolved "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz" resolved "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz"
integrity sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g== integrity sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==
"@photo-sphere-viewer/core@^5.13.2": "@photo-sphere-viewer/core@^5.13.2", "@photo-sphere-viewer/core@>=5.13.1":
version "5.14.0" version "5.14.0"
resolved "https://registry.npmjs.org/@photo-sphere-viewer/core/-/core-5.14.0.tgz" resolved "https://registry.npmjs.org/@photo-sphere-viewer/core/-/core-5.14.0.tgz"
integrity sha512-V0JeDSB1D2Q60Zqn7+0FPjq8gqbKEwuxMzNdTLydefkQugVztLvdZykO+4k5XTpweZ2QAWPH/QOI1xZbsdvR9A== integrity sha512-V0JeDSB1D2Q60Zqn7+0FPjq8gqbKEwuxMzNdTLydefkQugVztLvdZykO+4k5XTpweZ2QAWPH/QOI1xZbsdvR9A==
@@ -778,7 +624,7 @@
utility-types "^3.11.0" utility-types "^3.11.0"
zustand "^5.0.1" zustand "^5.0.1"
"@react-three/fiber@^9.1.2": "@react-three/fiber@^9.0.0", "@react-three/fiber@^9.1.2":
version "9.4.0" version "9.4.0"
resolved "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz" resolved "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz"
integrity sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g== integrity sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==
@@ -810,116 +656,11 @@
estree-walker "^2.0.2" estree-walker "^2.0.2"
picomatch "^4.0.2" picomatch "^4.0.2"
"@rollup/rollup-android-arm-eabi@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz#0f44a2f8668ed87b040b6fe659358ac9239da4db"
integrity sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==
"@rollup/rollup-android-arm64@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz#25b9a01deef6518a948431564c987bcb205274f5"
integrity sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==
"@rollup/rollup-darwin-arm64@4.52.5": "@rollup/rollup-darwin-arm64@4.52.5":
version "4.52.5" version "4.52.5"
resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz" resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz"
integrity sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA== integrity sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==
"@rollup/rollup-darwin-x64@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz#8e526417cd6f54daf1d0c04cf361160216581956"
integrity sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==
"@rollup/rollup-freebsd-arm64@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz#0e7027054493f3409b1f219a3eac5efd128ef899"
integrity sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==
"@rollup/rollup-freebsd-x64@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz#72b204a920139e9ec3d331bd9cfd9a0c248ccb10"
integrity sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==
"@rollup/rollup-linux-arm-gnueabihf@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz#ab1b522ebe5b7e06c99504cc38f6cd8b808ba41c"
integrity sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==
"@rollup/rollup-linux-arm-musleabihf@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz#f8cc30b638f1ee7e3d18eac24af47ea29d9beb00"
integrity sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==
"@rollup/rollup-linux-arm64-gnu@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz#7af37a9e85f25db59dc8214172907b7e146c12cc"
integrity sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==
"@rollup/rollup-linux-arm64-musl@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz#a623eb0d3617c03b7a73716eb85c6e37b776f7e0"
integrity sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==
"@rollup/rollup-linux-loong64-gnu@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz#76ea038b549c5c6c5f0d062942627c4066642ee2"
integrity sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==
"@rollup/rollup-linux-ppc64-gnu@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz#d9a4c3f0a3492bc78f6fdfe8131ac61c7359ccd5"
integrity sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==
"@rollup/rollup-linux-riscv64-gnu@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz#87ab033eebd1a9a1dd7b60509f6333ec1f82d994"
integrity sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==
"@rollup/rollup-linux-riscv64-musl@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz#bda3eb67e1c993c1ba12bc9c2f694e7703958d9f"
integrity sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==
"@rollup/rollup-linux-s390x-gnu@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz#f7bc10fbe096ab44694233dc42a2291ed5453d4b"
integrity sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==
"@rollup/rollup-linux-x64-gnu@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz#a151cb1234cc9b2cf5e8cfc02aa91436b8f9e278"
integrity sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==
"@rollup/rollup-linux-x64-musl@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz#7859e196501cc3b3062d45d2776cfb4d2f3a9350"
integrity sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==
"@rollup/rollup-openharmony-arm64@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz#85d0df7233734df31e547c1e647d2a5300b3bf30"
integrity sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==
"@rollup/rollup-win32-arm64-msvc@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz#e62357d00458db17277b88adbf690bb855cac937"
integrity sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==
"@rollup/rollup-win32-ia32-msvc@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz#fc7cd40f44834a703c1f1c3fe8bcc27ce476cd50"
integrity sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==
"@rollup/rollup-win32-x64-gnu@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz#1a22acfc93c64a64a48c42672e857ee51774d0d3"
integrity sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==
"@rollup/rollup-win32-x64-msvc@4.52.5":
version "4.52.5"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz#1657f56326bbe0ac80eedc9f9c18fc1ddd24e107"
integrity sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==
"@svgr/babel-plugin-add-jsx-attribute@8.0.0": "@svgr/babel-plugin-add-jsx-attribute@8.0.0":
version "8.0.0" version "8.0.0"
resolved "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz" resolved "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz"
@@ -974,7 +715,7 @@
"@svgr/babel-plugin-transform-react-native-svg" "8.1.0" "@svgr/babel-plugin-transform-react-native-svg" "8.1.0"
"@svgr/babel-plugin-transform-svg-component" "8.0.0" "@svgr/babel-plugin-transform-svg-component" "8.0.0"
"@svgr/core@^8.1.0": "@svgr/core@*", "@svgr/core@^8.1.0":
version "8.1.0" version "8.1.0"
resolved "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz" resolved "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz"
integrity sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA== integrity sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==
@@ -1016,73 +757,11 @@
source-map-js "^1.2.1" source-map-js "^1.2.1"
tailwindcss "4.1.16" tailwindcss "4.1.16"
"@tailwindcss/oxide-android-arm64@4.1.16":
version "4.1.16"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.16.tgz#9bd16c0a08db20d7c93907a9bd1564e0255307eb"
integrity sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==
"@tailwindcss/oxide-darwin-arm64@4.1.16": "@tailwindcss/oxide-darwin-arm64@4.1.16":
version "4.1.16" version "4.1.16"
resolved "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz" resolved "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz"
integrity sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA== integrity sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==
"@tailwindcss/oxide-darwin-x64@4.1.16":
version "4.1.16"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.16.tgz#6193bafbb1a885795702f12bbef9cc5eb4cc550b"
integrity sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==
"@tailwindcss/oxide-freebsd-x64@4.1.16":
version "4.1.16"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.16.tgz#0e2b064d71ba87a9001ac963be2752a8ddb64349"
integrity sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==
"@tailwindcss/oxide-linux-arm-gnueabihf@4.1.16":
version "4.1.16"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.16.tgz#8e80c959eeda81a08ed955e23eb6d228287b9672"
integrity sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==
"@tailwindcss/oxide-linux-arm64-gnu@4.1.16":
version "4.1.16"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.16.tgz#d5f54910920fc5808122515f5208c5ecc1a40545"
integrity sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==
"@tailwindcss/oxide-linux-arm64-musl@4.1.16":
version "4.1.16"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.16.tgz#67cdb932230ac47bf3bf5415ccc92417b27020ee"
integrity sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==
"@tailwindcss/oxide-linux-x64-gnu@4.1.16":
version "4.1.16"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.16.tgz#80ae0cfd8ebc970f239060ecdfdd07f6f6b14dce"
integrity sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==
"@tailwindcss/oxide-linux-x64-musl@4.1.16":
version "4.1.16"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.16.tgz#524e5b87e8e79a712de3d9bbb94d2fc2fa44391c"
integrity sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==
"@tailwindcss/oxide-wasm32-wasi@4.1.16":
version "4.1.16"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.16.tgz#dc31d6bc1f6c1e8119a335ae3f28deb4d7c560f2"
integrity sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==
dependencies:
"@emnapi/core" "^1.5.0"
"@emnapi/runtime" "^1.5.0"
"@emnapi/wasi-threads" "^1.1.0"
"@napi-rs/wasm-runtime" "^1.0.7"
"@tybys/wasm-util" "^0.10.1"
tslib "^2.4.0"
"@tailwindcss/oxide-win32-arm64-msvc@4.1.16":
version "4.1.16"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.16.tgz#f1f810cdb49dae8071d5edf0db5cc0da2ec6a7e8"
integrity sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==
"@tailwindcss/oxide-win32-x64-msvc@4.1.16":
version "4.1.16"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.16.tgz#76dcda613578f06569c0a6015f39f12746a24dce"
integrity sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==
"@tailwindcss/oxide@4.1.16": "@tailwindcss/oxide@4.1.16":
version "4.1.16" version "4.1.16"
resolved "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz" resolved "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz"
@@ -1122,13 +801,6 @@
resolved "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz" resolved "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz"
integrity sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA== integrity sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==
"@tybys/wasm-util@^0.10.1":
version "0.10.1"
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414"
integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==
dependencies:
tslib "^2.4.0"
"@types/babel__core@^7.20.5": "@types/babel__core@^7.20.5":
version "7.20.5" version "7.20.5"
resolved "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz" resolved "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz"
@@ -1198,7 +870,7 @@
dependencies: dependencies:
"@types/estree" "*" "@types/estree" "*"
"@types/estree@*", "@types/estree@1.0.8", "@types/estree@^1.0.0", "@types/estree@^1.0.6": "@types/estree@*", "@types/estree@^1.0.0", "@types/estree@^1.0.6", "@types/estree@1.0.8":
version "1.0.8" version "1.0.8"
resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz" resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz"
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
@@ -1237,7 +909,7 @@
resolved "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz" resolved "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz"
integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==
"@types/node@^22.15.24": "@types/node@^18.0.0 || ^20.0.0 || >=22.0.0", "@types/node@^22.15.24":
version "22.18.13" version "22.18.13"
resolved "https://registry.npmjs.org/@types/node/-/node-22.18.13.tgz" resolved "https://registry.npmjs.org/@types/node/-/node-22.18.13.tgz"
integrity sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A== integrity sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A==
@@ -1284,7 +956,7 @@
resolved "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz" resolved "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz"
integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w== integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==
"@types/react@^19.1.2": "@types/react@*", "@types/react@^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react@^18.2.25 || ^19", "@types/react@^19.1.2", "@types/react@^19.2.0", "@types/react@>=16.8", "@types/react@>=18", "@types/react@>=18.0.0":
version "19.2.2" version "19.2.2"
resolved "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz" resolved "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz"
integrity sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA== integrity sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==
@@ -1303,7 +975,7 @@
dependencies: dependencies:
"@types/estree" "*" "@types/estree" "*"
"@types/three@*": "@types/three@*", "@types/three@>=0.134.0":
version "0.180.0" version "0.180.0"
resolved "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz" resolved "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz"
integrity sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg== integrity sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==
@@ -1351,7 +1023,7 @@
natural-compare "^1.4.0" natural-compare "^1.4.0"
ts-api-utils "^2.1.0" ts-api-utils "^2.1.0"
"@typescript-eslint/parser@8.46.2": "@typescript-eslint/parser@^8.46.2", "@typescript-eslint/parser@8.46.2":
version "8.46.2" version "8.46.2"
resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz" resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz"
integrity sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g== integrity sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==
@@ -1379,7 +1051,7 @@
"@typescript-eslint/types" "8.46.2" "@typescript-eslint/types" "8.46.2"
"@typescript-eslint/visitor-keys" "8.46.2" "@typescript-eslint/visitor-keys" "8.46.2"
"@typescript-eslint/tsconfig-utils@8.46.2", "@typescript-eslint/tsconfig-utils@^8.46.2": "@typescript-eslint/tsconfig-utils@^8.46.2", "@typescript-eslint/tsconfig-utils@8.46.2":
version "8.46.2" version "8.46.2"
resolved "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz" resolved "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz"
integrity sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag== integrity sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==
@@ -1395,7 +1067,7 @@
debug "^4.3.4" debug "^4.3.4"
ts-api-utils "^2.1.0" ts-api-utils "^2.1.0"
"@typescript-eslint/types@8.46.2", "@typescript-eslint/types@^8.46.2": "@typescript-eslint/types@^8.46.2", "@typescript-eslint/types@8.46.2":
version "8.46.2" version "8.46.2"
resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz" resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz"
integrity sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ== integrity sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==
@@ -1478,7 +1150,7 @@ acorn-jsx@^5.3.2:
resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
acorn@^8.15.0: "acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.15.0:
version "8.15.0" version "8.15.0"
resolved "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz" resolved "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz"
integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
@@ -1582,7 +1254,7 @@ braces@^3.0.3:
dependencies: dependencies:
fill-range "^7.1.1" fill-range "^7.1.1"
browserslist@^4.24.0: browserslist@^4.24.0, "browserslist@>= 4.21.0":
version "4.27.0" version "4.27.0"
resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz"
integrity sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw== integrity sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==
@@ -1879,7 +1551,7 @@ earcut@^3.0.0, earcut@^3.0.2:
resolved "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz" resolved "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz"
integrity sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ== integrity sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==
easymde@^2.20.0: easymde@^2.20.0, "easymde@>= 2.0.0 < 3.0.0":
version "2.20.0" version "2.20.0"
resolved "https://registry.npmjs.org/easymde/-/easymde-2.20.0.tgz" resolved "https://registry.npmjs.org/easymde/-/easymde-2.20.0.tgz"
integrity sha512-V1Z5f92TfR42Na852OWnIZMbM7zotWQYTddNaLYZFVKj7APBbyZ3FYJ27gBw2grMW3R6Qdv9J8n5Ij7XRSIgXQ== integrity sha512-V1Z5f92TfR42Na852OWnIZMbM7zotWQYTddNaLYZFVKj7APBbyZ3FYJ27gBw2grMW3R6Qdv9J8n5Ij7XRSIgXQ==
@@ -2022,7 +1694,7 @@ eslint-visitor-keys@^4.2.1:
resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz" resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz"
integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
eslint@^9.25.0: "eslint@^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0", "eslint@^6.0.0 || ^7.0.0 || >=8.0.0", "eslint@^8.57.0 || ^9.0.0", eslint@^9.25.0, eslint@>=8.40:
version "9.38.0" version "9.38.0"
resolved "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz" resolved "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz"
integrity sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw== integrity sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==
@@ -2148,7 +1820,12 @@ fastq@^1.6.0:
dependencies: dependencies:
reusify "^1.0.4" reusify "^1.0.4"
fdir@^6.4.4, fdir@^6.5.0: fdir@^6.4.4:
version "6.5.0"
resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz"
integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
fdir@^6.5.0:
version "6.5.0" version "6.5.0"
resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz" resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz"
integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
@@ -2612,7 +2289,7 @@ its-fine@^2.0.0:
dependencies: dependencies:
"@types/react-reconciler" "^0.28.9" "@types/react-reconciler" "^0.28.9"
jiti@^2.6.1: jiti@*, jiti@^2.6.1, jiti@>=1.21.0:
version "2.6.1" version "2.6.1"
resolved "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz" resolved "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz"
integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ== integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==
@@ -2691,62 +2368,12 @@ lie@^3.0.2:
dependencies: dependencies:
immediate "~3.0.5" immediate "~3.0.5"
lightningcss-android-arm64@1.30.2:
version "1.30.2"
resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz#6966b7024d39c94994008b548b71ab360eb3a307"
integrity sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==
lightningcss-darwin-arm64@1.30.2: lightningcss-darwin-arm64@1.30.2:
version "1.30.2" version "1.30.2"
resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz" resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz"
integrity sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA== integrity sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==
lightningcss-darwin-x64@1.30.2: lightningcss@^1.21.0, lightningcss@1.30.2:
version "1.30.2"
resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz#5ce87e9cd7c4f2dcc1b713f5e8ee185c88d9b7cd"
integrity sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==
lightningcss-freebsd-x64@1.30.2:
version "1.30.2"
resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz#6ae1d5e773c97961df5cff57b851807ef33692a5"
integrity sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==
lightningcss-linux-arm-gnueabihf@1.30.2:
version "1.30.2"
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz#62c489610c0424151a6121fa99d77731536cdaeb"
integrity sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==
lightningcss-linux-arm64-gnu@1.30.2:
version "1.30.2"
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz#2a3661b56fe95a0cafae90be026fe0590d089298"
integrity sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==
lightningcss-linux-arm64-musl@1.30.2:
version "1.30.2"
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz#d7ddd6b26959245e026bc1ad9eb6aa983aa90e6b"
integrity sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==
lightningcss-linux-x64-gnu@1.30.2:
version "1.30.2"
resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz#5a89814c8e63213a5965c3d166dff83c36152b1a"
integrity sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==
lightningcss-linux-x64-musl@1.30.2:
version "1.30.2"
resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz#808c2e91ce0bf5d0af0e867c6152e5378c049728"
integrity sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==
lightningcss-win32-arm64-msvc@1.30.2:
version "1.30.2"
resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz#ab4a8a8a2e6a82a4531e8bbb6bf0ff161ee6625a"
integrity sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==
lightningcss-win32-x64-msvc@1.30.2:
version "1.30.2"
resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz#f01f382c8e0a27e1c018b0bee316d210eac43b6e"
integrity sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==
lightningcss@1.30.2:
version "1.30.2" version "1.30.2"
resolved "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz" resolved "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz"
integrity sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ== integrity sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==
@@ -3195,7 +2822,7 @@ mobx-react-lite@^4.1.0:
dependencies: dependencies:
use-sync-external-store "^1.4.0" use-sync-external-store "^1.4.0"
mobx@^6.13.7: mobx@^6.13.7, mobx@^6.9.0:
version "6.15.0" version "6.15.0"
resolved "https://registry.npmjs.org/mobx/-/mobx-6.15.0.tgz" resolved "https://registry.npmjs.org/mobx/-/mobx-6.15.0.tgz"
integrity sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g== integrity sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g==
@@ -3270,7 +2897,7 @@ overlayscrollbars-react@^0.5.6:
resolved "https://registry.npmjs.org/overlayscrollbars-react/-/overlayscrollbars-react-0.5.6.tgz" resolved "https://registry.npmjs.org/overlayscrollbars-react/-/overlayscrollbars-react-0.5.6.tgz"
integrity sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw== integrity sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw==
overlayscrollbars@^2.15.1: overlayscrollbars@^2.0.0, overlayscrollbars@^2.15.1:
version "2.15.1" version "2.15.1"
resolved "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.15.1.tgz" resolved "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.15.1.tgz"
integrity sha512-glX26JwjL+Tkzv0JNOWdW4VozP5dGXO+Wx8+TPrdTEJTSYT/8eJS8yXM+fewjU0nFq/JeCa+X+BqABNjC4YZSA== integrity sha512-glX26JwjL+Tkzv0JNOWdW4VozP5dGXO+Wx8+TPrdTEJTSYT/8eJS8yXM+fewjU0nFq/JeCa+X+BqABNjC4YZSA==
@@ -3386,12 +3013,12 @@ picomatch@^2.3.1:
resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
picomatch@^4.0.2, picomatch@^4.0.3: "picomatch@^3 || ^4", picomatch@^4.0.2, picomatch@^4.0.3:
version "4.0.3" version "4.0.3"
resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz" resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz"
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
pixi.js@^8.10.1: pixi.js@^8.10.1, pixi.js@^8.2.6:
version "8.14.0" version "8.14.0"
resolved "https://registry.npmjs.org/pixi.js/-/pixi.js-8.14.0.tgz" resolved "https://registry.npmjs.org/pixi.js/-/pixi.js-8.14.0.tgz"
integrity sha512-ituDiEBb1Oqx56RYwTtC6MjPUhPfF/i15fpUv5oEqmzC/ce3SaSumulJcOjKG7+y0J0Ekl9Rl4XTxaUw+MVFZw== integrity sha512-ituDiEBb1Oqx56RYwTtC6MjPUhPfF/i15fpUv5oEqmzC/ce3SaSumulJcOjKG7+y0J0Ekl9Rl4XTxaUw+MVFZw==
@@ -3448,7 +3075,7 @@ promise-worker-transferable@^1.0.4:
is-promise "^2.1.0" is-promise "^2.1.0"
lie "^3.0.2" lie "^3.0.2"
prop-types@^15.6.2, prop-types@^15.8.1: prop-types@^15.5.4, prop-types@^15.6.2, prop-types@^15.8.1:
version "15.8.1" version "15.8.1"
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz" resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@@ -3509,14 +3136,19 @@ rbush@^4.0.0:
dependencies: dependencies:
quickselect "^3.0.0" quickselect "^3.0.0"
react-dom@^19.1.0: "react-dom@^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom@^18 || ^19", "react-dom@^18.0.0 || ^19.0.0", react-dom@^19, react-dom@^19.0.0, react-dom@^19.1.0, react-dom@>=16.0.0, react-dom@>=16.13, react-dom@>=16.6.0, react-dom@>=16.8.2, react-dom@>=18:
version "19.2.0" version "19.2.0"
resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz" resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz"
integrity sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ== integrity sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==
dependencies: dependencies:
scheduler "^0.27.0" scheduler "^0.27.0"
react-is@^16.13.1, react-is@^16.7.0: react-is@^16.13.1:
version "16.13.1"
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-is@^16.7.0:
version "16.13.1" version "16.13.1"
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
@@ -3550,7 +3182,7 @@ react-photo-sphere-viewer@^6.2.3:
dependencies: dependencies:
eventemitter3 "^5.0.1" eventemitter3 "^5.0.1"
react-reconciler@0.31.0, react-reconciler@^0.31.0: react-reconciler@^0.31.0, react-reconciler@0.31.0:
version "0.31.0" version "0.31.0"
resolved "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz" resolved "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz"
integrity sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ== integrity sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==
@@ -3577,7 +3209,7 @@ react-router-dom@^7.6.1:
dependencies: dependencies:
react-router "7.9.4" react-router "7.9.4"
react-router@7.9.4, react-router@^7.9.4: react-router@^7.9.4, react-router@7.9.4:
version "7.9.4" version "7.9.4"
resolved "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz" resolved "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz"
integrity sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA== integrity sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA==
@@ -3614,12 +3246,12 @@ react-use-measure@^2.1.7:
resolved "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz" resolved "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz"
integrity sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg== integrity sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==
react@^19.1.0: "react@^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8.0 || ^17 || ^18 || ^19", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^17.0.0 || ^18.0.0 || ^19.0.0", "react@^18 || ^19", "react@^18.0 || ^19", "react@^18.0.0 || ^19.0.0", react@^19, react@^19.0.0, react@^19.1.0, react@^19.2.0, "react@>= 16.8.0", react@>=16.0.0, react@>=16.13, react@>=16.6.0, react@>=16.8, react@>=16.8.0, react@>=16.8.2, react@>=17.0, react@>=18, react@>=18.0.0, react@>=19.0.0:
version "19.2.0" version "19.2.0"
resolved "https://registry.npmjs.org/react/-/react-19.2.0.tgz" resolved "https://registry.npmjs.org/react/-/react-19.2.0.tgz"
integrity sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ== integrity sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==
redux@^5.0.1: redux@^5.0.0, redux@^5.0.1:
version "5.0.1" version "5.0.1"
resolved "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz" resolved "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz"
integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w== integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==
@@ -3705,7 +3337,7 @@ rollup-plugin-visualizer@^6.0.5:
source-map "^0.7.4" source-map "^0.7.4"
yargs "^17.5.1" yargs "^17.5.1"
rollup@^4.34.9: rollup@^1.20.0||^2.0.0||^3.0.0||^4.0.0, rollup@^4.34.9, "rollup@2.x || 3.x || 4.x":
version "4.52.5" version "4.52.5"
resolved "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz" resolved "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz"
integrity sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw== integrity sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==
@@ -3891,7 +3523,7 @@ svg-parser@^2.0.4:
resolved "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz" resolved "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz"
integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ== integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==
tailwindcss@4.1.16, tailwindcss@^4.1.8: tailwindcss@^4.1.8, "tailwindcss@>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1", tailwindcss@4.1.16:
version "4.1.16" version "4.1.16"
resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz" resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz"
integrity sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA== integrity sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==
@@ -3923,7 +3555,7 @@ three@^0.170.0:
resolved "https://registry.npmjs.org/three/-/three-0.170.0.tgz" resolved "https://registry.npmjs.org/three/-/three-0.170.0.tgz"
integrity sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ== integrity sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==
three@^0.177.0: three@^0.177.0, "three@>= 0.159.0", three@>=0.125.0, three@>=0.126.1, three@>=0.128.0, three@>=0.134.0, three@>=0.137, three@>=0.156, three@>=0.159:
version "0.177.0" version "0.177.0"
resolved "https://registry.npmjs.org/three/-/three-0.177.0.tgz" resolved "https://registry.npmjs.org/three/-/three-0.177.0.tgz"
integrity sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg== integrity sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg==
@@ -3993,9 +3625,9 @@ ts-api-utils@^2.1.0:
resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz" resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz"
integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ== integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==
tslib@^2.0.3, tslib@^2.4.0: tslib@^2.0.3:
version "2.8.1" version "2.8.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
tunnel-rat@^0.1.2: tunnel-rat@^0.1.2:
@@ -4022,7 +3654,7 @@ typescript-eslint@^8.30.1:
"@typescript-eslint/typescript-estree" "8.46.2" "@typescript-eslint/typescript-estree" "8.46.2"
"@typescript-eslint/utils" "8.46.2" "@typescript-eslint/utils" "8.46.2"
typescript@~5.8.3: typescript@>=4.8.4, "typescript@>=4.8.4 <6.0.0", typescript@>=4.9.5, typescript@~5.8.3:
version "5.8.3" version "5.8.3"
resolved "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz" resolved "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz"
integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==
@@ -4103,7 +3735,7 @@ uri-js@^4.2.2:
dependencies: dependencies:
punycode "^2.1.0" punycode "^2.1.0"
use-sync-external-store@^1.2.2, use-sync-external-store@^1.4.0, use-sync-external-store@^1.6.0: use-sync-external-store@^1.2.2, use-sync-external-store@^1.4.0, use-sync-external-store@^1.6.0, use-sync-external-store@>=1.2.0:
version "1.6.0" version "1.6.0"
resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz" resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz"
integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==
@@ -4163,7 +3795,7 @@ vite-plugin-svgr@^4.5.0:
"@svgr/core" "^8.1.0" "@svgr/core" "^8.1.0"
"@svgr/plugin-jsx" "^8.1.0" "@svgr/plugin-jsx" "^8.1.0"
vite@^6.3.5: "vite@^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", "vite@^5.2.0 || ^6 || ^7", vite@^6.3.5, vite@>=2.6.0:
version "6.4.1" version "6.4.1"
resolved "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz" resolved "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz"
integrity sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g== integrity sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==