Compare commits

..

3 Commits

Author SHA1 Message Date
Георгий
b3d902ab74 feat: deploy admin panel to stage
Some checks failed
Build White Nights admin stage / build-and-deploy (push) Failing after 14s
2026-10-27 22:19:45 +03:00
fd582cbab8 feat: support custom maintenance mode duration 2026-10-18 15:07:34 +03:00
71834904a1 feature/webgl-preview-update (#21)
Co-authored-by: itoshi <kkzemeow@gmail.com>
Reviewed-on: #21
2026-10-13 16:49:18 +00:00
73 changed files with 1861 additions and 4103 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.git
node_modules
dist
dist-ssr
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
# Backend content/auth API and media endpoint.
VITE_API_URL="https://server.white-nights-stage.makkah.verstack.ru"
VITE_KRBL_MEDIA="https://server.white-nights-stage.makkah.verstack.ru/media/"
# Device APIs used by the embedded route demo at /demo/:id.
VITE_GEO_API_URL="https://device.white-nights-stage.makkah.verstack.ru"
VITE_WEATHER_API_URL="https://device.white-nights-stage.makkah.verstack.ru"
VITE_NEED_AUTH="true"

View File

@@ -0,0 +1,57 @@
name: Build White Nights admin stage
on:
workflow_dispatch:
push:
branches:
- feature/stage-service-endpoints
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set image metadata
id: meta
run: |
IMAGE_PREFIX="${{ secrets.REGISTRY_URL }}/krbl/whitenightsadminpanel"
echo "IMAGE_PREFIX=${IMAGE_PREFIX}" >> "$GITEA_OUTPUT"
echo "IMAGE_TAG=$(echo \"${{ gitea.sha }}\" | cut -c1-7)" >> "$GITEA_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the container registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.REGISTRY_URL }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push admin panel
uses: docker/build-push-action@v5
with:
context: .
push: true
build-args: |
VITE_API_URL=https://server.white-nights-stage.makkah.verstack.ru
VITE_KRBL_MEDIA=https://server.white-nights-stage.makkah.verstack.ru/media/
VITE_GEO_API_URL=https://device.white-nights-stage.makkah.verstack.ru
VITE_WEATHER_API_URL=https://device.white-nights-stage.makkah.verstack.ru
tags: |
${{ steps.meta.outputs.IMAGE_PREFIX }}/service-admin-panel:${{ steps.meta.outputs.IMAGE_TAG }}
${{ steps.meta.outputs.IMAGE_PREFIX }}/service-admin-panel:stage
- name: Restart backend pod to pick up the stage frontend
env:
KUBE_CONFIG_DATA: ${{ secrets.WNS_KUBE_CONFIG }}
run: |
set -eu
KUBECONFIG_PATH="$RUNNER_TEMP/wn-kubeconfig"
printf '%s' "$KUBE_CONFIG_DATA" > "$KUBECONFIG_PATH"
chmod 600 "$KUBECONFIG_PATH"
export KUBECONFIG="$KUBECONFIG_PATH"
kubectl -n white-nights-stage rollout restart deployment/wn-backend
kubectl -n white-nights-stage rollout status deployment/wn-backend --timeout=5m

26
.gitignore vendored
View File

@@ -1,14 +1,7 @@
# Logs
<<<<<<< HEAD
logs
_.log
npm-debug.log_
=======
logs
*.log
npm-debug.log*
>>>>>>> 6b9aa78 (init: Init React Application)
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
@@ -17,23 +10,11 @@ lerna-debug.log*
node_modules
dist
dist-ssr
<<<<<<< HEAD
\*.local
# Editor directories and files
.vscode/_
!.vscode/extensions.json
.idea
.DS_Store
_.suo
_.ntvs_
_.njsproj
_.sln
\*.sw?
=======
*.local
# Build artifacts
*.tsbuildinfo
# Editor directories and files
.vscode/*
!.vscode/extensions.json
@@ -44,4 +25,3 @@ _.sln
*.njsproj
*.sln
*.sw?
>>>>>>> 6b9aa78 (init: Init React Application)

View File

@@ -1,5 +1,5 @@
# Stage 1: Build the application
FROM node:20-alpine AS build
FROM node:24-alpine AS build
# Set working directory
WORKDIR /app
@@ -7,6 +7,13 @@ WORKDIR /app
# Copy package.json and yarn.lock
COPY package.json yarn.lock ./
# Vite embeds these values into the static bundle at build time. When an
# argument is omitted, the checked-in .env remains the fallback.
ARG VITE_API_URL
ARG VITE_GEO_API_URL
ARG VITE_WEATHER_API_URL
ARG VITE_KRBL_MEDIA
# Install dependencies
RUN yarn install --frozen-lockfile
@@ -14,19 +21,23 @@ RUN yarn install --frozen-lockfile
COPY . .
# Build the application
RUN yarn build
RUN if [ -n "${VITE_API_URL:-}" ]; then export VITE_API_URL; fi && \
if [ -n "${VITE_GEO_API_URL:-}" ]; then export VITE_GEO_API_URL; fi && \
if [ -n "${VITE_WEATHER_API_URL:-}" ]; then export VITE_WEATHER_API_URL; fi && \
if [ -n "${VITE_KRBL_MEDIA:-}" ]; then export VITE_KRBL_MEDIA; fi && \
yarn build
# Stage 2: Serve the application with Nginx
FROM nginx:alpine
FROM nginxinc/nginx-unprivileged:alpine
# Copy the built application from the build stage
COPY --from=build /app/dist /usr/share/nginx/html
# Copy nginx configuration (optional, can be added later if needed)
# COPY nginx.conf /etc/nginx/conf.d/default.conf
# Keep client-side routes working on direct navigation and page refresh.
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port 80
EXPOSE 80
# The unprivileged image cannot bind to port 80.
EXPOSE 8080
# Start Nginx server
CMD ["nginx", "-g", "daemon off;"]
CMD ["nginx", "-g", "daemon off;"]

22
STAGE_DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,22 @@
# Stage deployment
The admin panel is a static Vite bundle. The backend and device remain
separate services.
Build a stage image with the service URLs embedded into the bundle:
```bash
docker build \
--build-arg VITE_API_URL=https://server.white-nights-stage.makkah.verstack.ru \
--build-arg VITE_KRBL_MEDIA=https://server.white-nights-stage.makkah.verstack.ru/media/ \
--build-arg VITE_GEO_API_URL=https://device.white-nights-stage.makkah.verstack.ru \
--build-arg VITE_WEATHER_API_URL=https://device.white-nights-stage.makkah.verstack.ru \
-t white-nights-admin-panel:stage .
```
The backend URL serves content, authentication, media and admin API requests.
The device URL serves geolocation and weather requests used by `/demo/:id`.
The device ingress must publish the geolocation and weather paths to the
corresponding device ports. The browser must also be authorized by Authelia
and the backend must allow the frontend origin through CORS.

11
nginx.conf Normal file
View File

@@ -0,0 +1,11 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

4
package-lock.json generated
View File

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

View File

@@ -18,7 +18,6 @@
"@mui/material": "^7.1.0",
"@mui/x-data-grid": "^8.5.1",
"@photo-sphere-viewer/core": "^5.13.2",
"@pixi/react": "^8.0.2",
"@react-three/drei": "^10.1.2",
"@react-three/fiber": "^9.1.2",
"@tailwindcss/vite": "^4.1.8",
@@ -33,7 +32,6 @@
"overlayscrollbars": "^2.15.1",
"overlayscrollbars-react": "^0.5.6",
"path": "^0.12.7",
"pixi.js": "^8.10.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",

View File

@@ -23,6 +23,7 @@ const App = observer(() => {
loadingStatus,
loadingProgress,
context,
carrier,
setIsLoading,
setLoadingStatus,
setLoadingProgress,
@@ -172,6 +173,7 @@ const App = observer(() => {
loadingStatus={loadingStatus}
loadingProgress={loadingProgress}
onToggleDebug={() => setIsDebugVisible((prev) => !prev)}
videoId={carrier?.video_preview}
/>
) : (
<div>

View File

@@ -77,6 +77,8 @@ export type GetCarrierResponse = {
full_name: string;
id: number;
logo: string;
preview_media?: string;
video_preview?: string;
short_name: string;
slogan: string;
main_color?: string;

View File

@@ -1,11 +1,24 @@
import axios from "axios";
const envUrl = import.meta.env.VITE_API_URL || "localhost";
const baseURL = envUrl.includes("://") ? envUrl : `http://${envUrl}`;
const isFullUrl =
baseURL.includes("://") &&
!envUrl.includes("localhost") &&
!envUrl.includes("127.0.0.1");
const trimTrailingSlash = (value) => value.replace(/\/+$/, "");
const serviceURL = (rawValue, defaultPort) => {
const value = String(rawValue || "").trim() || "localhost";
const hasScheme = value.includes("://");
const url = new URL(hasScheme ? value : `http://${value}`);
// Bare local hosts use service-specific ports. Full URLs keep their
// configured scheme and port, which is required behind ingress.
if (!hasScheme && !url.port) {
url.port = String(defaultPort);
}
return trimTrailingSlash(url.toString());
};
const apiURL = import.meta.env.VITE_API_URL || "localhost";
const geoURL = import.meta.env.VITE_GEO_API_URL || apiURL;
const weatherURL = import.meta.env.VITE_WEATHER_API_URL || apiURL;
const addAuthInterceptor = (instance) => {
instance.interceptors.request.use((config) => {
@@ -17,9 +30,9 @@ const addAuthInterceptor = (instance) => {
});
};
export const apiBaseURL = isFullUrl ? baseURL : `${baseURL}:8080`;
export const geoBaseURL = isFullUrl ? baseURL : `${baseURL}:6001`;
export const weatherBaseURL = isFullUrl ? baseURL : `${baseURL}:6002`;
export const apiBaseURL = serviceURL(apiURL, 8080);
export const geoBaseURL = serviceURL(geoURL, 6001);
export const weatherBaseURL = serviceURL(weatherURL, 6002);
export const getMediaUrl = (id) => {
const token = localStorage.getItem("token");

View File

@@ -95,9 +95,10 @@ const ListOfSights = observer(() => {
// Находим все достопримечательности, начинающиеся с выбранной буквы
const sightsForLetter = sightData.filter((sight) => {
if (!sight.name) return false;
const name = sight.name;
if (!name) return false;
let sightFirstChar = sight.name.trim().charAt(0);
let sightFirstChar = name.trim().charAt(0);
// Для китайского языка используем пиньинь или иероглиф
if (selectedLanguageRight === "zh") {
@@ -105,7 +106,7 @@ const ListOfSights = observer(() => {
sightFirstChar = sight.name_pinyin.trim().toUpperCase().charAt(0);
} else {
// Если пиньинь нет, используем первый иероглиф
sightFirstChar = sight.name.trim().charAt(0);
sightFirstChar = name.trim().charAt(0);
}
} else {
sightFirstChar = sightFirstChar.toUpperCase();
@@ -440,11 +441,6 @@ const ListOfSights = observer(() => {
isLangOpen={isLangMenuOpen}
/>
<TransferWidget
isOpen={isTransferWidgetOpen}
selectedLanguageRight={selectedLanguage}
/>
<LanguageSelector
selectedLanguageRight={selectedLanguageRight}
onLanguageChange={handleLanguageChange}
@@ -476,13 +472,19 @@ const ListOfSights = observer(() => {
isDisabled={isAlphabetDisabled}
/>
</div>
</div>
<div
className="transfer-button-container"
style={{
transition: "transform 0.3s ease",
}}
>
<TransferWidget
isOpen={isTransferWidgetOpen}
selectedLanguageRight={selectedLanguage}
/>
<div
className="transfer-button-container"
style={{
transition: "transform 0.3s ease",
}}
>
<div
style={{
backgroundColor: "black",
@@ -512,7 +514,6 @@ const ListOfSights = observer(() => {
</svg>
</div>
</div>
</div>
</div>
);
});

View File

@@ -14,8 +14,9 @@ const AlphabetNavigator = forwardRef(function AlphabetNavigator(
const letters = new Set();
sightData.forEach((sight) => {
if (sight.name && sight.name.trim()) {
let firstChar = sight.name.trim().charAt(0);
const name = sight.short_name || sight.name;
if (name && name.trim()) {
let firstChar = name.trim().charAt(0);
// Для китайского языка используем пиньинь (если доступен) или иероглиф
if (selectedLanguage === "zh") {
@@ -23,7 +24,7 @@ const AlphabetNavigator = forwardRef(function AlphabetNavigator(
firstChar = sight.name_pinyin.trim().toUpperCase().charAt(0);
} else {
// Если пиньинь нет, используем первый иероглиф
firstChar = sight.name.trim().charAt(0);
firstChar = name.trim().charAt(0);
}
} else {
firstChar = firstChar.toUpperCase();

View File

@@ -41,16 +41,35 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
const [isFullscreen3D, setIsFullscreen3D] = useState(false);
const [fullscreenFileUrl, setFullscreenFileUrl] = useState("");
const [threeViewResetKey, setThreeViewResetKey] = useState(0);
const [isSwitching, setIsSwitching] = useState(false);
const threeViewControlRef = useRef(null);
const mediaCache = useRef({});
const idleTimerRef = useRef(null);
const prevSightIdRef = useRef(null);
const textWrapperRef = useRef(null);
const mediaStackRef = useRef(null);
const titleRef = useRef(null);
const switchTimerRef = useRef(null);
const homeButtonRef = useRef(null);
const menuRef = useRef(null);
const [menuNeedsScroll, setMenuNeedsScroll] = useState(false);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const currentSection = articleSections?.[selectedSection] ?? null;
const requestSection = (target) => {
setIsFullscreen3D(false);
if (target === selectedSection) return;
if (switchTimerRef.current) clearTimeout(switchTimerRef.current);
setIsSwitching(true);
switchTimerRef.current = setTimeout(() => {
setSelectedSection(target);
setIsSwitching(false);
switchTimerRef.current = null;
}, 180);
};
const updateScrollState = useCallback(() => {
const menu = menuRef.current;
if (!menu) return;
@@ -83,55 +102,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
const totalChildrenWidth = children.reduce((sum, el) => sum + el.offsetWidth, 0);
const evenGap = (availableWidth - totalChildrenWidth) / (children.length + 1);
setMenuNeedsScroll(evenGap < 10);
}, [articleSections, selectedSection]);
// Автозакрытие fullscreen 3D при бездействии (60 сек)
useEffect(() => {
if (!isFullscreen3D) {
if (idleTimerRef.current) {
clearInterval(idleTimerRef.current);
idleTimerRef.current = null;
}
return;
}
let idleSeconds = 0;
const checkIdle = () => {
idleSeconds += 1;
if (idleSeconds >= 60) {
setIsFullscreen3D(false);
}
};
idleTimerRef.current = setInterval(checkIdle, 1000);
const resetIdle = () => {
idleSeconds = 0;
};
const events = [
"mousedown",
"mousemove",
"keypress",
"scroll",
"touchstart",
"click",
];
events.forEach((event) => {
window.addEventListener(event, resetIdle, { passive: true });
});
return () => {
if (idleTimerRef.current) {
clearInterval(idleTimerRef.current);
idleTimerRef.current = null;
}
events.forEach((event) => {
window.removeEventListener(event, resetIdle);
});
};
}, [isFullscreen3D]);
}, [articleSections]);
const {
routeSights,
@@ -171,11 +142,16 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
const controller = new AbortController();
const { signal } = controller;
setIsLoadingContent(true);
setContentError(null);
setModelAspectRatio(null);
setIsVisible(false);
setMediaData({});
const isSameSight = prevSightIdRef.current === sight_id;
prevSightIdRef.current = sight_id;
if (!isSameSight) {
setIsLoadingContent(true);
setContentError(null);
setModelAspectRatio(null);
setIsVisible(false);
setMediaData({});
}
if (!sight_id) {
setContentError("Не указан ID статьи.");
@@ -253,15 +229,18 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
const introSection = {
id: media?.id || "intro-title",
heading:
sight?.short_name ||
sight?.name ||
sight_name ||
"Название достопримечательности",
sight?.name || sight_name || "Название достопримечательности",
body: "",
};
const allSections = [introSection, ...rightArticles];
setArticleSections(allSections);
if (isSameSight) {
setIsLoadingContent(false);
setIsVisible(true);
return;
}
const cacheKey = `${sight_id}_${selectedLanguageRight}`;
if (mediaCache.current[cacheKey]) {
setMediaData(mediaCache.current[cacheKey]);
@@ -302,9 +281,43 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
useEffect(() => {
setSelectedSection(0);
setIsSwitching(false);
if (switchTimerRef.current) {
clearTimeout(switchTimerRef.current);
switchTimerRef.current = null;
}
}, [sight_id]);
const currentSection = articleSections?.[selectedSection] ?? null;
useLayoutEffect(() => {
const scrollable = textWrapperRef.current?.querySelector(".scrollable");
if (scrollable) scrollable.scrollTop = 0;
}, [selectedSection]);
useEffect(() => {
if (!isFullscreen3D) return;
let idleSeconds = 0;
const intervalId = setInterval(() => {
idleSeconds += 1;
if (idleSeconds >= 60) {
setIsFullscreen3D(false);
}
}, 1000);
const resetIdle = () => {
idleSeconds = 0;
};
const events = ["pointerdown", "pointermove", "touchstart", "keydown"];
events.forEach((e) => window.addEventListener(e, resetIdle, { passive: true }));
return () => {
clearInterval(intervalId);
events.forEach((e) => window.removeEventListener(e, resetIdle));
};
}, [isFullscreen3D]);
const renderCurrentMedia = () => {
if (!articleSections || Object.keys(mediaData).length === 0) {
@@ -600,7 +613,6 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
const processedSightName = useMemo(() => {
if (!sight_name) return sight_name;
// Handle \n line breaks (только в правом виджете)
if (sight_name.includes("\n")) {
return sight_name.split("\n").map((line, i) => (
<React.Fragment key={i}>
@@ -645,135 +657,145 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
}, [sight_name]);
return (
<div
className={`sight-frame ${isVisible ? "is-visible" : ""} ${
isFullscreen3D && isCurrentMedia3D ? "three-d-fullscreen" : ""
}`}
>
{sightData?.watermark_lu && !isFullscreen3D && (
<Watermark path={ContentAPI.getMediaPath(sightData.watermark_lu)} />
)}
<>
<div
className={`sight-frame-media-stack ${
isCurrentMedia3D ? "three-d-view" : ""
className={`sight-frame ${isVisible ? "is-visible" : ""} ${
isFullscreen3D && isCurrentMedia3D ? "three-d-fullscreen" : ""
}`}
style={getMediaStackStyle()}
>
{contentError ? (
<div className="sight-frame-image-placeholder error-message">
{contentError}
</div>
) : isLoadingContent || !articleSections ? (
<div className="sight-frame-image-placeholder">
Загрузка контента...
</div>
) : (
renderCurrentMedia()
{sightData?.watermark_lu && !isFullscreen3D && (
<Watermark path={ContentAPI.getMediaPath(sightData.watermark_lu)} />
)}
</div>
<div className="sight-frame-content">
{contentError ? (
<p className="error-message">{contentError}</p>
) : !currentSection ? (
<p>Информация отсутствует.</p>
) : (
<>
{!isFullscreen3D && (
<div
className={`sight-frame-title ${
selectedSection === 0 ? "intro-title" : ""
}`}
style={{ lineHeight: titleLineHeight }}
>
<p
style={{
whiteSpace: "normal",
wordBreak: "break-word",
overflowWrap: "break-word",
}}
<div
ref={mediaStackRef}
className={`sight-frame-media-stack ${
isCurrentMedia3D ? "three-d-view" : ""
}`}
style={getMediaStackStyle()}
>
{contentError ? (
<div className="sight-frame-image-placeholder error-message">
{contentError}
</div>
) : isLoadingContent || !articleSections ? (
<div className="sight-frame-image-placeholder">
Загрузка контента...
</div>
) : (
renderCurrentMedia()
)}
</div>
<div className={`sight-frame-content ${isSwitching ? "is-switching" : ""}`}>
{contentError ? (
<p className="error-message">{contentError}</p>
) : (
<>
{!isFullscreen3D && (
<div
ref={titleRef}
className={`sight-frame-title ${
selectedSection === 0 ? "intro-title" : ""
}`}
style={{ lineHeight: titleLineHeight }}
>
{selectedSection === 0
? processedSightName
: sightData?.short_name || sight_name}
</p>
</div>
)}
{selectedSection !== 0 && (
<p
key={selectedSection}
className="fade-in-text"
style={{
whiteSpace: "normal",
wordBreak: "break-word",
overflowWrap: "break-word",
}}
>
{selectedSection === 0
? processedSightName || sightData?.name
: sightData?.short_name || sightData?.name}
</p>
</div>
)}
<TouchableLayout
className="sight-frame-text-wrapper"
key={selectedSection}
className={`sight-frame-text-wrapper ${selectedSection === 0 ? "is-intro" : ""}`}
ref={textWrapperRef}
maxHeight="calc(80vh - 354px)"
>
<div className="sight-frame-text">
<ReactMarkdownComponent value={currentSection.body} />
<div className="sight-frame-text fade-in-text">
{isLoadingContent || !currentSection ? (
<div className="sight-frame-loading-placeholder" />
) : (
selectedSection !== 0 && (
<ReactMarkdownComponent value={currentSection.body} />
)
)}
</div>
</TouchableLayout>
)}
</>
)}
</>
)}
</div>
</div>
<div className="sight-frame-menu-wrapper">
<div
className="sight-frame-menu-wrapper"
style={{
opacity: isVisible ? 1 : 0,
transition: "opacity 0.9s cubic-bezier(0.16, 1, 0.3, 1) 0.08s",
pointerEvents: isVisible ? "auto" : "none",
}}
>
<div className="sight-frame-menu-fade left" style={{ opacity: canScrollLeft ? 1 : 0 }} />
<div className="sight-frame-menu-fade right" style={{ opacity: canScrollRight ? 1 : 0 }} />
<div
className="sight-frame-menu"
ref={menuRef}
style={menuNeedsScroll ? { justifyContent: 'space-between' } : undefined}
>
<div
style={{
position: "absolute",
left: "10px",
marginTop: "-4.5px",
zIndex: 1,
paddingLeft: "15px",
paddingRight: "7.5px",
paddingTop: "4.5px",
paddingBottom: "4.5px",
cursor: "pointer",
opacity: selectedSection !== 0 ? 1 : 0,
transform: selectedSection !== 0 ? "scale(1)" : "scale(0.5)",
transition: "opacity 0.3s ease, transform 0.3s ease",
pointerEvents: selectedSection !== 0 ? "auto" : "none",
}}
onPointerUp={() => {
setSelectedSection(0);
setIsFullscreen3D(false);
}}
className="sight-frame-menu"
ref={menuRef}
style={menuNeedsScroll ? { justifyContent: 'flex-start' } : undefined}
>
<img
src={subtractHomeIcon}
alt=""
width="24"
height="21"
style={{ display: "block" }}
/>
{contentError ? (
<p className="error-message">{contentError}</p>
) : (
articleSections &&
articleSections.length > 1 &&
articleSections.slice(1).map((section, index) => (
<div
onPointerUp={() => requestSection(index + 1)}
key={section.id || section.heading || index}
data-label={section.heading}
className={`sight-frame-menu-point ${
index + 1 === selectedSection ? "active" : ""
}`}
role="button"
tabIndex="0"
>
{section.heading}
</div>
))
)}
</div>
{contentError ? (
<p className="error-message">{contentError}</p>
) : (
articleSections &&
articleSections.length > 1 &&
articleSections.slice(1).map((section, index) => (
<div
onPointerUp={() => {
setSelectedSection(index + 1);
setIsFullscreen3D(false);
}}
key={section.id || section.heading || index}
className={`sight-frame-menu-point ${
index + 1 === selectedSection ? "active" : ""
}`}
role="button"
tabIndex="0"
>
{section.heading}
</div>
))
)}
</div>
<div
ref={homeButtonRef}
style={{
position: "absolute",
left: "10px",
bottom: "115px",
zIndex: 3,
paddingLeft: "15px",
paddingRight: "7.5px",
paddingTop: "4.5px",
paddingBottom: "4.5px",
cursor: "pointer",
opacity: selectedSection !== 0 ? 1 : 0,
transition: "opacity 0.3s ease",
pointerEvents: selectedSection !== 0 ? "auto" : "none",
}}
onPointerUp={() => requestSection(0)}
>
<img
src={subtractHomeIcon}
alt=""
width="24"
height="21"
style={{ display: "block" }}
/>
</div>
</div>
</>
);
});

View File

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

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import "../styles/Loader.css";
import { getMediaUrl } from "../api/apiConfig";
const loaderVideoPath = new URL("../assets/video/taganai.mp4", import.meta.url)
.href;
@@ -9,8 +10,14 @@ const Loader = ({
loadingStatus = null,
loadingProgress = null,
onToggleDebug,
videoId = null,
}) => {
const [showLoadingInfo, setShowLoadingInfo] = useState(false);
const [isCustomVideoError, setIsCustomVideoError] = useState(false);
useEffect(() => {
setIsCustomVideoError(false);
}, [videoId]);
useEffect(() => {
const handleKeyDown = (event) => {
@@ -55,7 +62,12 @@ const Loader = ({
loop
muted
playsInline
src={loaderVideoPath}
src={
videoId && !isCustomVideoError
? getMediaUrl(videoId)
: loaderVideoPath
}
onError={() => setIsCustomVideoError(true)}
alt="Loading"
className="error-gif"
style={{ width: "100%", height: "100%", objectFit: "cover" }}

View File

@@ -12,6 +12,7 @@ interface TouchableLayoutProps {
children?: ReactNode;
className?: string;
maxHeight?: string | number;
style?: React.CSSProperties;
}
function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
@@ -19,22 +20,30 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
height: 60,
top: 0,
hasScroll: false,
isAtTop: true,
isAtBottom: false,
});
const [visible, setVisible] = useState(false);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const rafRef = useRef<number | null>(null);
const update = useCallback(() => {
const el = scrollableRef.current;
if (!el) return;
if (el.closest('[data-height-animating="true"]')) {
return;
}
const sh = el.scrollHeight;
const ch = el.clientHeight;
const st = el.scrollTop;
const th = ch;
if (sh <= ch) {
setState((prev) => ({ ...prev, hasScroll: false }));
const isAtTop = st <= 0;
const isAtBottom = st + ch >= sh - 1;
if (sh <= ch + 2) {
setState((prev) => ({ ...prev, hasScroll: false, isAtTop: true, isAtBottom: true }));
return;
}
@@ -43,7 +52,7 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
const scrollRange = sh - ch;
const top = range <= 0 ? 0 : (st / scrollRange) * range;
setState({ height: thumbHeight, top, hasScroll: true });
setState({ height: thumbHeight, top, hasScroll: true, isAtTop, isAtBottom });
}, []);
useEffect(() => {
@@ -59,39 +68,36 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
};
el.addEventListener("scroll", schedule, { passive: true });
el.addEventListener("layoutchange", schedule);
const ro = new ResizeObserver(schedule);
ro.observe(el);
const mo = new MutationObserver(schedule);
mo.observe(el, { childList: true, subtree: true, characterData: true });
schedule();
return () => {
el.removeEventListener("scroll", schedule);
el.removeEventListener("layoutchange", schedule);
ro.disconnect();
mo.disconnect();
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
};
}, [update]);
useEffect(() => {
if (state.hasScroll) {
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
setVisible(true);
} else {
hideTimerRef.current = setTimeout(() => {
setVisible(false);
}, 200);
if (!state.hasScroll) {
setVisible(false);
return;
}
return () => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
};
const t = setTimeout(() => setVisible(true), 250);
return () => clearTimeout(t);
}, [state.hasScroll]);
return { ...state, visible };
}
export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
({ children, className, maxHeight }, ref) => {
({ children, className, maxHeight, style }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const scrollableRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
@@ -253,9 +259,12 @@ export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
};
}, [thumb.hasScroll]);
const containerClassName = className
? `scrollable-container ${className}`
: "scrollable-container";
const containerClassName = [
"scrollable-container",
className,
thumb.isAtTop ? "is-at-top" : "",
thumb.isAtBottom ? "is-at-bottom" : "",
].filter(Boolean).join(" ");
const viewportStyle: React.CSSProperties = maxHeight
? {
@@ -265,7 +274,7 @@ export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
: {};
return (
<div ref={setRefs} className={containerClassName}>
<div ref={setRefs} className={containerClassName} style={style}>
<div className="scrollable-viewport" style={viewportStyle}>
<div ref={scrollableRef} className="scrollable">
{children}
@@ -273,15 +282,18 @@ export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
<div
ref={trackRef}
className="custom-scrollbar-track"
style={{ opacity: thumb.hasScroll ? 1 : 0 }}
style={{ opacity: thumb.visible ? 1 : 0 }}
>
{thumb.visible && (
<div
ref={thumbRef}
className="custom-scrollbar-thumb"
style={{ height: thumb.height, top: thumb.top }}
/>
)}
<div
ref={thumbRef}
className="custom-scrollbar-thumb"
style={{
height: thumb.height,
top: thumb.top,
opacity: thumb.visible ? 1 : 0,
pointerEvents: thumb.visible ? "auto" : "none",
}}
/>
</div>
</div>
</div>

View File

@@ -8,6 +8,7 @@ import windIcon from "../assets/weather-icons/wind.png";
import humidityIcon from "../assets/weather-icons/humidity.png";
import { useGeolocationStore } from "../stores";
import { translateWeatherStatus } from "../utils/translateWeatherStatus";
import { apiStore } from "../api/ApiStore";
function WeatherDataLine({ icon, value, unit }) {
return (
@@ -43,7 +44,11 @@ const WeatherWidget = observer(() => {
const fetchWeather = async () => {
try {
const data = await weatherApi.getFormattedWeather();
const data = await weatherApi.getFormattedWeather({
lat: apiStore.context?.currentCoordinates?.latitude,
lng: apiStore.context?.currentCoordinates?.longitude,
cityCode: apiStore.city?.weather_city_code,
});
setWeather(data);
setError(null);
setHasLoadedOnce(true);

View File

@@ -1,349 +0,0 @@
import { FederatedPointerEvent, FederatedWheelEvent } from "pixi.js";
import { ReactNode, useEffect, useState, useRef } from "react";
import { useTransform } from "./transformContext";
import { BACKGROUND_COLOR, SCALE_FACTOR } from "../../assets/Constants";
import { useApplication } from "@pixi/react";
import { useCameraAnimationStore } from "../../stores";
import { observer } from "mobx-react-lite";
import debounce from "lodash/debounce";
import { apiStore } from "../../api/ApiStore/store";
export const InfiniteCanvas = observer(
({ children }: Readonly<{ children?: ReactNode }>) => {
const { route } = apiStore;
const {
position,
setPosition,
scale,
setScale,
setScreenCenter,
isAutoMode,
setIsAutoMode,
userActivityTimestamp,
updateUserActivity,
setAutoModeStartTimestamp,
} = useTransform();
const [loaded, setLoaded] = useState(false);
const applicationRef = useApplication();
const [isDragging, setIsDragging] = useState(false);
const [startMousePosition, setStartMousePosition] = useState({
x: 0,
y: 0,
});
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
const activePointers = useRef(new Map<number, { x: number; y: number }>());
const [isPinching, setIsPinching] = useState(false);
// Add new useRef for storing initial pinch gesture data
const pinchStartData = useRef<{
distance: number;
midpoint: { x: number; y: number };
scale: number;
position: { x: number; y: number };
} | null>(null);
// Keep these for backward compatibility, but we'll use pinchStartData for calculations
const [, setInitialPinchDistance] = useState<number | null>(null);
const [, setInitialPinchMidpoint] = useState<{
x: number;
y: number;
} | null>(null);
const [scaleMin, setScaleMin] = useState(0.1); // Default min scale
const [scaleMax, setScaleMax] = useState(3); // Default max scale
const cameraAnimationStore = useCameraAnimationStore();
// Add debounced version of syncState to reduce jittering
const syncStateDebounced = useRef(
debounce((pos, zoom) => {
cameraAnimationStore.syncState(pos, zoom);
}, 16) // ~60fps
).current;
// Функция для плавного ограничения масштаба
const getSmoothScale = (targetScale: number, currentScale: number) => {
if (isAutoMode) return targetScale; // В авто режиме без ограничений
// Плавное ограничение с затуханием
const damping = 0.3; // Коэффициент затухания (0-1)
if (targetScale < scaleMin) {
// Плавное замедление при приближении к минимальному масштабу
const distance = scaleMin - targetScale;
const dampedDistance = distance * damping;
return Math.max(scaleMin, targetScale + dampedDistance);
}
if (targetScale > scaleMax) {
// Плавное замедление при приближении к максимальному масштабу
const distance = targetScale - scaleMax;
const dampedDistance = distance * damping;
return Math.min(scaleMax, targetScale - dampedDistance);
}
// Плавное возвращение к границам, если текущий масштаб за пределами
if (currentScale < scaleMin) {
// Плавно возвращаемся к минимальному масштабу
const distance = scaleMin - currentScale;
const dampedDistance = distance * damping;
return Math.min(targetScale, currentScale + dampedDistance);
}
if (currentScale > scaleMax) {
// Плавно возвращаемся к максимальному масштабу
const distance = currentScale - scaleMax;
const dampedDistance = distance * damping;
return Math.max(targetScale, currentScale - dampedDistance);
}
return targetScale;
};
const handleUserActivity = () => {
updateUserActivity();
if (isAutoMode) {
setIsAutoMode(false);
}
// При любом действии пользователя останавливаем анимацию
cameraAnimationStore.stopAnimation();
};
// Автоматический режим - таймер для включения и управление масштабом
useEffect(() => {
const interval = setInterval(() => {
const timeSinceActivity = Date.now() - userActivityTimestamp;
if (timeSinceActivity >= 5000 && !isAutoMode) {
// 5 секунд бездействия - включаем авто режим
if (loaded) {
setIsAutoMode(true);
setAutoModeStartTimestamp(Date.now()); // Записываем время включения автопреследования
// Убираем мгновенную установку масштаба - теперь это делает CameraAnimationStore плавно
}
}
}, 1000); // Проверяем каждую секунду
return () => clearInterval(interval);
}, [
userActivityTimestamp,
isAutoMode,
setIsAutoMode,
setScale,
setAutoModeStartTimestamp,
]);
useEffect(() => {
async function fetchRouteData() {
try {
const newScaleMin = route?.scale_min! / SCALE_FACTOR;
const newScaleMax = route?.scale_max! / SCALE_FACTOR;
setScaleMin(newScaleMin);
setScaleMax(newScaleMax);
setLoaded(true);
} catch (error) {
console.error(
"Ошибка загрузки данных маршрута для scaleMin/Max:",
error
);
}
}
fetchRouteData();
}, [route]);
// Убираем жесткое ограничение масштаба - теперь используется плавное ограничение
useEffect(() => {
const canvas = applicationRef?.app.canvas;
if (!canvas) return;
const canvasRect = canvas.getBoundingClientRect();
const canvasLeft = canvasRect.left;
const canvasTop = canvasRect.top;
const centerX = window.innerWidth / 2 - canvasLeft;
const centerY = window.innerHeight / 2 - canvasTop;
setScreenCenter({ x: centerX, y: centerY });
}, [applicationRef?.app.canvas, setScreenCenter]);
const handlePointerDown = (e: FederatedPointerEvent) => {
handleUserActivity();
activePointers.current.set(e.pointerId, { x: e.globalX, y: e.globalY });
if (activePointers.current.size === 1) {
setIsPinching(false);
setInitialPinchDistance(null);
setInitialPinchMidpoint(null);
pinchStartData.current = null;
setIsDragging(true);
setStartPosition({ x: position.x, y: position.y });
setStartMousePosition({ x: e.globalX, y: e.globalY });
} else if (activePointers.current.size === 2) {
setIsDragging(false); // Останавливаем перетаскивание, начинаем пинч
const pointersArray = Array.from(activePointers.current.values());
const p1 = pointersArray[0];
const p2 = pointersArray[1];
// Calculate initial values
const initialDistance = Math.hypot(p2.x - p1.x, p2.y - p1.y);
const initialMidpoint = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
// Save initial gesture data in pinchStartData
pinchStartData.current = {
distance: initialDistance,
midpoint: initialMidpoint,
scale: scale,
position: { ...position },
};
// Keep these for backward compatibility
setInitialPinchDistance(initialDistance);
setInitialPinchMidpoint(initialMidpoint);
setIsPinching(true);
}
e.stopPropagation();
};
const handlePointerMove = (e: FederatedPointerEvent) => {
if (!activePointers.current.has(e.pointerId)) return;
updateUserActivity();
activePointers.current.set(e.pointerId, { x: e.globalX, y: e.globalY });
if (
isPinching &&
activePointers.current.size === 2 &&
pinchStartData.current
) {
const pointersArray = Array.from(activePointers.current.values());
const p1 = pointersArray[0];
const p2 = pointersArray[1];
const currentDistance = Math.hypot(p2.x - p1.x, p2.y - p1.y);
const currentMidpoint = {
x: (p1.x + p2.x) / 2,
y: (p1.y + p2.y) / 2,
};
// 1. Calculate zoomFactor relative to the INITIAL distance
const zoomFactor = currentDistance / pinchStartData.current.distance;
// 2. Calculate new scale relative to the INITIAL scale
const targetScale = pinchStartData.current.scale * zoomFactor;
const newScale = getSmoothScale(targetScale, scale);
// 3. Calculate new position relative to the INITIAL position
// This is the standard formula for "zoom to point" (in our case, to the midpoint between fingers)
const newPosition = {
x:
pinchStartData.current.midpoint.x +
(pinchStartData.current.position.x -
pinchStartData.current.midpoint.x) *
(newScale / pinchStartData.current.scale),
y:
pinchStartData.current.midpoint.y +
(pinchStartData.current.position.y -
pinchStartData.current.midpoint.y) *
(newScale / pinchStartData.current.scale),
};
setPosition(newPosition);
setScale(newScale);
syncStateDebounced(newPosition, newScale);
// We do NOT update pinchStartData here - it remains fixed for the entire gesture
// Update these for backward compatibility, but they're not used for calculations
setInitialPinchDistance(currentDistance);
setInitialPinchMidpoint(currentMidpoint);
} else if (activePointers.current.size === 1) {
setIsPinching(false);
setInitialPinchDistance(null);
setInitialPinchMidpoint(null);
pinchStartData.current = null;
if (isDragging) {
const newPosition = {
x: startPosition.x - startMousePosition.x + e.globalX,
y: startPosition.y - startMousePosition.y + e.globalY,
};
setPosition(newPosition);
syncStateDebounced(newPosition, scale);
}
}
e.stopPropagation();
};
const handlePointerUp = (e: FederatedPointerEvent) => {
handleUserActivity();
activePointers.current.delete(e.pointerId);
if (activePointers.current.size < 2) {
setIsPinching(false);
setInitialPinchDistance(null);
setInitialPinchMidpoint(null);
pinchStartData.current = null; // Clear pinch gesture data
}
if (activePointers.current.size === 0) {
setIsDragging(false);
} else if (activePointers.current.size === 1) {
// If one finger remains after pinch, start dragging from the new position
const remainingPointer = Array.from(activePointers.current.values())[0];
setStartPosition({ x: position.x, y: position.y });
setStartMousePosition({ x: remainingPointer.x, y: remainingPointer.y });
setIsDragging(true);
}
e.stopPropagation();
};
const handleWheel = (e: FederatedWheelEvent) => {
e.stopPropagation();
handleUserActivity(); // Используем новую функцию
const mouseX = e.globalX - position.x;
const mouseY = e.globalY - position.y;
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
// Используем плавное ограничение масштаба
const targetScale = scale * zoomFactor;
const newScale = getSmoothScale(targetScale, scale);
const actualZoomFactor = newScale / scale;
if (scale !== newScale && newScale >= scaleMin && newScale <= scaleMax) {
// Убираем из условия && newScale >= scaleMin && newScale <= scaleMax
const newPosition = {
x: position.x + mouseX * (1 - actualZoomFactor),
y: position.y + mouseY * (1 - actualZoomFactor),
};
setPosition(newPosition);
setScale(newScale);
// Используем дебаунсированную функцию для синхронизации стора после зума
syncStateDebounced(newPosition, newScale);
}
};
return (
<>
<pixiGraphics
draw={(g) => {
const canvas = applicationRef.app.canvas;
g.clear();
g.rect(0, 0, canvas?.width ?? 0, canvas?.height ?? 0);
g.fill(BACKGROUND_COLOR);
}}
eventMode="static"
interactive
onPointerDown={handlePointerDown}
onGlobalPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
onWheel={handleWheel}
/>
<pixiContainer position={position} scale={scale}>
{children}
</pixiContainer>
</>
);
}
);

View File

@@ -5,8 +5,6 @@ import {
useMemo,
} from "react";
import { observer } from "mobx-react-lite";
import { extend } from "@pixi/react";
import { Container, Graphics, Sprite, Text } from "pixi.js";
import { MapDataProvider, useMapData } from "./MapDataContext";
import { TransformProvider, useTransform } from "./transformContext";
// @ts-ignore
@@ -19,8 +17,6 @@ import { SCALE_FACTOR } from "../../assets/Constants";
import { apiStore } from "../../api/ApiStore/store";
import WebGLMap from "./WebGLMap";
extend({ Container, Graphics, Text, Sprite });
export function Map() {
return (
<MapDataProvider>

View File

@@ -1,393 +0,0 @@
// SightsLayer.tsx
import { Graphics, Assets, Texture, TextStyle } from "pixi.js";
import { useCallback, useEffect, useState, useMemo } from "react";
import { useTransform } from "./transformContext";
import { SightData } from "./types";
import sightIcon from "../../assets/images/sight.svg";
import { useGeolocationStore } from "../../stores"; // Импортируем useGeolocationStore
const BASE_ICON_SIZE = 30;
const CLUSTER_RADIUS_BASE = 10;
type Cluster = {
id: string;
longitude: number;
latitude: number;
count: number;
sights: SightData[];
};
type PointItem = { type: "point"; id: string; data: SightData };
type ClusterItem = { type: "cluster"; id: string; data: Cluster };
type ClusteredItem = PointItem | ClusterItem;
const getDistance = (
p1: { longitude: number; latitude: number },
p2: { longitude: number; latitude: number }
) => {
return Math.sqrt(
Math.pow(p1.longitude - p2.longitude, 2) +
Math.pow(p1.latitude - p2.latitude, 2)
);
};
const getDistanceFromPointToPath = (
point: { longitude: number; latitude: number },
pathPoints: { x: number; y: number }[]
): number => {
if (!pathPoints || pathPoints.length < 2) {
return Infinity;
}
let minDistance = Infinity;
for (let i = 0; i < pathPoints.length - 1; i++) {
const p1 = pathPoints[i];
const p2 = pathPoints[i + 1];
const lineLengthSq = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
if (lineLengthSq === 0) continue;
const t =
((point.longitude - p1.x) * (p2.x - p1.x) +
(point.latitude - p1.y) * (p2.y - p1.y)) /
lineLengthSq;
const projectionT = Math.max(0, Math.min(1, t));
const projectedX = p1.x + projectionT * (p2.x - p1.x);
const projectedY = p1.y + projectionT * (p2.y - p1.y);
const dist = Math.sqrt(
(point.longitude - projectedX) ** 2 + (point.latitude - projectedY) ** 2
);
minDistance = Math.min(minDistance, dist);
}
return minDistance;
};
const useSightClustering = (
sights: SightData[],
distanceThreshold: number,
pathPoints: { x: number; y: number }[]
): ClusteredItem[] => {
return useMemo(() => {
const unclusteredSights: (SightData & { visited?: boolean })[] = sights.map(
(s) => ({ ...s })
);
const clusteredResult: ClusteredItem[] = [];
for (const sight of unclusteredSights) {
if (sight.visited) {
continue;
}
const clusterSights: SightData[] = [];
const queue = [sight];
sight.visited = true;
while (queue.length > 0 && clusterSights.length < 4) {
const current = queue.shift()!;
clusterSights.push(current);
for (const potentialNeighbor of unclusteredSights) {
if (
!potentialNeighbor.visited &&
clusterSights.length < 4 &&
getDistance(current, potentialNeighbor) < distanceThreshold
) {
potentialNeighbor.visited = true;
queue.push(potentialNeighbor);
}
}
}
if (clusterSights.length > 1) {
let furthestSight: SightData | null = null;
let maxDistanceToPath = -1;
for (const s of clusterSights) {
const dist = getDistanceFromPointToPath(s, pathPoints);
if (dist > maxDistanceToPath) {
maxDistanceToPath = dist;
furthestSight = s;
}
}
const count = clusterSights.length;
const longitude = furthestSight
? furthestSight.longitude
: clusterSights.reduce((sum, s) => sum + s.longitude, 0) / count;
const latitude = furthestSight
? furthestSight.latitude
: clusterSights.reduce((sum, s) => sum + s.latitude, 0) / count;
const id = `cluster-${clusterSights[0].id}`;
clusteredResult.push({
type: "cluster",
id,
data: { id, count, longitude, latitude, sights: clusterSights },
});
} else {
const singleSight = clusterSights[0];
clusteredResult.push({
type: "point",
id: String(singleSight.id),
data: singleSight,
});
}
}
return clusteredResult;
}, [sights, distanceThreshold, pathPoints]);
};
// Изменяем пропсы для SingleSight, чтобы он мог передавать ID при тапе
function SingleSight({
sight,
onSightClick,
}: {
readonly sight: SightData;
onSightClick: (sightId: string) => void;
}) {
useTransform();
const [texture, setTexture] = useState<Texture>(Texture.EMPTY);
const store = useGeolocationStore();
const { setIsGovernorWidgetOpen } = store;
useEffect(() => {
Assets.load(sightIcon).then(setTexture).catch(console.error);
}, []);
const handlePointerTap = useCallback(() => {
setIsGovernorWidgetOpen(false);
onSightClick(String(sight.id)); // Передаем ID выбранной достопримечательности
}, [sight.id, onSightClick]);
if (texture === Texture.EMPTY) {
return null;
}
const dynamicSize = BASE_ICON_SIZE;
return (
<pixiSprite
texture={texture}
x={sight.longitude}
y={sight.latitude}
width={dynamicSize}
height={dynamicSize}
anchor={0.5}
eventMode="static"
onPointerTap={handlePointerTap}
/>
);
}
// Добавляем onSightSelectedInCluster в пропсы
function SightCluster({
cluster,
onClusterToggle,
isExpanded,
onSightSelectedInCluster,
}: {
readonly cluster: Cluster;
onClusterToggle: (clusterId: string | null) => void;
isExpanded: boolean;
onSightSelectedInCluster: (sightId: string) => void;
}) {
const store = useGeolocationStore();
const { setIsGovernorWidgetOpen } = store;
useTransform();
const radius = CLUSTER_RADIUS_BASE;
const [texture, setTexture] = useState<Texture>(Texture.EMPTY);
const fontSize = 14;
useEffect(() => {
Assets.load(sightIcon).then(setTexture).catch(console.error);
}, []);
const clusterTextStyle = useMemo(
() =>
new TextStyle({
fill: "white",
fontSize: fontSize,
fontWeight: "bold",
}),
[fontSize]
);
const handleClusterTap = useCallback(() => {
onClusterToggle(isExpanded ? null : cluster.id);
}, [cluster.id, cluster.count, isExpanded, onClusterToggle]);
const handleSightSelect = useCallback(
(sightId: string) => {
setIsGovernorWidgetOpen(false);
onSightSelectedInCluster(sightId); // Передаем выбранный ID наверх
onClusterToggle(null); // Закрываем кластер после выбора достопримечательности
},
[onClusterToggle, onSightSelectedInCluster, setIsGovernorWidgetOpen]
); // Добавляем onSightSelectedInCluster в зависимости
const drawClusterGraphics = useCallback(
(g: Graphics) => {
g.clear();
g.beginFill(0x896f58);
g.drawCircle(0, 0, radius);
g.endFill();
},
[radius]
);
const drawExpandedClusterBackground = useCallback((g: Graphics) => {
g.clear();
const expandedRadius = BASE_ICON_SIZE * 2;
g.beginFill(0x896f58, 0.3);
g.lineStyle(2, 0x896f58, 1);
g.drawCircle(0, 0, expandedRadius);
g.endFill();
}, []);
const handleBackgroundTap = useCallback(() => {
onClusterToggle(null); // Закрываем кластер при клике по фону
}, [cluster.id, onClusterToggle]);
if (texture === Texture.EMPTY) {
return null;
}
const dynamicSize = BASE_ICON_SIZE;
const offsetX = dynamicSize / 2;
const offsetY = -dynamicSize / 2;
const getPositionForSight = (index: number, total: number) => {
const angle = (index / total) * Math.PI * 2;
const distance = BASE_ICON_SIZE * 1.2;
return {
x: distance * Math.cos(angle),
y: distance * Math.sin(angle),
};
};
return (
<pixiContainer x={cluster.longitude} y={cluster.latitude}>
{isExpanded && (
<pixiGraphics
draw={drawExpandedClusterBackground}
eventMode="static"
onPointerTap={handleBackgroundTap}
/>
)}
{!isExpanded ? (
<>
<pixiSprite
texture={texture}
x={0}
y={0}
width={dynamicSize}
height={dynamicSize}
anchor={0.5}
eventMode="static"
onPointerTap={handleClusterTap}
/>
<pixiGraphics draw={drawClusterGraphics} x={offsetX} y={offsetY} />
<pixiText
text={String(cluster.count)}
x={offsetX}
y={offsetY}
anchor={0.5}
style={clusterTextStyle}
resolution={4}
/>
</>
) : (
<>
{cluster.sights.map((sight, index) => {
const pos = getPositionForSight(index, cluster.sights.length);
return (
<pixiSprite
key={sight.id}
texture={texture}
x={pos.x}
y={pos.y}
width={dynamicSize}
height={dynamicSize}
anchor={0.5}
eventMode="static"
onPointerTap={() => handleSightSelect(String(sight.id))}
/>
);
})}
</>
)}
</pixiContainer>
);
}
interface SightsLayerProps {
sights: SightData[];
pathPoints: { x: number; y: number }[];
}
export function SightsLayer({
sights,
pathPoints,
}: Readonly<SightsLayerProps>) {
useTransform();
const distanceThreshold = BASE_ICON_SIZE * 3;
const store = useGeolocationStore(); // Получаем доступ к MobX хранилищу
const {
setSelectedSightId,
setIsManualSelection,
setIsRightWidgetSelectorOpen,
} = store; // Получаем нужные экшены
const items = useSightClustering(sights, distanceThreshold, pathPoints);
const [activeClusterId, setActiveClusterId] = useState<string | null>(null);
const handleClusterToggle = useCallback((clusterId: string | null) => {
setActiveClusterId(clusterId);
}, []);
const handleSightSelected = useCallback(
(sightId: string) => {
setSelectedSightId(sightId);
setIsManualSelection(true);
setIsRightWidgetSelectorOpen(false); // Закрываем селектор правого виджета при клике по достопримечательности
// Закрываем виджет губернатора при выборе достопримечательности
store.closeGovernorModal();
},
[
setSelectedSightId,
setIsManualSelection,
setIsRightWidgetSelectorOpen,
store,
]
);
return (
<>
{items.map((item) => {
if (item.type === "cluster") {
return (
<SightCluster
key={item.id}
cluster={item.data}
onClusterToggle={handleClusterToggle}
isExpanded={activeClusterId === item.id}
onSightSelectedInCluster={handleSightSelected}
/>
);
}
return (
<SingleSight
key={item.id}
sight={item.data}
onSightClick={handleSightSelected}
/>
);
})}
</>
);
}

View File

@@ -1,174 +0,0 @@
import { Graphics } from "pixi.js";
import { useCallback, useMemo } from "react";
import {
BACKGROUND_COLOR,
PATH_COLOR,
STATION_RADIUS,
STATION_OUTLINE_WIDTH,
UNPASSED_STATION_COLOR,
} from "./Constants";
import { StationData } from "./types";
import { useTransform } from "./transformContext";
import { observer } from "mobx-react-lite";
import { useGeolocationStore } from "../../stores/hooks/useGeolocationStore";
import { apiStore } from "../../api/ApiStore/store";
interface StationProps {
station: StationData;
stationEn?: StationData | null;
stationZh?: StationData | null;
isPassed: boolean;
}
const BASE_FONT_SIZE = 16;
const POINT_LABEL_FONT_SIZE = 13;
const DEFAULT_LABEL_OFFSET_X = 25;
const DEFAULT_LABEL_OFFSET_Y = 0;
const getAnchorFromOffset = (
offsetX: number,
offsetY: number
): { x: number; y: number } => {
if (offsetX === 0 && offsetY === 0) {
return { x: 0, y: 0.5 };
}
const length = Math.hypot(offsetX, offsetY);
const nx = offsetX / length;
const ny = offsetY / length;
return { x: (1 - nx) / 2, y: (1 - ny) / 2 };
};
export const Station = observer(
({ station, stationEn, stationZh, isPassed }: Readonly<StationProps>) => {
const { scale } = useTransform();
const { context } = apiStore;
const { selectedLanguage } = useGeolocationStore();
const ZOOM_THRESHOLD_FOR_HIGH_RESOLUTION = 2; // Порог масштаба (в 2 раза)
const HIGH_RESOLUTION_VALUE = 4;
const LOW_RESOLUTION_VALUE = 2;
const dynamicResolution =
scale > ZOOM_THRESHOLD_FOR_HIGH_RESOLUTION
? HIGH_RESOLUTION_VALUE
: LOW_RESOLUTION_VALUE;
const draw = useCallback(
(g: Graphics) => {
g.clear();
// Проверяем, является ли станция начальной или конечной
const isTerminalStation =
context &&
(station.id.toString() === (context as any)?.startStopId ||
station.id.toString() === (context as any)?.endStopId);
// Определяем радиус станции
const stationRadius = isTerminalStation
? STATION_RADIUS * 1.5
: STATION_RADIUS;
// Рисуем основной круг станции
g.circle(station.longitude, station.latitude, stationRadius);
g.fill({ color: isPassed ? PATH_COLOR : UNPASSED_STATION_COLOR });
g.stroke({ color: BACKGROUND_COLOR, width: STATION_OUTLINE_WIDTH });
// Если это терминальная станция, рисуем дополнительный круг по центру
if (isTerminalStation) {
const centerCircleRadius = stationRadius * 0.5; // 50% от радиуса станции
g.circle(station.longitude, station.latitude, centerCircleRadius);
g.fill({ color: BACKGROUND_COLOR });
}
},
[station.latitude, station.longitude, station.id, context, isPassed]
);
const dynamicFontSize = BASE_FONT_SIZE;
const dynamicPointLabelFontSize = POINT_LABEL_FONT_SIZE;
// Определяем фактические смещения. Если station.offset_x и station.offset_y оба равны 0,
// используем дефолтный отступ для обеспечения видимости.
const labelOffsetX =
station.offset_x === 0 && station.offset_y === 0
? DEFAULT_LABEL_OFFSET_X
: station.offset_x / 3;
const labelOffsetY =
station.offset_x === 0 && station.offset_y === 0
? DEFAULT_LABEL_OFFSET_Y
: station.offset_y / 3;
// Вычисляем позицию текстового блока. Это прямые координаты карты.
const textBlockPositionX = station.longitude + labelOffsetX / 3;
const textBlockPositionY = station.latitude + labelOffsetY;
// Используем useMemo для запоминания dynamicAnchor на основе *фактически используемого* отступа.
const dynamicAnchor = useMemo(
() => getAnchorFromOffset(labelOffsetX, labelOffsetY),
[labelOffsetX, labelOffsetY]
);
return (
<pixiContainer>
<pixiGraphics draw={draw} zIndex={0} />
<pixiText
anchor={dynamicAnchor} // Используем динамический якорь
text={station.name}
position={{
x: textBlockPositionX,
y: textBlockPositionY,
}}
style={{
fontSize: dynamicFontSize,
fontFamily: "Roboto",
fontWeight: "bold",
fill: "#fff",
}}
resolution={dynamicResolution}
zIndex={100}
/>
{(selectedLanguage === "en" || selectedLanguage === "ru") && (
<pixiText
anchor={dynamicAnchor} // Используем динамический якорь
text={stationEn?.name}
position={{
x: textBlockPositionX,
y: textBlockPositionY + 14, // Это смещение второй надписи относительно первой
}}
style={{
fontSize: dynamicPointLabelFontSize,
fontFamily: "Roboto",
fontWeight: "normal",
fill: "#CBCBCB",
}}
resolution={dynamicResolution}
zIndex={100}
/>
)}
{selectedLanguage === "zh" && (
<pixiText
anchor={dynamicAnchor} // Используем динамический якорь
text={stationZh?.name}
position={{
x: textBlockPositionX,
y: textBlockPositionY + 14, // Это смещение второй надписи относительно первой
}}
style={{
fontSize: dynamicPointLabelFontSize,
fontFamily: "Roboto",
fontWeight: "normal",
fill: "#CBCBCB",
}}
resolution={dynamicResolution}
zIndex={100}
/>
)}
</pixiContainer>
);
}
);

View File

@@ -1,375 +0,0 @@
import { Texture, Assets } from "pixi.js";
import { useEffect, useState, useMemo, useRef } from "react";
import { useTransform } from "./transformContext";
import { lerp, lerpAngle } from "../../utils/animationUtils";
const basePath = new URL(
"../../assets/tramPosition/Tram Base.svg",
import.meta.url
).href;
const tramPath = new URL("../../assets/tramPosition/Tram.svg", import.meta.url)
.href;
const LERP_SPEED = 0.1; // Скорость интерполяции (10% каждый кадр)
// Функция для проверки расстояния до ближайшей точки маршрута
const getDistanceToPath = (
point: { x: number; y: number },
pathPoints: { x: number; y: number }[]
) => {
if (!pathPoints || pathPoints.length < 2) return Infinity;
let minDistance = Infinity;
for (let i = 0; i < pathPoints.length - 1; i++) {
const p1 = pathPoints[i];
const p2 = pathPoints[i + 1];
const lineLengthSq = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
if (lineLengthSq === 0) continue;
const t =
((point.x - p1.x) * (p2.x - p1.x) + (point.y - p1.y) * (p2.y - p1.y)) /
lineLengthSq;
const projectionT = Math.max(0, Math.min(1, t));
const projectedX = p1.x + projectionT * (p2.x - p1.x);
const projectedY = p1.y + projectionT * (p2.y - p1.y);
const dist = Math.sqrt(
(point.x - projectedX) ** 2 + (point.y - projectedY) ** 2
);
if (dist < minDistance) {
minDistance = dist;
}
}
return minDistance;
};
// Функция для проверки расстояния до пройденной части маршрута
const getDistanceToPassedPath = (
point: { x: number; y: number },
pathPoints: { x: number; y: number }[],
passedSegmentIndex: number
) => {
if (!pathPoints || pathPoints.length < 2 || passedSegmentIndex < 0)
return Infinity;
let minDistance = Infinity;
// Проверяем только пройденную часть (до passedSegmentIndex включительно)
for (
let i = 0;
i <= Math.min(passedSegmentIndex, pathPoints.length - 2);
i++
) {
const p1 = pathPoints[i];
const p2 = pathPoints[i + 1];
const lineLengthSq = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
if (lineLengthSq === 0) continue;
const t =
((point.x - p1.x) * (p2.x - p1.x) + (point.y - p1.y) * (p2.y - p1.y)) /
lineLengthSq;
const projectionT = Math.max(0, Math.min(1, t));
const projectedX = p1.x + projectionT * (p2.x - p1.x);
const projectedY = p1.y + projectionT * (p2.y - p1.y);
const dist = Math.sqrt(
(point.x - projectedX) ** 2 + (point.y - projectedY) ** 2
);
if (dist < minDistance) {
minDistance = dist;
}
}
return minDistance;
};
// Функция для проверки расстояния до ближайшей станции
const getDistanceToStations = (
point: { x: number; y: number },
stations: {
longitude: number;
latitude: number;
offset_x?: number;
offset_y?: number;
}[],
_debug: boolean = false
) => {
if (!stations || stations.length === 0) {
return Infinity;
}
let minDistance = Infinity;
for (const station of stations) {
// Рассчитываем позицию текста станции с учетом смещений (как в Station.tsx)
const DEFAULT_LABEL_OFFSET_X = 25;
const DEFAULT_LABEL_OFFSET_Y = 0;
const labelOffsetX =
station.offset_x === 0 && station.offset_y === 0
? DEFAULT_LABEL_OFFSET_X
: (station.offset_x || 0) / 3;
const labelOffsetY =
station.offset_x === 0 && station.offset_y === 0
? DEFAULT_LABEL_OFFSET_Y
: (station.offset_y || 0) / 3;
const textBlockPositionX = station.longitude + labelOffsetX;
const textBlockPositionY = station.latitude + labelOffsetY;
// Вычисляем расстояние до позиции текста станции (не до центра станции)
const dist = Math.sqrt(
(point.x - textBlockPositionX) ** 2 + (point.y - textBlockPositionY) ** 2
);
if (dist < minDistance) {
minDistance = dist;
}
}
return minDistance;
};
// Функция для поиска оптимального угла поворота метки относительно трамвая
const findOptimalAngle = (
tramX: number,
tramY: number,
pathPoints: { x: number; y: number }[],
stations: {
longitude: number;
latitude: number;
offset_x?: number;
offset_y?: number;
}[],
passedSegmentIndex: number,
scale: number
) => {
const testRadiusInMapCoords = 100 / scale; // Радиус в координатах карты
const minSafeDistanceToPath = 60; // Минимальное безопасное расстояние до пути в пикселях
const minSafeDistanceToPassedPath = 60; // Минимальное безопасное расстояние до пройденной части в пикселях
const minSafeDistanceToStation = 50; // Минимальное безопасное расстояние до станции в пикселях
let bestAngle = 0;
let bestScore = Infinity; // Ищем минимальный вес
const segmentWeights: { angle: number; weight: number; distances: any }[] =
[];
// Проверяем 12 углов (каждые 30 градусов)
for (let i = 0; i < 12; i++) {
const testAngle = (i * Math.PI * 2) / 12;
const testX = tramX + Math.cos(testAngle) * testRadiusInMapCoords;
const testY = tramY + Math.sin(testAngle) * testRadiusInMapCoords;
const distanceToPath =
getDistanceToPath({ x: testX, y: testY }, pathPoints) * scale; // В пикселях
const distanceToPassedPath =
getDistanceToPassedPath(
{ x: testX, y: testY },
pathPoints,
passedSegmentIndex
) * scale; // В пикселях
const distanceToStation =
getDistanceToStations({ x: testX, y: testY }, stations, false) * scale; // В пикселях с отладкой
// Вычисляем вес для этого угла (чем меньше расстояние, тем больше вес)
let weight = 0;
// Путь - вес 100
if (distanceToPath < minSafeDistanceToPath) {
weight += 100 * (1 - distanceToPath / minSafeDistanceToPath);
}
// Текст прошедшей станции - вес 10
if (distanceToPassedPath < minSafeDistanceToPassedPath) {
weight += 10 * (1 - distanceToPassedPath / minSafeDistanceToPassedPath);
}
// Текст следующей станции - вес 1000
if (distanceToStation < minSafeDistanceToStation) {
weight += 1000 * (1 - distanceToStation / minSafeDistanceToStation);
}
segmentWeights.push({
angle: testAngle,
weight: Math.round(weight * 10) / 10, // Округляем для читаемости
distances: {
path: Math.round(distanceToPath),
passedPath: Math.round(distanceToPassedPath),
station: Math.round(distanceToStation),
},
});
if (weight < bestScore) {
bestScore = weight;
bestAngle = testAngle;
}
}
// Если несколько сегментов имеют одинаковый минимальный вес,
// выбираем тот, который максимально удален от препятствий
const minWeightSegments = segmentWeights.filter(
(s) => s.weight === bestScore
);
if (minWeightSegments.length > 1) {
// Из сегментов с минимальным весом выбираем тот, который максимально удален от препятствий
let bestDistanceSum = -1;
for (const segment of minWeightSegments) {
const distanceSum =
segment.distances.path +
segment.distances.passedPath +
segment.distances.station;
if (distanceSum > bestDistanceSum) {
bestDistanceSum = distanceSum;
bestAngle = segment.angle;
}
}
}
return { bestAngle, segmentWeights };
};
export function TramIcon({
x,
y,
angle,
pathPoints = [],
stations = [],
passedSegmentIndex = -1,
}: {
x: number;
y: number;
angle: number;
pathPoints?: { x: number; y: number }[];
stations?: {
longitude: number;
latitude: number;
offset_x?: number;
offset_y?: number;
}[];
passedSegmentIndex?: number;
}) {
const { scale } = useTransform();
// Находим оптимальный угол поворота метки относительно трамвая
const optimalAngle = useMemo(
() =>
findOptimalAngle(x, y, pathPoints, stations, passedSegmentIndex, scale)
.bestAngle,
[x, y, pathPoints, stations, passedSegmentIndex, scale]
);
// Состояние для плавной анимации позиции и углов (как в HTML файле)
const [smoothPosition, setSmoothPosition] = useState({ x, y });
const [smoothOptimalAngle, setSmoothOptimalAngle] = useState(optimalAngle);
const [smoothTramAngle, setSmoothTramAngle] = useState(angle);
const animationRef = useRef<number | undefined>(undefined);
// Плавная анимация позиции и углов (логика из HTML файла)
useEffect(() => {
const animate = () => {
// Анимируем позицию (как в HTML файле)
setSmoothPosition((prev) => {
const newX = lerp(prev.x, x, LERP_SPEED);
const newY = lerp(prev.y, y, LERP_SPEED);
return {
x: Math.abs(newX - x) < 0.1 ? x : newX,
y: Math.abs(newY - y) < 0.1 ? y : newY,
};
});
// Анимируем оптимальный угол
setSmoothOptimalAngle((prev) => {
const newAngle = lerpAngle(prev, optimalAngle, LERP_SPEED);
return Math.abs(newAngle - optimalAngle) < 0.01
? optimalAngle
: newAngle;
});
// Анимируем угол трамвая
setSmoothTramAngle((prev) => {
const newAngle = lerpAngle(prev, angle, LERP_SPEED);
return Math.abs(newAngle - angle) < 0.01 ? angle : newAngle;
});
// Продолжаем анимацию, если что-то еще не достигло цели
if (
Math.abs(smoothPosition.x - x) > 0.1 ||
Math.abs(smoothPosition.y - y) > 0.1 ||
Math.abs(smoothOptimalAngle - optimalAngle) > 0.01 ||
Math.abs(smoothTramAngle - angle) > 0.01
) {
animationRef.current = requestAnimationFrame(animate);
}
};
animationRef.current = requestAnimationFrame(animate);
return () => {
if (animationRef.current !== undefined) {
cancelAnimationFrame(animationRef.current);
}
};
}, [
x,
y,
optimalAngle,
angle,
smoothPosition,
smoothOptimalAngle,
smoothTramAngle,
]);
// Обычные размеры без увеличения
const backgroundWidth = 111 / scale;
const backgroundHeight = 82 / scale;
const tramWidth = 31 / scale;
const tramHeight = 52 / scale;
const [baseTexture, setBaseTexture] = useState<Texture | null>(null);
const [tramTexture, setTramTexture] = useState<Texture | null>(null);
useEffect(() => {
Assets.load(basePath).then(setBaseTexture).catch(console.error);
Assets.load(tramPath).then(setTramTexture).catch(console.error);
}, []);
if (!baseTexture || !tramTexture) return null;
return (
<pixiContainer x={smoothPosition.x} y={smoothPosition.y}>
{/* вращающийся контейнер с плавным оптимальным углом */}
<pixiContainer rotation={smoothOptimalAngle + Math.PI}>
<pixiSprite
texture={baseTexture}
anchor={{ x: 1, y: 0.5 }}
width={backgroundWidth > 53.7 ? backgroundWidth : 53.7}
height={backgroundHeight > 39.68 ? backgroundHeight : 39.68}
/>
{/* контейнер иконки трамвая с плавным поворотом направления движения */}
<pixiContainer
x={
backgroundWidth > 53.7
? -backgroundWidth / 1.42
: -backgroundWidth / 0.98
}
y={0}
>
<pixiSprite
texture={tramTexture}
anchor={0.5}
rotation={-smoothOptimalAngle - Math.PI}
width={tramWidth}
height={tramHeight}
/>
</pixiContainer>
</pixiContainer>
</pixiContainer>
);
}

