From 1bb3f43979c1537e212a872432044dbe1da2185a Mon Sep 17 00:00:00 2001 From: itoshi Date: Wed, 20 May 2026 12:48:00 +0300 Subject: [PATCH] feat: add scrollable menu with fade hints and home button transition --- .../components/ListOfSights/SightFrame.jsx | 106 +++++++++++++----- src/client/src/styles/ListOfSights.css | 42 ++++++- src/pages/Article/ArticleCreatePage/index.tsx | 9 +- src/pages/Article/ArticleEditPage/index.tsx | 7 +- src/pages/Carrier/CarrierCreatePage/index.tsx | 6 + src/pages/Carrier/CarrierEditPage/index.tsx | 6 + src/pages/City/CityListPage/index.tsx | 22 ++-- src/pages/Country/CountryListPage/index.tsx | 18 +-- src/pages/CreateSightPage/index.tsx | 18 ++- src/pages/Media/MediaCreatePage/index.tsx | 7 +- src/pages/Media/MediaEditPage/index.tsx | 6 + src/pages/Route/RouteListPage/index.tsx | 7 +- src/pages/Sight/SightListPage/index.tsx | 1 - .../Snapshot/SnapshotCreatePage/index.tsx | 10 +- src/pages/Station/StationListPage/index.tsx | 7 +- src/pages/User/UserCreatePage/index.tsx | 6 + src/pages/User/UserEditPage/index.tsx | 6 + src/pages/Vehicle/VehicleCreatePage/index.tsx | 5 + src/pages/Vehicle/VehicleEditPage/index.tsx | 5 + src/pages/Vehicle/VehicleListPage/index.tsx | 1 - src/shared/store/SnapshotStore/index.ts | 6 - 21 files changed, 211 insertions(+), 90 deletions(-) diff --git a/src/client/src/components/ListOfSights/SightFrame.jsx b/src/client/src/components/ListOfSights/SightFrame.jsx index 47a77b3..223f5cb 100644 --- a/src/client/src/components/ListOfSights/SightFrame.jsx +++ b/src/client/src/components/ListOfSights/SightFrame.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useMemo } from "react"; +import React, { useState, useEffect, useRef, useMemo, useLayoutEffect, useCallback } from "react"; import axios from "axios"; import { observer } from "mobx-react-lite"; import { useGeolocationStore } from "../../stores"; @@ -46,6 +46,44 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => { const idleTimerRef = useRef(null); const textWrapperRef = useRef(null); + const menuRef = useRef(null); + const [menuNeedsScroll, setMenuNeedsScroll] = useState(false); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + + const updateScrollState = useCallback(() => { + const menu = menuRef.current; + if (!menu) return; + setCanScrollLeft(menu.scrollLeft > 2); + setCanScrollRight(menu.scrollLeft + menu.clientWidth < menu.scrollWidth - 2); + }, []); + + useEffect(() => { + const menu = menuRef.current; + if (!menu || !menuNeedsScroll) { + setCanScrollLeft(false); + setCanScrollRight(false); + return; + } + updateScrollState(); + menu.addEventListener('scroll', updateScrollState); + return () => menu.removeEventListener('scroll', updateScrollState); + }, [menuNeedsScroll, updateScrollState]); + + useLayoutEffect(() => { + const menu = menuRef.current; + if (!menu) return; + const children = Array.from(menu.querySelectorAll('.sight-frame-menu-point')); + if (children.length < 2) { + setMenuNeedsScroll(false); + return; + } + const style = getComputedStyle(menu); + const availableWidth = menu.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight); + const totalChildrenWidth = children.reduce((sum, el) => sum + el.offsetWidth, 0); + const evenGap = (availableWidth - totalChildrenWidth) / (children.length + 1); + setMenuNeedsScroll(evenGap < 10); + }, [articleSections, selectedSection]); // Автозакрытие fullscreen 3D при бездействии (60 сек) useEffect(() => { @@ -674,34 +712,43 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => { )} -
- {selectedSection !== 0 && ( -
{ - setSelectedSection(0); - setIsFullscreen3D(false); - }} - > - -
- )} +
+
+
+
+
{ + setSelectedSection(0); + setIsFullscreen3D(false); + }} + > + +
{contentError ? (

{contentError}

) : ( @@ -725,6 +772,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => { )) )}
+
); }); diff --git a/src/client/src/styles/ListOfSights.css b/src/client/src/styles/ListOfSights.css index 371d125..eff1f6c 100644 --- a/src/client/src/styles/ListOfSights.css +++ b/src/client/src/styles/ListOfSights.css @@ -362,9 +362,37 @@ margin-bottom: 0; } +.sight-frame-menu-wrapper { + position: relative; + width: 100%; + flex-shrink: 0; +} + +.sight-frame-menu-fade { + position: absolute; + top: 0; + bottom: 0; + width: 120px; + z-index: 3; + pointer-events: none; + transition: opacity 0.4s ease; +} + +.sight-frame-menu-fade.left { + left: 0; + background: linear-gradient(to right, rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.95), transparent); + border-radius: 0 0 0 10px; +} + +.sight-frame-menu-fade.right { + right: 0; + background: linear-gradient(to left, rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.95), transparent); + border-radius: 0 0 10px 0; +} + .sight-frame-menu { position: relative; - padding: 7px; + padding: 7px 60px; width: 100%; display: flex; align-items: center; @@ -382,6 +410,17 @@ backdrop-filter: blur(10px); box-sizing: border-box; flex-shrink: 0; + overflow-x: auto; + overflow-y: hidden; +} + +.sight-frame-menu::-webkit-scrollbar { + display: none; +} + +.sight-frame-menu { + -ms-overflow-style: none; + scrollbar-width: none; } .sight-frame-menu-point { @@ -393,6 +432,7 @@ font-weight: 400; padding: 8px 12px; white-space: nowrap; + flex-shrink: 0; transition: background-color 0.1s ease, color 0.1s ease; diff --git a/src/pages/Article/ArticleCreatePage/index.tsx b/src/pages/Article/ArticleCreatePage/index.tsx index ac5cfaf..6783858 100644 --- a/src/pages/Article/ArticleCreatePage/index.tsx +++ b/src/pages/Article/ArticleCreatePage/index.tsx @@ -1,12 +1,17 @@ -import React from "react"; +import React, { useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { ArrowLeft } from "lucide-react"; import { LanguageSwitcher } from "@widgets"; -import { articlesStore } from "@shared"; +import { articlesStore, selectedCityStore } from "@shared"; const ArticleCreatePage: React.FC = () => { const navigate = useNavigate(); + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + const { articleData } = articlesStore; return ( diff --git a/src/pages/Article/ArticleEditPage/index.tsx b/src/pages/Article/ArticleEditPage/index.tsx index 47cd0ff..8ab546a 100644 --- a/src/pages/Article/ArticleEditPage/index.tsx +++ b/src/pages/Article/ArticleEditPage/index.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { ArrowLeft } from "lucide-react"; import { LanguageSwitcher } from "@widgets"; -import { articlesStore, languageStore } from "@shared"; +import { articlesStore, languageStore, selectedCityStore } from "@shared"; import { observer } from "mobx-react-lite"; const ArticleEditPage: React.FC = observer(() => { @@ -11,6 +11,11 @@ const ArticleEditPage: React.FC = observer(() => { const { articleData, getArticle } = articlesStore; + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + useEffect(() => { // Устанавливаем русский язык при загрузке страницы languageStore.setLanguage("ru"); diff --git a/src/pages/Carrier/CarrierCreatePage/index.tsx b/src/pages/Carrier/CarrierCreatePage/index.tsx index 6ffcfbd..fc8c5b5 100644 --- a/src/pages/Carrier/CarrierCreatePage/index.tsx +++ b/src/pages/Carrier/CarrierCreatePage/index.tsx @@ -20,6 +20,7 @@ import { languageStore, isMediaIdEmpty, useSelectedCity, + selectedCityStore, SelectMediaDialog, UploadMediaDialog, PreviewMediaDialog, @@ -93,6 +94,11 @@ export const CarrierCreatePage = observer(() => { "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null >(null); + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + useEffect(() => { const fetchCities = async () => { if (!authStore.me) { diff --git a/src/pages/Carrier/CarrierEditPage/index.tsx b/src/pages/Carrier/CarrierEditPage/index.tsx index 84ad32c..f4bbe8d 100644 --- a/src/pages/Carrier/CarrierEditPage/index.tsx +++ b/src/pages/Carrier/CarrierEditPage/index.tsx @@ -21,6 +21,7 @@ import { languageStore, isMediaIdEmpty, LoadingSpinner, + selectedCityStore, } from "@shared"; import { useState, useEffect } from "react"; import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets"; @@ -103,6 +104,11 @@ export const CarrierEditPage = observer(() => { "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null >(null); + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + useEffect(() => { (async () => { if (!id) { diff --git a/src/pages/City/CityListPage/index.tsx b/src/pages/City/CityListPage/index.tsx index d7e823b..6710407 100644 --- a/src/pages/City/CityListPage/index.tsx +++ b/src/pages/City/CityListPage/index.tsx @@ -1,6 +1,6 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { ruRU } from "@mui/x-data-grid/locales"; -import { authStore, languageStore, cityStore, countryStore, selectedCityStore, SearchInput } from "@shared"; +import { authStore, languageStore, cityStore, countryStore, SearchInput } from "@shared"; import { useEffect, useState, useMemo } from "react"; import { observer } from "mobx-react-lite"; import { Pencil, Trash2, Minus } from "lucide-react"; @@ -59,21 +59,16 @@ export const CityListPage = observer(() => { }, [cities, countryStore.countries, language, isLoading]); const filteredRows = useMemo(() => { - const { selectedCityId } = selectedCityStore; - if (!selectedCityId) return []; - const query = searchQuery.trim().toLowerCase(); - const result = rows.filter((row) => row.id === selectedCityId); - - if (!query) return result; - return result.filter((row) => { + if (!query) return rows; + return rows.filter((row) => { const cityName = (row.name ?? "").toLowerCase(); const countryName = ( countryStore.countries[language]?.data?.find((c) => c.code === row.country)?.name ?? "" ).toLowerCase(); return cityName.includes(query) || countryName.includes(query); }); - }, [rows, searchQuery, countryStore.countries, language, selectedCityStore.selectedCityId]); + }, [rows, searchQuery, countryStore.countries, language]); const columns: GridColDef[] = [ { @@ -145,10 +140,9 @@ export const CityListPage = observer(() => {

Города

{canWriteCities && ( - )}
@@ -214,8 +208,6 @@ export const CityListPage = observer(() => { {isLoading ? ( - ) : !selectedCityStore.selectedCityId ? ( - "Выберите город" ) : ( "Нет городов" )} diff --git a/src/pages/Country/CountryListPage/index.tsx b/src/pages/Country/CountryListPage/index.tsx index ee6c898..a64e7ae 100644 --- a/src/pages/Country/CountryListPage/index.tsx +++ b/src/pages/Country/CountryListPage/index.tsx @@ -1,6 +1,6 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { ruRU } from "@mui/x-data-grid/locales"; -import { authStore, countryStore, languageStore, selectedCityStore, SearchInput } from "@shared"; +import { authStore, countryStore, languageStore, SearchInput } from "@shared"; import { useEffect, useState, useMemo } from "react"; import { observer } from "mobx-react-lite"; import { Trash2, Minus } from "lucide-react"; @@ -74,20 +74,15 @@ export const CountryListPage = observer(() => { ]; const rows = useMemo(() => { - const { selectedCity } = selectedCityStore; - if (!selectedCity) { - return []; - } const query = searchQuery.trim().toLowerCase(); return (countries[language]?.data ?? []) - .filter((country) => country.code === selectedCity.country_code) .filter((country) => !query || (country.name ?? "").toLowerCase().includes(query)) .map((country) => ({ id: country.code, code: country.code, name: country.name, })); - }, [countries[language]?.data, searchQuery, selectedCityStore.selectedCity]); + }, [countries[language]?.data, searchQuery]); return ( <> @@ -97,10 +92,9 @@ export const CountryListPage = observer(() => {

Страны

{canWriteCountries && ( - )}
@@ -161,8 +155,6 @@ export const CountryListPage = observer(() => { {isLoading ? ( - ) : !selectedCityStore.selectedCityId ? ( - "Выберите город" ) : ( "Нет стран" )} diff --git a/src/pages/CreateSightPage/index.tsx b/src/pages/CreateSightPage/index.tsx index 80f7d7b..0f0803e 100644 --- a/src/pages/CreateSightPage/index.tsx +++ b/src/pages/CreateSightPage/index.tsx @@ -37,16 +37,6 @@ export const CreateSightPage = observer(() => { return () => selectedCityStore.setIsLocked(false); }, []); - useEffect(() => { - const { selectedCityId, selectedCity } = selectedCityStore; - if (selectedCityId && selectedCity && !createSightStore.sight.city_id) { - runInAction(() => { - createSightStore.sight.city_id = selectedCityId; - createSightStore.sight.city = selectedCity.name; - }); - } - }, []); - const handleChange = (_: React.SyntheticEvent, newValue: number) => { setValue(newValue); }; @@ -73,6 +63,14 @@ export const CreateSightPage = observer(() => { await authStore.fetchMeCities().catch(() => undefined); } await getArticles(languageStore.language); + + const { selectedCityId, selectedCity } = selectedCityStore; + if (selectedCityId && selectedCity && !createSightStore.sight.city_id) { + runInAction(() => { + createSightStore.sight.city_id = selectedCityId; + createSightStore.sight.city = selectedCity.name; + }); + } }; fetchData(); }, []); diff --git a/src/pages/Media/MediaCreatePage/index.tsx b/src/pages/Media/MediaCreatePage/index.tsx index d5e0bc7..8a934bb 100644 --- a/src/pages/Media/MediaCreatePage/index.tsx +++ b/src/pages/Media/MediaCreatePage/index.tsx @@ -10,7 +10,7 @@ import { import { mediaStore, MEDIA_TYPE_LABELS, selectedCityStore } from "@shared"; import { observer } from "mobx-react-lite"; import { ArrowLeft, Loader2, Save } from "lucide-react"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "react-toastify"; @@ -20,6 +20,11 @@ export const MediaCreatePage = observer(() => { const [type, setType] = useState(""); const [isLoading, setIsLoading] = useState(false); + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + const handleCreate = async () => { try { setIsLoading(true); diff --git a/src/pages/Media/MediaEditPage/index.tsx b/src/pages/Media/MediaEditPage/index.tsx index f2136f2..934d093 100644 --- a/src/pages/Media/MediaEditPage/index.tsx +++ b/src/pages/Media/MediaEditPage/index.tsx @@ -22,6 +22,7 @@ import { MEDIA_TYPE_LABELS, languageStore, LoadingSpinner, + selectedCityStore, } from "@shared"; import { MediaViewer } from "@widgets"; @@ -42,6 +43,11 @@ export const MediaEditPage = observer(() => { const [mediaType, setMediaType] = useState(media?.media_type ?? 1); const [availableMediaTypes, setAvailableMediaTypes] = useState([]); + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + useEffect(() => { if (id) { mediaStore.getOneMedia(id); diff --git a/src/pages/Route/RouteListPage/index.tsx b/src/pages/Route/RouteListPage/index.tsx index c27a81f..a6b7dd8 100644 --- a/src/pages/Route/RouteListPage/index.tsx +++ b/src/pages/Route/RouteListPage/index.tsx @@ -251,10 +251,9 @@ export const RouteListPage = observer(() => {

Маршруты

{canWriteRoutes && ( - )}
diff --git a/src/pages/Sight/SightListPage/index.tsx b/src/pages/Sight/SightListPage/index.tsx index 849a0f6..5416d01 100644 --- a/src/pages/Sight/SightListPage/index.tsx +++ b/src/pages/Sight/SightListPage/index.tsx @@ -165,7 +165,6 @@ export const SightListPage = observer(() => { )}
diff --git a/src/pages/Snapshot/SnapshotCreatePage/index.tsx b/src/pages/Snapshot/SnapshotCreatePage/index.tsx index 711c0df..8b41209 100644 --- a/src/pages/Snapshot/SnapshotCreatePage/index.tsx +++ b/src/pages/Snapshot/SnapshotCreatePage/index.tsx @@ -6,10 +6,10 @@ import { DialogContent, DialogActions, } from "@mui/material"; -import { snapshotStore, authStore, routeStore } from "@shared"; +import { snapshotStore, authStore, routeStore, selectedCityStore } from "@shared"; import { observer } from "mobx-react-lite"; import { ArrowLeft, Loader2, Save } from "lucide-react"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "react-toastify"; import { runInAction } from "mobx"; @@ -17,6 +17,12 @@ import { runInAction } from "mobx"; export const SnapshotCreatePage = observer(() => { const { createSnapshot, getSnapshotStatus, getStorageInfo, snapshotStatus } = snapshotStore; const navigate = useNavigate(); + + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + const [name, setName] = useState(""); const [isLoading, setIsLoading] = useState(false); const [duplicateWarningOpen, setDuplicateWarningOpen] = useState(false); diff --git a/src/pages/Station/StationListPage/index.tsx b/src/pages/Station/StationListPage/index.tsx index 3f1c3f1..f2015f3 100644 --- a/src/pages/Station/StationListPage/index.tsx +++ b/src/pages/Station/StationListPage/index.tsx @@ -206,10 +206,9 @@ export const StationListPage = observer(() => {

Остановки

{canWriteStations && ( - )}
diff --git a/src/pages/User/UserCreatePage/index.tsx b/src/pages/User/UserCreatePage/index.tsx index 721cfc5..a7142fd 100644 --- a/src/pages/User/UserCreatePage/index.tsx +++ b/src/pages/User/UserCreatePage/index.tsx @@ -25,6 +25,7 @@ import { SelectMediaDialog, UploadMediaDialog, PreviewMediaDialog, + selectedCityStore, } from "@shared"; import { useState, useEffect } from "react"; import { ImageUploadCard } from "@widgets"; @@ -79,6 +80,11 @@ export const UserCreatePage = observer(() => { createUserData.roles ?? ["articles_ro", "articles_rw", "media_ro", "media_rw"] ); + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + useEffect(() => { mediaStore.getMedia(); }, []); diff --git a/src/pages/User/UserEditPage/index.tsx b/src/pages/User/UserEditPage/index.tsx index 4b37403..9482f37 100644 --- a/src/pages/User/UserEditPage/index.tsx +++ b/src/pages/User/UserEditPage/index.tsx @@ -30,6 +30,7 @@ import { authStore, cityStore, MultiSelect, + selectedCityStore, type User, type UserCity, } from "@shared"; @@ -92,6 +93,11 @@ export const UserEditPage = observer(() => { "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null >(null); + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + useEffect(() => { languageStore.setLanguage("ru"); }, []); diff --git a/src/pages/Vehicle/VehicleCreatePage/index.tsx b/src/pages/Vehicle/VehicleCreatePage/index.tsx index 4d8b386..7395af8 100644 --- a/src/pages/Vehicle/VehicleCreatePage/index.tsx +++ b/src/pages/Vehicle/VehicleCreatePage/index.tsx @@ -32,6 +32,11 @@ export const VehicleCreatePage = observer(() => { const [isLoading, setIsLoading] = useState(false); const { language } = languageStore; + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + useEffect(() => { carrierStore.getCarriers(language); cityStore.getCities("ru"); diff --git a/src/pages/Vehicle/VehicleEditPage/index.tsx b/src/pages/Vehicle/VehicleEditPage/index.tsx index f10071b..93a50a2 100644 --- a/src/pages/Vehicle/VehicleEditPage/index.tsx +++ b/src/pages/Vehicle/VehicleEditPage/index.tsx @@ -40,6 +40,11 @@ export const VehicleEditPage = observer(() => { const [isLoading, setIsLoading] = useState(false); const [isLoadingData, setIsLoadingData] = useState(true); + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + useEffect(() => { // Устанавливаем русский язык при загрузке страницы languageStore.setLanguage("ru"); diff --git a/src/pages/Vehicle/VehicleListPage/index.tsx b/src/pages/Vehicle/VehicleListPage/index.tsx index dacf787..76cff17 100644 --- a/src/pages/Vehicle/VehicleListPage/index.tsx +++ b/src/pages/Vehicle/VehicleListPage/index.tsx @@ -170,7 +170,6 @@ export const VehicleListPage = observer(() => {
diff --git a/src/shared/store/SnapshotStore/index.ts b/src/shared/store/SnapshotStore/index.ts index 94ba3d5..0cd670b 100644 --- a/src/shared/store/SnapshotStore/index.ts +++ b/src/shared/store/SnapshotStore/index.ts @@ -60,12 +60,6 @@ class SnapshotStore { articlesStore.articleData = null; articlesStore.articleMedia = null; - countryStore.countries = { - ru: { data: [], loaded: false }, - en: { data: [], loaded: false }, - zh: { data: [], loaded: false }, - }; - carrierStore.carriers = { ru: { data: [], loaded: false }, en: { data: [], loaded: false },