feat: add markdown, lang atribute, button_text

This commit is contained in:
2026-05-25 12:46:53 +03:00
parent 55cdea17ea
commit d4c5db61ea
29 changed files with 281 additions and 117 deletions

View File

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

View File

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

View File

@@ -20,6 +20,8 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
top: 0,
hasScroll: false,
});
const [visible, setVisible] = useState(false);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const rafRef = useRef<number | null>(null);
const update = useCallback(() => {
@@ -32,7 +34,7 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
const th = ch;
if (sh <= ch) {
setState({ height: th, top: 0, hasScroll: false });
setState((prev) => ({ ...prev, hasScroll: false }));
return;
}
@@ -68,7 +70,24 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
};
}, [update]);
return state;
useEffect(() => {
if (state.hasScroll) {
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
setVisible(true);
} else {
hideTimerRef.current = setTimeout(() => {
setVisible(false);
}, 200);
}
return () => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
};
}, [state.hasScroll]);
return { ...state, visible };
}
export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
@@ -251,15 +270,19 @@ export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
<div ref={scrollableRef} className="scrollable">
{children}
</div>
{thumb.hasScroll && (
<div ref={trackRef} className="custom-scrollbar-track">
<div
ref={trackRef}
className="custom-scrollbar-track"
style={{ opacity: thumb.hasScroll ? 1 : 0 }}
>
{thumb.visible && (
<div
ref={thumbRef}
className="custom-scrollbar-thumb"
style={{ height: thumb.height, top: thumb.top }}
/>
</div>
)}
)}
</div>
</div>
</div>
);

View File