View File

@@ -1,92 +0,0 @@
import { Graphics } from "pixi.js";
import { useCallback } from "react";
import { PATH_COLOR, PATH_WIDTH, UNPASSED_STATION_COLOR } from "./Constants";
interface TravelPathProps {
points: { x: number; y: number }[];
busCoordinates?: { x: number; y: number };
}
const PASSED_PATH_COLOR = PATH_COLOR;
const UNPASSED_PATH_COLOR = UNPASSED_STATION_COLOR;
export function TravelPath({
points,
busCoordinates,
}: Readonly<TravelPathProps>) {
const draw = useCallback(
(g: Graphics) => {
g.clear();
if (points.length < 2) {
return;
}
g.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
g.lineTo(points[i].x, points[i].y);
}
g.stroke({
color: UNPASSED_PATH_COLOR,
width: PATH_WIDTH,
});
if (!busCoordinates) {
return;
}
let minDistance = Infinity;
let closestSegmentIndex = -1;
let busSegmentStartPoint: { x: number; y: number } | null = null;
for (let i = 0; i < points.length - 1; i++) {
const p1 = points[i];
const p2 = points[i + 1];
const lineLengthSq = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
if (lineLengthSq === 0) continue;
const t =
((busCoordinates.x - p1.x) * (p2.x - p1.x) +
(busCoordinates.y - p1.y) * (p2.y - p1.y)) /
lineLengthSq;
const projectionT = Math.max(0, Math.min(1, t));
const projectedX = p1.x + projectionT * (p2.x - p1.x);
const projectedY = p1.y + projectionT * (p2.y - p1.y);
const dist = Math.sqrt(
(busCoordinates.x - projectedX) ** 2 +
(busCoordinates.y - projectedY) ** 2
);
if (dist < minDistance) {
minDistance = dist;
closestSegmentIndex = i;
busSegmentStartPoint = { x: projectedX, y: projectedY };
}
}
if (closestSegmentIndex !== -1 && busSegmentStartPoint) {
g.moveTo(points[0].x, points[0].y);
for (let i = 1; i <= closestSegmentIndex; i++) {
g.lineTo(points[i].x, points[i].y);
}
g.lineTo(busSegmentStartPoint.x, busSegmentStartPoint.y);
g.stroke({
color: PASSED_PATH_COLOR,
width: PATH_WIDTH,
});
}
},
[points, busCoordinates]
);
if (points.length === 0) {
console.error("points is empty");
return null;
}
return <pixiGraphics draw={draw} />;
}