@@ -5,6 +5,8 @@ import { useGeolocationStore } from "../../stores";
import "../../styles/LeftWidget.css";
import { apiStore } from "../../api/ApiStore/store";
import { apiBaseURL } from "../../api/apiConfig";
import { ReactMarkdownComponent } from "../ReactMarkdown";
import { TouchableLayout } from "../TouchableLayout";
const LeftWidget = observer(
({ selectedSightId, onClose, isVisible, sightTop }) => {
@@ -15,8 +17,7 @@ const LeftWidget = observer(
const [isImageLoaded, setIsImageLoaded] = useState(false);
const [widgetHeight, setWidgetHeight] = useState(0);
const textRef = useRef(null);
const activeTouch = useRef(null);
const layoutRef = useRef(null);
const widgetRef = useRef(null);
const store = useGeolocationStore();
@@ -37,64 +38,10 @@ const LeftWidget = observer(
}, [selectedSightData, isImageLoaded, isVisible, isLoading, error]);
useEffect(() => {
const scrollContainer = textRef.current;
if (!scrollContainer) return;
const handleTouchStart = (e) => {
e.stopPropagation();
if (e.touches.length === 1) {
activeTouch.current = {
identifier: e.touches[0].identifier,
lastY: e.touches[0].clientY,
};
}
};
const handleTouchMove = (e) => {
e.preventDefault();
if (activeTouch.current) {
for (const touch of e.changedTouches) {
if (touch.identifier === activeTouch.current.identifier) {
const deltaY = touch.clientY - activeTouch.current.lastY;
scrollContainer.scrollTop -= deltaY;
activeTouch.current.lastY = touch.clientY;
break;
}
}
}
};
const handleTouchEnd = (e) => {
for (const touch of e.changedTouches) {
if (
activeTouch.current &&
touch.identifier === activeTouch.current.identifier
) {
activeTouch.current = null;
break;
}
}
};
scrollContainer.addEventListener("touchstart", handleTouchStart, {
passive: true,
});
scrollContainer.addEventListener("touchmove", handleTouchMove, {
passive: false,
});
scrollContainer.addEventListener("touchend", handleTouchEnd, {
passive: true,
});
scrollContainer.addEventListener("touchcancel", handleTouchEnd, {
passive: true,
});
return () => {
scrollContainer.removeEventListener("touchstart", handleTouchStart);
scrollContainer.removeEventListener("touchmove", handleTouchMove);
scrollContainer.removeEventListener("touchend", handleTouchEnd);
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
};
if (layoutRef.current) {
const scrollable = layoutRef.current.querySelector(".scrollable");
if (scrollable) scrollable.scrollTop = 0;
}
}, [selectedSightData]);
useEffect(() => {
@@ -238,9 +185,11 @@ const LeftWidget = observer(
<div className="left-widget-address">
{selectedSightData.address}
</div>
<div ref={textRef} className="left-widget-text">
{selectedSightData.text}
</div>
<TouchableLayout ref={layoutRef} className="left-widget-text-scroll">
<div className="left-widget-text">
<ReactMarkdownComponent value={selectedSightData.text} />
</div>
</TouchableLayout>
</div>
</>
) : (isVisible || selectedSightData) && !isLoading ? (

View File

@@ -447,11 +447,13 @@ const SideMenu = observer(({ onMenuToggle }) => {
}}
className="appeal-button"
>
{selectedLanguage == "ru"
? "Обращение губернатора"
: selectedLanguage == "zh"
? "州长致辞"
: "Governor's appeal"}
{route?.button_text
? route.button_text
: selectedLanguage == "ru"
? "Обращение губернатора"
: selectedLanguage == "zh"
? "州长致辞"
: "Governor's appeal"}
</div>
)}
<div className="side-menu-buttons" style={{ marginTop: route?.governor_appeal > 0 ? '40px' : '260px' }}>

View File

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

View File

@@ -1,6 +1,7 @@
import { useRef, useEffect } from "react";
import "../../styles/AppealWidget.css";
import { TouchableLayout } from "../TouchableLayout";
import { ReactMarkdownComponent } from "../ReactMarkdown";
function AppealWidget({
widgetImgPath,
@@ -38,7 +39,9 @@ function AppealWidget({
ref={layoutRef}
className="dynamic-widget-text-scroll"
>
<div className="dynamic-widget-text">{widgetText}</div>
<div className="dynamic-widget-text">
<ReactMarkdownComponent value={widgetText} />
</div>
</TouchableLayout>
</div>
);

View File

@@ -288,7 +288,7 @@ export const ThreeView: React.FC<ThreeViewProps> = ({
{!isReady && (
<div style={{
position: "absolute", inset: 0,
backgroundColor: BACKGROUND_COLOR,
backgroundColor: `#${BACKGROUND_COLOR.toString(16).padStart(6, "0")}`,
zIndex: 1,
}} />
)}

View File

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

View File

@@ -66,8 +66,63 @@
.dynamic-widget-text {
font-size: 14px;
font-weight: 400;
line-height: 190%;
font-size: 16px;
font-weight: 300;
line-height: 135%;
padding-right: 5px;
}
.dynamic-widget-text .react-markdown-container {
font-size: 16px;
line-height: 135%;
font-weight: 300;
}
.dynamic-widget-text .react-markdown-container p {
font-size: 16px;
line-height: 135%;
margin-bottom: 8px;
}
.dynamic-widget-text .react-markdown-container p:last-child {
margin-bottom: 0;
}
.dynamic-widget-text .react-markdown-container h1,
.dynamic-widget-text .react-markdown-container h2,
.dynamic-widget-text .react-markdown-container h3,
.dynamic-widget-text .react-markdown-container h4,
.dynamic-widget-text .react-markdown-container h5,
.dynamic-widget-text .react-markdown-container h6 {
font-size: 18px;
margin-top: 10px;
margin-bottom: 4px;
font-weight: 600;
}
.dynamic-widget-text .react-markdown-container ul,
.dynamic-widget-text .react-markdown-container ol {
margin-bottom: 8px;
padding-left: 20px;
}
.dynamic-widget-text .react-markdown-container li {
margin-bottom: 4px;
}
.dynamic-widget-text .react-markdown-container blockquote {
margin-top: 8px;
margin-bottom: 8px;
padding-left: 12px;
border-left: 3px solid rgba(255, 255, 255, 0.4);
}
.dynamic-widget-text .react-markdown-container img {
max-width: 100%;
border-radius: 6px;
}
.dynamic-widget-text .react-markdown-container a {
color: rgba(255, 255, 255, 0.9);
text-decoration: underline;
}

View File

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

View File

@@ -1,8 +1,16 @@
@keyframes pulse-chevron {
0% { transform: rotate(var(--r, 0deg)) translateY(0px) scale(1); }
40% { transform: rotate(var(--r, 0deg)) translateY(-4px) scale(1.12); }
60% { transform: rotate(var(--r, 0deg)) translateY(-5px) scale(1.14); }
100% { transform: rotate(var(--r, 0deg)) translateY(0px) scale(1); }
0% {
transform: rotate(var(--r, 0deg)) translateY(0px) scale(1);
}
40% {
transform: rotate(var(--r, 0deg)) translateY(-4px) scale(1.12);
}
60% {
transform: rotate(var(--r, 0deg)) translateY(-5px) scale(1.14);
}
100% {
transform: rotate(var(--r, 0deg)) translateY(0px) scale(1);
}
}
.chevron-svg {
@@ -39,7 +47,7 @@
rgba(255, 255, 255, 0) 8.71%,
rgba(255, 255, 255, 0.16) 69.69%
),
var(--carrier-right, #806C59);
var(--carrier-right, #806c59);
color: white;
max-height: 68px;
@@ -87,7 +95,7 @@
background-color: color-mix(
in srgb,
var(--carrier-right, #806C59) 80%,
var(--carrier-right, #806c59) 80%,
black
);
}
@@ -220,7 +228,7 @@
rgba(255, 255, 255, 0) 8.71%,
rgba(255, 255, 255, 0.16) 69.69%
),
var(--carrier-right, #806C59);
var(--carrier-right, #806c59);
max-height: calc(100vh - 128px);
}
@@ -340,7 +348,7 @@
background: linear-gradient(
to right,
transparent 35%,
color-mix(in srgb, var(--carrier-right, #806C59) 80%, black) 50%,
color-mix(in srgb, var(--carrier-right, #806c59) 80%, black) 50%,
transparent 65%
);
border-radius: 3px;
@@ -380,17 +388,26 @@
.sight-frame-menu-fade.left {
left: 0;
background: linear-gradient(to right, rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.95), transparent);
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);
background: linear-gradient(
to left,
rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.95),
transparent
);
border-radius: 0 0 10px 0;
}
.sight-frame-menu {
z-index: 10000;
position: relative;
padding: 7px 60px;
width: 100%;
@@ -825,7 +842,7 @@
border-radius: 32px;
right: 20px;
bottom: 20px;
background: var(--carrier-right, #806C59);
background: var(--carrier-right, #806c59);
z-index: 9999;
display: flex;
}

View File

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

View File

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

View File

@@ -108,7 +108,7 @@ export const ArticleListPage = observer(() => {
</div>
)}
{rows.length > 0 && (
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}

View File

@@ -162,7 +162,7 @@ export const CarrierListPage = observer(() => {
</div>
)}
{rows.length > 0 && (
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}

View File

@@ -159,7 +159,7 @@ export const CityListPage = observer(() => {
</div>
)}
{rows.length > 0 && (
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}

View File

@@ -111,7 +111,7 @@ export const CountryListPage = observer(() => {
</div>
)}
{rows.length > 0 && (
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}

View File

@@ -113,7 +113,7 @@ export const MediaListPage = observer(() => {
return (
<>
<div className="w-full">
{rows.length > 0 && (
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}

View File

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

View File

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

View File

@@ -270,7 +270,7 @@ export const RouteListPage = observer(() => {
</div>
)}
{rows.length > 0 && (
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}

View File

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

View File

@@ -181,7 +181,7 @@ export const SightListPage = observer(() => {
</div>
)}
{rows.length > 0 && (
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}

View File

@@ -316,7 +316,7 @@ export const SnapshotListPage = observer(() => {
</Alert>
)}
{rows.length > 0 && (
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}

View File

@@ -225,7 +225,7 @@ export const StationListPage = observer(() => {
</div>
)}
{rows.length > 0 && (
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}

View File

@@ -147,7 +147,7 @@ export const UserListPage = observer(() => {
</div>
)}
{rows.length > 0 && (
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}

View File

@@ -173,7 +173,7 @@ export const VehicleListPage = observer(() => {
/>
</div>
{rows.length > 0 && (
{(rows.length > 0 || searchQuery) && (
<SearchInput value={searchQuery} onChange={setSearchQuery} />
)}

View File

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