View File

@@ -155,6 +155,19 @@ const useSightClustering = (
continue;
}
const hasCustomIcon =
sight.is_default_icon === false && !isMediaIdEmpty(sight.icon ?? null);
if (hasCustomIcon) {
sight.visited = true;
clusteredResult.push({
type: "point",
id: String(sight.id),
data: sight,
});
continue;
}
const clusterSights: SightData[] = [];
const queue = [sight];
sight.visited = true;
@@ -164,8 +177,12 @@ const useSightClustering = (
clusterSights.push(current);
for (const potentialNeighbor of unclusteredSights) {
const neighborHasCustomIcon =
potentialNeighbor.is_default_icon === false &&
!isMediaIdEmpty(potentialNeighbor.icon ?? null);
if (
!potentialNeighbor.visited &&
!neighborHasCustomIcon &&
clusterSights.length < 4 &&
getDistance(current, potentialNeighbor) < distanceThreshold
) {
@@ -175,6 +192,10 @@ const useSightClustering = (
}
}
for (const leftover of queue) {
leftover.visited = false;
}
if (clusterSights.length > 1) {
let furthestSight: SightData | null = null;
let maxDistanceToPath = -1;
@@ -381,12 +402,14 @@ export const WebGLMap = observer(() => {
return livePercent;
}
if (
sight != null &&
typeof sight.icon_size === "number" &&
Number.isFinite(sight.icon_size)
) {
return sight.icon_size;
if (sight?.is_default_icon === false) {
if (
typeof sight.icon_size === "number" &&
Number.isFinite(sight.icon_size)
) {
return sight.icon_size;
}
return 100;
}
if (

View File

@@ -0,0 +1,39 @@
import { useRef, useEffect } from "react";
const Collapsible = ({ open, className = "", children }) => {
const ref = useRef(null);
const isFirst = useRef(true);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (isFirst.current) {
isFirst.current = false;
el.style.height = open ? "auto" : "0px";
return;
}
if (open) {
el.style.height = `${el.scrollHeight}px`;
const onEnd = (e) => {
if (e.target !== el || e.propertyName !== "height") return;
el.style.height = "auto";
el.removeEventListener("transitionend", onEnd);
};
el.addEventListener("transitionend", onEnd);
} else {
el.style.height = `${el.scrollHeight}px`;
void el.offsetHeight;
el.style.height = "0px";
}
}, [open]);
return (
<div ref={ref} className={`${className} ${open ? "open" : ""}`}>
{children}
</div>
);
};
export default Collapsible;

View File

@@ -4,6 +4,7 @@ import { useEffect, useState, useCallback, useRef } from "react";
import { observer } from "mobx-react-lite";
import sideMenuPhoto from "/side-menu-photo.png";
import RouteWidget from "../widgets/RouteWidget";
import WeatherWidget from "../WeatherWidget";
import ContentAPI from "../../api/content/content.api";
import { useGeolocationStore, useColorStore } from "../../stores";
import "../../styles/LeftWidget.css";
@@ -12,12 +13,10 @@ import StationsList from "./StationsList";
import LeftWidget from "./LeftWidget";
import { apiStore } from "../../api/ApiStore/store";
import { getMediaUrl } from "../../api/apiConfig";
import defaultCrest from "../../assets/images/Герб.png";
const SideMenu = observer(({ onMenuToggle }) => {
const {
carrier,
city,
route,
getArticle,
sightArticles,
@@ -32,6 +31,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
const [isStationOpen, openStation] = useState(false);
const [isWidgetOpen, openWidget] = useState(false);
const [isWeatherVisible, setIsWeatherVisible] = useState(true);
const [isPreviewMediaError, setIsPreviewMediaError] = useState(false);
const [isLeftWidgetVisible, setIsLeftWidgetVisible] = useState(false);
const [localSelectedSightId, setLocalSelectedSightId] = useState(null);
const [isSightsAnimating, setIsSightsAnimating] = useState(false);
@@ -85,7 +85,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
let scrollTarget = null;
const targetElement = document.elementFromPoint(
touch.clientX,
touch.clientY
touch.clientY,
);
// Определяем, над каким из скролл-контейнеров находится палец
@@ -185,27 +185,12 @@ const SideMenu = observer(({ onMenuToggle }) => {
}
}, [isLeftWidgetVisible]);
const [designData, setDesignData] = useState(null);
const [routeData, setRouteData] = useState(null);
const [isLangMenuOpen, setIsLangMenuOpen] = useState(false);
useEffect(() => {
async function fetchCarrierAndCrest() {
try {
const carrierPath = carrier.logo ? getMediaUrl(carrier.logo) : null;
const cityPath = city.arms ? getMediaUrl(city.arms) : null;
const response = {
carrierPath: carrierPath,
creastPath: cityPath,
};
setDesignData(response);
} catch (err) {
console.error("Ошибка в получении данных герба и перевозчика:", err);
}
}
fetchCarrierAndCrest();
}, [contextData?.routeId, isGeolocationLoading, selectedLanguage]);
setIsPreviewMediaError(false);
}, [carrier?.preview_media]);
useEffect(() => {
if (isGeolocationLoading || !contextData?.routeId) {
@@ -263,18 +248,17 @@ const SideMenu = observer(({ onMenuToggle }) => {
}
};
const isMenuOpenRef = useRef(isMenuOpen);
const handleMenuToggleRef = useRef(handleMenuToggle);
useEffect(() => { isMenuOpenRef.current = isMenuOpen; }, [isMenuOpen]);
useEffect(() => { handleMenuToggleRef.current = handleMenuToggle; });
handleMenuToggleRef.current = handleMenuToggle;
const isMenuOpenRef = useRef(isMenuOpen);
isMenuOpenRef.current = isMenuOpen;
useEffect(() => {
// Автоматическое закрытие сайд-меню после 60 секунд бездействия
let idleSeconds = 0;
const checkIdle = () => {
idleSeconds += 1;
if (idleSeconds >= 60 && isMenuOpenRef.current) {
handleMenuToggleRef.current(false);
}
@@ -307,6 +291,47 @@ const SideMenu = observer(({ onMenuToggle }) => {
};
}, []);
// Автоматическое скрытие левого виджета через 60 секунд бездействия
useEffect(() => {
if (!isLeftWidgetVisible) return;
let idleSeconds = 0;
const checkIdle = () => {
idleSeconds += 1;
if (idleSeconds >= 60) {
setIsLeftWidgetVisible(false);
setTimeout(() => {
setIsLeftWidgetOpen(false);
}, 1000);
}
};
const intervalId = setInterval(checkIdle, 1000);
const resetIdle = () => {
idleSeconds = 0;
};
const events = [
"mousedown",
"mousemove",
"keypress",
"scroll",
"touchstart",
"click",
];
events.forEach((event) =>
window.addEventListener(event, resetIdle, { passive: true }),
);
return () => {
clearInterval(intervalId);
events.forEach((event) => window.removeEventListener(event, resetIdle));
};
}, [isLeftWidgetVisible, setIsLeftWidgetOpen]);
// Закрываем и открываем список достопримечательностей при изменении сортировки
const prevSortingByRef = useRef(sortingBy);
const isFirstRenderRef = useRef(true);
@@ -374,14 +399,6 @@ const SideMenu = observer(({ onMenuToggle }) => {
"background 0.3s ease, backdrop-filter 0.3s ease, box-shadow 0.3s ease",
}}
>
<img
className="side-menu-crest"
src={designData?.creastPath || defaultCrest}
alt="Герб"
/>
{carrier?.slogan && (
<div className="side-menu-label">{carrier.slogan}</div>
)}
{shouldRenderSights && (
<SightsList
ref={sightsListRef}
@@ -498,16 +515,16 @@ const SideMenu = observer(({ onMenuToggle }) => {
}, 300);
}
}}
className={`side-menu-button ${
className={`side-menu-button side-menu-button--sights ${
isSightsOpen ? "side-menu-button--active" : ""
}`}
>
{selectedLanguage == "ru"
? "Достопримечательности"
: selectedLanguage == "zh"
? "景点"
: "Attractions"}
</div>
? "景点"
: "Attractions"}
</div>
<div
onPointerUp={() => {
if (!isStationOpen) {
@@ -553,9 +570,9 @@ const SideMenu = observer(({ onMenuToggle }) => {
{selectedLanguage == "ru"
? "Остановки"
: selectedLanguage == "zh"
? "车站"
: "Stations"}
</div>
? "车站"
: "Stations"}
</div>
</div>
<div className="side-menu-tag">
{/* {selectedLanguage == "ru"
@@ -565,29 +582,41 @@ const SideMenu = observer(({ onMenuToggle }) => {
: "#EveryoneOnTheWay"} */}
</div>
<div className="side-menu-bottom-section">
<div className="side-menu-carrier-block">
{designData?.carrierPath && (
<img
className="carrier-logo"
src={designData?.carrierPath}
alt="ГЭТ - Электротранспорт Санкт-Петербурга"
/>
)}
{carrier?.full_name && (
<div className="side-menu-slogan">{carrier.short_name}</div>
)}
</div>
<img className="side-menu-bottom-photo" src={sideMenuPhoto} alt="" />
<img
className="side-menu-bottom-photo"
src={
carrier?.preview_media && !isPreviewMediaError
? getMediaUrl(carrier.preview_media)
: sideMenuPhoto
}
onError={() => setIsPreviewMediaError(true)}
alt=""
/>
</div>
</div>
<div className="side-menu-control-panel">
<RouteWidget />
<div
style={{
transform:
isWeatherVisible && !isGovernorWidgetOpen
? "translateX(0)"
: "translateX(-200%)",
transition: "transform 1s ease",
opacity: isWeatherVisible && !isGovernorWidgetOpen ? 1 : 0,
pointerEvents:
isWeatherVisible && !isGovernorWidgetOpen ? "auto" : "none",
}}
>
<WeatherWidget />
</div>
<AppealWidget
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;
return mediaId ? ContentAPI.getMediaPath(mediaId) : undefined;
})()}
isOpen={isWidgetOpen}
style={{
@@ -600,15 +629,15 @@ const SideMenu = observer(({ onMenuToggle }) => {
selectedLanguage == "ru"
? sightArticles.get(route?.governor_appeal + "_ru")?.heading
: selectedLanguage == "zh"
? sightArticlesZh.get(route?.governor_appeal + "_zh")?.heading
: sightArticlesEn.get(route?.governor_appeal + "_en")?.heading
? sightArticlesZh.get(route?.governor_appeal + "_zh")?.heading
: sightArticlesEn.get(route?.governor_appeal + "_en")?.heading
}
widgetText={
selectedLanguage == "ru"
? sightArticles.get(route?.governor_appeal + "_ru")?.body
: selectedLanguage == "zh"
? sightArticlesZh.get(route?.governor_appeal + "_zh")?.body
: sightArticlesEn.get(route?.governor_appeal + "_en")?.body
? sightArticlesZh.get(route?.governor_appeal + "_zh")?.body
: sightArticlesEn.get(route?.governor_appeal + "_en")?.body
}
/>

View File

@@ -10,6 +10,7 @@ import { useGeolocationStore } from "../../stores";
import { apiStore } from "../../api/ApiStore/store";
import { useClickDetection } from "../../hooks/useClickDetection";
import { TouchableLayout } from "../TouchableLayout";
import Collapsible from "./Collapsible";
import { getMediaUrl } from "../../api/apiConfig";
import stationIcon from "../../assets/transport-icons/station.svg";
@@ -38,25 +39,8 @@ const SightItem = ({
const [shouldAnimate, setShouldAnimate] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
// Получаем название для отображения
const getSightName = () => {
// Если есть короткое название, используем его
if (sight.short_name) {
return sight.short_name;
}
if (!sight.left_article) {
return sight.name;
}
const leftArticleData =
selectedLanguage === "ru"
? sightArticles.get(sight.left_article + "_ru")
: selectedLanguage === "en"
? sightArticlesEn.get(sight.left_article + "_en")
: sightArticlesZh.get(sight.left_article + "_zh");
return leftArticleData?.heading || sight.name;
return sight.short_name || sight.name;
};
const sightName = getSightName();
@@ -84,12 +68,6 @@ const SightItem = ({
return () => window.removeEventListener("resize", checkWidth);
}, [sightName]);
useEffect(() => {
if (localSelectedSightId !== sight.id) {
setIsExpanded(false);
}
}, [localSelectedSightId, sight.id]);
const handleClick = (e) => {
const newExpanded = !isExpanded;
setIsExpanded(newExpanded);
@@ -101,13 +79,31 @@ const SightItem = ({
const cacheKey = `${sight.id}_${selectedLanguage}`;
const stations = sightStationsCache.get(cacheKey) || [];
useEffect(() => {
if (localSelectedSightId !== sight.id) {
setIsExpanded(false);
}
}, [localSelectedSightId, sight.id]);
// Логирование при открытии списка станций для достопримечательности (только один раз)
const wasExpandedRef = useRef(false);
useEffect(() => {
// Выводим логи только когда список открывается (переход из закрытого в открытое состояние)
if (isExpanded && !wasExpandedRef.current && stations.length > 0) {
wasExpandedRef.current = true;
}
// Сбрасываем флаг когда список закрывается
if (!isExpanded) {
wasExpandedRef.current = false;
}
}, [isExpanded, sight.id, selectedLanguage, stations.length]);
return (
<div
className={
localSelectedSightId === sight.id
? "side-menu-sight-selected-wrapper"
: ""
}
className={`side-menu-sight-wrapper ${
localSelectedSightId === sight.id ? "side-menu-sight-selected-wrapper" : ""
}`}
>
<div
ref={containerRef}
@@ -120,12 +116,11 @@ const SightItem = ({
{sightName}
</span>
</div>
<div
className={`side-menu-sight-transfer-list ${isExpanded ? "open" : ""}`}
>
<Collapsible className="side-menu-sight-transfer-list" open={isExpanded}>
{stations.length > 0 ? (
stations.map((station, index) => {
const iconSrc = isMediaIdEmpty(station.icon)
let isMediaIdEmptyResult = isMediaIdEmpty(station.icon);
const iconSrc = isMediaIdEmptyResult
? stationIcon
: getMediaUrl(station.icon);
@@ -166,7 +161,7 @@ const SightItem = ({
: "No stations"}
</div>
)}
</div>
</Collapsible>
</div>
);
};
@@ -242,24 +237,8 @@ const SightsList = observer(
: selectedLanguage === "en"
? routeSightsEn
: routeSightsZh;
// Функция для получения названия для сортировки
const getSightNameForSort = (sight) => {
if (sight.short_name) {
return sight.short_name.trim();
}
if (!sight.left_article) {
return sight.name.trim();
}
const leftArticleData =
selectedLanguage === "ru"
? sightArticles.get(sight.left_article + "_ru")
: selectedLanguage === "en"
? sightArticlesEn.get(sight.left_article + "_en")
: sightArticlesZh.get(sight.left_article + "_zh");
return (leftArticleData?.heading || sight.name).trim();
return (sight.short_name || sight.name).trim();
};
const sortedSights = [...(sights || [])].sort((a, b) => {
@@ -358,24 +337,17 @@ const SightsList = observer(
}, [localSelectedSightId, sightList, className]);
const handleSightClick = (sightId) => {
// Если кликнули на уже выбранную достопримечательность — закрываем виджет и сбрасываем цвет
if (isLeftWidgetOpen && localSelectedSightId === sightId) {
setLocalSelectedSightId(null);
setIsLeftWidgetOpen(false);
return;
}
// Открываем левый виджет
if (isLeftWidgetOpen && localSelectedSightId !== sightId) {
setLocalSelectedSightId(sightId);
setIsLeftWidgetOpen(true);
if (onSightSelected) {
onSightSelected(sightId);
}
} else if (!isLeftWidgetOpen) {
setLocalSelectedSightId(sightId);
setIsLeftWidgetOpen(true);
if (onSightSelected) {
onSightSelected(sightId);
}
} else {
// Если виджет уже открыт для этой достопримечательности, просто обновляем список остановок
if (onSightSelected) {
onSightSelected(sightId);
}
setLocalSelectedSightId(sightId);
setIsLeftWidgetOpen(true);
if (onSightSelected) {
onSightSelected(sightId);
}
};

View File

@@ -28,10 +28,10 @@ const SightItem = ({
const textRef = useRef(null);
const [shouldAnimate, setShouldAnimate] = useState(false);
// Получаем название из left_article
// Получаем название из left_article или используем short_name/name
const getSightName = () => {
if (!sight.left_article) {
return sight.name;
return sight.short_name || sight.name;
}
const leftArticleData =
@@ -41,7 +41,7 @@ const SightItem = ({
? sightArticlesEn.get(sight.left_article + "_en")
: sightArticlesZh.get(sight.left_article + "_zh");
return leftArticleData?.heading || sight.name;
return leftArticleData?.heading || sight.short_name || sight.name;
};
const sightName = getSightName();
@@ -119,10 +119,9 @@ const StationSightsList = observer(
selectedLanguage
);
// Функция для получения названия из left_article
const getSightNameForSort = (sight) => {
if (!sight.left_article) {
return sight.name.trim();
return (sight.short_name || sight.name).trim();
}
const leftArticleData =
@@ -132,7 +131,7 @@ const StationSightsList = observer(
? sightArticlesEn.get(sight.left_article + "_en")
: sightArticlesZh.get(sight.left_article + "_zh");
return (leftArticleData?.heading || sight.name).trim();
return (leftArticleData?.heading || sight.short_name || sight.name).trim();
};
const sortedSights = [...(response || [])].sort((a, b) => {
@@ -310,3 +309,4 @@ const StationSightsList = observer(
);
export default StationSightsList;
nSightsList;

View File

@@ -10,6 +10,7 @@ import { useGeolocationStore } from "../../stores";
import { apiStore } from "../../api/ApiStore/store";
import { useClickDetection } from "../../hooks/useClickDetection";
import { TouchableLayout } from "../TouchableLayout";
import Collapsible from "./Collapsible";
const SightTransferItem = ({ name, style, onPointerUp }) => {
const containerRef = useRef(null);
@@ -92,26 +93,15 @@ const StationItem = ({
const sights = stationSightsCache.get(cacheKey) || [];
const getSightName = (sight) => {
if (sight.short_name) {
return sight.short_name;
}
if (!sight.left_article) {
return sight.name;
}
const leftArticleData =
selectedLanguage === "ru"
? sightArticles.get(sight.left_article + "_ru")
: selectedLanguage === "en"
? sightArticlesEn.get(sight.left_article + "_en")
: sightArticlesZh.get(sight.left_article + "_zh");
return leftArticleData?.heading || sight.name;
return sight.short_name || sight.name;
};
return (
<div>
<div
className={`side-menu-sight-wrapper ${
selectedStationId === station.id ? "side-menu-sight-selected-wrapper" : ""
}`}
>
<div
ref={containerRef}
className="side-menu-sight"
@@ -129,10 +119,9 @@ const StationItem = ({
{station.name}
</span>
</div>
<div
className={`side-menu-sight-transfer-list ${
selectedStationId === station.id ? "open" : ""
}`}
<Collapsible
className="side-menu-sight-transfer-list"
open={selectedStationId === station.id}
>
{sights.length > 0 ? (
sights.map((sight, index) => (
@@ -165,7 +154,7 @@ const StationItem = ({
: "No sights"}
</div>
)}
</div>
</Collapsible>
</div>
);
};

View File

@@ -8,7 +8,7 @@ import { apiStore } from "../../api/ApiStore/store";
const RouteWidget = observer(() => {
const store = useGeolocationStore();
const { contextData, isLoading, error, selectedLanguage } = store;
const { routeStations, routeStationsEn, routeStationsZh, context, route } = apiStore;
const { routeStations, routeStationsEn, routeStationsZh, context } = apiStore;
const [startStation, setStartStation] = useState(null);
const [startStationEn, setStartStationEn] = useState(null);
const [endStation, setEndStation] = useState(null);
@@ -84,22 +84,22 @@ const RouteWidget = observer(() => {
const routeZhSubtitle = `${startStationZh?.name} - ${endStationZh?.name}`;
return (
<div className="route-widget">
<div className={`route-widget-number ${getNumberSizeClass(route?.route_sys_number || context?.routeNumber)}`}>
{route?.route_sys_number || context?.routeNumber || ""}
<div className={`route-widget-number ${getNumberSizeClass(context?.routeNumber)}`}>
{context?.routeNumber || "No number"}
</div>
<div className="route-widget-content">
<div
className={`route-widget-label ${
shouldAnimate(startStation?.name, 18) ? "marquee" : ""
} ${getLabelSizeClass(startStation?.name)}`}
>
>
{startStation?.name}
</div>
<div
className={`route-widget-label ${
shouldAnimate(endStation?.name, 18) ? "marquee" : ""
} ${getLabelSizeClass(endStation?.name)}`}
>
>
{endStation?.name}
</div>
{(selectedLanguage === "en" || selectedLanguage === "ru") && (
@@ -107,7 +107,7 @@ const RouteWidget = observer(() => {
className={`route-widget-subtitle ${
shouldAnimate(routeEnSubtitle, 50) ? "marquee" : ""
}`}
>
>
{routeEnSubtitle}
</div>
)}
@@ -116,7 +116,7 @@ const RouteWidget = observer(() => {
className={`route-widget-subtitle ${
shouldAnimate(routeZhSubtitle, 50) ? "marquee" : ""
}`}
>
>
{routeZhSubtitle}
</div>
)}

View File

@@ -166,24 +166,15 @@
animation: side-menu-marquee 14s linear infinite;
}
/* Анимация для списка пересадок */
.side-menu-sight-transfer-list.entering,
.side-menu-sight-transfer-list.entered {
max-height: 500px; /* Достаточно большое значение, чтобы вместить все пересадки */
opacity: 1;
transition: max-height 0.3s ease-out, opacity 0.3s ease-out;
overflow: hidden;
}
.side-menu-sight-transfer-list {
max-height: 0;
height: 0;
opacity: 0;
overflow: hidden;
transition: max-height 0.3s ease-out, opacity 0.3s ease-out; /* Анимация при открытии/закрытии */
transition:
height 0.3s cubic-bezier(0.16, 1, 0.3, 1),
opacity 0.3s ease;
}
/* Активное состояние - когда список открыт */
.side-menu-sight-transfer-list.open {
max-height: 500px; /* Достаточно большое значение, чтобы вместить все пересадки */
opacity: 1;
}

View File

@@ -1,3 +1,15 @@
@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);
@@ -36,6 +48,8 @@
}
.list-of-sights {
position: relative;
z-index: 10;
-webkit-box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
-moz-box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2);
@@ -51,7 +65,7 @@
color: white;
max-height: 68px;
transition: max-height 0.15s ease;
transition: max-height 0.35s cubic-bezier(0.16, 1, 0.3, 1);
overflow: hidden;
}
@@ -93,11 +107,7 @@
border-radius: 10px;
width: 128px;
background-color: color-mix(
in srgb,
var(--carrier-right, #806c59) 80%,
black
);
background-color: var(--carrier-right-dark, #005a2f);
}
.list-of-sights-title {
@@ -115,7 +125,7 @@
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease-in-out 0.15s;
transition: opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1) 0.1s;
overscroll-behavior: contain;
touch-action: pan-y;
@@ -124,6 +134,29 @@
backface-visibility: hidden;
}
.list-of-sights-content .scrollable {
--fade-top: 0px;
--fade-bottom: 45px;
mask-image: linear-gradient(
to bottom,
transparent 0px,
black var(--fade-top),
black calc(100% - var(--fade-bottom)),
transparent 100%
);
transition:
--fade-top 0.5s ease,
--fade-bottom 0.5s ease;
}
.list-of-sights-content:not(.is-at-top) .scrollable {
--fade-top: 15px;
}
.list-of-sights-content.is-at-bottom .scrollable {
--fade-bottom: 0px;
}
.list-of-sights-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
@@ -137,6 +170,12 @@
pointer-events: auto;
}
.list-of-sights-content .custom-scrollbar-track {
margin-top: 14px;
margin-bottom: 10px;
overflow: hidden;
}
.sight-component {
display: flex;
flex-direction: column;
@@ -209,18 +248,18 @@
height: 1px;
width: 100%;
background-color: rgba(255, 255, 255, 0.3);
margin-bottom: 14px;
margin-bottom: 0;
}
.sight-frame {
z-index: -1;
position: absolute;
bottom: 66px;
bottom: 100px;
left: 0;
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 32px;
width: 550px;
width: 100%;
border-radius: 10px;
background:
linear-gradient(
@@ -230,6 +269,12 @@
),
var(--carrier-right, #806c59);
max-height: calc(100vh - 128px);
transform: translate3d(0, 0, 0);
overflow: hidden;
}
.sight-frame.three-d-fullscreen {
overflow: visible;
}
.sight-frame-image {
@@ -246,6 +291,11 @@
flex-direction: column;
flex-grow: 1;
width: 100%;
transition: opacity 0.18s ease;
}
.sight-frame-content.is-switching {
opacity: 0;
}
.sight-frame-get-back-wrapper {
@@ -276,10 +326,22 @@
box-sizing: border-box;
color: white;
word-wrap: break-word;
overflow-wrap: break-word;
min-width: 0;
}
.fade-in-text {
animation: fadeInText 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes fadeInText {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.sight-frame-title:not(.intro-title) {
background:
linear-gradient(
@@ -307,18 +369,40 @@
}
.sight-frame-text-wrapper {
flex-grow: 1;
padding: 16px;
box-sizing: border-box;
max-height: calc(80vh - 354px);
min-height: 0;
flex-shrink: 0;
padding: 16px 16px 76px 16px;
box-sizing: border-box;
overflow: hidden;
display: flex;
flex-direction: column;
transition: opacity 0.3s ease;
}
.sight-frame-text-wrapper.scrollable-container {
padding: 16px;
padding: 16px 16px 76px 16px;
transition: opacity 0.3s ease;
}
.sight-frame-text-wrapper.is-intro {
height: 0;
padding-top: 0;
padding-bottom: 0;
border: none;
opacity: 0;
pointer-events: none;
overflow: hidden;
}
.sight-frame-loading-placeholder {
flex-shrink: 0;
}
.sight-frame-text-wrapper[data-scrollbar-no-transition="true"] .custom-scrollbar-track,
.sight-frame-text-wrapper[data-scrollbar-no-transition="true"] .custom-scrollbar-thumb {
transition: opacity 0.3s ease !important;
}
.sight-frame-text-wrapper .scrollable-viewport {
flex: 1;
@@ -329,11 +413,12 @@
}
.sight-frame-text {
display: flow-root; /* BFC prevents margin collapsing for correct scrollHeight measurement */
padding-right: 10px;
text-align: left;
color: #fff;
font-family: "Roboto";
font-size: 18px;
font-size: 20px;
font-style: normal;
font-weight: 400;
line-height: 150%;
@@ -348,7 +433,7 @@
background: linear-gradient(
to right,
transparent 35%,
color-mix(in srgb, var(--carrier-right, #806c59) 80%, black) 50%,
#0e8953 50%,
transparent 65%
);
border-radius: 3px;
@@ -371,8 +456,14 @@
}
.sight-frame-menu-wrapper {
position: relative;
position: absolute;
bottom: 100px;
left: 0;
width: 100%;
height: 60px;
z-index: 2;
border-radius: 0 0 10px 10px;
overflow: hidden;
flex-shrink: 0;
}
@@ -381,7 +472,7 @@
top: 0;
bottom: 0;
width: 120px;
z-index: 3;
z-index: 100001;
pointer-events: none;
transition: opacity 0.4s ease;
}
@@ -407,10 +498,11 @@
}
.sight-frame-menu {
z-index: 10000;
z-index: 100000;
position: relative;
padding: 7px 60px;
width: 100%;
height: 60px;
display: flex;
align-items: center;
justify-content: space-evenly;
@@ -457,7 +549,7 @@
}
.sight-frame-menu-point.active {
font-weight: 600;
text-shadow: 0 0 0.4px #fff, 0 0 0.4px #fff;
border-bottom-color: #fff;
}
@@ -483,30 +575,20 @@
}
.sight-frame {
opacity: 0;
transform: translateY(20px);
transition:
opacity 0.2s ease-out,
transform 0.2s ease-out;
transition: opacity 0.9s ease-out;
}
.sight-frame.is-visible {
opacity: 1;
transform: translateY(0);
animation: fadeInScale 0.6s ease-out forwards;
animation: fadeInScale 0.9s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes fadeInScale {
0% {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
50% {
opacity: 0.7;
transform: translateY(-5px) scale(1.02);
}
100% {
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@@ -558,6 +640,7 @@
font-size: 40px;
font-weight: 600;
line-height: 150%;
padding-bottom: 60px;
}
.svg-container {
@@ -615,15 +698,17 @@
width: 40px;
flex-shrink: 0;
margin-right: 10px;
padding-top: 24px;
margin-top: 14px;
margin-bottom: 10px;
padding-top: 10px;
padding-bottom: 10px;
display: flex;
align-items: center;
flex-direction: column;
gap: 16px;
gap: 20px;
overflow-y: auto;
overflow-x: hidden;
height: 650px;
padding-bottom: 30px;
height: 676px;
touch-action: pan-y;
overscroll-behavior: contain;
scrollbar-width: none;
@@ -842,7 +927,7 @@
border-radius: 32px;
right: 20px;
bottom: 20px;
background: var(--carrier-right, #806c59);
background: var(--carrier-main, #006f3a);
z-index: 9999;
display: flex;
}

View File

@@ -52,8 +52,7 @@
height: 96px;
background-color: #fcd500;
color: black;
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
border-radius: 10px 0 0 10px;
display: flex;
justify-content: center;
align-items: center;

View File

@@ -16,17 +16,6 @@
var(--carrier-left, #006f3a);
}
.side-menu-label {
margin-top: 10px;
text-align: left;
font-size: 15px;
padding: 0 20px;
align-self: flex-start;
font-style: normal;
font-weight: 400;
line-height: 150%;
}
.appeal-button {
color: black;
padding: 8px 16px;
@@ -41,7 +30,6 @@
.side-menu-buttons {
width: 220px;
margin-top: 40px;
}
.side-menu-button {
@@ -67,24 +55,6 @@
flex-direction: column;
}
.side-menu-carrier-block {
padding: 0 20px;
}
.carrier-logo {
width: 170px;
}
.side-menu-slogan {
margin-top: 4px;
text-align: left;
font-size: 16px;
font-style: normal;
font-weight: 700;
line-height: 150%;
color: #fff;
}
.side-menu-bottom-photo {
width: 100%;
margin-top: 32px;
@@ -198,6 +168,8 @@
overflow: hidden;
top: 250px;
bottom: 0;
display: flex;
flex-direction: column;
border-radius: 10px 10px 0px 0px;
background:
linear-gradient(
@@ -213,8 +185,6 @@
transition:
transform 0.3s ease-out,
opacity 0.3s ease-out;
display: flex;
flex-direction: column;
}
.side-menu-sights.slide-in {
@@ -253,10 +223,15 @@
position: relative;
}
.side-menu-sight-selected-wrapper {
background: rgba(0, 0, 0, 0.2);
.side-menu-sight-wrapper {
margin-left: -20px;
padding-left: 20px;
background-color: transparent;
transition: background-color 0.3s ease;
}
.side-menu-sight-selected-wrapper {
background-color: rgba(0, 0, 0, 0.2);
}
.side-menu-sight > span {
@@ -280,12 +255,6 @@
}
}
.side-menu-crest {
width: 170px;
align-self: flex-start;
margin-left: 20px;
}
.side-menu-control-lang-open {
gap: 10px;
display: inline-flex;

View File

@@ -43,7 +43,8 @@
position: relative;
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
transition: opacity 0.2s ease;
overflow: hidden;
transition: opacity 0.5s ease;
}
.custom-scrollbar-thumb {
@@ -56,6 +57,7 @@
pointer-events: auto;
cursor: grab;
touch-action: none;
transition: opacity 0.5s ease, height 0.5s ease;
}
.custom-scrollbar-thumb:active {
@@ -63,7 +65,7 @@
}
.side-menu-sights-block .scrollable-viewport {
height: calc(92%);
height: calc(98%);
}
.side-menu-sights-block .scrollable {

View File

@@ -1,6 +1,15 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
html,
body,
#root {
margin: 0;
height: 100%;
min-height: 100%;
overflow: hidden;
}
button {
cursor: pointer;
}

View File

@@ -1,6 +1,5 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
@@ -24,9 +23,10 @@ import {
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
editSightStore,
} from "@shared";
import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
import { ImageUploadCard, LanguageSwitcher, VideoPreviewCard } from "@widgets";
type ColorFields = { main_color: string; left_color: string; right_color: string };
@@ -80,18 +80,29 @@ const ColorPickerField = ({
export const CarrierCreatePage = observer(() => {
const navigate = useNavigate();
const { createCarrierData, setCreateCarrierData } = carrierStore;
const {
createCarrierData,
setCreateCarrierData,
setCreateCarrierMedia,
} = carrierStore;
const { language } = languageStore;
const canReadCities = authStore.canRead("cities");
const { selectedCityId, selectedCity } = useSelectedCity();
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [activeMediaField, setActiveMediaField] = useState<
"preview_media" | "video_preview"
>("preview_media");
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
| "thumbnail"
| "watermark_lu"
| "watermark_rd"
| "image"
| "video_preview"
| null
>(null);
useEffect(() => {
@@ -137,12 +148,16 @@ export const CarrierCreatePage = observer(() => {
createCarrierData[language].full_name,
createCarrierData[language].short_name,
selectedCityId,
createCarrierData[language].slogan,
selectedMediaId || "",
language,
);
}
}, [selectedCityId, createCarrierData.city_id]);
}, [
selectedCityId,
createCarrierData,
createCarrierData.city_id,
language,
setCreateCarrierData,
]);
const handleCreate = async () => {
try {
@@ -151,7 +166,7 @@ export const CarrierCreatePage = observer(() => {
toast.success("Перевозчик успешно создан");
navigate("/carrier");
} catch (error) {
} catch {
toast.error("Ошибка при создании перевозчика");
} finally {
setIsLoading(false);
@@ -164,27 +179,22 @@ export const CarrierCreatePage = observer(() => {
media_name?: string;
media_type: number;
}) => {
setSelectedMediaId(media.id);
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
createCarrierData[language].slogan,
media.id,
language,
);
setCreateCarrierMedia(activeMediaField, media.id);
};
const selectedMedia =
selectedMediaId && !isMediaIdEmpty(selectedMediaId)
? mediaStore.media.find((m) => m.id === selectedMediaId)
: null;
const effectiveLogoUrl = isMediaIdEmpty(selectedMediaId)
const effectivePreviewMediaUrl = isMediaIdEmpty(
createCarrierData.preview_media,
)
? null
: selectedMedia?.id ?? selectedMediaId ?? null;
: createCarrierData.preview_media;
const effectiveVideoPreviewId = isMediaIdEmpty(
createCarrierData.video_preview,
)
? null
: createCarrierData.video_preview;
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />
<div className="flex items-center gap-4">
<button
@@ -212,8 +222,6 @@ export const CarrierCreatePage = observer(() => {
createCarrierData[language].full_name,
createCarrierData[language].short_name,
e.target.value as number,
createCarrierData[language].slogan,
selectedMediaId || "",
language,
)
}
@@ -236,8 +244,6 @@ export const CarrierCreatePage = observer(() => {
e.target.value,
createCarrierData[language].short_name,
createCarrierData.city_id,
createCarrierData[language].slogan,
selectedMediaId || "",
language,
)
}
@@ -253,24 +259,6 @@ export const CarrierCreatePage = observer(() => {
createCarrierData[language].full_name,
e.target.value,
createCarrierData.city_id,
createCarrierData[language].slogan,
selectedMediaId || "",
language,
)
}
/>
<TextField
fullWidth
label="Слоган"
value={createCarrierData[language].slogan}
onChange={(e) =>
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
e.target.value,
selectedMediaId || "",
language,
)
}
@@ -286,8 +274,6 @@ export const CarrierCreatePage = observer(() => {
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
createCarrierData[language].slogan,
selectedMediaId || "",
language,
{ ...colorFields(createCarrierData), main_color: val }
)
@@ -306,8 +292,6 @@ export const CarrierCreatePage = observer(() => {
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
createCarrierData[language].slogan,
selectedMediaId || "",
language,
{ ...colorFields(createCarrierData), left_color: val }
)
@@ -326,8 +310,6 @@ export const CarrierCreatePage = observer(() => {
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
createCarrierData[language].slogan,
selectedMediaId || "",
language,
{ ...colorFields(createCarrierData), right_color: val }
)
@@ -339,36 +321,57 @@ export const CarrierCreatePage = observer(() => {
</div>
</div>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard
title="Логотип перевозчика"
imageKey="thumbnail"
imageUrl={effectiveLogoUrl}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(effectiveLogoUrl ?? "");
}}
onDeleteImageClick={() => {
setSelectedMediaId(null);
setActiveMenuType(null);
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
createCarrierData[language].slogan,
"",
language,
);
}}
onSelectFileClick={() => {
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("image");
}}
/>
<div className="w-full flex justify-center items-start gap-10 flex-wrap">
<div className="w-full max-w-[300px]">
<ImageUploadCard
title="Изображение сайд-меню"
imageKey="image"
imageUrl={effectivePreviewMediaUrl}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(effectivePreviewMediaUrl ?? "");
}}
onDeleteImageClick={() =>
setCreateCarrierMedia("preview_media", "")
}
onSelectFileClick={() => {
setActiveMediaField("preview_media");
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setActiveMediaField("preview_media");
setActiveMenuType("image");
setIsUploadMediaOpen(true);
}}
/>
</div>
<div className="w-full max-w-[300px]">
<VideoPreviewCard
title="Видео загрузки"
videoId={effectiveVideoPreviewId}
onVideoClick={() => {
if (effectiveVideoPreviewId) {
setMediaId(effectiveVideoPreviewId);
setIsPreviewMediaOpen(true);
}
}}
onDeleteVideoClick={() =>
setCreateCarrierMedia("video_preview", "")
}
onSelectVideoClick={(file) => {
setActiveMediaField("video_preview");
setActiveMenuType("video_preview");
if (file) {
editSightStore.setFileToUpload(file);
setIsUploadMediaOpen(true);
} else {
setIsSelectMediaOpen(true);
}
}}
className="w-full"
/>
</div>
</div>
<Button
@@ -395,7 +398,7 @@ export const CarrierCreatePage = observer(() => {
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1}
mediaType={activeMediaField === "video_preview" ? 2 : 1}
/>
<UploadMediaDialog
@@ -412,6 +415,6 @@ export const CarrierCreatePage = observer(() => {
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
</Paper>
</div>
);
});

View File

@@ -1,6 +1,5 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
@@ -22,9 +21,14 @@ import {
isMediaIdEmpty,
LoadingSpinner,
selectedCityStore,
editSightStore,
} from "@shared";
import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets";
import {
ImageUploadCard,
LanguageSwitcher,
VideoPreviewCard,
} from "@widgets";
import {
SelectMediaDialog,
PreviewMediaDialog,
@@ -88,7 +92,12 @@ const ColorPickerField = ({
export const CarrierEditPage = observer(() => {
const navigate = useNavigate();
const { id } = useParams();
const { getCarrier, setEditCarrierData, editCarrierData } = carrierStore;
const {
getCarrier,
setEditCarrierData,
setEditCarrierMedia,
editCarrierData,
} = carrierStore;
const { language } = languageStore;
const canReadCities = authStore.canRead("cities");
@@ -98,10 +107,17 @@ export const CarrierEditPage = observer(() => {
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [isDeleteLogoModalOpen, setIsDeleteLogoModalOpen] = useState(false);
const [initialCityName, setInitialCityName] = useState("");
const [activeMediaField, setActiveMediaField] = useState<
"preview_media" | "video_preview"
>("preview_media");
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
| "thumbnail"
| "watermark_lu"
| "watermark_rd"
| "image"
| "video_preview"
| null
>(null);
useEffect(() => {
@@ -137,8 +153,6 @@ export const CarrierEditPage = observer(() => {
carrierData.ru?.full_name || "",
carrierData.ru?.short_name || "",
carrierData.ru?.city_id || 0,
carrierData.ru?.slogan || "",
carrierData.ru?.logo || "",
"ru",
colors,
);
@@ -146,18 +160,22 @@ export const CarrierEditPage = observer(() => {
carrierData.en?.full_name || "",
carrierData.en?.short_name || "",
carrierData.en?.city_id || 0,
carrierData.en?.slogan || "",
carrierData.en?.logo || "",
"en",
);
setEditCarrierData(
carrierData.zh?.full_name || "",
carrierData.zh?.short_name || "",
carrierData.zh?.city_id || 0,
carrierData.zh?.slogan || "",
carrierData.zh?.logo || "",
"zh",
);
setEditCarrierMedia(
"video_preview",
carrierData.ru?.video_preview || "",
);
setEditCarrierMedia(
"preview_media",
carrierData.ru?.preview_media || "",
);
setInitialCityName(carrierData.ru?.city || "");
}
@@ -168,7 +186,7 @@ export const CarrierEditPage = observer(() => {
})();
languageStore.setLanguage("ru");
}, [id]);
}, [id, getCarrier, setEditCarrierData, setEditCarrierMedia]);
const handleEdit = async () => {
try {
@@ -176,7 +194,7 @@ export const CarrierEditPage = observer(() => {
await carrierStore.editCarrier(Number(id));
toast.success("Перевозчик успешно обновлен");
navigate("/carrier");
} catch (error) {
} catch {
toast.error("Ошибка при обновлении перевозчика");
} finally {
setIsLoading(false);
@@ -189,23 +207,19 @@ export const CarrierEditPage = observer(() => {
media_name?: string;
media_type: number;
}) => {
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
media.id,
language,
);
setEditCarrierMedia(activeMediaField, media.id);
};
const selectedMedia =
editCarrierData.logo && !isMediaIdEmpty(editCarrierData.logo)
? mediaStore.media.find((m) => m.id === editCarrierData.logo)
: null;
const effectiveLogoUrl = isMediaIdEmpty(editCarrierData.logo)
const effectivePreviewMediaUrl = isMediaIdEmpty(
editCarrierData.preview_media,
)
? null
: (selectedMedia?.id ?? editCarrierData.logo);
: editCarrierData.preview_media;
const effectiveVideoPreviewId = isMediaIdEmpty(
editCarrierData.video_preview,
)
? null
: editCarrierData.video_preview;
const baseCities = canReadCities
? cityStore.cities["ru"].data
@@ -248,7 +262,7 @@ export const CarrierEditPage = observer(() => {
}
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />
<div className="flex items-center gap-4">
<button
@@ -275,8 +289,6 @@ export const CarrierEditPage = observer(() => {
editCarrierData[language].full_name,
editCarrierData[language].short_name,
Number(e.target.value),
editCarrierData[language].slogan,
editCarrierData.logo,
language,
)
}
@@ -299,8 +311,6 @@ export const CarrierEditPage = observer(() => {
e.target.value,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
editCarrierData.logo,
language,
)
}
@@ -316,24 +326,6 @@ export const CarrierEditPage = observer(() => {
editCarrierData[language].full_name,
e.target.value,
editCarrierData.city_id,
editCarrierData[language].slogan,
editCarrierData.logo,
language,
)
}
/>
<TextField
fullWidth
label="Слоган"
value={editCarrierData[language].slogan}
onChange={(e) =>
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
e.target.value,
editCarrierData.logo,
language,
)
}
@@ -349,8 +341,6 @@ export const CarrierEditPage = observer(() => {
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
editCarrierData.logo,
language,
{ ...colorFields(editCarrierData), main_color: val },
)
@@ -370,8 +360,6 @@ export const CarrierEditPage = observer(() => {
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
editCarrierData.logo,
language,
{ ...colorFields(editCarrierData), left_color: val },
)
@@ -391,8 +379,6 @@ export const CarrierEditPage = observer(() => {
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
editCarrierData.logo,
language,
{ ...colorFields(editCarrierData), right_color: val },
)
@@ -405,27 +391,57 @@ export const CarrierEditPage = observer(() => {
</div>
</div>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard
title="Логотип перевозчика"
imageKey="thumbnail"
imageUrl={effectiveLogoUrl}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(effectiveLogoUrl ?? "");
}}
onDeleteImageClick={() => {
setIsDeleteLogoModalOpen(true);
}}
onSelectFileClick={() => {
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("image");
}}
/>
<div className="w-full flex justify-center items-start gap-10 flex-wrap">
<div className="w-full max-w-[300px]">
<ImageUploadCard
title="Изображение сайд-меню"
imageKey="image"
imageUrl={effectivePreviewMediaUrl}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(effectivePreviewMediaUrl ?? "");
}}
onDeleteImageClick={() =>
setEditCarrierMedia("preview_media", "")
}
onSelectFileClick={() => {
setActiveMediaField("preview_media");
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setActiveMediaField("preview_media");
setActiveMenuType("image");
setIsUploadMediaOpen(true);
}}
/>
</div>
<div className="w-full max-w-[300px]">
<VideoPreviewCard
title="Видео загрузки"
videoId={effectiveVideoPreviewId}
onVideoClick={() => {
if (effectiveVideoPreviewId) {
setMediaId(effectiveVideoPreviewId);
setIsPreviewMediaOpen(true);
}
}}
onDeleteVideoClick={() =>
setEditCarrierMedia("video_preview", "")
}
onSelectVideoClick={(file) => {
setActiveMediaField("video_preview");
setActiveMenuType("video_preview");
if (file) {
editSightStore.setFileToUpload(file);
setIsUploadMediaOpen(true);
} else {
setIsSelectMediaOpen(true);
}
}}
className="w-full"
/>
</div>
</div>
<Button
@@ -451,7 +467,7 @@ export const CarrierEditPage = observer(() => {
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1}
mediaType={activeMediaField === "video_preview" ? 2 : 1}
/>
<UploadMediaDialog
@@ -469,22 +485,6 @@ export const CarrierEditPage = observer(() => {
mediaId={mediaId}
/>
<DeleteModal
open={isDeleteLogoModalOpen}
onDelete={() => {
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
"",
language,
);
setIsDeleteLogoModalOpen(false);
}}
onCancel={() => setIsDeleteLogoModalOpen(false)}
edit
/>
</Paper>
</div>
);
});

View File

@@ -12,11 +12,7 @@ import {
DialogContent,
DialogActions,
} from "@mui/material";
import {
MediaViewer,
VideoPreviewCard,
ImageUploadCard,
} from "@widgets";
import { MediaViewer, VideoPreviewCard, ImageUploadCard } from "@widgets";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save, Plus, X } from "lucide-react";
import { useEffect, useState, useMemo } from "react";
@@ -140,6 +136,16 @@ export const RouteCreatePage = observer(() => {
articlesStore.getArticleList();
};
const handleIconSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setIcon(media.id);
setIsSelectIconDialogOpen(false);
};
const handleVideoSelect = (media: {
id: string;
filename: string;
@@ -170,20 +176,6 @@ export const RouteCreatePage = observer(() => {
setFileToUpload(null);
};
const handleVideoPreviewClick = () => {
setIsVideoPreviewOpen(true);
};
const handleIconSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setIcon(media.id);
setIsSelectIconDialogOpen(false);
};
const selectedIconMedia =
icon && !isMediaIdEmpty(icon)
? mediaStore.media.find((m) => m.id === icon)
@@ -489,7 +481,7 @@ export const RouteCreatePage = observer(() => {
<VideoPreviewCard
title="Видеозаставка"
videoId={effectiveVideoId}
onVideoClick={handleVideoPreviewClick}
onVideoClick={() => setIsVideoPreviewOpen(true)}
onDeleteVideoClick={() => setVideoPreview("")}
onSelectVideoClick={handleVideoFileSelect}
className="w-full"
@@ -578,7 +570,7 @@ export const RouteCreatePage = observer(() => {
/>
<TextField
className="w-full"
label="Таймер видео заставки (сек)"
label="Таймер показа видео при бездействии (сек)"
type="number"
value={videoTimer}
onChange={(e) => {
@@ -651,11 +643,10 @@ export const RouteCreatePage = observer(() => {
}}
hardcodeType="video_preview"
contextObjectName={routeName || "Маршрут"}
contextType="sight"
contextType="route"
initialFile={fileToUpload || undefined}
afterUpload={handleVideoUpload}
/>
<SelectMediaDialog
open={isSelectIconDialogOpen}
onClose={() => setIsSelectIconDialogOpen(false)}

View File

@@ -244,9 +244,7 @@ export const RouteEditPage = observer(() => {
media_name?: string;
media_type: number;
}) => {
routeStore.setEditRouteData({
video_preview: media.id,
});
routeStore.setEditRouteData({ video_preview: media.id });
setIsSelectVideoDialogOpen(false);
};
@@ -265,19 +263,11 @@ export const RouteEditPage = observer(() => {
media_name?: string;
media_type: number;
}) => {
routeStore.setEditRouteData({
video_preview: media.id,
});
routeStore.setEditRouteData({ video_preview: media.id });
setIsUploadVideoDialogOpen(false);
setFileToUpload(null);
};
const handleVideoPreviewClick = () => {
if (editRouteData.video_preview && editRouteData.video_preview !== "") {
setIsVideoPreviewOpen(true);
}
};
// Получаем название выбранной статьи для отображения
const selectedArticle = articlesStore.articleList.ru.data.find(
(article) => article.id === editRouteData.governor_appeal
@@ -555,7 +545,7 @@ export const RouteEditPage = observer(() => {
/>
<TextField
className="w-full"
label="Таймер видео заставки (сек)"
label="Таймер показа видео при бездействии (сек)"
type="number"
value={editRouteData.video_timer ?? 420}
onChange={(e) => {
@@ -650,10 +640,10 @@ export const RouteEditPage = observer(() => {
<VideoPreviewCard
title="Видеозаставка"
videoId={effectiveVideoId}
onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => {
routeStore.setEditRouteData({ video_preview: "" });
}}
onVideoClick={() => setIsVideoPreviewOpen(true)}
onDeleteVideoClick={() =>
routeStore.setEditRouteData({ video_preview: "" })
}
onSelectVideoClick={handleVideoFileSelect}
className="w-full"
/>
@@ -741,11 +731,10 @@ export const RouteEditPage = observer(() => {
}}
hardcodeType="video_preview"
contextObjectName={editRouteData.route_name || "Маршрут"}
contextType="sight"
contextType="route"
initialFile={fileToUpload || undefined}
afterUpload={handleVideoUpload}
/>
<SelectMediaDialog
open={isSelectIconDialogOpen}
onClose={() => setIsSelectIconDialogOpen(false)}

View File

@@ -1,232 +0,0 @@
import { FederatedMouseEvent, FederatedWheelEvent } from "pixi.js";
import { Component, ReactNode, useEffect, useState, useRef } from "react";
import { useTransform } from "./TransformContext";
import { useMapData } from "./MapDataContext";
import { SCALE_FACTOR } from "./Constants";
import { useApplication } from "@pixi/react";
class ErrorBoundary extends Component<
{ children: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("Error caught:", error, info);
}
render() {
return this.state.hasError ? <p>Whoopsie Daisy!</p> : this.props.children;
}
}
export function InfiniteCanvas({
children,
}: Readonly<{ children?: ReactNode }>) {
const {
position,
setPosition,
scale,
setScale,
rotation,
setRotation,
setScreenCenter,
screenCenter,
} = useTransform();
const { routeData, originalRouteData, setSelectedSight } = useMapData();
const applicationRef = useApplication();
const [isDragging, setIsDragging] = useState(false);
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
const [startRotation, setStartRotation] = useState(0);
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
const [isPointerDown, setIsPointerDown] = useState(false);
const [isUserInteracting, setIsUserInteracting] = useState(false);
const lastOriginalRotation = useRef<number | undefined>(undefined);
useEffect(() => {
if (!applicationRef?.app?.canvas) return;
const canvas = applicationRef.app.canvas;
const canvasRect = canvas.getBoundingClientRect();
const canvasLeft = canvasRect.left;
const canvasTop = canvasRect.top;
const centerX = window.innerWidth / 2 - canvasLeft;
const centerY = window.innerHeight / 2 - canvasTop;
setScreenCenter({ x: centerX, y: centerY });
}, [applicationRef?.app, setScreenCenter]);
const handlePointerDown = (e: FederatedMouseEvent) => {
setIsPointerDown(true);
setIsDragging(false);
setIsUserInteracting(true);
setStartPosition({
x: position.x,
y: position.y,
});
setStartMousePosition({
x: e.globalX,
y: e.globalY,
});
setStartRotation(rotation);
e.stopPropagation();
};
useEffect(() => {
const newRotation = originalRouteData?.rotate ?? 0;
if (!isUserInteracting && lastOriginalRotation.current !== newRotation) {
setRotation((newRotation * Math.PI) / 180);
lastOriginalRotation.current = newRotation;
}
}, [originalRouteData?.rotate, isUserInteracting, setRotation]);
const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isPointerDown) return;
if (!isDragging) {
const dx = e.globalX - startMousePosition.x;
const dy = e.globalY - startMousePosition.y;
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
setIsDragging(true);
} else {
return;
}
}
if (e.shiftKey) {
const center = screenCenter ?? { x: 0, y: 0 };
const startAngle = Math.atan2(
startMousePosition.y - center.y,
startMousePosition.x - center.x
);
const currentAngle = Math.atan2(
e.globalY - center.y,
e.globalX - center.x
);
const rotationDiff = currentAngle - startAngle;
setRotation(startRotation + rotationDiff);
const cosDelta = Math.cos(rotationDiff);
const sinDelta = Math.sin(rotationDiff);
setPosition({
x:
center.x * (1 - cosDelta) +
startPosition.x * cosDelta +
(center.y - startPosition.y) * sinDelta,
y:
center.y * (1 - cosDelta) +
startPosition.y * cosDelta +
(startPosition.x - center.x) * sinDelta,
});
} else {
setRotation(startRotation);
setPosition({
x: startPosition.x - startMousePosition.x + e.globalX,
y: startPosition.y - startMousePosition.y + e.globalY,
});
}
e.stopPropagation();
};
const handlePointerUp = (e: FederatedMouseEvent) => {
if (!isDragging) {
setSelectedSight(undefined);
}
setIsPointerDown(false);
setIsDragging(false);
setTimeout(() => {
setIsUserInteracting(false);
}, 100);
e.stopPropagation();
};
const handleWheel = (e: FederatedWheelEvent) => {
e.stopPropagation();
setIsUserInteracting(true);
const mouseX = e.globalX - position.x;
const mouseY = e.globalY - position.y;
const scaleMin = (routeData?.scale_min ?? 10) / SCALE_FACTOR;
const scaleMax = (routeData?.scale_max ?? 20) / SCALE_FACTOR;
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor));
const actualZoomFactor = newScale / scale;
if (scale === newScale) {
setTimeout(() => {
setIsUserInteracting(false);
}, 100);
return;
}
setPosition({
x: position.x + mouseX * (1 - actualZoomFactor),
y: position.y + mouseY * (1 - actualZoomFactor),
});
setScale(newScale);
setTimeout(() => {
setIsUserInteracting(false);
}, 100);
};
useEffect(() => {
applicationRef?.app.render();
}, [position, scale, rotation]);
return (
<ErrorBoundary>
{applicationRef?.app && (
<pixiGraphics
draw={(g) => {
const canvas = applicationRef.app.canvas;
g.clear();
g.rect(0, 0, canvas?.width ?? 0, canvas?.height ?? 0);
g.fill("#111");
}}
eventMode={"static"}
interactive
onPointerDown={handlePointerDown}
onGlobalPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerUpOutside={handlePointerUp}
onWheel={handleWheel}
/>
)}
<pixiContainer
x={position.x}
y={position.y}
scale={scale}
rotation={rotation}
>
{children}
</pixiContainer>
{/* Show center of the screen.
<pixiGraphics
eventMode="none"
draw={(g) => {
g.clear();
const center = screenCenter ?? {x: 0, y: 0};
g.circle(center.x, center.y, 1);
g.fill("#fff");
}}
/> */}
</ErrorBoundary>
);
}

View File

@@ -1,10 +1,9 @@
import { Button } from "@mui/material";
import { useNavigate, useNavigationType } from "react-router";
import { MediaViewer } from "@widgets";
import { useMapData } from "./MapDataContext";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { authInstance, isMediaIdEmpty } from "@shared";
import { authInstance } from "@shared";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import LanguageSelector from "./web-gl/LanguageSelector";
@@ -17,9 +16,8 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
const navigate = useNavigate();
const navigationType = useNavigationType();
const { routeData } = useMapData();
const [carrierLogo, setCarrierLogo] = useState<string | null>(null);
const [carrierSlogan, setCarrierSlogan] = useState<string | null>(null);
const [carrierShortName, setCarrierShortName] = useState<string | null>(null);
const [previewMedia, setPreviewMedia] = useState<string | null>(null);
const [isPreviewMediaError, setIsPreviewMediaError] = useState(false);
useEffect(() => {
async function fetchCarrierData() {
@@ -27,9 +25,8 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
const carrier = (
await authInstance.get(`/carrier/${routeData.carrier_id}`)
).data;
setCarrierLogo(carrier.logo);
setCarrierSlogan(carrier.slogan ?? null);
setCarrierShortName(carrier.short_name ?? null);
setPreviewMedia(carrier.preview_media ?? null);
setIsPreviewMediaError(false);
}
}
fetchCarrierData();
@@ -53,7 +50,14 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
}}
>
{/* Кнопка назад — вне основного меню */}
<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
onClick={handleBack}
variant="contained"
@@ -87,43 +91,6 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
pointerEvents: open ? "auto" : "none",
}}
>
{/* Герб — .side-menu-crest */}
<div
style={{
width: 170,
height: 170,
alignSelf: "flex-start",
marginLeft: 20,
backgroundColor: "rgba(255,255,255,0.15)",
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "rgba(255,255,255,0.5)",
fontSize: 14,
fontWeight: 500,
}}
>
Герб
</div>
{/* Слоган — .side-menu-label */}
{carrierSlogan && (
<div
style={{
marginTop: 10,
textAlign: "left",
fontSize: 15,
padding: "0 20px",
alignSelf: "flex-start",
fontWeight: 400,
lineHeight: "150%",
}}
>
{carrierSlogan}
</div>
)}
{/* Кнопки — .side-menu-buttons */}
<div
style={{
@@ -168,39 +135,14 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
flexDirection: "column",
}}
>
{/* .side-menu-carrier-block */}
<div style={{ padding: "0 20px" }}>
{carrierLogo && !isMediaIdEmpty(carrierLogo) && (
<div style={{ width: 170 }}>
<MediaViewer
media={{
id: carrierLogo,
media_type: 1,
filename: "carrier_logo",
}}
fullWidth
/>
</div>
)}
{carrierShortName && (
<div
style={{
marginTop: 4,
textAlign: "left",
fontSize: 16,
fontWeight: 700,
lineHeight: "150%",
color: "#fff",
}}
>
{carrierShortName}
</div>
)}
</div>
{/* .side-menu-bottom-photo */}
<img
src="/side-menu-photo.png"
src={
previewMedia && !isPreviewMediaError
? `${import.meta.env.VITE_KRBL_MEDIA}${previewMedia}/download?token=${localStorage.getItem("token") ?? ""}`
: "/side-menu-photo.png"
}
onError={() => setIsPreviewMediaError(true)}
alt=""
style={{
width: "288px",
@@ -212,7 +154,13 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
</div>
</div>
<div className="absolute bottom-[20px] -right-[520px] z-10">
<div
className="absolute bottom-[20px] z-10"
style={{
right: open ? -520 : -312,
transition: "right 0.3s ease",
}}
>
<LanguageSelector onBack={onToggle} isSidebarOpen={open} />
</div>
</div>

View File

@@ -1,137 +0,0 @@
import { useEffect, useState } from "react";
import { useTransform } from "./TransformContext";
import { SightData } from "./types";
import { Assets, FederatedMouseEvent, Texture } from "pixi.js";
import { SIGHT_SIZE, UP_SCALE } from "./Constants";
import { coordinatesToLocal, localToCoordinates } from "./utils";
import { useMapData } from "./MapDataContext";
interface SightProps {
sight: SightData;
id: number;
}
export const Sight = ({ sight, id }: Readonly<SightProps>) => {
const { rotation, scale } = useTransform();
const { setSightCoordinates, setSelectedSight } = useMapData();
const [position, setPosition] = useState(
coordinatesToLocal(sight.latitude, sight.longitude)
);
const [isDragging, setIsDragging] = useState(false);
const [isPointerDown, setIsPointerDown] = useState(false);
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
const handlePointerDown = (e: FederatedMouseEvent) => {
setIsPointerDown(true);
setIsDragging(false);
setStartPosition({
x: position.x,
y: position.y,
});
setStartMousePosition({
x: e.globalX,
y: e.globalY,
});
e.stopPropagation();
};
const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isPointerDown) return;
if (!isDragging) {
const dx = e.globalX - startMousePosition.x;
const dy = e.globalY - startMousePosition.y;
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
setIsDragging(true);
} else {
return;
}
}
const dx = (e.globalX - startMousePosition.x) / scale / UP_SCALE;
const dy = (e.globalY - startMousePosition.y) / scale / UP_SCALE;
const cos = Math.cos(rotation);
const sin = Math.sin(rotation);
const newPosition = {
x: startPosition.x + dx * cos + dy * sin,
y: startPosition.y - dx * sin + dy * cos,
};
setPosition(newPosition);
const coordinates = localToCoordinates(newPosition.x, newPosition.y);
setSightCoordinates(sight.id, coordinates.latitude, coordinates.longitude);
e.stopPropagation();
};
const handlePointerUp = (e: FederatedMouseEvent) => {
setIsPointerDown(false);
// Если не было перетаскивания, то это клик
if (!isDragging) {
setSelectedSight(sight);
}
setIsDragging(false);
e.stopPropagation();
};
const [texture, setTexture] = useState(Texture.EMPTY);
useEffect(() => {
Assets.load("/sight_icon.svg").then(setTexture);
}, []);
useEffect(() => {}, [id, sight.latitude, sight.longitude]);
if (!sight) {
console.error("sight is null");
return null;
}
// Компенсируем масштаб, но при зуме текст немного увеличивается
const clampedScale = Math.min(Math.max(scale, 1), 3);
const textScaleFactor = 1 + (clampedScale - 1) * 0.4;
const compensatedSize = SIGHT_SIZE / scale;
const compensatedFontSize = (24 / scale) * textScaleFactor;
return (
<pixiContainer
rotation={-rotation}
eventMode="static"
interactive
onPointerDown={handlePointerDown}
onGlobalPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerUpOutside={handlePointerUp}
x={position.x * UP_SCALE - SIGHT_SIZE / 2}
y={position.y * UP_SCALE - SIGHT_SIZE / 2}
>
<pixiSprite
texture={texture}
width={compensatedSize}
height={compensatedSize}
/>
<pixiGraphics
draw={(g) => {
g.clear();
g.circle(0, 0, 20 / scale);
g.fill({ color: "#000" });
}}
x={compensatedSize}
y={0}
/>
<pixiText
text={`${id + 1}`}
x={compensatedSize + 1 / scale}
y={0}
anchor={0.5}
style={{
fontSize: compensatedFontSize,
fontWeight: "bold",
fill: "#ffffff",
}}
/>
</pixiContainer>
);
};

View File

@@ -1,557 +0,0 @@
import { FederatedMouseEvent, Graphics } from "pixi.js";
import { useCallback, useState, useEffect, useRef, FC, useMemo } from "react";
import { observer } from "mobx-react-lite";
import {
BACKGROUND_COLOR,
PATH_COLOR,
STATION_RADIUS,
STATION_OUTLINE_WIDTH,
UP_SCALE,
} from "./Constants";
import { useTransform } from "./TransformContext";
import { StationData } from "./types";
import { useMapData } from "./MapDataContext";
import { coordinatesToLocal } from "./utils";
import { languageStore } from "@shared";
declare const pixiContainer: any;
declare const pixiGraphics: any;
declare const pixiText: any;
type HorizontalAlign = "left" | "center" | "right";
type VerticalAlign = "top" | "center" | "bottom";
type TextAlign = HorizontalAlign | `${HorizontalAlign} ${VerticalAlign}`;
type LabelAlign = "left" | "center" | "right";
/**
* Преобразует текстовое позиционирование в anchor координаты.
*/
/**
* Получает координату anchor.x из типа выравнивания.
*/
interface StationProps {
station: StationData;
ruLabel: string | null;
anchorPoint?: { x: number; y: number };
/** Anchor для всего блока с текстом. По умолчанию: `"right center"` */
labelBlockAnchor?: TextAlign | { x: number; y: number };
/** Внутреннее выравнивание текста в блоке. По умолчанию: `"left"` */
labelAlign?: LabelAlign;
/** Callback для изменения внутреннего выравнивания */
onLabelAlignChange?: (align: LabelAlign) => void;
/** Callback для отслеживания наведения на текст */
onTextHover?: (isHovered: boolean) => void;
}
interface LabelAlignmentControlProps {
scale: number;
currentAlign: LabelAlign;
onAlignChange: (align: LabelAlign) => void;
onPointerOver: () => void;
onPointerOut: () => void;
onControlPointerEnter: () => void;
onControlPointerLeave: () => void;
}
interface StationLabelProps
extends Omit<StationProps, "ruLabelAnchor" | "nameLabelAnchor"> {}
const getAnchorFromOffset = (
offsetX: number,
offsetY: number
): { x: number; y: number } => {
if (offsetX === 0 && offsetY === 0) {
return { x: 0.5, y: 0.5 };
}
const length = Math.hypot(offsetX, offsetY);
const nx = offsetX / length;
const ny = offsetY / length;
return { x: (1 - nx) / 2, y: (1 - ny) / 2 };
};
const LabelAlignmentControl: FC<LabelAlignmentControlProps> = ({
scale,
currentAlign,
onAlignChange,
onControlPointerEnter,
onControlPointerLeave,
}) => {
const controlHeight = 50 / scale;
const controlWidth = 200 / scale;
const fontSize = 18 / scale;
const borderRadius = 8 / scale;
const compensatedRuFontSize = (26 * 0.75) / scale;
const buttonWidth = controlWidth / 3;
const strokeWidth = 2 / scale;
const drawBg = useCallback(
(g: Graphics) => {
g.clear();
g.roundRect(
-controlWidth / 2,
0,
controlWidth,
controlHeight,
borderRadius
);
g.fill({ color: "#1a1a1a" });
g.roundRect(
-controlWidth / 2,
0,
controlWidth,
controlHeight,
borderRadius
);
g.stroke({ color: "#333333", width: strokeWidth });
for (let i = 1; i < 3; i++) {
const x = -controlWidth / 2 + buttonWidth * i;
g.moveTo(x, strokeWidth);
g.lineTo(x, controlHeight - strokeWidth);
g.stroke({ color: "#333333", width: strokeWidth });
}
},
[controlWidth, controlHeight, borderRadius, buttonWidth, strokeWidth]
);
const drawButtonHighlight = useCallback(
(g: Graphics, index: number, isActive: boolean) => {
g.clear();
if (isActive) {
const x = -controlWidth / 2 + buttonWidth * index;
g.roundRect(
x + strokeWidth,
strokeWidth,
buttonWidth - strokeWidth * 2,
controlHeight - strokeWidth * 2,
borderRadius / 2
);
g.fill({ color: "#0066cc", alpha: 0.8 });
}
},
[controlWidth, controlHeight, buttonWidth, strokeWidth, borderRadius]
);
const getTextStyle = (isActive: boolean) => ({
fontSize,
fontWeight: isActive ? ("bold" as const) : ("normal" as const),
fill: isActive ? "#ffffff" : "#cccccc",
fontFamily: "Arial, sans-serif",
});
const alignOptions = [
{ key: "left" as const, label: "Left" },
{ key: "center" as const, label: "Center" },
{ key: "right" as const, label: "Right" },
];
return (
<pixiContainer
position={{ x: 0, y: compensatedRuFontSize * 1.1 + 15 / scale }}
zIndex={999999999999999999}
eventMode="static"
onPointerOver={(e: FederatedMouseEvent) => {
e.stopPropagation();
onControlPointerEnter();
}}
onPointerOut={(e: FederatedMouseEvent) => {
e.stopPropagation();
onControlPointerLeave();
}}
onPointerDown={(e: FederatedMouseEvent) => {
e.stopPropagation();
}}
>
{/* Основной фон */}
<pixiGraphics draw={drawBg} />
{/* Кнопки с подсветкой */}
{alignOptions.map((option, index) => (
<pixiContainer key={option.key}>
{/* Подсветка активной кнопки */}
<pixiGraphics
draw={(g: Graphics) =>
drawButtonHighlight(g, index, option.key === currentAlign)
}
/>
{/* Текст кнопки */}
<pixiText
text={option.label}
anchor={{ x: 0.5, y: 0.5 }}
position={{
x: -controlWidth / 2 + buttonWidth * (index + 0.5),
y: controlHeight / 2,
}}
style={getTextStyle(option.key === currentAlign)}
eventMode="static"
cursor="pointer"
onClick={(e: FederatedMouseEvent) => {
e.stopPropagation();
onAlignChange(option.key);
}}
onPointerDown={(e: FederatedMouseEvent) => {
e.stopPropagation();
onAlignChange(option.key);
}}
onPointerOver={(e: FederatedMouseEvent) => {
e.stopPropagation();
onControlPointerEnter();
}}
/>
</pixiContainer>
))}
</pixiContainer>
);
};
const StationLabel = observer(
({
station,
ruLabel,
labelAlign: labelAlignProp = "center",
onLabelAlignChange,
onTextHover,
}: Readonly<StationLabelProps>) => {
const { language } = languageStore;
const { rotation, scale } = useTransform();
const { setStationOffset, setStationAlign } = useMapData();
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [isPointerDown, setIsPointerDown] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isControlHovered, setIsControlHovered] = useState(false);
const [currentLabelAlign, setCurrentLabelAlign] = useState(labelAlignProp);
const [ruLabelWidth, setRuLabelWidth] = useState(0);
const dragStartPos = useRef({ x: 0, y: 0 });
const mouseStartPos = useRef({ x: 0, y: 0 });
const hideTimer = useRef<NodeJS.Timeout | null>(null);
const ruLabelRef = useRef<any>(null);
useEffect(() => {
return () => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
}
};
}, []);
const handlePointerEnter = () => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
setIsHovered(true);
onTextHover?.(true);
};
const handleControlPointerEnter = () => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
setIsControlHovered(true);
setIsHovered(true);
onTextHover?.(true);
};
const handleControlPointerLeave = () => {
setIsControlHovered(false);
if (!isHovered) {
hideTimer.current = setTimeout(() => {
setIsHovered(false);
onTextHover?.(false);
}, 0);
}
};
const handlePointerLeave = () => {
hideTimer.current = setTimeout(() => {
setIsHovered(false);
if (!isControlHovered) {
setIsControlHovered(false);
}
onTextHover?.(false);
}, 100);
};
useEffect(() => {
setPosition({ x: station.offset_x ?? 0, y: station.offset_y ?? 0 });
}, [station.offset_x, station.offset_y, station.id]);
const convertNumericAlign = (align: number): LabelAlign => {
switch (align) {
case 0:
return "left";
case 1:
return "center";
case 2:
return "right";
default:
return "center";
}
};
const convertStringAlign = (align: LabelAlign): number => {
switch (align) {
case "left":
return 0;
case "center":
return 1;
case "right":
return 2;
default:
return 1;
}
};
useEffect(() => {
setCurrentLabelAlign(convertNumericAlign(station.align ?? 1));
}, [station.align]);
if (!station) return null;
const coordinates = coordinatesToLocal(station.latitude, station.longitude);
const clampedScale = Math.min(Math.max(scale, 1), 3);
const textScaleFactor = 1 + (clampedScale - 1) * 0.4;
const compensatedRuFontSize = ((26 * 0.75) / scale) * textScaleFactor;
const compensatedNameFontSize = ((16 * 0.75) / scale) * textScaleFactor;
useEffect(() => {
if (ruLabelRef.current && ruLabel) {
setRuLabelWidth(ruLabelRef.current.width);
}
}, [ruLabel, compensatedRuFontSize]);
const handlePointerDown = (e: FederatedMouseEvent) => {
setIsPointerDown(true);
setIsDragging(false);
dragStartPos.current = { ...position };
mouseStartPos.current = { x: e.global.x, y: e.global.y };
e.stopPropagation();
};
const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isPointerDown) return;
if (!isDragging) {
const dx = e.global.x - mouseStartPos.current.x;
const dy = e.global.y - mouseStartPos.current.y;
if (Math.hypot(dx, dy) > 3) setIsDragging(true);
else return;
}
const dx_screen = e.global.x - mouseStartPos.current.x;
const dy_screen = e.global.y - mouseStartPos.current.y;
const newPosition = {
x: dragStartPos.current.x + dx_screen,
y: dragStartPos.current.y + dy_screen,
};
if (
Math.abs(newPosition.x - position.x) > 0.01 ||
Math.abs(newPosition.y - position.y) > 0.01
) {
setPosition(newPosition);
setStationOffset(station.id, newPosition.x, newPosition.y);
}
e.stopPropagation();
};
const handlePointerUp = (e: FederatedMouseEvent) => {
setIsPointerDown(false);
setTimeout(() => setIsDragging(false), 50);
e.stopPropagation();
};
const handleAlignChange = async (align: LabelAlign) => {
setCurrentLabelAlign(align);
onLabelAlignChange?.(align);
const numericAlign = convertStringAlign(align);
setStationAlign(station.id, numericAlign);
};
const dynamicAnchor = useMemo(
() => getAnchorFromOffset(position.x, position.y),
[position.x, position.y]
);
const getSecondLabelPosition = (): number => {
if (!ruLabelWidth) return 0;
switch (currentLabelAlign) {
case "left":
return -ruLabelWidth / 2;
case "center":
return 0;
case "right":
return ruLabelWidth / 2;
default:
return 0;
}
};
const getSecondLabelAnchor = (): number => {
switch (currentLabelAlign) {
case "left":
return 0;
case "center":
return 0.5;
case "right":
return 1;
default:
return 0.5;
}
};
return (
<pixiContainer
x={coordinates.x * UP_SCALE}
y={coordinates.y * UP_SCALE}
rotation={-rotation}
zIndex={isHovered || isControlHovered ? 1000 : 0}
eventMode="static"
interactive
cursor={isDragging ? "grabbing" : "grab"}
onPointerOver={handlePointerEnter}
onPointerOut={handlePointerLeave}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerUpOutside={handlePointerUp}
onGlobalPointerMove={handlePointerMove}
>
<pixiContainer
position={{
x:
(position.x + Math.cos(Math.atan2(position.y, position.x))) /
scale,
y:
(position.y + Math.sin(Math.atan2(position.y, position.x))) /
scale,
}}
anchor={dynamicAnchor}
zIndex={isHovered || isControlHovered ? 1000 : 0}
>
{ruLabelWidth > 0 && (
<pixiGraphics
draw={(g: Graphics) => {
g.clear();
const hasSecondLabel = !!(station.name && language !== "ru" && ruLabel);
const pad = 10 / scale;
const w = ruLabelWidth + pad * 2;
const top = -compensatedRuFontSize / 2 - pad;
const bottom = hasSecondLabel
? compensatedRuFontSize * 1.1 + compensatedNameFontSize / 2 + pad
: compensatedRuFontSize / 2 + pad;
g.rect(-w / 2, top, w, bottom - top);
g.fill({ color: 0x000000, alpha: 0.001 });
}}
/>
)}
{ruLabel && (
<pixiText
ref={ruLabelRef}
text={ruLabel}
position={{ x: 0, y: 0 }}
anchor={{ x: 0.5, y: 0.5 }}
style={{
fontSize: compensatedRuFontSize,
fontWeight: "bold",
fill: "#ffffff",
}}
/>
)}
{station.name && language !== "ru" && ruLabel && (
<pixiText
text={station.name}
position={{
x: getSecondLabelPosition(),
y: compensatedRuFontSize * 1.1,
}}
anchor={{ x: getSecondLabelAnchor(), y: 0.5 }}
style={{
fontSize: compensatedNameFontSize,
fontWeight: "bold",
fill: "#CCCCCC",
}}
/>
)}
{(isHovered || isControlHovered) && !isDragging && (
<LabelAlignmentControl
scale={scale}
currentAlign={currentLabelAlign}
onAlignChange={handleAlignChange}
onPointerOver={handlePointerEnter}
onPointerOut={handlePointerLeave}
onControlPointerEnter={handleControlPointerEnter}
onControlPointerLeave={handleControlPointerLeave}
/>
)}
</pixiContainer>
</pixiContainer>
);
}
);
export const Station = ({
station,
ruLabel,
labelAlign,
onLabelAlignChange,
}: Readonly<StationProps>) => {
const [isTextHovered, setIsTextHovered] = useState(false);
const draw = useCallback(
(g: Graphics) => {
g.clear();
const coordinates = coordinatesToLocal(
station.latitude,
station.longitude
);
const radius = STATION_RADIUS;
const strokeWidth = STATION_OUTLINE_WIDTH;
g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, radius);
if (isTextHovered) {
g.fill({ color: 0x00aaff });
g.stroke({ color: 0xffffff, width: strokeWidth + 1 });
} else {
g.fill({ color: PATH_COLOR });
g.stroke({ color: BACKGROUND_COLOR, width: strokeWidth });
}
},
[station.latitude, station.longitude, isTextHovered]
);
return (
<pixiContainer zIndex={isTextHovered ? 1000 : 0}>
<pixiGraphics draw={draw} />
<StationLabel
station={station}
ruLabel={ruLabel}
labelAlign={labelAlign}
onLabelAlignChange={onLabelAlignChange}
onTextHover={setIsTextHovered}
/>
</pixiContainer>
);
};

View File

@@ -1,34 +0,0 @@
import { Graphics } from "pixi.js";
import { useCallback } from "react";
import { PATH_COLOR, PATH_WIDTH } from "./Constants";
import { coordinatesToLocal } from "./utils";
interface TravelPathProps {
points: { x: number; y: number }[];
}
export function TravelPath({ points }: Readonly<TravelPathProps>) {
const draw = useCallback(
(g: Graphics) => {
g.clear();
const coordStart = coordinatesToLocal(points[0].x, points[0].y);
g.moveTo(coordStart.x, coordStart.y);
for (let i = 1; i <= points.length - 1; i++) {
const coordinates = coordinatesToLocal(points[i].x, points[i].y);
g.lineTo(coordinates.x, coordinates.y);
}
g.stroke({
color: PATH_COLOR,
width: PATH_WIDTH,
});
},
[points]
);
if (points.length === 0) {
console.error("points is empty");
return null;
}
return <pixiGraphics draw={draw} />;
}

View File

@@ -1,14 +1,5 @@
import { useRef, useEffect, useState } from "react";
import { Widgets } from "./Widgets";
import { extend } from "@pixi/react";
import {
Container,
Graphics,
Sprite,
Texture,
TilingSprite,
Text,
} from "pixi.js";
import { Box, Stack } from "@mui/material";
import { MapDataProvider, useMapData } from "./MapDataContext";
import { TransformProvider, useTransform } from "./TransformContext";
@@ -22,15 +13,6 @@ import { UP_SCALE } from "./Constants";
import { WebGLRouteMapPrototype } from "./webgl-prototype/WebGLRouteMapPrototype";
import { CircularProgress } from "@mui/material";
extend({
Container,
Graphics,
Sprite,
Texture,
TilingSprite,
Text,
});
const Loading = () => {
const { isRouteLoading, isStationLoading, isSightLoading } = useMapData();
@@ -54,6 +36,7 @@ export const RoutePreview = () => {
<Box
sx={{
position: "relative",
zIndex: 20,
width: isLeftSidebarOpen ? 288 : 0,
transition: "width 0.3s ease",
overflow: "visible",
@@ -93,9 +76,10 @@ export const RouteMap = observer(() => {
const parentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const previousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "auto";
document.body.style.overflow = previousBodyOverflow;
};
}, []);
@@ -171,25 +155,6 @@ export const RouteMap = observer(() => {
return (
<div style={{ width: "100%", height: "100%" }} ref={parentRef}>
{/* <Application resizeTo={parentRef} background="#000" preference="webgl">
<InfiniteCanvas>
<TravelPath points={points} />
{stationData[language].map((obj, index) => (
<Station
station={obj}
key={obj.id}
ruLabel={
language === "ru"
? stationData.ru[index].name
: stationData.ru[index].name
}
/>
))}
{originalSightData?.map((sight: SightData, index: number) => {
return <Sight sight={sight} id={index} key={sight.id} />;
})}
</InfiniteCanvas>
</Application> */}
<WebGLRouteMapPrototype />
</div>
);

View File

@@ -2327,9 +2327,6 @@ export const WebGLRouteMapPrototype = observer(() => {
const stationScreenY =
rotatedY * camera.scale + camera.translation.y;
const labelX = stationScreenX + offsetX;
const labelY = stationScreenY + offsetY;
const backendAlign = station.align;
const anchor = getAnchorFromOffset(backendAlign ?? 2);
@@ -2339,8 +2336,6 @@ export const WebGLRouteMapPrototype = observer(() => {
const dpr = Math.max(1, window.devicePixelRatio || 1);
const cssX = labelX / dpr;
const cssY = labelY / dpr;
const rotationCss = `${rotationAngle}rad`;
const counterRotationCss = `${-rotationAngle}rad`;
@@ -2359,6 +2354,13 @@ export const WebGLRouteMapPrototype = observer(() => {
const scaleFactor = 1 + (zoomClampedScale - 1) * 0.4;
const primaryFontSize = 16 * fontScale * scaleFactor;
const mainLabelHeight = primaryFontSize * 1.2;
const labelX = stationScreenX + offsetX;
const labelY = stationScreenY + offsetY + mainLabelHeight / 2;
const cssX = labelX / dpr;
const cssY = labelY / dpr;
const secondaryFontSize = 13 * fontScale * scaleFactor;
const secondaryMarginTop = 5 * fontScale * scaleFactor;
@@ -2404,7 +2406,7 @@ export const WebGLRouteMapPrototype = observer(() => {
hoveredStationIconId === station.id ||
resizingStationIconId === station.id;
const secondaryLineHeight = 1.2;
const secondaryLineHeight = 1.2 * scaleFactor;
return (
<div key={station.id}>
@@ -2438,7 +2440,6 @@ export const WebGLRouteMapPrototype = observer(() => {
cursor: "grab",
userSelect: "none",
touchAction: "none",
lineHeight: 1,
}}
>
<div
@@ -2549,7 +2550,6 @@ export const WebGLRouteMapPrototype = observer(() => {
position: "relative",
fontWeight: 700,
fontSize: primaryFontSize,
lineHeight: 1,
textShadow: "0 0 4px rgba(0,0,0,0.6)",
pointerEvents: "none",
whiteSpace: "nowrap",

View File

@@ -6,7 +6,7 @@ import {
DialogContent,
DialogActions,
} from "@mui/material";
import { snapshotStore, authStore, routeStore, selectedCityStore, cityStore } from "@shared";
import { snapshotStore, authStore, routeStore, selectedCityStore, cityStore, carrierStore } from "@shared";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useState, useEffect, useMemo } from "react";
@@ -93,22 +93,55 @@ export const SnapshotCreatePage = observer(() => {
routeStore.routes.loaded = false;
});
await routeStore.getRoutes();
await carrierStore.getCarriers("ru");
const routes = routeStore.routes.data;
const numberCount = new Map<string, number>();
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) {
numberCount.set(num, (numberCount.get(num) ?? 0) + 1);
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 duplicates = Array.from(numberCount.entries())
.filter(([, count]) => count > 1)
.map(([num]) => num);
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 (duplicates.length > 0) {
setDuplicateRouteNumbers(duplicates);
if (duplicateMessages.length > 0) {
setDuplicateRouteNumbers(duplicateMessages);
setDuplicateWarningOpen(true);
} else {
await startExport();
@@ -190,10 +223,8 @@ export const SnapshotCreatePage = observer(() => {
некорректным данным в экспорте.
</p>
<ul className="list-disc pl-5">
{duplicateRouteNumbers.map((num) => (
<li key={num}>
Найдены повторяющиеся маршруты с номером трассы №{num}
</li>
{duplicateRouteNumbers.map((msg, i) => (
<li key={i}>{msg}</li>
))}
</ul>
</DialogContent>

View File

@@ -1,11 +1,11 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, languageStore, snapshotStore, cityStore, SearchInput } from "@shared";
import { authStore, languageStore, snapshotStore, cityStore, vehicleStore, SearchInput } from "@shared";
import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite";
import { DatabaseBackup, Trash2 } from "lucide-react";
import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets";
import { Alert, Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from "@mui/material";
import { Alert, Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, TextField, Typography } from "@mui/material";
const LOW_STORAGE_THRESHOLD_GB = 10;
@@ -43,11 +43,14 @@ export const SnapshotListPage = observer(() => {
createEmptySnapshot,
} = snapshotStore;
const canWriteDevices = authStore.canWrite("devices");
const canReadDevices = authStore.canRead("devices");
const canCreateSnapshot =
authStore.hasRole("snapshot_create") && canWriteDevices;
const canManageSnapshots = authStore.canWrite("snapshot") && canWriteDevices;
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isSnapshotOnDeviceWarning, setIsSnapshotOnDeviceWarning] = useState(false);
const [devicesWithSnapshot, setDevicesWithSnapshot] = useState<string[]>([]);
const [rowId, setRowId] = useState<string | null>(null);
const { language } = languageStore;
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
@@ -77,7 +80,11 @@ export const SnapshotListPage = observer(() => {
useEffect(() => {
const fetchSnapshots = async () => {
setIsLoading(true);
await Promise.all([getSnapshots(), getStorageInfo()]);
const promises: Promise<void>[] = [getSnapshots(), getStorageInfo()];
if (canReadDevices && !vehicleStore.vehicles.loaded) {
promises.push(vehicleStore.getVehicles());
}
await Promise.all(promises);
setIsLoading(false);
};
fetchSnapshots();
@@ -124,7 +131,7 @@ export const SnapshotListPage = observer(() => {
},
{
field: "created_at",
headerName: "Дата создания",
headerName: "Дата создания (UTC+03:00, MSK)",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return <div>{params.value ? params.value : "-"}</div>;
@@ -164,8 +171,20 @@ export const SnapshotListPage = observer(() => {
<button
title="Удалить"
onClick={() => {
const snapshotId = params.row.id;
if (canReadDevices) {
const devicesUsing = vehicleStore.vehicles.data
.filter(v => v.vehicle.current_snapshot_uuid === snapshotId)
.map(v => v.vehicle.tail_number || v.vehicle.uuid || `ID ${v.vehicle.id}`);
if (devicesUsing.length > 0) {
setDevicesWithSnapshot(devicesUsing);
setIsSnapshotOnDeviceWarning(true);
setRowId(snapshotId);
return;
}
}
setIsDeleteModalOpen(true);
setRowId(params.row.id);
setRowId(snapshotId);
}}
>
<Trash2 size={20} className="text-red-500" />
@@ -409,6 +428,36 @@ export const SnapshotListPage = observer(() => {
</DialogActions>
</Dialog>
<Dialog
open={isSnapshotOnDeviceWarning}
onClose={() => setIsSnapshotOnDeviceWarning(false)}
fullWidth
maxWidth="xs"
>
<DialogTitle>Удаление невозможно</DialogTitle>
<DialogContent>
<Alert severity="warning" sx={{ mt: 1 }}>
Этот экспорт загружен на устройства. Удалите или замените экспорт на
устройствах перед удалением.
</Alert>
<Box sx={{ mt: 2 }}>
<Typography variant="body2" fontWeight={600} gutterBottom>
Устройства:
</Typography>
{devicesWithSnapshot.map((name, i) => (
<Typography key={i} variant="body2">
• {name}
</Typography>
))}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsSnapshotOnDeviceWarning(false)}>
Закрыть
</Button>
</DialogActions>
</Dialog>
<SnapshotRestore
open={isRestoreModalOpen}
loading={isLoading}

View File

@@ -2,16 +2,8 @@ import {
Button,
Paper,
TextField,
Checkbox,
Typography,
Box,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Radio,
RadioGroup,
Divider,
} from "@mui/material";
import { observer } from "mobx-react-lite";
@@ -28,41 +20,7 @@ import {
selectedCityStore,
} from "@shared";
import { useState, useEffect } from "react";
import { ImageUploadCard } from "@widgets";
const ROLE_RESOURCES = [
{ key: "snapshot", label: "Экспорт" },
{ key: "devices", label: "Устройства" },
{ key: "vehicles", label: "Транспорт" },
{ key: "users", label: "Пользователи" },
{ key: "sights", label: "Достопримечательности" },
{ key: "stations", label: "Остановки" },
{ key: "routes", label: "Маршруты" },
{ key: "countries", label: "Страны" },
{ key: "cities", label: "Города" },
{ key: "carriers", label: "Перевозчики" },
] as const;
type PermissionLevel = "none" | "ro" | "rw";
function getPermissionLevel(roles: string[], resource: string): PermissionLevel {
if (roles.includes(`${resource}_rw`)) return "rw";
if (roles.includes(`${resource}_ro`)) return "ro";
return "none";
}
function applyPermissionChange(
roles: string[],
resource: string,
level: PermissionLevel,
): string[] {
const filtered = roles.filter(
(r) => r !== `${resource}_ro` && r !== `${resource}_rw`,
);
if (level === "ro") return [...filtered, `${resource}_ro`];
if (level === "rw") return [...filtered, `${resource}_rw`];
return filtered;
}
import { ImageUploadCard, PermissionsTable, RolesHintTable, ROLE_RESOURCES } from "@widgets";
export const UserCreatePage = observer(() => {
const navigate = useNavigate();
@@ -276,133 +234,8 @@ export const UserCreatePage = observer(() => {
</Button>
</Box>
<Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
<Table size="small">
<TableHead>
<TableRow sx={{ bgcolor: "action.hover" }}>
<TableCell sx={{ fontWeight: 600, width: 220 }}>Ресурс</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Нет доступа</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение/Запись</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>
Доп. права
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{ROLE_RESOURCES.map(({ key, label }) => {
const level = getPermissionLevel(localRoles, key);
const isSnapshotResource = key === "snapshot";
const handleChange = (val: string) => {
setLocalRoles((prev) => {
let updated = applyPermissionChange(prev, key, val as PermissionLevel);
if (key === "devices") {
updated = applyPermissionChange(
updated,
"vehicles",
val as PermissionLevel,
);
}
return updated;
});
};
const isDevicesResource = key === "devices";
const handleSnapshotCreateChange = (checked: boolean) => {
if (!isSnapshotResource) {
return;
}
setLocalRoles((prev) => {
const withoutSnapshotCreate = prev.filter(
(role) => role !== "snapshot_create"
);
return checked
? [...withoutSnapshotCreate, "snapshot_create"]
: withoutSnapshotCreate;
});
};
const handleMaintenanceChange = (checked: boolean) => {
setLocalRoles((prev) => {
const without = prev.filter((r) => r !== "devices_maintenance_rw");
return checked ? [...without, "devices_maintenance_rw"] : without;
});
};
return (
<TableRow key={key} hover>
<TableCell>{label}</TableCell>
<TableCell align="center" padding="checkbox">
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="none" size="small" />
</RadioGroup>
</TableCell>
<TableCell align="center" padding="checkbox">
{isSnapshotResource ? (
<Typography variant="body2" color="text.secondary">
-
</Typography>
) : (
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="ro" size="small" />
</RadioGroup>
)}
</TableCell>
<TableCell align="center" padding="checkbox">
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="rw" size="small" />
</RadioGroup>
</TableCell>
<TableCell align="center" padding="checkbox">
{isSnapshotResource ? (
<Checkbox
checked={localRoles.includes("snapshot_create")}
onChange={(e) =>
handleSnapshotCreateChange(e.target.checked)
}
size="small"
title="Разрешает создавать новые снапшоты"
/>
) : isDevicesResource ? (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "center" }}>
<Checkbox
checked={localRoles.includes("devices_maintenance_rw")}
onChange={(e) => handleMaintenanceChange(e.target.checked)}
size="small"
title="Техническое обслуживание (ТО)"
/>
</Box>
) : (
<Typography variant="body2" color="text.secondary">
-
</Typography>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Box>
<PermissionsTable localRoles={localRoles} setLocalRoles={setLocalRoles} />
<RolesHintTable />
</section>
<Button

View File

@@ -1,17 +1,9 @@
import {
Button,
Checkbox,
Paper,
TextField,
Box,
Typography,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Radio,
RadioGroup,
Divider,
} from "@mui/material";
import { observer } from "mobx-react-lite";
@@ -35,41 +27,7 @@ import {
type UserCity,
} from "@shared";
import { useEffect, useState } from "react";
import { ImageUploadCard, DeleteModal } from "@widgets";
const ROLE_RESOURCES = [
{ key: "snapshot", label: "Экспорт" },
{ key: "devices", label: "Устройства" },
{ key: "vehicles", label: "Транспорт" },
{ key: "users", label: "Пользователи" },
{ key: "sights", label: "Достопримечательности" },
{ key: "stations", label: "Остановки" },
{ key: "routes", label: "Маршруты" },
{ key: "countries", label: "Страны" },
{ key: "cities", label: "Города" },
{ key: "carriers", label: "Перевозчики" },
] as const;
type PermissionLevel = "none" | "ro" | "rw";
function getPermissionLevel(roles: string[], resource: string): PermissionLevel {
if (roles.includes(`${resource}_rw`)) return "rw";
if (roles.includes(`${resource}_ro`)) return "ro";
return "none";
}
function applyPermissionChange(
roles: string[],
resource: string,
level: PermissionLevel,
): string[] {
const filtered = roles.filter(
(r) => r !== `${resource}_ro` && r !== `${resource}_rw`,
);
if (level === "ro") return [...filtered, `${resource}_ro`];
if (level === "rw") return [...filtered, `${resource}_rw`];
return filtered;
}
import { ImageUploadCard, DeleteModal, PermissionsTable, RolesHintTable, ROLE_RESOURCES } from "@widgets";
export const UserEditPage = observer(() => {
const navigate = useNavigate();
@@ -358,133 +316,8 @@ export const UserEditPage = observer(() => {
</Button>
</Box>
<Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
<Table size="small">
<TableHead>
<TableRow sx={{ bgcolor: "action.hover" }}>
<TableCell sx={{ fontWeight: 600, width: 220 }}>Ресурс</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Нет доступа</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение/Запись</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>
Доп. права
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{ROLE_RESOURCES.map(({ key, label }) => {
const level = getPermissionLevel(localRoles, key);
const isSnapshotResource = key === "snapshot";
const handleChange = (val: string) => {
setLocalRoles((prev) => {
let updated = applyPermissionChange(prev, key, val as PermissionLevel);
if (key === "devices") {
updated = applyPermissionChange(
updated,
"vehicles",
val as PermissionLevel,
);
}
return updated;
});
};
const isDevicesResource = key === "devices";
const handleSnapshotCreateChange = (checked: boolean) => {
if (!isSnapshotResource) {
return;
}
setLocalRoles((prev) => {
const withoutSnapshotCreate = prev.filter(
(role) => role !== "snapshot_create"
);
return checked
? [...withoutSnapshotCreate, "snapshot_create"]
: withoutSnapshotCreate;
});
};
const handleMaintenanceChange = (checked: boolean) => {
setLocalRoles((prev) => {
const without = prev.filter((r) => r !== "devices_maintenance_rw");
return checked ? [...without, "devices_maintenance_rw"] : without;
});
};
return (
<TableRow key={key} hover>
<TableCell>{label}</TableCell>
<TableCell align="center" padding="checkbox">
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="none" size="small" />
</RadioGroup>
</TableCell>
<TableCell align="center" padding="checkbox">
{isSnapshotResource ? (
<Typography variant="body2" color="text.secondary">
-
</Typography>
) : (
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="ro" size="small" />
</RadioGroup>
)}
</TableCell>
<TableCell align="center" padding="checkbox">
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="rw" size="small" />
</RadioGroup>
</TableCell>
<TableCell align="center" padding="checkbox">
{isSnapshotResource ? (
<Checkbox
checked={localRoles.includes("snapshot_create")}
onChange={(e) =>
handleSnapshotCreateChange(e.target.checked)
}
size="small"
title="Разрешает создавать новые снапшоты"
/>
) : isDevicesResource ? (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "center" }}>
<Checkbox
checked={localRoles.includes("devices_maintenance_rw")}
onChange={(e) => handleMaintenanceChange(e.target.checked)}
size="small"
title="Техническое обслуживание (ТО)"
/>
</Box>
) : (
<Typography variant="body2" color="text.secondary">
-
</Typography>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Box>
<PermissionsTable localRoles={localRoles} setLocalRoles={setLocalRoles} />
<RolesHintTable />
</section>
<Divider />

View File

@@ -1,4 +1,8 @@
import { mediaStore, MEDIA_TYPE_LABELS } from "@shared";
import {
mediaStore,
MEDIA_TYPE_LABELS,
PreviewBackgroundToggle,
} from "@shared";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import {
@@ -29,6 +33,8 @@ export const PreviewMediaDialog = observer(
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [isPreviewBackgroundDark, setIsPreviewBackgroundDark] =
useState(false);
const media = mediaId
? mediaStore.media.find((m) => m.id === mediaId)
: null;
@@ -40,6 +46,7 @@ export const PreviewMediaDialog = observer(
if (media) {
setMediaName(media.media_name);
setMediaFilename(media.filename);
setIsPreviewBackgroundDark(false);
}
}, [media]);
@@ -141,8 +148,19 @@ export const PreviewMediaDialog = observer(
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
backgroundColor: isPreviewBackgroundDark ? "#000" : "#fff",
transition: "background-color 150ms ease",
}}
>
{[1, 3, 4, 5].includes(media.media_type) && (
<PreviewBackgroundToggle
dark={isPreviewBackgroundDark}
onToggle={() =>
setIsPreviewBackgroundDark((value) => !value)
}
/>
)}
<MediaViewer
media={{
id: mediaId,

View File

@@ -6,6 +6,7 @@ import {
clearBlobAndGLTFCache,
authStore,
snapshotStore,
PreviewBackgroundToggle,
} from "@shared";
import { observer } from "mobx-react-lite";
import { useEffect, useState, useRef } from "react";
@@ -90,6 +91,8 @@ export const UploadMediaDialog = observer(
[]
);
const [isPreviewLoaded, setIsPreviewLoaded] = useState(false);
const [isPreviewBackgroundDark, setIsPreviewBackgroundDark] =
useState(false);
const previousMediaUrlRef = useRef<string | null>(null);
useEffect(() => {
@@ -347,6 +350,7 @@ export const UploadMediaDialog = observer(
setMediaUrl(null);
setMediaFile(null);
setIsPreviewLoaded(false);
setIsPreviewBackgroundDark(false);
previousMediaUrlRef.current = null;
onClose();
};
@@ -416,8 +420,18 @@ export const UploadMediaDialog = observer(
justifyContent: "center",
height: "100%",
position: "relative",
backgroundColor: isPreviewBackgroundDark ? "#000" : "#fff",
transition: "background-color 150ms ease",
}}
>
{mediaUrl && mediaType !== 2 && mediaType !== 6 && (
<PreviewBackgroundToggle
dark={isPreviewBackgroundDark}
onToggle={() =>
setIsPreviewBackgroundDark((value) => !value)
}
/>
)}
{!isPreviewLoaded && mediaUrl && (
<Box
sx={{

View File

@@ -12,10 +12,10 @@ export type Carrier = {
id: number;
short_name: string;
full_name: string;
slogan: string;
city: string;
city_id: number;
logo: string;
preview_media?: string;
video_preview?: string;
main_color: string;
left_color: string;
right_color: string;
@@ -111,38 +111,40 @@ class CarrierStore {
createCarrierData = {
city_id: 0,
logo: "",
preview_media: "",
video_preview: "",
main_color: "",
left_color: "",
right_color: "",
ru: {
full_name: "",
short_name: "",
slogan: "",
},
en: {
full_name: "",
short_name: "",
slogan: "",
},
zh: {
full_name: "",
short_name: "",
slogan: "",
},
};
setCreateCarrierMedia = (
field: "preview_media" | "video_preview",
mediaId: string
) => {
this.createCarrierData[field] = mediaId;
};
setCreateCarrierData = (
fullName: string,
shortName: string,
cityId: number,
slogan: string,
logoId: string,
language: Language,
colors?: { main_color?: string; left_color?: string; right_color?: string }
) => {
this.createCarrierData.city_id = cityId;
this.createCarrierData.logo = logoId;
if (colors) {
if (colors.main_color !== undefined) this.createCarrierData.main_color = colors.main_color;
if (colors.left_color !== undefined) this.createCarrierData.left_color = colors.left_color;
@@ -151,7 +153,6 @@ class CarrierStore {
this.createCarrierData[language] = {
full_name: fullName,
short_name: shortName,
slogan: slogan,
};
};
@@ -206,8 +207,12 @@ class CarrierStore {
short_name: (this.createCarrierData[language].short_name || "").trim(),
city: cityName,
city_id: this.createCarrierData.city_id,
slogan: (this.createCarrierData[language].slogan || "").trim(),
...(this.createCarrierData.logo ? { logo: this.createCarrierData.logo } : {}),
...(this.createCarrierData.preview_media
? { preview_media: this.createCarrierData.preview_media }
: {}),
...(this.createCarrierData.video_preview
? { video_preview: this.createCarrierData.video_preview }
: {}),
...(this.createCarrierData.main_color ? { main_color: this.createCarrierData.main_color } : {}),
...(this.createCarrierData.left_color ? { left_color: this.createCarrierData.left_color } : {}),
...(this.createCarrierData.right_color ? { right_color: this.createCarrierData.right_color } : {}),
@@ -221,55 +226,55 @@ class CarrierStore {
this.carriers[language].data.push(response.data);
});
for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) {
for (const lang of (["ru", "en", "zh"] as const).filter(
(item) => item !== language
)) {
const cityNameForLang = this.resolveCityName(
this.createCarrierData.city_id,
lang as Language
lang
);
const localizedData = this.createCarrierData[lang];
const patchPayload = {
// @ts-ignore
full_name: ((this.createCarrierData[lang as any].full_name as string) || "").trim(),
// @ts-ignore
short_name: ((this.createCarrierData[lang as any].short_name as string) || "").trim(),
full_name: (localizedData.full_name || "").trim(),
short_name: (localizedData.short_name || "").trim(),
city: cityNameForLang || cityName,
city_id: this.createCarrierData.city_id,
// @ts-ignore
slogan: ((this.createCarrierData[lang as any].slogan as string) || "").trim(),
...(this.createCarrierData.logo
? { logo: this.createCarrierData.logo }
...(this.createCarrierData.preview_media
? { preview_media: this.createCarrierData.preview_media }
: {}),
...(this.createCarrierData.video_preview
? { video_preview: this.createCarrierData.video_preview }
: {}),
};
const response = await languageInstance(lang as Language).patch(
const response = await languageInstance(lang).patch(
`/carrier/${carrierId}`,
patchPayload
);
runInAction(() => {
this.carriers[lang as keyof Carriers].data.push(response.data);
this.carriers[lang].data.push(response.data);
});
}
this.createCarrierData = {
city_id: 0,
logo: "",
preview_media: "",
video_preview: "",
main_color: "",
left_color: "",
right_color: "",
ru: {
full_name: "",
short_name: "",
slogan: "",
},
en: {
full_name: "",
short_name: "",
slogan: "",
},
zh: {
full_name: "",
short_name: "",
slogan: "",
},
};
};
@@ -278,36 +283,38 @@ class CarrierStore {
ru: {
full_name: "",
short_name: "",
slogan: "",
},
en: {
full_name: "",
short_name: "",
slogan: "",
},
zh: {
full_name: "",
short_name: "",
slogan: "",
},
city_id: 0,
logo: "",
preview_media: "",
video_preview: "",
main_color: "",
left_color: "",
right_color: "",
};
setEditCarrierMedia = (
field: "preview_media" | "video_preview",
mediaId: string
) => {
this.editCarrierData[field] = mediaId;
};
setEditCarrierData = (
fullName: string,
shortName: string,
cityId: number,
slogan: string,
logoId: string,
language: Language,
colors?: { main_color?: string; left_color?: string; right_color?: string }
) => {
this.editCarrierData.city_id = cityId;
this.editCarrierData.logo = logoId;
if (colors) {
if (colors.main_color !== undefined) this.editCarrierData.main_color = colors.main_color;
if (colors.left_color !== undefined) this.editCarrierData.left_color = colors.left_color;
@@ -316,7 +323,6 @@ class CarrierStore {
this.editCarrierData[language] = {
full_name: fullName,
short_name: shortName,
slogan: slogan,
};
};
@@ -327,10 +333,10 @@ class CarrierStore {
...this.editCarrierData[lang],
full_name: (this.editCarrierData[lang].full_name || "").trim(),
short_name: (this.editCarrierData[lang].short_name || "").trim(),
slogan: (this.editCarrierData[lang].slogan || "").trim(),
city: cityName,
city_id: this.editCarrierData.city_id,
...(this.editCarrierData.logo ? { logo: this.editCarrierData.logo } : {}),
preview_media: this.editCarrierData.preview_media || null,
video_preview: this.editCarrierData.video_preview || null,
...(this.editCarrierData.main_color ? { main_color: this.editCarrierData.main_color } : {}),
...(this.editCarrierData.left_color ? { left_color: this.editCarrierData.left_color } : {}),
...(this.editCarrierData.right_color ? { right_color: this.editCarrierData.right_color } : {}),

View File

@@ -1,7 +1,7 @@
import { authInstance, languageInstance, mobxFetch } from "@shared";
import { makeAutoObservable, runInAction } from "mobx";
import { getVehicleSessionsApi } from "./api";
import { Vehicle, VehicleMaintenanceSession } from "./types";
import { Vehicle, VehicleMaintenanceSession, SetMaintenanceModeResponse } from "./types";
export type { Vehicle, VehicleMaintenanceSession } from "./types";
@@ -218,23 +218,32 @@ class VehicleStore {
});
};
setMaintenanceMode = async (uuid: string, enabled: boolean) => {
const response = await authInstance.post(
setMaintenanceMode = async (
uuid: string,
enabled: boolean,
durationSeconds?: number,
): Promise<SetMaintenanceModeResponse> => {
const response = await authInstance.post<SetMaintenanceModeResponse>(
`/devices/${uuid}/maintenance-mode`,
{
enabled,
...(enabled && durationSeconds !== undefined
? { duration_seconds: durationSeconds }
: {}),
},
);
const normalizedVehicle = this.normalizeVehicleItem(response.data);
if (!response.data.saved) {
throw new Error("Maintenance mode was not saved");
}
runInAction(() => {
this.mergeVehicleInCaches({
...normalizedVehicle.vehicle,
uuid,
maintenance_mode_on:
normalizedVehicle.vehicle.maintenance_mode_on ?? enabled,
maintenance_mode_on: response.data.enabled,
maintenance_mode_duration_seconds: response.data.duration_seconds,
});
});
return response.data;
};
setDemoMode = async (uuid: string, enabled: boolean) => {

View File

@@ -11,6 +11,7 @@ export type Vehicle = {
snapshot_update_blocked?: boolean;
demo_mode_enabled?: boolean;
maintenance_mode_on?: boolean;
maintenance_mode_duration_seconds?: number;
city_id?: number;
};
device_status?: {
@@ -21,13 +22,24 @@ export type Vehicle = {
last_update: string;
is_connected: boolean;
current_route_id?: number;
maintenance_mode_on?: boolean;
maintenance_mode_duration_seconds?: number;
};
};
export type VehicleMaintenanceSession = {
duration_seconds: number;
ended_at: string;
duration_seconds?: number | null;
ended_at?: string | null;
id: number;
started_at: string;
vehicle_id: number;
};
export type SetMaintenanceModeResponse = {
message: string;
enabled: boolean;
duration_seconds: number;
saved: boolean;
sent: boolean;
warning?: string;
};

View File

@@ -0,0 +1,40 @@
import { IconButton, Tooltip } from "@mui/material";
import { Contrast } from "lucide-react";
interface PreviewBackgroundToggleProps {
dark: boolean;
onToggle: () => void;
}
export const PreviewBackgroundToggle = ({
dark,
onToggle,
}: PreviewBackgroundToggleProps) => (
<Tooltip title={dark ? "Сделать фон белым" : "Сделать фон чёрным"}>
<IconButton
size="small"
aria-label={dark ? "Сделать фон белым" : "Сделать фон чёрным"}
onClick={(event) => {
event.stopPropagation();
onToggle();
}}
sx={{
position: "absolute",
top: 8,
left: 8,
zIndex: 2,
color: dark ? "#fff" : "#000",
backgroundColor: dark
? "rgba(255, 255, 255, 0.16)"
: "rgba(255, 255, 255, 0.8)",
"&:hover": {
backgroundColor: dark
? "rgba(255, 255, 255, 0.28)"
: "rgba(255, 255, 255, 1)",
},
}}
>
<Contrast size={18} />
</IconButton>
</Tooltip>
);

View File

@@ -6,3 +6,4 @@ export * from "./AnimatedCircleButton";
export * from "./LoadingSpinner";
export * from "./MultiSelect";
export * from "./SearchInput";
export * from "./PreviewBackgroundToggle";

View File

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

View File

@@ -21,7 +21,7 @@ interface VehicleSessionsModalProps {
onClose: () => void;
}
const formatDateTime = (value: string) => {
const formatDateTime = (value?: string | null) => {
if (!value) return "-";
const date = new Date(value);
@@ -38,8 +38,8 @@ const formatDateTime = (value: string) => {
}).format(date);
};
const formatDuration = (durationSeconds: number) => {
if (!Number.isFinite(durationSeconds) || durationSeconds < 0) {
const formatDuration = (durationSeconds?: number | null) => {
if (durationSeconds == null || !Number.isFinite(durationSeconds) || durationSeconds < 0) {
return "-";
}
@@ -152,10 +152,10 @@ export const VehicleSessionsModal = observer(
{formatDateTime(session.started_at)}
</TableCell>
<TableCell>
{formatDateTime(session.ended_at)}
{session.ended_at ? formatDateTime(session.ended_at) : "В процессе"}
</TableCell>
<TableCell align="right">
{formatDuration(session.duration_seconds)}
{session.ended_at ? formatDuration(session.duration_seconds) : "—"}
</TableCell>
</TableRow>
))}

View File

@@ -34,12 +34,26 @@ import {
Typography,
Checkbox,
Tooltip,
TextField,
MenuItem,
} from "@mui/material";
import { toast } from "react-toastify";
import { useNavigate } from "react-router-dom";
import { DeleteModal } from "@widgets";
import { DeviceLogsModal } from "./DeviceLogsModal";
import { VehicleSessionsModal } from "./VehicleSessionsModal";
import { isAxiosError } from "axios";
const maintenanceDurations = [0, 900, 1800, 3600, 7200];
const MAX_MAINTENANCE_DURATION_SECONDS = 2_147_483_647;
const formatMaintenanceDuration = (seconds: number) => {
if (seconds === 0) return "Без автоматического отключения";
return [
Math.floor(seconds / 3600) ? `${Math.floor(seconds / 3600)} ч` : "",
Math.floor((seconds % 3600) / 60) ? `${Math.floor((seconds % 3600) / 60)} мин` : "",
seconds % 60 ? `${seconds % 60} с` : "",
].filter(Boolean).join(" ");
};
export type ConnectedDevice = string;
@@ -81,6 +95,7 @@ type RowData = {
current_snapshot_uuid: string | null;
snapshot_update_blocked: boolean;
maintenance_mode_on: boolean;
maintenance_mode_duration_seconds: number;
demo_mode_enabled: boolean;
current_route_id: number | null;
};
@@ -120,6 +135,7 @@ const transformToRows = (vehicles: Vehicle[]): RowData[] => {
current_snapshot_uuid: vehicle.vehicle.current_snapshot_uuid ?? null,
snapshot_update_blocked: vehicle.vehicle.snapshot_update_blocked ?? false,
maintenance_mode_on: vehicle.vehicle.maintenance_mode_on ?? false,
maintenance_mode_duration_seconds: vehicle.vehicle.maintenance_mode_duration_seconds ?? 0,
demo_mode_enabled: vehicle.vehicle.demo_mode_enabled ?? false,
current_route_id: vehicle.device_status?.current_route_id ?? null,
};
@@ -169,6 +185,11 @@ export const DevicesTable = observer(() => {
);
const [maintenanceConfirm, setMaintenanceConfirm] =
useState<PendingModeToggle | null>(null);
const [maintenanceDuration, setMaintenanceDuration] = useState(0);
const [customMaintenanceDuration, setCustomMaintenanceDuration] = useState(false);
const [customMaintenanceHours, setCustomMaintenanceHours] = useState(0);
const [customMaintenanceMinutes, setCustomMaintenanceMinutes] = useState(0);
const [savedMaintenanceDuration, setSavedMaintenanceDuration] = useState(0);
const [demoConfirm, setDemoConfirm] = useState<PendingModeToggle | null>(
null,
);
@@ -238,6 +259,7 @@ export const DevicesTable = observer(() => {
}, [rows, selectedIds]);
const applyMaintenanceMode = async (toggle: PendingModeToggle) => {
if (!canMaintenanceDevices) return false;
setMaintenanceLoadingUuids((prev) => {
const next = new Set(prev);
next.add(toggle.deviceUuid);
@@ -245,20 +267,42 @@ export const DevicesTable = observer(() => {
});
try {
await setMaintenanceMode(toggle.deviceUuid, toggle.nextEnabled);
await getVehicles();
await getDevices();
toast.success(
toggle.nextEnabled
? "Устройство отправлено на ТО"
: "Режим ТО отключен",
const result = await setMaintenanceMode(
toggle.deviceUuid,
toggle.nextEnabled,
toggle.nextEnabled ? maintenanceDuration : undefined,
);
if (!result.sent) {
toast.warning(
result.enabled
? "Настройка сохранена. Режим ТО включится после подключения устройства."
: "Настройка сохранена. Режим ТО отключится после подключения устройства.",
);
} else {
toast.success(result.enabled
? `Команда включения ТО отправлена. Длительность: ${formatMaintenanceDuration(result.duration_seconds)}`
: "Команда отключения ТО отправлена");
}
const refresh = await Promise.allSettled([getVehicles(), getDevices()]);
if (refresh.some((result) => result.status === "rejected")) {
toast.warning("Настройка ТО сохранена, но обновить таблицу не удалось. Обновите страницу.");
}
return true;
} catch (error) {
console.error(
`Error toggling maintenance mode for ${toggle.deviceUuid}:`,
error,
);
toast.error("Не удалось изменить режим ТО");
const status = isAxiosError(error) ? error.response?.status : undefined;
const messages: Record<number, string> = {
400: "Некорректные параметры режима ТО",
401: "Войдите в систему, чтобы изменить режим ТО",
403: "Нет права на управление режимом ТО",
404: "Устройство не найдено",
500: "Сервер не смог сохранить настройку ТО",
};
toast.error((status && messages[status]) || "Не удалось изменить режим ТО");
return false;
} finally {
setMaintenanceLoadingUuids((prev) => {
const next = new Set(prev);
@@ -298,7 +342,12 @@ export const DevicesTable = observer(() => {
};
const openMaintenanceConfirm = (row: RowData) => {
if (!row.device_uuid) return;
if (!row.device_uuid || !canMaintenanceDevices || maintenanceConfirmSubmitting) return;
setMaintenanceDuration(row.maintenance_mode_duration_seconds);
setCustomMaintenanceDuration(false);
setCustomMaintenanceHours(Math.floor(row.maintenance_mode_duration_seconds / 3600));
setCustomMaintenanceMinutes(Math.floor((row.maintenance_mode_duration_seconds % 3600) / 60));
setSavedMaintenanceDuration(row.maintenance_mode_duration_seconds);
setMaintenanceConfirm({
deviceUuid: row.device_uuid,
nextEnabled: !row.maintenance_mode_on,
@@ -316,12 +365,13 @@ export const DevicesTable = observer(() => {
};
const handleConfirmMaintenanceToggle = async () => {
if (!maintenanceConfirm) return;
if (!maintenanceConfirm || !canMaintenanceDevices || maintenanceConfirmSubmitting) return;
setMaintenanceConfirmSubmitting(true);
try {
await applyMaintenanceMode(maintenanceConfirm);
setMaintenanceConfirm(null);
if (await applyMaintenanceMode(maintenanceConfirm)) {
setMaintenanceConfirm(null);
}
} finally {
setMaintenanceConfirmSubmitting(false);
}
@@ -441,9 +491,13 @@ export const DevicesTable = observer(() => {
sx={{ display: "flex", justifyContent: "center", width: "100%" }}
onClick={(e) => e.stopPropagation()}
>
<Tooltip title={`Сохранённая длительность ТО: ${formatMaintenanceDuration(rowData.maintenance_mode_duration_seconds)}`}>
<span>
<Checkbox
checked={rowData.maintenance_mode_on}
inputProps={{ "aria-label": `Режим ТО: ${rowData.tail_number}` }}
disabled={
!canMaintenanceDevices ||
!rowData.device_uuid ||
isMaintenanceLoading ||
maintenanceConfirmSubmitting
@@ -452,6 +506,8 @@ export const DevicesTable = observer(() => {
sx={{ p: 0 }}
onChange={() => openMaintenanceConfirm(rowData)}
/>
</span>
</Tooltip>
</Box>
);
},
@@ -694,6 +750,7 @@ export const DevicesTable = observer(() => {
demoConfirmSubmitting,
routes,
canWriteDevices,
canMaintenanceDevices,
isMaintenanceOnly,
],
);
@@ -708,7 +765,7 @@ export const DevicesTable = observer(() => {
}
if (!canWriteDevices) {
return columns.filter(
(c) => c.field !== "maintenance_mode_on" && c.field !== "demo_mode_enabled",
(c) => c.field !== "demo_mode_enabled",
);
}
return columns;
@@ -732,6 +789,29 @@ export const DevicesTable = observer(() => {
fetchData();
}, [getDevices, getSnapshots, getVehicles, getRoutes, isMaintenanceOnly, selectedCityStore.cityVersion]);
useEffect(() => {
let refreshing = false;
const refreshMaintenance = async () => {
if (document.visibilityState !== "visible" || refreshing || maintenanceConfirmSubmitting) return;
refreshing = true;
try {
const results = await Promise.allSettled([getVehicles(), getDevices()]);
for (const result of results) {
if (result.status === "rejected") {
console.error("Failed to refresh maintenance state:", result.reason);
}
}
} finally {
refreshing = false;
}
};
const timer = window.setInterval(refreshMaintenance, 15_000);
document.addEventListener("visibilitychange", refreshMaintenance);
return () => {
window.clearInterval(timer);
document.removeEventListener("visibilitychange", refreshMaintenance);
};
}, [getVehicles, getDevices, maintenanceConfirmSubmitting]);
const handleOpenSendSnapshotModal = () => {
if (!canWriteDevices) {
@@ -1027,13 +1107,83 @@ export const DevicesTable = observer(() => {
sx={{ width: "min(640px, 92vw)", p: 3 }}
>
<Box component="h2" sx={{ mb: 1, typography: "h6" }}>
Подтверждение режима ТО
{maintenanceConfirm?.nextEnabled ? "Включить режим ТО" : "Отключить режим ТО"}
</Box>
<Box sx={{ mb: 3 }}>
{maintenanceConfirm?.nextEnabled
? `Включить режим ТО для устройства ${maintenanceConfirm.tailNumber}?`
: `Отключить режим ТО для устройства ${maintenanceConfirm?.tailNumber}?`}
</Box>
{maintenanceConfirm?.nextEnabled && (
<Box sx={{ mb: 3 }}>
<TextField
select
fullWidth
label="Длительность"
value={customMaintenanceDuration ? -1 : maintenanceDuration}
disabled={maintenanceConfirmSubmitting}
onChange={(event) => {
const seconds = Number(event.target.value);
if (seconds === -1) {
setCustomMaintenanceDuration(true);
return;
}
setCustomMaintenanceDuration(false);
setMaintenanceDuration(seconds);
}}
helperText="Автоотключение выполняется на устройстве после применения команды."
>
{maintenanceDurations.map((seconds) => (
<MenuItem key={seconds} value={seconds}>
{formatMaintenanceDuration(seconds)}
</MenuItem>
))}
{!maintenanceDurations.includes(savedMaintenanceDuration) && (
<MenuItem value={savedMaintenanceDuration}>
{formatMaintenanceDuration(savedMaintenanceDuration)} (сохранено)
</MenuItem>
)}
<MenuItem value={-1}>Своё время</MenuItem>
</TextField>
{customMaintenanceDuration && (
<Box sx={{ display: "flex", gap: 1.5, mt: 2 }}>
<TextField
type="number"
fullWidth
label="Часы"
value={customMaintenanceHours}
disabled={maintenanceConfirmSubmitting}
inputProps={{ min: 0, max: Math.floor(MAX_MAINTENANCE_DURATION_SECONDS / 3600), step: 1 }}
onChange={(event) => {
const hours = Math.max(0, Math.floor(Number(event.target.value) || 0));
setCustomMaintenanceHours(hours);
setMaintenanceDuration(hours * 3600 + customMaintenanceMinutes * 60);
}}
/>
<TextField
type="number"
fullWidth
label="Минуты"
value={customMaintenanceMinutes}
disabled={maintenanceConfirmSubmitting}
inputProps={{ min: 0, max: 59, step: 1 }}
onChange={(event) => {
const minutes = Math.min(59, Math.max(0, Math.floor(Number(event.target.value) || 0)));
setCustomMaintenanceMinutes(minutes);
setMaintenanceDuration(customMaintenanceHours * 3600 + minutes * 60);
}}
/>
</Box>
)}
{customMaintenanceDuration && (maintenanceDuration < 60 || maintenanceDuration > MAX_MAINTENANCE_DURATION_SECONDS) && (
<Typography color="error" variant="caption" sx={{ display: "block", mt: 1 }}>
{maintenanceDuration < 60
? "Укажите длительность не меньше одной минуты."
: "Указанная длительность превышает допустимый максимум."}
</Typography>
)}
</Box>
)}
<Box sx={{ display: "flex", gap: 1.5 }}>
<Button
fullWidth
@@ -1047,10 +1197,15 @@ export const DevicesTable = observer(() => {
<Button
fullWidth
variant="contained"
disabled={maintenanceConfirmSubmitting}
disabled={
maintenanceConfirmSubmitting ||
(Boolean(maintenanceConfirm?.nextEnabled) &&
customMaintenanceDuration &&
(maintenanceDuration < 60 || maintenanceDuration > MAX_MAINTENANCE_DURATION_SECONDS))
}
onClick={handleConfirmMaintenanceToggle}
>
Подтвердить
{maintenanceConfirmSubmitting ? "Сохранение…" : maintenanceConfirm?.nextEnabled ? "Включить" : "Отключить"}
</Button>
</Box>
</Modal>

View File

@@ -1,7 +1,7 @@
import React, { useRef, DragEvent } from "react";
import React, { useRef, useState, DragEvent } from "react";
import { Paper, Box, Typography, Button, Tooltip } from "@mui/material";
import { X, Info, Plus } from "lucide-react";
import { editSightStore } from "@shared";
import { editSightStore, PreviewBackgroundToggle } from "@shared";
import { toast } from "react-toastify";
interface ImageUploadCardProps {
title: string;
@@ -33,6 +33,7 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
tooltipText,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isPreviewBackgroundDark, setIsPreviewBackgroundDark] = useState(false);
const { setFileToUpload } = editSightStore;
const handleZoneClick = () => {
@@ -124,9 +125,21 @@ export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
borderRadius: 1,
mb: 1,
cursor: imageUrl ? "pointer" : "default",
backgroundColor: imageUrl
? isPreviewBackgroundDark
? "#000"
: "#fff"
: "transparent",
transition: "background-color 150ms ease",
}}
onClick={onImageClick}
>
{imageUrl && (
<PreviewBackgroundToggle
dark={isPreviewBackgroundDark}
onToggle={() => setIsPreviewBackgroundDark((value) => !value)}
/>
)}
{imageUrl && (
<button
className="absolute top-2 right-2"

View File

@@ -42,7 +42,14 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
};
return (
<Box sx={{ display: "flex" }}>
<Box
sx={{
display: "flex",
height: "100vh",
minHeight: 0,
overflow: "hidden",
}}
>
<AppBar position="fixed" open={open}>
<Toolbar className="flex justify-between">
<div className="flex items-center">
@@ -149,9 +156,14 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
sx={{
width: "100%",
flexGrow: 1,
minWidth: 0,
minHeight: 0,
height: "100vh",
p: 3,
overflow: "auto",
overflowY: "auto",
overflowX: "hidden",
maxWidth: "100vw",
boxSizing: "border-box",
}}
>
<div className="mt-16"></div>

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;
padding: 7px;
width: 100%;
height: 60px;
display: flex;
align-items: center;
justify-content: space-around;
@@ -267,4 +268,3 @@
.sfp-sight-frame-media-stack.three-d-view {
background-color: #111 !important;
}

View File

@@ -21,3 +21,4 @@ export * from "./SaveWithoutCityAgree";
export * from "./CitySelector";
export * from "./modals";
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"
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"
resolved "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz"
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"
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":
version "11.13.5"
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"
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"
resolved "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz"
integrity sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==
@@ -267,7 +245,7 @@
resolved "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz"
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"
resolved "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz"
integrity sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==
@@ -299,136 +277,11 @@
resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz"
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":
version "0.25.11"
resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz"
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":
version "4.9.0"
resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz"
@@ -479,7 +332,7 @@
minimatch "^3.1.2"
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"
resolved "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz"
integrity sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==
@@ -589,7 +442,7 @@
dependencies:
"@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"
resolved "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz"
integrity sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==
@@ -628,7 +481,7 @@
csstype "^3.1.3"
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"
resolved "https://registry.npmjs.org/@mui/system/-/system-7.3.3.tgz"
integrity sha512-Lqq3emZr5IzRLKaHPuMaLBDVaGvxoh6z7HMWd1RPKawBM5uMRaQ4ImsmmgXWtwJdfZux5eugfDhXJUo2mliS8Q==
@@ -693,13 +546,6 @@
"@mui/utils" "^7.3.3"
"@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":
version "2.1.5"
resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz"
@@ -708,7 +554,7 @@
"@nodelib/fs.stat" "2.0.5"
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"
resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
@@ -726,7 +572,7 @@
resolved "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz"
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"
resolved "https://registry.npmjs.org/@photo-sphere-viewer/core/-/core-5.14.0.tgz"
integrity sha512-V0JeDSB1D2Q60Zqn7+0FPjq8gqbKEwuxMzNdTLydefkQugVztLvdZykO+4k5XTpweZ2QAWPH/QOI1xZbsdvR9A==
@@ -778,7 +624,7 @@
utility-types "^3.11.0"
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"
resolved "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz"
integrity sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==
@@ -810,116 +656,11 @@
estree-walker "^2.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":
version "4.52.5"
resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz"
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":
version "8.0.0"
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-svg-component" "8.0.0"
"@svgr/core@^8.1.0":
"@svgr/core@*", "@svgr/core@^8.1.0":
version "8.1.0"
resolved "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz"
integrity sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==
@@ -1016,73 +757,11 @@
source-map-js "^1.2.1"
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":
version "4.1.16"
resolved "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz"
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":
version "4.1.16"
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"
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":
version "7.20.5"
resolved "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz"
@@ -1198,7 +870,7 @@
dependencies:
"@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"
resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz"
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
@@ -1237,7 +909,7 @@
resolved "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz"
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"
resolved "https://registry.npmjs.org/@types/node/-/node-22.18.13.tgz"
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"
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"
resolved "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz"
integrity sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==
@@ -1303,7 +975,7 @@
dependencies:
"@types/estree" "*"
"@types/three@*":
"@types/three@*", "@types/three@>=0.134.0":
version "0.180.0"
resolved "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz"
integrity sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==
@@ -1351,7 +1023,7 @@
natural-compare "^1.4.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"
resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz"
integrity sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==
@@ -1379,7 +1051,7 @@
"@typescript-eslint/types" "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"
resolved "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz"
integrity sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==
@@ -1395,7 +1067,7 @@
debug "^4.3.4"
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"
resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz"
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"
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"
resolved "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz"
integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
@@ -1582,7 +1254,7 @@ braces@^3.0.3:
dependencies:
fill-range "^7.1.1"
browserslist@^4.24.0:
browserslist@^4.24.0, "browserslist@>= 4.21.0":
version "4.27.0"
resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz"
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"
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"
resolved "https://registry.npmjs.org/easymde/-/easymde-2.20.0.tgz"
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"
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"
resolved "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz"
integrity sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==
@@ -2148,7 +1820,12 @@ fastq@^1.6.0:
dependencies:
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"
resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz"
integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
@@ -2612,7 +2289,7 @@ its-fine@^2.0.0:
dependencies:
"@types/react-reconciler" "^0.28.9"
jiti@^2.6.1:
jiti@*, jiti@^2.6.1, jiti@>=1.21.0:
version "2.6.1"
resolved "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz"
integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==
@@ -2691,62 +2368,12 @@ lie@^3.0.2:
dependencies:
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:
version "1.30.2"
resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz"
integrity sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==
lightningcss-darwin-x64@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:
lightningcss@^1.21.0, lightningcss@1.30.2:
version "1.30.2"
resolved "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz"
integrity sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==
@@ -3195,7 +2822,7 @@ mobx-react-lite@^4.1.0:
dependencies:
use-sync-external-store "^1.4.0"
mobx@^6.13.7:
mobx@^6.13.7, mobx@^6.9.0:
version "6.15.0"
resolved "https://registry.npmjs.org/mobx/-/mobx-6.15.0.tgz"
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"
integrity sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw==
overlayscrollbars@^2.15.1:
overlayscrollbars@^2.0.0, overlayscrollbars@^2.15.1:
version "2.15.1"
resolved "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.15.1.tgz"
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"
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"
resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz"
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
pixi.js@^8.10.1:
pixi.js@^8.10.1, pixi.js@^8.2.6:
version "8.14.0"
resolved "https://registry.npmjs.org/pixi.js/-/pixi.js-8.14.0.tgz"
integrity sha512-ituDiEBb1Oqx56RYwTtC6MjPUhPfF/i15fpUv5oEqmzC/ce3SaSumulJcOjKG7+y0J0Ekl9Rl4XTxaUw+MVFZw==
@@ -3448,7 +3075,7 @@ promise-worker-transferable@^1.0.4:
is-promise "^2.1.0"
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"
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@@ -3509,14 +3136,19 @@ rbush@^4.0.0:
dependencies:
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"
resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz"
integrity sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==
dependencies:
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"
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
@@ -3550,7 +3182,7 @@ react-photo-sphere-viewer@^6.2.3:
dependencies:
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"
resolved "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz"
integrity sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==
@@ -3577,7 +3209,7 @@ react-router-dom@^7.6.1:
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:
version "7.9.4"
resolved "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz"
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"
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"
resolved "https://registry.npmjs.org/react/-/react-19.2.0.tgz"
integrity sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==
redux@^5.0.1:
redux@^5.0.0, redux@^5.0.1:
version "5.0.1"
resolved "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz"
integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==
@@ -3705,7 +3337,7 @@ rollup-plugin-visualizer@^6.0.5:
source-map "^0.7.4"
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"
resolved "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz"
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"
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"
resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz"
integrity sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==
@@ -3923,7 +3555,7 @@ three@^0.170.0:
resolved "https://registry.npmjs.org/three/-/three-0.170.0.tgz"
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"
resolved "https://registry.npmjs.org/three/-/three-0.177.0.tgz"
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"
integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==
tslib@^2.0.3, tslib@^2.4.0:
tslib@^2.0.3:
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==
tunnel-rat@^0.1.2:
@@ -4022,7 +3654,7 @@ typescript-eslint@^8.30.1:
"@typescript-eslint/typescript-estree" "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"
resolved "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz"
integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==
@@ -4103,7 +3735,7 @@ uri-js@^4.2.2:
dependencies:
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"
resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz"
integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==
@@ -4163,7 +3795,7 @@ vite-plugin-svgr@^4.5.0:
"@svgr/core" "^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"
resolved "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz"
integrity sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==