Compare commits
2 Commits
e3469763ce
...
d758dbffa6
| Author | SHA1 | Date | |
|---|---|---|---|
| d758dbffa6 | |||
| 6af95bb449 |
14
.env
14
.env
@@ -1,8 +1,8 @@
|
|||||||
VITE_API_URL='https://wn.st.unprism.ru'
|
# VITE_API_URL='https://wn.st.unprism.ru'
|
||||||
VITE_REACT_APP ='https://wn.st.unprism.ru/'
|
# VITE_REACT_APP ='https://wn.st.unprism.ru/'
|
||||||
VITE_KRBL_MEDIA='https://wn.st.unprism.ru/media/'
|
# VITE_KRBL_MEDIA='https://wn.st.unprism.ru/media/'
|
||||||
VITE_NEED_AUTH='true'
|
|
||||||
# VITE_API_URL='https://wn.krbl.ru'
|
|
||||||
# VITE_REACT_APP ='https://wn.krbl.ru/'
|
|
||||||
# VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
|
|
||||||
# VITE_NEED_AUTH='true'
|
# VITE_NEED_AUTH='true'
|
||||||
|
VITE_API_URL='https://wn.krbl.ru'
|
||||||
|
VITE_REACT_APP ='https://wn.krbl.ru/'
|
||||||
|
VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
|
||||||
|
VITE_NEED_AUTH='true'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "white-nights",
|
"name": "white-nights",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.6",
|
"version": "1.0.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
|||||||
requiredPermissions.length > 0 &&
|
requiredPermissions.length > 0 &&
|
||||||
!requiredPermissions.every((permission) => authStore.canAccess(permission))
|
!requiredPermissions.every((permission) => authStore.canAccess(permission))
|
||||||
) {
|
) {
|
||||||
|
if (location.pathname === "/devices" && authStore.hasRole("devices_maintenance_rw")) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
return <Navigate to="/" replace />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,43 @@ import {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { orderStationsByRoute } from "../../utils/routeStationsUtils";
|
import { orderStationsByRoute } from "../../utils/routeStationsUtils";
|
||||||
import { resamplePath } from "../../utils/animationUtils";
|
import { resamplePath } from "../../utils/animationUtils";
|
||||||
|
import { colorStore } from "../../stores/ColorStore";
|
||||||
|
|
||||||
|
function hexToRgbString(hex: string): string | null {
|
||||||
|
const clean = hex.trim().replace(/^#/, "");
|
||||||
|
const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
|
||||||
|
if (full.length !== 6) return null;
|
||||||
|
const r = parseInt(full.slice(0, 2), 16);
|
||||||
|
const g = parseInt(full.slice(2, 4), 16);
|
||||||
|
const b = parseInt(full.slice(4, 6), 16);
|
||||||
|
return `${r}, ${g}, ${b}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function darkenHex(hex: string, amount: number): string {
|
||||||
|
const rgb = hexToRgbString(hex);
|
||||||
|
if (!rgb) return hex;
|
||||||
|
const [r, g, b] = rgb.split(",").map(Number);
|
||||||
|
const factor = 1 - amount;
|
||||||
|
const dr = Math.round(r * factor);
|
||||||
|
const dg = Math.round(g * factor);
|
||||||
|
const db = Math.round(b * factor);
|
||||||
|
return `#${dr.toString(16).padStart(2, "0")}${dg.toString(16).padStart(2, "0")}${db.toString(16).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCarrierColors(carrier: { main_color?: string; left_color?: string; right_color?: string }) {
|
||||||
|
const mainColor = carrier.main_color || "#006f3a";
|
||||||
|
const leftColor = carrier.left_color || "#006f3a";
|
||||||
|
const rightColor = carrier.right_color || "#006f3a";
|
||||||
|
const mainDark = darkenHex(mainColor, 0.3);
|
||||||
|
|
||||||
|
document.documentElement.style.setProperty("--carrier-main", mainColor);
|
||||||
|
document.documentElement.style.setProperty("--carrier-main-rgb", hexToRgbString(mainColor) ?? "0, 111, 58");
|
||||||
|
document.documentElement.style.setProperty("--carrier-main-dark", mainDark);
|
||||||
|
document.documentElement.style.setProperty("--carrier-left", leftColor);
|
||||||
|
document.documentElement.style.setProperty("--carrier-left-rgb", hexToRgbString(leftColor) ?? "0, 111, 58");
|
||||||
|
document.documentElement.style.setProperty("--carrier-right", rightColor);
|
||||||
|
document.documentElement.style.setProperty("--carrier-right-rgb", hexToRgbString(rightColor) ?? "0, 111, 58");
|
||||||
|
}
|
||||||
|
|
||||||
class ApiStore {
|
class ApiStore {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
@@ -115,6 +152,10 @@ class ApiStore {
|
|||||||
|
|
||||||
getCarrier = async () => {
|
getCarrier = async () => {
|
||||||
this.carrier = await getCarrier(this.route!.carrier_id!);
|
this.carrier = await getCarrier(this.route!.carrier_id!);
|
||||||
|
applyCarrierColors(this.carrier);
|
||||||
|
if (this.carrier.main_color) {
|
||||||
|
colorStore.setMainColor(this.carrier.main_color);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
getCity = async () => {
|
getCity = async () => {
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ export type GetCarrierResponse = {
|
|||||||
logo: string;
|
logo: string;
|
||||||
short_name: string;
|
short_name: string;
|
||||||
slogan: string;
|
slogan: string;
|
||||||
|
main_color?: string;
|
||||||
|
left_color?: string;
|
||||||
|
right_color?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetCityResponse = {
|
export type GetCityResponse = {
|
||||||
|
|||||||
@@ -417,7 +417,7 @@ const ListOfSights = observer(() => {
|
|||||||
key={currentSelectedSight.id}
|
key={currentSelectedSight.id}
|
||||||
media={sightFrameMedia}
|
media={sightFrameMedia}
|
||||||
sight_id={currentSelectedSight.id}
|
sight_id={currentSelectedSight.id}
|
||||||
sight_name={currentSelectedSight.short_name || currentSelectedSight.name}
|
sight_name={currentSelectedSight.name}
|
||||||
selectedLanguageRight={selectedLanguageRight}
|
selectedLanguageRight={selectedLanguageRight}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -43,9 +43,58 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
const [threeViewResetKey, setThreeViewResetKey] = useState(0);
|
const [threeViewResetKey, setThreeViewResetKey] = useState(0);
|
||||||
const threeViewControlRef = useRef(null);
|
const threeViewControlRef = useRef(null);
|
||||||
const mediaCache = useRef({});
|
const mediaCache = useRef({});
|
||||||
|
const idleTimerRef = useRef(null);
|
||||||
|
|
||||||
const textWrapperRef = useRef(null);
|
const textWrapperRef = useRef(null);
|
||||||
|
|
||||||
|
// Автозакрытие fullscreen 3D при бездействии (45 сек)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isFullscreen3D) {
|
||||||
|
if (idleTimerRef.current) {
|
||||||
|
clearInterval(idleTimerRef.current);
|
||||||
|
idleTimerRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let idleSeconds = 0;
|
||||||
|
|
||||||
|
const checkIdle = () => {
|
||||||
|
idleSeconds += 1;
|
||||||
|
if (idleSeconds >= 45) {
|
||||||
|
setIsFullscreen3D(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
idleTimerRef.current = setInterval(checkIdle, 1000);
|
||||||
|
|
||||||
|
const resetIdle = () => {
|
||||||
|
idleSeconds = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
"mousedown",
|
||||||
|
"mousemove",
|
||||||
|
"keypress",
|
||||||
|
"scroll",
|
||||||
|
"touchstart",
|
||||||
|
"click",
|
||||||
|
];
|
||||||
|
events.forEach((event) => {
|
||||||
|
window.addEventListener(event, resetIdle, { passive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (idleTimerRef.current) {
|
||||||
|
clearInterval(idleTimerRef.current);
|
||||||
|
idleTimerRef.current = null;
|
||||||
|
}
|
||||||
|
events.forEach((event) => {
|
||||||
|
window.removeEventListener(event, resetIdle);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [isFullscreen3D]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
routeSights,
|
routeSights,
|
||||||
routeSightsEn,
|
routeSightsEn,
|
||||||
@@ -166,7 +215,10 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
const introSection = {
|
const introSection = {
|
||||||
id: media?.id || "intro-title",
|
id: media?.id || "intro-title",
|
||||||
heading:
|
heading:
|
||||||
sight?.short_name || sight?.name || sight_name || "Название достопримечательности",
|
sight?.short_name ||
|
||||||
|
sight?.name ||
|
||||||
|
sight_name ||
|
||||||
|
"Название достопримечательности",
|
||||||
body: "",
|
body: "",
|
||||||
};
|
};
|
||||||
const allSections = [introSection, ...rightArticles];
|
const allSections = [introSection, ...rightArticles];
|
||||||
@@ -244,9 +296,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
alt=""
|
alt=""
|
||||||
className={className}
|
className={className}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
console.warn(
|
console.warn(`Failed to load image: ${currentMediaData.path}`);
|
||||||
`Failed to load image: ${currentMediaData.path}`,
|
|
||||||
);
|
|
||||||
e.target.style.display = "none";
|
e.target.style.display = "none";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -261,9 +311,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
playsInline
|
playsInline
|
||||||
className={className}
|
className={className}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
console.warn(
|
console.warn(`Failed to load video: ${currentMediaData.path}`);
|
||||||
`Failed to load video: ${currentMediaData.path}`,
|
|
||||||
);
|
|
||||||
e.target.style.display = "none";
|
e.target.style.display = "none";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -314,9 +362,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="three-d-control-btn"
|
className="three-d-control-btn"
|
||||||
title="Уменьшить"
|
title="Уменьшить"
|
||||||
onPointerUp={() =>
|
onPointerUp={() => threeViewControlRef.current?.zoomOut?.()}
|
||||||
threeViewControlRef.current?.zoomOut?.()
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<MinusIcon />
|
<MinusIcon />
|
||||||
</button>
|
</button>
|
||||||
@@ -324,9 +370,7 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="three-d-control-btn"
|
className="three-d-control-btn"
|
||||||
title="Увеличить"
|
title="Увеличить"
|
||||||
onPointerUp={() =>
|
onPointerUp={() => threeViewControlRef.current?.zoomIn?.()}
|
||||||
threeViewControlRef.current?.zoomIn?.()
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
</button>
|
</button>
|
||||||
@@ -368,87 +412,100 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{isFullscreen3D && <div
|
{isFullscreen3D && (
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 94,
|
|
||||||
right: 10,
|
|
||||||
zIndex: 10,
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className="cluster-sights-list"
|
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(0, 111, 58, 0.4)`,
|
position: "absolute",
|
||||||
backdropFilter: "blur(10px)",
|
top: 94,
|
||||||
borderRadius: "8px",
|
right: 10,
|
||||||
width: 200,
|
zIndex: 10,
|
||||||
boxShadow:
|
pointerEvents: "none",
|
||||||
"0 0 0 1px rgba(255, 255, 255, 0.3) inset, 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
padding: "8px 13px",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[
|
<div
|
||||||
{
|
className="cluster-sights-list"
|
||||||
label: "Вращать",
|
style={{
|
||||||
icon: <img src={rotate3DIcon} alt="" width="14" height="14" />,
|
background: `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(var(--carrier-right-rgb, 0, 111, 58), 0.4)`,
|
||||||
},
|
backdropFilter: "blur(10px)",
|
||||||
{
|
borderRadius: "8px",
|
||||||
label: "Приблизить / Отдалить",
|
width: 200,
|
||||||
icon: <img src={zoom3DIcon} alt="" width="14" height="14" />,
|
boxShadow:
|
||||||
},
|
"0 0 0 1px rgba(255, 255, 255, 0.3) inset, 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset",
|
||||||
{
|
display: "flex",
|
||||||
label: "Переместить",
|
flexDirection: "column",
|
||||||
icon: <img src={pan3DIcon} alt="" width="14" height="14" />,
|
padding: "8px 13px",
|
||||||
},
|
}}
|
||||||
].map((item, index, arr) => (
|
>
|
||||||
<div
|
{[
|
||||||
key={index}
|
{
|
||||||
style={{
|
label: "Вращать",
|
||||||
display: "flex",
|
icon: (
|
||||||
alignItems: "center",
|
<img
|
||||||
height: "30px",
|
src={rotate3DIcon}
|
||||||
userSelect: "none",
|
alt=""
|
||||||
touchAction: "none",
|
width="14"
|
||||||
padding: "0 4px",
|
height="14"
|
||||||
borderBottom:
|
/>
|
||||||
index < arr.length - 1
|
),
|
||||||
? "1px solid rgba(255, 255, 255, 0.1)"
|
},
|
||||||
: "none",
|
{
|
||||||
transition: "background-color 0.2s",
|
label: "Приблизить / Отдалить",
|
||||||
}}
|
icon: (
|
||||||
>
|
<img src={zoom3DIcon} alt="" width="14" height="14" />
|
||||||
<span
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Переместить",
|
||||||
|
icon: (
|
||||||
|
<img src={pan3DIcon} alt="" width="14" height="14" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
].map((item, index, arr) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
style={{
|
style={{
|
||||||
display: "block",
|
display: "flex",
|
||||||
marginRight: "8px",
|
alignItems: "center",
|
||||||
flexShrink: 0,
|
height: "30px",
|
||||||
lineHeight: 0,
|
userSelect: "none",
|
||||||
|
touchAction: "none",
|
||||||
|
padding: "0 4px",
|
||||||
|
borderBottom:
|
||||||
|
index < arr.length - 1
|
||||||
|
? "1px solid rgba(255, 255, 255, 0.1)"
|
||||||
|
: "none",
|
||||||
|
transition: "background-color 0.2s",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.icon}
|
<span
|
||||||
</span>
|
style={{
|
||||||
<span
|
display: "block",
|
||||||
style={{
|
marginRight: "8px",
|
||||||
color: "white",
|
flexShrink: 0,
|
||||||
fontSize: "12px",
|
lineHeight: 0,
|
||||||
lineHeight: "1.5",
|
}}
|
||||||
fontWeight: "400",
|
>
|
||||||
overflow: "hidden",
|
{item.icon}
|
||||||
textOverflow: "ellipsis",
|
</span>
|
||||||
whiteSpace: "nowrap",
|
<span
|
||||||
flex: 1,
|
style={{
|
||||||
}}
|
color: "white",
|
||||||
>
|
fontSize: "12px",
|
||||||
{item.label}
|
lineHeight: "1.5",
|
||||||
</span>
|
fontWeight: "400",
|
||||||
</div>
|
overflow: "hidden",
|
||||||
))}
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
@@ -605,7 +662,9 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
overflowWrap: "break-word",
|
overflowWrap: "break-word",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectedSection === 0 ? processedSightName : (sightData?.short_name || sight_name)}
|
{selectedSection === 0
|
||||||
|
? processedSightName
|
||||||
|
: sightData?.short_name || sight_name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -637,9 +696,18 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
paddingBottom: "4.5px",
|
paddingBottom: "4.5px",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
onPointerUp={() => { setSelectedSection(0); setIsFullscreen3D(false); }}
|
onPointerUp={() => {
|
||||||
|
setSelectedSection(0);
|
||||||
|
setIsFullscreen3D(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<img src={subtractHomeIcon} alt="" width="24" height="21" style={{ display: "block" }} />
|
<img
|
||||||
|
src={subtractHomeIcon}
|
||||||
|
alt=""
|
||||||
|
width="24"
|
||||||
|
height="21"
|
||||||
|
style={{ display: "block" }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{contentError ? (
|
{contentError ? (
|
||||||
@@ -649,7 +717,10 @@ const SightFrame = observer(({ media, sight_id, sight_name }) => {
|
|||||||
articleSections.length > 1 &&
|
articleSections.length > 1 &&
|
||||||
articleSections.slice(1).map((section, index) => (
|
articleSections.slice(1).map((section, index) => (
|
||||||
<div
|
<div
|
||||||
onPointerUp={() => { setSelectedSection(index + 1); setIsFullscreen3D(false); }}
|
onPointerUp={() => {
|
||||||
|
setSelectedSection(index + 1);
|
||||||
|
setIsFullscreen3D(false);
|
||||||
|
}}
|
||||||
key={section.id || section.heading || index}
|
key={section.id || section.heading || index}
|
||||||
className={`sight-frame-menu-point ${
|
className={`sight-frame-menu-point ${
|
||||||
index + 1 === selectedSection ? "active" : ""
|
index + 1 === selectedSection ? "active" : ""
|
||||||
|
|||||||
@@ -95,21 +95,36 @@ const TransferWidget = observer(function TransferWidget({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getTransferLabel = () => {
|
const getTransferLabel = () => {
|
||||||
if (selectedLanguageRight === "ru") {
|
if (!stationName) {
|
||||||
return stationName
|
if (selectedLanguageRight === "en") return "Nearest station not found";
|
||||||
? `Пересадки остановки ${stationName}:`
|
if (selectedLanguageRight === "zh") return "最近的站点未找到";
|
||||||
: "Ближайшая остановка не обнаружена";
|
return "Ближайшая остановка не обнаружена";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedLanguageRight === "en") {
|
if (selectedLanguageRight === "en") {
|
||||||
return stationName
|
return (
|
||||||
? `Available transfers at station ${stationName}`
|
<>
|
||||||
: "Nearest station not found";
|
Transfer at stop<br />
|
||||||
|
«{stationName}»:
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return stationName
|
if (selectedLanguageRight === "zh") {
|
||||||
? `在车站可用的换乘:${stationName}`
|
return (
|
||||||
: "最近的站点未找到";
|
<>
|
||||||
|
换乘站<br />
|
||||||
|
«{stationName}»:
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
Пересадка на остановке<br />
|
||||||
|
«{stationName}»:
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNoTransfersMessage = () => {
|
const getNoTransfersMessage = () => {
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.react-markdown-container blockquote {
|
.react-markdown-container blockquote {
|
||||||
border-left: 4px solid #006F3A;
|
border-left: 4px solid var(--carrier-main, #006F3A);
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
114deg,
|
114deg,
|
||||||
rgba(255, 255, 255, 0.1) 8.71%,
|
rgba(255, 255, 255, 0.1) 8.71%,
|
||||||
rgba(255, 255, 255, 0.05) 69.69%
|
rgba(255, 255, 255, 0.05) 69.69%
|
||||||
), #006F3A;
|
), var(--carrier-main, #006F3A);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||||
color: white;
|
color: white;
|
||||||
|
|||||||
@@ -881,25 +881,36 @@ export const WebGLMap = observer(() => {
|
|||||||
|
|
||||||
// Сегментный индекс каждой упорядоченной станции на routePath (canvas-пространство)
|
// Сегментный индекс каждой упорядоченной станции на routePath (canvas-пространство)
|
||||||
const orderedStationSegs = useMemo(() => {
|
const orderedStationSegs = useMemo(() => {
|
||||||
if (!orderedRouteStations || !stationData || routePath.length < 4) return [] as number[];
|
if (!orderedRouteStations || !stationData || routePath.length < 4)
|
||||||
|
return [] as number[];
|
||||||
return (orderedRouteStations as any[]).map((ordStation) => {
|
return (orderedRouteStations as any[]).map((ordStation) => {
|
||||||
const stIdx = stationData.findIndex((s: any) => String(s.id) === String(ordStation.id));
|
const stIdx = stationData.findIndex(
|
||||||
|
(s: any) => String(s.id) === String(ordStation.id),
|
||||||
|
);
|
||||||
if (stIdx < 0) return -1;
|
if (stIdx < 0) return -1;
|
||||||
const sx = stationPoints[stIdx * 2];
|
const sx = stationPoints[stIdx * 2];
|
||||||
const sy = stationPoints[stIdx * 2 + 1];
|
const sy = stationPoints[stIdx * 2 + 1];
|
||||||
if (sx === undefined || sy === undefined) return -1;
|
if (sx === undefined || sy === undefined) return -1;
|
||||||
let best = -1, bestD = Infinity;
|
let best = -1,
|
||||||
|
bestD = Infinity;
|
||||||
for (let i = 0; i < routePath.length - 2; i += 2) {
|
for (let i = 0; i < routePath.length - 2; i += 2) {
|
||||||
const p1x = routePath[i], p1y = routePath[i + 1];
|
const p1x = routePath[i],
|
||||||
const p2x = routePath[i + 2], p2y = routePath[i + 3];
|
p1y = routePath[i + 1];
|
||||||
const dx = p2x - p1x, dy = p2y - p1y;
|
const p2x = routePath[i + 2],
|
||||||
|
p2y = routePath[i + 3];
|
||||||
|
const dx = p2x - p1x,
|
||||||
|
dy = p2y - p1y;
|
||||||
const len2 = dx * dx + dy * dy;
|
const len2 = dx * dx + dy * dy;
|
||||||
if (!len2) continue;
|
if (!len2) continue;
|
||||||
const t = ((sx - p1x) * dx + (sy - p1y) * dy) / len2;
|
const t = ((sx - p1x) * dx + (sy - p1y) * dy) / len2;
|
||||||
const cl = Math.max(0, Math.min(1, t));
|
const cl = Math.max(0, Math.min(1, t));
|
||||||
const px = p1x + cl * dx, py = p1y + cl * dy;
|
const px = p1x + cl * dx,
|
||||||
|
py = p1y + cl * dy;
|
||||||
const d = Math.hypot(sx - px, sy - py);
|
const d = Math.hypot(sx - px, sy - py);
|
||||||
if (d < bestD) { bestD = d; best = i / 2; }
|
if (d < bestD) {
|
||||||
|
bestD = d;
|
||||||
|
best = i / 2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return best;
|
return best;
|
||||||
});
|
});
|
||||||
@@ -1144,7 +1155,9 @@ export const WebGLMap = observer(() => {
|
|||||||
const curIdx = apiStore.positionIndex;
|
const curIdx = apiStore.positionIndex;
|
||||||
const prevIdx = prevPositionIndexRef.current;
|
const prevIdx = prevPositionIndexRef.current;
|
||||||
const pathLen = apiStore.route?.path?.length ?? 0;
|
const pathLen = apiStore.route?.path?.length ?? 0;
|
||||||
const isWrap = prevIdx >= 0 && pathLen > 0 &&
|
const isWrap =
|
||||||
|
prevIdx >= 0 &&
|
||||||
|
pathLen > 0 &&
|
||||||
Math.abs(curIdx - prevIdx) > pathLen / 4;
|
Math.abs(curIdx - prevIdx) > pathLen / 4;
|
||||||
prevPositionIndexRef.current = curIdx;
|
prevPositionIndexRef.current = curIdx;
|
||||||
|
|
||||||
@@ -1414,32 +1427,72 @@ export const WebGLMap = observer(() => {
|
|||||||
|
|
||||||
gl.uniform1f(u_pointSize, pointInnerSizePx);
|
gl.uniform1f(u_pointSize, pointInnerSizePx);
|
||||||
|
|
||||||
if (tramSegIndex >= 0 && orderedRouteStations && stationData && orderedStationSegs.length > 0) {
|
if (
|
||||||
|
tramSegIndex >= 0 &&
|
||||||
|
orderedRouteStations &&
|
||||||
|
stationData &&
|
||||||
|
orderedStationSegs.length > 0
|
||||||
|
) {
|
||||||
const passedPts1: number[] = [];
|
const passedPts1: number[] = [];
|
||||||
const unpassedPts1: number[] = [];
|
const unpassedPts1: number[] = [];
|
||||||
for (let i = 0; i < orderedRouteStations.length; i++) {
|
for (let i = 0; i < orderedRouteStations.length; i++) {
|
||||||
const orderedStation = (orderedRouteStations as any[])[i];
|
const orderedStation = (orderedRouteStations as any[])[i];
|
||||||
const stationSeg = orderedStationSegs[i] ?? -1;
|
const stationSeg = orderedStationSegs[i] ?? -1;
|
||||||
if (!orderedStation || stationSeg < 0) continue;
|
if (!orderedStation || stationSeg < 0) continue;
|
||||||
const isPassed = simulationDirection === 1 ? stationSeg < tramSegIndex : stationSeg > tramSegIndex;
|
const isPassed =
|
||||||
const stIdx = stationData.findIndex((s: any) => String(s.id) === String(orderedStation.id));
|
simulationDirection === 1
|
||||||
|
? stationSeg < tramSegIndex
|
||||||
|
: stationSeg > tramSegIndex;
|
||||||
|
const stIdx = stationData.findIndex(
|
||||||
|
(s: any) => String(s.id) === String(orderedStation.id),
|
||||||
|
);
|
||||||
if (stIdx < 0) continue;
|
if (stIdx < 0) continue;
|
||||||
const sx = stationPoints[stIdx * 2] as number;
|
const sx = stationPoints[stIdx * 2] as number;
|
||||||
const sy = stationPoints[stIdx * 2 + 1] as number;
|
const sy = stationPoints[stIdx * 2 + 1] as number;
|
||||||
if (isPassed) { passedPts1.push(sx, sy); } else { unpassedPts1.push(sx, sy); }
|
if (isPassed) {
|
||||||
|
passedPts1.push(sx, sy);
|
||||||
|
} else {
|
||||||
|
unpassedPts1.push(sx, sy);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (passedPts1.length > 0) {
|
if (passedPts1.length > 0) {
|
||||||
gl.uniform4f(u_color_pts, ((PATH_COLOR >> 16) & 0xff) / 255, ((PATH_COLOR >> 8) & 0xff) / 255, (PATH_COLOR & 0xff) / 255, 1);
|
gl.uniform4f(
|
||||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(passedPts1), gl.STATIC_DRAW);
|
u_color_pts,
|
||||||
|
((PATH_COLOR >> 16) & 0xff) / 255,
|
||||||
|
((PATH_COLOR >> 8) & 0xff) / 255,
|
||||||
|
(PATH_COLOR & 0xff) / 255,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
gl.bufferData(
|
||||||
|
gl.ARRAY_BUFFER,
|
||||||
|
new Float32Array(passedPts1),
|
||||||
|
gl.STATIC_DRAW,
|
||||||
|
);
|
||||||
gl.drawArrays(gl.POINTS, 0, passedPts1.length / 2);
|
gl.drawArrays(gl.POINTS, 0, passedPts1.length / 2);
|
||||||
}
|
}
|
||||||
if (unpassedPts1.length > 0) {
|
if (unpassedPts1.length > 0) {
|
||||||
gl.uniform4f(u_color_pts, ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255, ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255, (UNPASSED_STATION_COLOR & 0xff) / 255, 1);
|
gl.uniform4f(
|
||||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(unpassedPts1), gl.STATIC_DRAW);
|
u_color_pts,
|
||||||
|
((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255,
|
||||||
|
((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255,
|
||||||
|
(UNPASSED_STATION_COLOR & 0xff) / 255,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
gl.bufferData(
|
||||||
|
gl.ARRAY_BUFFER,
|
||||||
|
new Float32Array(unpassedPts1),
|
||||||
|
gl.STATIC_DRAW,
|
||||||
|
);
|
||||||
gl.drawArrays(gl.POINTS, 0, unpassedPts1.length / 2);
|
gl.drawArrays(gl.POINTS, 0, unpassedPts1.length / 2);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
gl.uniform4f(u_color_pts, ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255, ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255, (UNPASSED_STATION_COLOR & 0xff) / 255, 1);
|
gl.uniform4f(
|
||||||
|
u_color_pts,
|
||||||
|
((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255,
|
||||||
|
((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255,
|
||||||
|
(UNPASSED_STATION_COLOR & 0xff) / 255,
|
||||||
|
1,
|
||||||
|
);
|
||||||
gl.bufferData(gl.ARRAY_BUFFER, stationPoints, gl.STATIC_DRAW);
|
gl.bufferData(gl.ARRAY_BUFFER, stationPoints, gl.STATIC_DRAW);
|
||||||
gl.drawArrays(gl.POINTS, 0, stationPoints.length / 2);
|
gl.drawArrays(gl.POINTS, 0, stationPoints.length / 2);
|
||||||
}
|
}
|
||||||
@@ -1513,12 +1566,17 @@ export const WebGLMap = observer(() => {
|
|||||||
const passedStationIds = new Set<string>();
|
const passedStationIds = new Set<string>();
|
||||||
const unpassedStationIds = new Set<string>();
|
const unpassedStationIds = new Set<string>();
|
||||||
|
|
||||||
if (tramSegIndex >= 0 && orderedRouteStations && orderedStationSegs.length === orderedRouteStations.length) {
|
if (
|
||||||
|
tramSegIndex >= 0 &&
|
||||||
|
orderedRouteStations &&
|
||||||
|
orderedStationSegs.length === orderedRouteStations.length
|
||||||
|
) {
|
||||||
for (let i = 0; i < orderedRouteStations.length; i++) {
|
for (let i = 0; i < orderedRouteStations.length; i++) {
|
||||||
const station = (orderedRouteStations as any[])[i];
|
const station = (orderedRouteStations as any[])[i];
|
||||||
const seg = orderedStationSegs[i] ?? -1;
|
const seg = orderedStationSegs[i] ?? -1;
|
||||||
if (!station || seg < 0) continue;
|
if (!station || seg < 0) continue;
|
||||||
const isPassed = simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex;
|
const isPassed =
|
||||||
|
simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex;
|
||||||
if (isPassed) passedStationIds.add(String(station.id));
|
if (isPassed) passedStationIds.add(String(station.id));
|
||||||
else unpassedStationIds.add(String(station.id));
|
else unpassedStationIds.add(String(station.id));
|
||||||
}
|
}
|
||||||
@@ -1662,11 +1720,26 @@ export const WebGLMap = observer(() => {
|
|||||||
const sin = Math.sin(rotationAngle);
|
const sin = Math.sin(rotationAngle);
|
||||||
|
|
||||||
const startStationData = orderedRouteStations?.[0]
|
const startStationData = orderedRouteStations?.[0]
|
||||||
? stationData.find((station: any) => station.id.toString() === String(orderedRouteStations[0].id))
|
? stationData.find(
|
||||||
: stationData.find((station: any) => station.id.toString() === apiStore.context?.startStopId);
|
(station: any) =>
|
||||||
|
station.id.toString() === String(orderedRouteStations[0].id),
|
||||||
|
)
|
||||||
|
: stationData.find(
|
||||||
|
(station: any) =>
|
||||||
|
station.id.toString() === apiStore.context?.startStopId,
|
||||||
|
);
|
||||||
const endStationData = orderedRouteStations?.length
|
const endStationData = orderedRouteStations?.length
|
||||||
? stationData.find((station: any) => station.id.toString() === String(orderedRouteStations[orderedRouteStations.length - 1].id))
|
? stationData.find(
|
||||||
: stationData.find((station: any) => station.id.toString() === apiStore.context?.endStopId);
|
(station: any) =>
|
||||||
|
station.id.toString() ===
|
||||||
|
String(
|
||||||
|
orderedRouteStations[orderedRouteStations.length - 1].id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: stationData.find(
|
||||||
|
(station: any) =>
|
||||||
|
station.id.toString() === apiStore.context?.endStopId,
|
||||||
|
);
|
||||||
|
|
||||||
const terminalStations: number[] = [];
|
const terminalStations: number[] = [];
|
||||||
|
|
||||||
@@ -1766,7 +1839,13 @@ export const WebGLMap = observer(() => {
|
|||||||
}
|
}
|
||||||
return best;
|
return best;
|
||||||
})();
|
})();
|
||||||
return tramSegIndex !== -1 && seg !== -1 && (simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex);
|
return (
|
||||||
|
tramSegIndex !== -1 &&
|
||||||
|
seg !== -1 &&
|
||||||
|
(simulationDirection === 1
|
||||||
|
? seg < tramSegIndex
|
||||||
|
: seg > tramSegIndex)
|
||||||
|
);
|
||||||
})()
|
})()
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
@@ -1799,7 +1878,13 @@ export const WebGLMap = observer(() => {
|
|||||||
}
|
}
|
||||||
return best;
|
return best;
|
||||||
})();
|
})();
|
||||||
return tramSegIndex !== -1 && seg !== -1 && (simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex);
|
return (
|
||||||
|
tramSegIndex !== -1 &&
|
||||||
|
seg !== -1 &&
|
||||||
|
(simulationDirection === 1
|
||||||
|
? seg < tramSegIndex
|
||||||
|
: seg > tramSegIndex)
|
||||||
|
);
|
||||||
})()
|
})()
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
@@ -1825,11 +1910,24 @@ export const WebGLMap = observer(() => {
|
|||||||
const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255;
|
const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255;
|
||||||
|
|
||||||
if (startStationData && endStationData) {
|
if (startStationData && endStationData) {
|
||||||
const startIsPassed = simulationDirection === 1 ? true : isStartPassed;
|
const startIsPassed =
|
||||||
|
simulationDirection === 1 ? true : isStartPassed;
|
||||||
const endIsPassed = simulationDirection === -1 ? true : isEndPassed;
|
const endIsPassed = simulationDirection === -1 ? true : isEndPassed;
|
||||||
gl.uniform4f(u_color_pts, startIsPassed ? r_passed : r_unpassed, startIsPassed ? g_passed : g_unpassed, startIsPassed ? b_passed : b_unpassed, 1.0);
|
gl.uniform4f(
|
||||||
|
u_color_pts,
|
||||||
|
startIsPassed ? r_passed : r_unpassed,
|
||||||
|
startIsPassed ? g_passed : g_unpassed,
|
||||||
|
startIsPassed ? b_passed : b_unpassed,
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
gl.drawArrays(gl.POINTS, 0, 1);
|
gl.drawArrays(gl.POINTS, 0, 1);
|
||||||
gl.uniform4f(u_color_pts, endIsPassed ? r_passed : r_unpassed, endIsPassed ? g_passed : g_unpassed, endIsPassed ? b_passed : b_unpassed, 1.0);
|
gl.uniform4f(
|
||||||
|
u_color_pts,
|
||||||
|
endIsPassed ? r_passed : r_unpassed,
|
||||||
|
endIsPassed ? g_passed : g_unpassed,
|
||||||
|
endIsPassed ? b_passed : b_unpassed,
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
gl.drawArrays(gl.POINTS, 1, 1);
|
gl.drawArrays(gl.POINTS, 1, 1);
|
||||||
} else {
|
} else {
|
||||||
const isStartStation = startStationData !== undefined;
|
const isStartStation = startStationData !== undefined;
|
||||||
@@ -2529,7 +2627,7 @@ export const WebGLMap = observer(() => {
|
|||||||
)
|
)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
const badgeColor = "#006F3A";
|
const badgeColor = "var(--carrier-main, #006F3A)";
|
||||||
const listPanelWidth = 200;
|
const listPanelWidth = 200;
|
||||||
const listItemHeight = 30;
|
const listItemHeight = 30;
|
||||||
const listMaxHeight = 250;
|
const listMaxHeight = 250;
|
||||||
@@ -2607,7 +2705,6 @@ export const WebGLMap = observer(() => {
|
|||||||
<div
|
<div
|
||||||
data-expanded-cluster={cluster.id}
|
data-expanded-cluster={cluster.id}
|
||||||
onTouchStart={handleCircleInteraction}
|
onTouchStart={handleCircleInteraction}
|
||||||
onTouchMove={handleCircleInteraction}
|
|
||||||
onMouseMove={handleCircleInteraction}
|
onMouseMove={handleCircleInteraction}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@@ -2673,15 +2770,14 @@ export const WebGLMap = observer(() => {
|
|||||||
<div
|
<div
|
||||||
className="cluster-sights-list"
|
className="cluster-sights-list"
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(0, 111, 58, 0.4)`,
|
background: `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(var(--carrier-main-rgb, 0, 111, 58), 0.4)`,
|
||||||
backdropFilter: "blur(10px)",
|
backdropFilter: "blur(10px)",
|
||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
width: listPanelWidth,
|
width: listPanelWidth,
|
||||||
maxHeight: hasMoreThanTwo ? listMaxHeight : undefined,
|
maxHeight: hasMoreThanTwo ? listMaxHeight : undefined,
|
||||||
boxShadow:
|
boxShadow:
|
||||||
"0 0 0 1px rgba(255, 255, 255, 0.3) inset, 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset",
|
"inset 0 0 0 1px rgba(255, 255, 255, 0.3), inset 4px 4px 12px 0 rgba(255, 255, 255, 0.12)",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
|||||||
@@ -120,8 +120,8 @@ const LeftWidget = observer(
|
|||||||
selectedLanguage === "ru"
|
selectedLanguage === "ru"
|
||||||
? routeSights.find((sight) => sight.id === selectedSightId)
|
? routeSights.find((sight) => sight.id === selectedSightId)
|
||||||
: selectedLanguage === "en"
|
: selectedLanguage === "en"
|
||||||
? routeSightsEn.find((sight) => sight.id === selectedSightId)
|
? routeSightsEn.find((sight) => sight.id === selectedSightId)
|
||||||
: routeSightsZh.find((sight) => sight.id === selectedSightId);
|
: routeSightsZh.find((sight) => sight.id === selectedSightId);
|
||||||
|
|
||||||
const leftArticle = sight.left_article;
|
const leftArticle = sight.left_article;
|
||||||
|
|
||||||
@@ -129,18 +129,18 @@ const LeftWidget = observer(
|
|||||||
selectedLanguage === "ru"
|
selectedLanguage === "ru"
|
||||||
? sightArticles.get(leftArticle + "_" + selectedLanguage)
|
? sightArticles.get(leftArticle + "_" + selectedLanguage)
|
||||||
: selectedLanguage === "en"
|
: selectedLanguage === "en"
|
||||||
? sightArticlesEn.get(leftArticle + "_" + selectedLanguage)
|
? sightArticlesEn.get(leftArticle + "_" + selectedLanguage)
|
||||||
: sightArticlesZh.get(leftArticle + "_" + selectedLanguage);
|
: sightArticlesZh.get(leftArticle + "_" + selectedLanguage);
|
||||||
|
|
||||||
const media = await ContentAPI.getMediaPreview(
|
const media = await ContentAPI.getMediaPreview(
|
||||||
leftArticleData.media[0].id,
|
leftArticleData.media[0].id,
|
||||||
selectedLanguage
|
selectedLanguage,
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
mediaPath: media.path,
|
mediaPath: media.path,
|
||||||
mediaType: media.type,
|
mediaType: media.type,
|
||||||
title: sight.short_name || sight.name || leftArticleData.heading,
|
title: leftArticleData.heading,
|
||||||
text: leftArticleData.body,
|
text: leftArticleData.body,
|
||||||
address: sight.address,
|
address: sight.address,
|
||||||
};
|
};
|
||||||
@@ -178,7 +178,7 @@ const LeftWidget = observer(
|
|||||||
setIsImageLoaded(false);
|
setIsImageLoaded(false);
|
||||||
console.error(
|
console.error(
|
||||||
"Ошибка загрузки изображения для достопримечательности:",
|
"Ошибка загрузки изображения для достопримечательности:",
|
||||||
selectedSightId
|
selectedSightId,
|
||||||
);
|
);
|
||||||
if (isVisible) {
|
if (isVisible) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -244,13 +244,13 @@ const LeftWidget = observer(
|
|||||||
{selectedLanguage === "ru"
|
{selectedLanguage === "ru"
|
||||||
? "Выберите достопримечательность для просмотра деталей."
|
? "Выберите достопримечательность для просмотра деталей."
|
||||||
: selectedLanguage === "zh"
|
: selectedLanguage === "zh"
|
||||||
? "选择一个地标来查看详细信息。"
|
? "选择一个地标来查看详细信息。"
|
||||||
: "Select a landmark to view details."}
|
: "Select a landmark to view details."}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export default LeftWidget;
|
export default LeftWidget;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import StationsList from "./StationsList";
|
|||||||
import LeftWidget from "./LeftWidget";
|
import LeftWidget from "./LeftWidget";
|
||||||
import { apiStore } from "../../api/ApiStore/store";
|
import { apiStore } from "../../api/ApiStore/store";
|
||||||
import { getMediaUrl } from "../../api/apiConfig";
|
import { getMediaUrl } from "../../api/apiConfig";
|
||||||
|
import defaultCrest from "../../assets/images/Герб.png";
|
||||||
|
|
||||||
const SideMenu = observer(({ onMenuToggle }) => {
|
const SideMenu = observer(({ onMenuToggle }) => {
|
||||||
const {
|
const {
|
||||||
@@ -357,7 +358,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
background:
|
background:
|
||||||
isSightsOpen || isStationOpen
|
isSightsOpen || isStationOpen
|
||||||
? `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(76, 175, 75, 0.4)`
|
? `linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%), rgba(var(--carrier-left-rgb, 76, 175, 75), 0.4)`
|
||||||
: undefined,
|
: undefined,
|
||||||
backdropFilter:
|
backdropFilter:
|
||||||
isSightsOpen || isStationOpen ? "blur(10px)" : undefined,
|
isSightsOpen || isStationOpen ? "blur(10px)" : undefined,
|
||||||
@@ -369,13 +370,11 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
"background 0.3s ease, backdrop-filter 0.3s ease, box-shadow 0.3s ease",
|
"background 0.3s ease, backdrop-filter 0.3s ease, box-shadow 0.3s ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{designData?.creastPath && (
|
<img
|
||||||
<img
|
className="side-menu-crest"
|
||||||
className="side-menu-crest"
|
src={designData?.creastPath || defaultCrest}
|
||||||
src={designData?.creastPath}
|
alt="Герб"
|
||||||
alt="Герб"
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{carrier?.slogan && (
|
{carrier?.slogan && (
|
||||||
<div className="side-menu-label">{carrier.slogan}</div>
|
<div className="side-menu-label">{carrier.slogan}</div>
|
||||||
)}
|
)}
|
||||||
@@ -493,7 +492,7 @@ const SideMenu = observer(({ onMenuToggle }) => {
|
|||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`side-menu-button side-menu-button--sights ${
|
className={`side-menu-button ${
|
||||||
isSightsOpen ? "side-menu-button--active" : ""
|
isSightsOpen ? "side-menu-button--active" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -11,6 +11,42 @@ import { apiStore } from "../../api/ApiStore/store";
|
|||||||
import { useClickDetection } from "../../hooks/useClickDetection";
|
import { useClickDetection } from "../../hooks/useClickDetection";
|
||||||
import { TouchableLayout } from "../TouchableLayout";
|
import { TouchableLayout } from "../TouchableLayout";
|
||||||
|
|
||||||
|
const SightTransferItem = ({ name, style, onPointerUp }) => {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const textRef = useRef(null);
|
||||||
|
const [shouldAnimate, setShouldAnimate] = useState(false);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const checkWidth = () => {
|
||||||
|
if (containerRef.current && textRef.current) {
|
||||||
|
const containerWidth = containerRef.current.offsetWidth;
|
||||||
|
const textWidth = textRef.current.scrollWidth;
|
||||||
|
const shouldAnimateValue = textWidth > containerWidth;
|
||||||
|
setShouldAnimate(shouldAnimateValue);
|
||||||
|
if (shouldAnimateValue) {
|
||||||
|
containerRef.current.style.setProperty("--container-width", `${containerWidth}px`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkWidth();
|
||||||
|
window.addEventListener("resize", checkWidth);
|
||||||
|
return () => window.removeEventListener("resize", checkWidth);
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="side-menu-sight-transfer pointer"
|
||||||
|
style={style}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
>
|
||||||
|
<span ref={textRef} className={shouldAnimate ? "marquee-text" : ""}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const StationItem = ({
|
const StationItem = ({
|
||||||
station,
|
station,
|
||||||
handlePointerDown,
|
handlePointerDown,
|
||||||
@@ -101,9 +137,9 @@ const StationItem = ({
|
|||||||
>
|
>
|
||||||
{sights.length > 0 ? (
|
{sights.length > 0 ? (
|
||||||
sights.map((sight, index) => (
|
sights.map((sight, index) => (
|
||||||
<div
|
<SightTransferItem
|
||||||
key={sight.id}
|
key={sight.id}
|
||||||
className="side-menu-sight-transfer pointer"
|
name={getSightName(sight)}
|
||||||
style={{
|
style={{
|
||||||
borderBottom:
|
borderBottom:
|
||||||
index < sights.length - 1
|
index < sights.length - 1
|
||||||
@@ -115,19 +151,11 @@ const StationItem = ({
|
|||||||
onPointerUp={(e) => {
|
onPointerUp={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (onSightClick) {
|
if (onSightClick) {
|
||||||
// Вычисляем позицию элемента для правильного позиционирования левого виджета
|
const elementRect = e.currentTarget.getBoundingClientRect();
|
||||||
const element = e.currentTarget;
|
onSightClick(sight.id, elementRect.top);
|
||||||
const elementRect = element.getBoundingClientRect();
|
|
||||||
|
|
||||||
// Используем позицию элемента относительно viewport (elementRect.top)
|
|
||||||
// чтобы верхняя граница виджета совпадала с верхней границей элемента
|
|
||||||
const elementTop = elementRect.top;
|
|
||||||
onSightClick(sight.id, elementTop);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{getSightName(sight)}
|
|
||||||
</div>
|
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="side-menu-sight-transfer-empty">
|
<div className="side-menu-sight-transfer-empty">
|
||||||
|
|||||||
@@ -1,21 +1,45 @@
|
|||||||
import { makeAutoObservable, runInAction } from "mobx";
|
import { makeAutoObservable, runInAction } from "mobx";
|
||||||
|
|
||||||
const COLOR_WHITE = { h: 151, s: 0, l: 100 };
|
|
||||||
const COLOR_GREEN = { h: 151, s: 100, l: 22 };
|
|
||||||
|
|
||||||
const TRANSITION_DURATION = 60000;
|
const TRANSITION_DURATION = 60000;
|
||||||
const TICK_INTERVAL = 100;
|
const TICK_INTERVAL = 100;
|
||||||
const TICK_STEP = TICK_INTERVAL / TRANSITION_DURATION;
|
const TICK_STEP = TICK_INTERVAL / TRANSITION_DURATION;
|
||||||
|
|
||||||
|
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||||
|
const clean = hex.trim().replace(/^#/, "");
|
||||||
|
const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
|
||||||
|
if (full.length !== 6) return null;
|
||||||
|
return {
|
||||||
|
r: parseInt(full.slice(0, 2), 16),
|
||||||
|
g: parseInt(full.slice(2, 4), 16),
|
||||||
|
b: parseInt(full.slice(4, 6), 16),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolateRgb(
|
||||||
|
from: { r: number; g: number; b: number },
|
||||||
|
to: { r: number; g: number; b: number },
|
||||||
|
t: number
|
||||||
|
): string {
|
||||||
|
const r = Math.round(from.r + (to.r - from.r) * t);
|
||||||
|
const g = Math.round(from.g + (to.g - from.g) * t);
|
||||||
|
const b = Math.round(from.b + (to.b - from.b) * t);
|
||||||
|
return `rgb(${r}, ${g}, ${b})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WHITE = { r: 255, g: 255, b: 255 };
|
||||||
|
const DEFAULT_MAIN = { r: 0, g: 111, b: 58 };
|
||||||
|
|
||||||
interface ColorStore {
|
interface ColorStore {
|
||||||
currentColor: string;
|
currentColor: string;
|
||||||
setCurrentColor: (color: string) => void;
|
setCurrentColor: (color: string) => void;
|
||||||
|
setMainColor: (hex: string) => void;
|
||||||
startColorAnimation: () => void;
|
startColorAnimation: () => void;
|
||||||
stopColorAnimation: () => void;
|
stopColorAnimation: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ColorStore implements ColorStore {
|
class ColorStore implements ColorStore {
|
||||||
currentColor: string = "#fff";
|
currentColor: string = "#fff";
|
||||||
|
private mainColor: { r: number; g: number; b: number } = DEFAULT_MAIN;
|
||||||
private progress: number = 0;
|
private progress: number = 0;
|
||||||
private direction: number = 1;
|
private direction: number = 1;
|
||||||
private tickInterval: ReturnType<typeof setInterval> | null = null;
|
private tickInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -28,12 +52,12 @@ class ColorStore implements ColorStore {
|
|||||||
this.currentColor = color;
|
this.currentColor = color;
|
||||||
};
|
};
|
||||||
|
|
||||||
private interpolateColor(progress: number): string {
|
setMainColor = (hex: string) => {
|
||||||
const h = Math.round(COLOR_WHITE.h + (COLOR_GREEN.h - COLOR_WHITE.h) * progress);
|
const parsed = hexToRgb(hex);
|
||||||
const s = Math.round(COLOR_WHITE.s + (COLOR_GREEN.s - COLOR_WHITE.s) * progress);
|
if (parsed) {
|
||||||
const l = Math.round(COLOR_WHITE.l + (COLOR_GREEN.l - COLOR_WHITE.l) * progress);
|
this.mainColor = parsed;
|
||||||
return `hsl(${h}, ${s}%, ${l}%)`;
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
startColorAnimation = () => {
|
startColorAnimation = () => {
|
||||||
if (this.tickInterval) return;
|
if (this.tickInterval) return;
|
||||||
@@ -50,7 +74,7 @@ class ColorStore implements ColorStore {
|
|||||||
this.direction = 1;
|
this.direction = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentColor = this.interpolateColor(this.progress);
|
this.currentColor = interpolateRgb(WHITE, this.mainColor, this.progress);
|
||||||
});
|
});
|
||||||
}, TICK_INTERVAL);
|
}, TICK_INTERVAL);
|
||||||
};
|
};
|
||||||
@@ -68,4 +92,4 @@ class ColorStore implements ColorStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const colorStore = new ColorStore();
|
export const colorStore = new ColorStore();
|
||||||
export { ColorStore };
|
export { ColorStore };
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
rgba(255, 255, 255, 0) 8.71%,
|
rgba(255, 255, 255, 0) 8.71%,
|
||||||
rgba(255, 255, 255, 0.16) 69.69%
|
rgba(255, 255, 255, 0.16) 69.69%
|
||||||
),
|
),
|
||||||
#006F3A;
|
var(--carrier-main, #006F3A);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
rgba(255, 255, 255, 0) 8.71%,
|
rgba(255, 255, 255, 0) 8.71%,
|
||||||
rgba(255, 255, 255, 0.16) 69.69%
|
rgba(255, 255, 255, 0.16) 69.69%
|
||||||
),
|
),
|
||||||
#006F3A;
|
var(--carrier-left, #006F3A);
|
||||||
will-change: transform, opacity;
|
will-change: transform, opacity;
|
||||||
backface-visibility: hidden;
|
backface-visibility: hidden;
|
||||||
}
|
}
|
||||||
@@ -93,6 +93,16 @@
|
|||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
padding-bottom: 6px;
|
padding-bottom: 6px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-menu-sight-transfer span {
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-menu-sight-transfer span.marquee-text {
|
||||||
|
animation: side-menu-marquee 14s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Анимация для списка пересадок */
|
/* Анимация для списка пересадок */
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
rgba(255, 255, 255, 0) 8.71%,
|
rgba(255, 255, 255, 0) 8.71%,
|
||||||
rgba(255, 255, 255, 0.16) 69.69%
|
rgba(255, 255, 255, 0.16) 69.69%
|
||||||
),
|
),
|
||||||
#006f3a;
|
var(--carrier-right, #006f3a);
|
||||||
color: white;
|
color: white;
|
||||||
|
|
||||||
max-height: 68px;
|
max-height: 68px;
|
||||||
@@ -63,7 +63,11 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
width: 128px;
|
width: 128px;
|
||||||
|
|
||||||
background-color: #0e8953;
|
background-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--carrier-right, #006f3a) 80%,
|
||||||
|
black
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-of-sights-title {
|
.list-of-sights-title {
|
||||||
@@ -194,7 +198,7 @@
|
|||||||
rgba(255, 255, 255, 0) 8.71%,
|
rgba(255, 255, 255, 0) 8.71%,
|
||||||
rgba(255, 255, 255, 0.16) 69.69%
|
rgba(255, 255, 255, 0.16) 69.69%
|
||||||
),
|
),
|
||||||
#006f3a;
|
var(--carrier-right, #006f3a);
|
||||||
max-height: calc(100vh - 128px);
|
max-height: calc(100vh - 128px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +241,7 @@
|
|||||||
rgba(255, 255, 255, 0.22) 0%,
|
rgba(255, 255, 255, 0.22) 0%,
|
||||||
rgba(255, 255, 255, 0.04) 100%
|
rgba(255, 255, 255, 0.04) 100%
|
||||||
),
|
),
|
||||||
rgba(0, 111, 58, 0.72);
|
rgba(var(--carrier-right-rgb, 0, 111, 58), 0.72);
|
||||||
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -304,7 +308,7 @@
|
|||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to right,
|
to right,
|
||||||
transparent 35%,
|
transparent 35%,
|
||||||
#0e8953 50%,
|
color-mix(in srgb, var(--carrier-right, #006f3a) 80%, black) 50%,
|
||||||
transparent 65%
|
transparent 65%
|
||||||
);
|
);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@@ -341,7 +345,7 @@
|
|||||||
rgba(255, 255, 255, 0.2) 0%,
|
rgba(255, 255, 255, 0.2) 0%,
|
||||||
rgba(255, 255, 255, 0) 100%
|
rgba(255, 255, 255, 0) 100%
|
||||||
),
|
),
|
||||||
rgba(0, 111, 58, 0.4);
|
rgba(var(--carrier-right-rgb, 0, 111, 58), 0.4);
|
||||||
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -607,14 +611,14 @@
|
|||||||
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid #006f3a;
|
border: 1px solid var(--carrier-main, #006f3a);
|
||||||
background:
|
background:
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
rgba(255, 255, 255, 0.2) 0%,
|
rgba(255, 255, 255, 0.2) 0%,
|
||||||
rgba(255, 255, 255, 0) 100%
|
rgba(255, 255, 255, 0) 100%
|
||||||
),
|
),
|
||||||
rgba(0, 111, 58, 0.4);
|
rgba(var(--carrier-main-rgb, 0, 111, 58), 0.4);
|
||||||
|
|
||||||
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
@@ -747,7 +751,7 @@
|
|||||||
border-radius: 32px;
|
border-radius: 32px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
background: #006f3a;
|
background: var(--carrier-right, #006f3a);
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
rgba(255, 255, 255, 0.2) 0%,
|
rgba(255, 255, 255, 0.2) 0%,
|
||||||
rgba(255, 255, 255, 0) 100%
|
rgba(255, 255, 255, 0) 100%
|
||||||
),
|
),
|
||||||
rgba(0, 111, 58, 0.4);
|
rgba(var(--carrier-main-rgb, 0, 111, 58), 0.4);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
z-index: 10000001;
|
z-index: 10000001;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
rgba(255, 255, 255, 0.2) 0%,
|
rgba(255, 255, 255, 0.2) 0%,
|
||||||
rgba(255, 255, 255, 0) 100%
|
rgba(255, 255, 255, 0) 100%
|
||||||
),
|
),
|
||||||
#006f3a;
|
var(--carrier-left, #006f3a);
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-menu-label {
|
.side-menu-label {
|
||||||
@@ -51,10 +51,6 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-menu-button--sights {
|
|
||||||
background-color: #fcd500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-menu-button--active {
|
.side-menu-button--active {
|
||||||
background-color: #fcd500;
|
background-color: #fcd500;
|
||||||
color: #000;
|
color: #000;
|
||||||
@@ -138,10 +134,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
3.33% {
|
3.33% {
|
||||||
fill: rgb(76, 175, 75);
|
fill: var(--carrier-left, rgb(76, 175, 75));
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
fill: rgb(76, 175, 75);
|
fill: var(--carrier-left, rgb(76, 175, 75));
|
||||||
}
|
}
|
||||||
53.33% {
|
53.33% {
|
||||||
fill: #ffffff;
|
fill: #ffffff;
|
||||||
@@ -191,7 +187,7 @@
|
|||||||
top: -2px;
|
top: -2px;
|
||||||
width: 100px;
|
width: 100px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
background-color: #0e8953;
|
background-color: color-mix(in srgb, var(--carrier-left, #006f3a) 80%, black);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +203,7 @@
|
|||||||
rgba(255, 255, 255, 0) 8.71%,
|
rgba(255, 255, 255, 0) 8.71%,
|
||||||
rgba(255, 255, 255, 0.16) 69.69%
|
rgba(255, 255, 255, 0.16) 69.69%
|
||||||
),
|
),
|
||||||
#006f3a;
|
var(--carrier-left, #006f3a);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 288px;
|
width: 288px;
|
||||||
transform: translateY(100%);
|
transform: translateY(100%);
|
||||||
@@ -247,7 +243,8 @@
|
|||||||
margin-right: 20px;
|
margin-right: 20px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
border-bottom: 1px solid #0e8953;
|
border-bottom: 1px solid
|
||||||
|
color-mix(in srgb, var(--carrier-left, #006f3a) 80%, black);
|
||||||
font-family: "Roboto";
|
font-family: "Roboto";
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
rgba(255, 255, 255, 0.2) 0%,
|
rgba(255, 255, 255, 0.2) 0%,
|
||||||
rgba(255, 255, 255, 0) 100%
|
rgba(255, 255, 255, 0) 100%
|
||||||
),
|
),
|
||||||
rgba(0, 111, 58, 0.4);
|
rgba(179, 165, 152, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.weather-widget-time {
|
.weather-widget-time {
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ const isItemVisible = (item: (typeof NAVIGATION_ITEMS.primary)[number]): boolean
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Пользователь с ролью ТО всегда видит раздел устройств
|
||||||
|
if (item.path === "/devices" && authStore.hasRole("devices_maintenance_rw")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const routePermissions = item.path ? ROUTE_REQUIRED_RESOURCES[item.path] ?? [] : [];
|
const routePermissions = item.path ? ROUTE_REQUIRED_RESOURCES[item.path] ?? [] : [];
|
||||||
const canAccessRoute = routePermissions.every((permission) =>
|
const canAccessRoute = routePermissions.every((permission) =>
|
||||||
authStore.canAccess(permission),
|
authStore.canAccess(permission),
|
||||||
|
|||||||
@@ -27,6 +27,56 @@ import {
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
|
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
|
||||||
|
|
||||||
|
type ColorFields = { main_color: string; left_color: string; right_color: string };
|
||||||
|
|
||||||
|
const colorFields = (data: ColorFields) => ({
|
||||||
|
main_color: data.main_color,
|
||||||
|
left_color: data.left_color,
|
||||||
|
right_color: data.right_color,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ColorPickerField = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (val: string) => void;
|
||||||
|
}) => (
|
||||||
|
<div className="flex items-center gap-3 w-full">
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded border border-gray-300 flex-shrink-0 cursor-pointer overflow-hidden relative"
|
||||||
|
style={{ backgroundColor: value || "#ffffff" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={value || "#ffffff"}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={label}
|
||||||
|
value={value}
|
||||||
|
placeholder="#000000"
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: value ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange("")}
|
||||||
|
className="text-gray-400 hover:text-gray-600 text-xs px-1"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
) : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
export const CarrierCreatePage = observer(() => {
|
export const CarrierCreatePage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { createCarrierData, setCreateCarrierData } = carrierStore;
|
const { createCarrierData, setCreateCarrierData } = carrierStore;
|
||||||
@@ -220,6 +270,69 @@ export const CarrierCreatePage = observer(() => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="w-full flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<ColorPickerField
|
||||||
|
label="Основной цвет"
|
||||||
|
value={createCarrierData.main_color}
|
||||||
|
onChange={(val) =>
|
||||||
|
setCreateCarrierData(
|
||||||
|
createCarrierData[language].full_name,
|
||||||
|
createCarrierData[language].short_name,
|
||||||
|
createCarrierData.city_id,
|
||||||
|
createCarrierData[language].slogan,
|
||||||
|
selectedMediaId || "",
|
||||||
|
language,
|
||||||
|
{ ...colorFields(createCarrierData), main_color: val }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 pl-1">
|
||||||
|
Используется в: виджет маршрута, виджет обращений, значки на карте, скопление достопримечательностей на карте, информационный виджет
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<ColorPickerField
|
||||||
|
label="Левый цвет"
|
||||||
|
value={createCarrierData.left_color}
|
||||||
|
onChange={(val) =>
|
||||||
|
setCreateCarrierData(
|
||||||
|
createCarrierData[language].full_name,
|
||||||
|
createCarrierData[language].short_name,
|
||||||
|
createCarrierData.city_id,
|
||||||
|
createCarrierData[language].slogan,
|
||||||
|
selectedMediaId || "",
|
||||||
|
language,
|
||||||
|
{ ...colorFields(createCarrierData), left_color: val }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 pl-1">
|
||||||
|
Используется в: боковое меню, левый виджет достопримечательности
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<ColorPickerField
|
||||||
|
label="Правый цвет"
|
||||||
|
value={createCarrierData.right_color}
|
||||||
|
onChange={(val) =>
|
||||||
|
setCreateCarrierData(
|
||||||
|
createCarrierData[language].full_name,
|
||||||
|
createCarrierData[language].short_name,
|
||||||
|
createCarrierData.city_id,
|
||||||
|
createCarrierData[language].slogan,
|
||||||
|
selectedMediaId || "",
|
||||||
|
language,
|
||||||
|
{ ...colorFields(createCarrierData), right_color: val }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 pl-1">
|
||||||
|
Используется в: список достопримечательностей, страница достопримечательности
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||||
<ImageUploadCard
|
<ImageUploadCard
|
||||||
title="Логотип перевозчика"
|
title="Логотип перевозчика"
|
||||||
|
|||||||
@@ -30,6 +30,56 @@ import {
|
|||||||
UploadMediaDialog,
|
UploadMediaDialog,
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
|
|
||||||
|
type ColorFields = { main_color: string; left_color: string; right_color: string };
|
||||||
|
|
||||||
|
const colorFields = (data: ColorFields) => ({
|
||||||
|
main_color: data.main_color,
|
||||||
|
left_color: data.left_color,
|
||||||
|
right_color: data.right_color,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ColorPickerField = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (val: string) => void;
|
||||||
|
}) => (
|
||||||
|
<div className="flex items-center gap-3 w-full">
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded border border-gray-300 flex-shrink-0 cursor-pointer overflow-hidden relative"
|
||||||
|
style={{ backgroundColor: value || "#ffffff" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={value || "#ffffff"}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={label}
|
||||||
|
value={value}
|
||||||
|
placeholder="#000000"
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: value ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange("")}
|
||||||
|
className="text-gray-400 hover:text-gray-600 text-xs px-1"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
) : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
export const CarrierEditPage = observer(() => {
|
export const CarrierEditPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -68,13 +118,19 @@ export const CarrierEditPage = observer(() => {
|
|||||||
const carrierData = await getCarrier(Number(id));
|
const carrierData = await getCarrier(Number(id));
|
||||||
|
|
||||||
if (carrierData) {
|
if (carrierData) {
|
||||||
|
const colors = {
|
||||||
|
main_color: carrierData.ru?.main_color || "",
|
||||||
|
left_color: carrierData.ru?.left_color || "",
|
||||||
|
right_color: carrierData.ru?.right_color || "",
|
||||||
|
};
|
||||||
setEditCarrierData(
|
setEditCarrierData(
|
||||||
carrierData.ru?.full_name || "",
|
carrierData.ru?.full_name || "",
|
||||||
carrierData.ru?.short_name || "",
|
carrierData.ru?.short_name || "",
|
||||||
carrierData.ru?.city_id || 0,
|
carrierData.ru?.city_id || 0,
|
||||||
carrierData.ru?.slogan || "",
|
carrierData.ru?.slogan || "",
|
||||||
carrierData.ru?.logo || "",
|
carrierData.ru?.logo || "",
|
||||||
"ru"
|
"ru",
|
||||||
|
colors
|
||||||
);
|
);
|
||||||
setEditCarrierData(
|
setEditCarrierData(
|
||||||
carrierData.en?.full_name || "",
|
carrierData.en?.full_name || "",
|
||||||
@@ -273,6 +329,69 @@ export const CarrierEditPage = observer(() => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="w-full flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<ColorPickerField
|
||||||
|
label="Основной цвет"
|
||||||
|
value={editCarrierData.main_color}
|
||||||
|
onChange={(val) =>
|
||||||
|
setEditCarrierData(
|
||||||
|
editCarrierData[language].full_name,
|
||||||
|
editCarrierData[language].short_name,
|
||||||
|
editCarrierData.city_id,
|
||||||
|
editCarrierData[language].slogan,
|
||||||
|
editCarrierData.logo,
|
||||||
|
language,
|
||||||
|
{ ...colorFields(editCarrierData), main_color: val }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 pl-1">
|
||||||
|
Используется в: виджет маршрута, виджет обращений, значки на карте, скопление достопримечательностей на карте, информационный виджет
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<ColorPickerField
|
||||||
|
label="Левый цвет"
|
||||||
|
value={editCarrierData.left_color}
|
||||||
|
onChange={(val) =>
|
||||||
|
setEditCarrierData(
|
||||||
|
editCarrierData[language].full_name,
|
||||||
|
editCarrierData[language].short_name,
|
||||||
|
editCarrierData.city_id,
|
||||||
|
editCarrierData[language].slogan,
|
||||||
|
editCarrierData.logo,
|
||||||
|
language,
|
||||||
|
{ ...colorFields(editCarrierData), left_color: val }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 pl-1">
|
||||||
|
Используется в: боковое меню, левый виджет достопримечательности
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<ColorPickerField
|
||||||
|
label="Правый цвет"
|
||||||
|
value={editCarrierData.right_color}
|
||||||
|
onChange={(val) =>
|
||||||
|
setEditCarrierData(
|
||||||
|
editCarrierData[language].full_name,
|
||||||
|
editCarrierData[language].short_name,
|
||||||
|
editCarrierData.city_id,
|
||||||
|
editCarrierData[language].slogan,
|
||||||
|
editCarrierData.logo,
|
||||||
|
language,
|
||||||
|
{ ...colorFields(editCarrierData), right_color: val }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 pl-1">
|
||||||
|
Используется в: список достопримечательностей, страница достопримечательности
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
||||||
<ImageUploadCard
|
<ImageUploadCard
|
||||||
title="Логотип перевозчика"
|
title="Логотип перевозчика"
|
||||||
|
|||||||
@@ -21,12 +21,19 @@ import {
|
|||||||
SelectMediaDialog,
|
SelectMediaDialog,
|
||||||
UploadMediaDialog,
|
UploadMediaDialog,
|
||||||
PreviewMediaDialog,
|
PreviewMediaDialog,
|
||||||
|
selectedCityStore,
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
||||||
|
|
||||||
export const CityCreatePage = observer(() => {
|
export const CityCreatePage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedCityStore.setIsLocked(true);
|
||||||
|
return () => selectedCityStore.setIsLocked(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
const { createCityData, setCreateCityData, setCreateCityWeatherCode } =
|
const { createCityData, setCreateCityData, setCreateCityWeatherCode } =
|
||||||
cityStore;
|
cityStore;
|
||||||
|
|||||||
@@ -23,12 +23,19 @@ import {
|
|||||||
SelectMediaDialog,
|
SelectMediaDialog,
|
||||||
UploadMediaDialog,
|
UploadMediaDialog,
|
||||||
PreviewMediaDialog,
|
PreviewMediaDialog,
|
||||||
|
selectedCityStore,
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
|
||||||
|
|
||||||
export const CityEditPage = observer(() => {
|
export const CityEditPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedCityStore.setIsLocked(true);
|
||||||
|
return () => selectedCityStore.setIsLocked(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { ruRU } from "@mui/x-data-grid/locales";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import { authStore, languageStore, cityStore, countryStore, SearchInput } from "@shared";
|
import { authStore, languageStore, cityStore, countryStore, selectedCityStore, SearchInput } from "@shared";
|
||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Pencil, Trash2, Minus } from "lucide-react";
|
import { Pencil, Trash2, Minus } from "lucide-react";
|
||||||
@@ -59,16 +59,21 @@ export const CityListPage = observer(() => {
|
|||||||
}, [cities, countryStore.countries, language, isLoading]);
|
}, [cities, countryStore.countries, language, isLoading]);
|
||||||
|
|
||||||
const filteredRows = useMemo(() => {
|
const filteredRows = useMemo(() => {
|
||||||
|
const { selectedCityId } = selectedCityStore;
|
||||||
|
if (!selectedCityId) return [];
|
||||||
|
|
||||||
const query = searchQuery.trim().toLowerCase();
|
const query = searchQuery.trim().toLowerCase();
|
||||||
if (!query) return rows;
|
const result = rows.filter((row) => row.id === selectedCityId);
|
||||||
return rows.filter((row) => {
|
|
||||||
|
if (!query) return result;
|
||||||
|
return result.filter((row) => {
|
||||||
const cityName = (row.name ?? "").toLowerCase();
|
const cityName = (row.name ?? "").toLowerCase();
|
||||||
const countryName = (
|
const countryName = (
|
||||||
countryStore.countries[language]?.data?.find((c) => c.code === row.country)?.name ?? ""
|
countryStore.countries[language]?.data?.find((c) => c.code === row.country)?.name ?? ""
|
||||||
).toLowerCase();
|
).toLowerCase();
|
||||||
return cityName.includes(query) || countryName.includes(query);
|
return cityName.includes(query) || countryName.includes(query);
|
||||||
});
|
});
|
||||||
}, [rows, searchQuery, countryStore.countries, language]);
|
}, [rows, searchQuery, countryStore.countries, language, selectedCityStore.selectedCityId]);
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
{
|
{
|
||||||
@@ -139,7 +144,11 @@ export const CityListPage = observer(() => {
|
|||||||
<div className="flex justify-between items-center mb-10">
|
<div className="flex justify-between items-center mb-10">
|
||||||
<h1 className="text-2xl">Города</h1>
|
<h1 className="text-2xl">Города</h1>
|
||||||
{canWriteCities && (
|
{canWriteCities && (
|
||||||
<CreateButton label="Создать город" path="/city/create" />
|
<CreateButton
|
||||||
|
label="Создать город"
|
||||||
|
path="/city/create"
|
||||||
|
disabled={!selectedCityStore.selectedCityId}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -195,7 +204,13 @@ export const CityListPage = observer(() => {
|
|||||||
slots={{
|
slots={{
|
||||||
noRowsOverlay: () => (
|
noRowsOverlay: () => (
|
||||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
{isLoading ? <CircularProgress size={20} /> : "Нет городов"}
|
{isLoading ? (
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
) : !selectedCityStore.selectedCityId ? (
|
||||||
|
"Выберите город"
|
||||||
|
) : (
|
||||||
|
"Нет городов"
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -15,11 +15,18 @@ import {
|
|||||||
RU_COUNTRIES,
|
RU_COUNTRIES,
|
||||||
EN_COUNTRIES,
|
EN_COUNTRIES,
|
||||||
ZH_COUNTRIES,
|
ZH_COUNTRIES,
|
||||||
|
selectedCityStore,
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
export const CountryAddPage = observer(() => {
|
export const CountryAddPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedCityStore.setIsLocked(true);
|
||||||
|
return () => selectedCityStore.setIsLocked(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { createCountryData, setCountryData, createCountry } = countryStore;
|
const { createCountryData, setCountryData, createCountry } = countryStore;
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,18 @@ import { ArrowLeft, Save } from "lucide-react";
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { countryStore, languageStore } from "@shared";
|
import { countryStore, languageStore, selectedCityStore } from "@shared";
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { LanguageSwitcher } from "@widgets";
|
import { LanguageSwitcher } from "@widgets";
|
||||||
|
|
||||||
export const CountryCreatePage = observer(() => {
|
export const CountryCreatePage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedCityStore.setIsLocked(true);
|
||||||
|
return () => selectedCityStore.setIsLocked(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
const { createCountryData, setCountryData, createCountry } = countryStore;
|
const { createCountryData, setCountryData, createCountry } = countryStore;
|
||||||
|
|||||||
@@ -4,12 +4,18 @@ import { ArrowLeft, Save } from "lucide-react";
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { countryStore, languageStore, LoadingSpinner } from "@shared";
|
import { countryStore, languageStore, LoadingSpinner, selectedCityStore } from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LanguageSwitcher } from "@widgets";
|
import { LanguageSwitcher } from "@widgets";
|
||||||
|
|
||||||
export const CountryEditPage = observer(() => {
|
export const CountryEditPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedCityStore.setIsLocked(true);
|
||||||
|
return () => selectedCityStore.setIsLocked(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
import { ruRU } from "@mui/x-data-grid/locales";
|
import { ruRU } from "@mui/x-data-grid/locales";
|
||||||
import { authStore, countryStore, languageStore, SearchInput } from "@shared";
|
import { authStore, countryStore, languageStore, selectedCityStore, SearchInput } from "@shared";
|
||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Trash2, Minus } from "lucide-react";
|
import { Trash2, Minus } from "lucide-react";
|
||||||
@@ -74,15 +74,20 @@ export const CountryListPage = observer(() => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
|
const { selectedCity } = selectedCityStore;
|
||||||
|
if (!selectedCity) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const query = searchQuery.trim().toLowerCase();
|
const query = searchQuery.trim().toLowerCase();
|
||||||
return (countries[language]?.data ?? [])
|
return (countries[language]?.data ?? [])
|
||||||
|
.filter((country) => country.code === selectedCity.country_code)
|
||||||
.filter((country) => !query || (country.name ?? "").toLowerCase().includes(query))
|
.filter((country) => !query || (country.name ?? "").toLowerCase().includes(query))
|
||||||
.map((country) => ({
|
.map((country) => ({
|
||||||
id: country.code,
|
id: country.code,
|
||||||
code: country.code,
|
code: country.code,
|
||||||
name: country.name,
|
name: country.name,
|
||||||
}));
|
}));
|
||||||
}, [countries[language]?.data, searchQuery]);
|
}, [countries[language]?.data, searchQuery, selectedCityStore.selectedCity]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -92,7 +97,11 @@ export const CountryListPage = observer(() => {
|
|||||||
<div className="flex justify-between items-center mb-10">
|
<div className="flex justify-between items-center mb-10">
|
||||||
<h1 className="text-2xl">Страны</h1>
|
<h1 className="text-2xl">Страны</h1>
|
||||||
{canWriteCountries && (
|
{canWriteCountries && (
|
||||||
<CreateButton label="Добавить страну" path="/country/add" />
|
<CreateButton
|
||||||
|
label="Добавить страну"
|
||||||
|
path="/country/add"
|
||||||
|
disabled={!selectedCityStore.selectedCityId}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -148,7 +157,13 @@ export const CountryListPage = observer(() => {
|
|||||||
slots={{
|
slots={{
|
||||||
noRowsOverlay: () => (
|
noRowsOverlay: () => (
|
||||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
{isLoading ? <CircularProgress size={20} /> : "Нет стран"}
|
{isLoading ? (
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
) : !selectedCityStore.selectedCityId ? (
|
||||||
|
"Выберите город"
|
||||||
|
) : (
|
||||||
|
"Нет стран"
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
cityStore,
|
cityStore,
|
||||||
createSightStore,
|
createSightStore,
|
||||||
languageStore,
|
languageStore,
|
||||||
|
selectedCityStore,
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import {
|
import {
|
||||||
CreateInformationTab,
|
CreateInformationTab,
|
||||||
@@ -30,6 +31,11 @@ export const CreateSightPage = observer(() => {
|
|||||||
const { getArticles } = articlesStore;
|
const { getArticles } = articlesStore;
|
||||||
const needLeave = createSightStore.needLeaveAgree;
|
const needLeave = createSightStore.needLeaveAgree;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedCityStore.setIsLocked(true);
|
||||||
|
return () => selectedCityStore.setIsLocked(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleChange = (_: React.SyntheticEvent, newValue: number) => {
|
const handleChange = (_: React.SyntheticEvent, newValue: number) => {
|
||||||
setValue(newValue);
|
setValue(newValue);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
cityStore,
|
cityStore,
|
||||||
editSightStore,
|
editSightStore,
|
||||||
LoadingSpinner,
|
LoadingSpinner,
|
||||||
|
selectedCityStore,
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { useBlocker, useParams } from "react-router-dom";
|
import { useBlocker, useParams } from "react-router-dom";
|
||||||
|
|
||||||
@@ -25,6 +26,11 @@ export const EditSightPage = observer(() => {
|
|||||||
const { sight, getSightInfo, needLeaveAgree, getRightArticles } = editSightStore;
|
const { sight, getSightInfo, needLeaveAgree, getRightArticles } = editSightStore;
|
||||||
const { getArticles } = articlesStore;
|
const { getArticles } = articlesStore;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedCityStore.setIsLocked(true);
|
||||||
|
return () => selectedCityStore.setIsLocked(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { getCities } = cityStore;
|
const { getCities } = cityStore;
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ import type { Route } from "@shared";
|
|||||||
|
|
||||||
export const RouteCreatePage = observer(() => {
|
export const RouteCreatePage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedCityStore.setIsLocked(true);
|
||||||
|
return () => selectedCityStore.setIsLocked(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [carrier, setCarrier] = useState<string>("");
|
const [carrier, setCarrier] = useState<string>("");
|
||||||
const [routeNumber, setRouteNumber] = useState("");
|
const [routeNumber, setRouteNumber] = useState("");
|
||||||
const [routeCoords, setRouteCoords] = useState("");
|
const [routeCoords, setRouteCoords] = useState("");
|
||||||
@@ -51,7 +57,7 @@ export const RouteCreatePage = observer(() => {
|
|||||||
const [turn, setTurn] = useState("");
|
const [turn, setTurn] = useState("");
|
||||||
const [centerLat, setCenterLat] = useState("");
|
const [centerLat, setCenterLat] = useState("");
|
||||||
const [centerLng, setCenterLng] = useState("");
|
const [centerLng, setCenterLng] = useState("");
|
||||||
const [videoTimer, setVideoTimer] = useState(60);
|
const [videoTimer, setVideoTimer] = useState(420);
|
||||||
const [videoPreview, setVideoPreview] = useState<string>("");
|
const [videoPreview, setVideoPreview] = useState<string>("");
|
||||||
const [icon, setIcon] = useState<string>("");
|
const [icon, setIcon] = useState<string>("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -555,7 +561,7 @@ export const RouteCreatePage = observer(() => {
|
|||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
className="w-full"
|
className="w-full"
|
||||||
label="Таймер видео (сек)"
|
label="Таймер видео заставки (сек)"
|
||||||
type="number"
|
type="number"
|
||||||
value={videoTimer}
|
value={videoTimer}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|||||||
@@ -36,11 +36,18 @@ import {
|
|||||||
UploadMediaDialog,
|
UploadMediaDialog,
|
||||||
PreviewMediaDialog,
|
PreviewMediaDialog,
|
||||||
LoadingSpinner,
|
LoadingSpinner,
|
||||||
|
selectedCityStore,
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { LinkedItems } from "../LinekedStations";
|
import { LinkedItems } from "../LinekedStations";
|
||||||
|
|
||||||
export const RouteEditPage = observer(() => {
|
export const RouteEditPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedCityStore.setIsLocked(true);
|
||||||
|
return () => selectedCityStore.setIsLocked(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { editRouteData, copyRouteAction } = routeStore;
|
const { editRouteData, copyRouteAction } = routeStore;
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -548,9 +555,9 @@ export const RouteEditPage = observer(() => {
|
|||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
className="w-full"
|
className="w-full"
|
||||||
label="Таймер видео (сек)"
|
label="Таймер видео заставки (сек)"
|
||||||
type="number"
|
type="number"
|
||||||
value={editRouteData.video_timer ?? 60}
|
value={editRouteData.video_timer ?? 420}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = Math.max(1, Math.round(Number(e.target.value)));
|
const val = Math.max(1, Math.round(Number(e.target.value)));
|
||||||
if (Number.isFinite(val)) {
|
if (Number.isFinite(val)) {
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export const RouteListPage = observer(() => {
|
|||||||
headerAlign: "center" as const,
|
headerAlign: "center" as const,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
renderHeader: (params: any) => (
|
renderHeader: (params: any) => (
|
||||||
<Tooltip title="Количество привязанных достопримечательностей">
|
<Tooltip title="Отображает количество привязанных достопримечательностей">
|
||||||
<span>{params.colDef.headerName}</span>
|
<span>{params.colDef.headerName}</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
@@ -157,7 +157,7 @@ export const RouteListPage = observer(() => {
|
|||||||
headerAlign: "center" as const,
|
headerAlign: "center" as const,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
renderHeader: (params: any) => (
|
renderHeader: (params: any) => (
|
||||||
<Tooltip title="Количество привязанных остановок">
|
<Tooltip title="Отображает количество привязанных остановок">
|
||||||
<span>{params.colDef.headerName}</span>
|
<span>{params.colDef.headerName}</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
@@ -210,6 +210,9 @@ export const RouteListPage = observer(() => {
|
|||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const { selectedCityId } = selectedCityStore;
|
const { selectedCityId } = selectedCityStore;
|
||||||
|
if (!selectedCityId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const query = searchQuery.trim().toLowerCase();
|
const query = searchQuery.trim().toLowerCase();
|
||||||
let filtered = routes.data;
|
let filtered = routes.data;
|
||||||
if (selectedCityId) {
|
if (selectedCityId) {
|
||||||
@@ -247,7 +250,11 @@ export const RouteListPage = observer(() => {
|
|||||||
<div className="flex justify-between items-center mb-10">
|
<div className="flex justify-between items-center mb-10">
|
||||||
<h1 className="text-2xl">Маршруты</h1>
|
<h1 className="text-2xl">Маршруты</h1>
|
||||||
{canWriteRoutes && (
|
{canWriteRoutes && (
|
||||||
<CreateButton label="Создать маршрут" path="/route/create" />
|
<CreateButton
|
||||||
|
label="Создать маршрут"
|
||||||
|
path="/route/create"
|
||||||
|
disabled={!selectedCityStore.selectedCityId}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -304,7 +311,13 @@ export const RouteListPage = observer(() => {
|
|||||||
slots={{
|
slots={{
|
||||||
noRowsOverlay: () => (
|
noRowsOverlay: () => (
|
||||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
{isLoading ? <CircularProgress size={20} /> : "Нет маршрутов"}
|
{isLoading ? (
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
) : !selectedCityStore.selectedCityId ? (
|
||||||
|
"Выберите город"
|
||||||
|
) : (
|
||||||
|
"Нет маршрутов"
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Box, Stack, Typography, Button } from "@mui/material";
|
import { Button } from "@mui/material";
|
||||||
import { useNavigate, useNavigationType } from "react-router";
|
import { useNavigate, useNavigationType } from "react-router";
|
||||||
import { MediaViewer } from "@widgets";
|
import { MediaViewer } from "@widgets";
|
||||||
import { useMapData } from "./MapDataContext";
|
import { useMapData } from "./MapDataContext";
|
||||||
@@ -15,22 +15,22 @@ type LeftSidebarProps = {
|
|||||||
|
|
||||||
export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
|
export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const navigationType = useNavigationType(); // PUSH, POP, REPLACE
|
const navigationType = useNavigationType();
|
||||||
const { routeData } = useMapData();
|
const { routeData } = useMapData();
|
||||||
const [carrierThumbnail, setCarrierThumbnail] = useState<string | null>(null);
|
|
||||||
const [carrierLogo, setCarrierLogo] = useState<string | null>(null);
|
const [carrierLogo, setCarrierLogo] = useState<string | null>(null);
|
||||||
|
const [carrierSlogan, setCarrierSlogan] = useState<string | null>(null);
|
||||||
|
const [carrierShortName, setCarrierShortName] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchCarrierThumbnail() {
|
async function fetchCarrierData() {
|
||||||
if (routeData?.carrier_id) {
|
if (routeData?.carrier_id) {
|
||||||
const { city_id, logo } = (
|
const carrier = (await authInstance.get(`/carrier/${routeData.carrier_id}`)).data;
|
||||||
await authInstance.get(`/carrier/${routeData.carrier_id}`)
|
setCarrierLogo(carrier.logo);
|
||||||
).data;
|
setCarrierSlogan(carrier.slogan ?? null);
|
||||||
const { arms } = (await authInstance.get(`/city/${city_id}`)).data;
|
setCarrierShortName(carrier.short_name ?? null);
|
||||||
setCarrierThumbnail(arms);
|
|
||||||
setCarrierLogo(logo);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchCarrierThumbnail();
|
fetchCarrierData();
|
||||||
}, [routeData?.carrier_id]);
|
}, [routeData?.carrier_id]);
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
@@ -42,131 +42,162 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<div
|
||||||
sx={{
|
style={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
transition: "padding 0.3s ease",
|
|
||||||
p: open ? 2 : 0,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "stretch",
|
|
||||||
justifyContent: "flex-start",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack
|
{/* Кнопка назад — вне основного меню */}
|
||||||
direction="column"
|
<div style={{ padding: "12px 12px 0" }}>
|
||||||
height="100%"
|
<Button
|
||||||
width="100%"
|
onClick={handleBack}
|
||||||
spacing={4}
|
variant="contained"
|
||||||
alignItems="stretch"
|
sx={{
|
||||||
justifyContent="space-between"
|
backgroundColor: "#222",
|
||||||
sx={{
|
color: "#fff",
|
||||||
|
borderRadius: 1.5,
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
"&:hover": { backgroundColor: "#2d2d2d" },
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
startIcon={<ArrowBackIcon />}
|
||||||
|
>
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Основное меню — повторяет .side-menu */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
boxSizing: "border-box",
|
||||||
|
paddingTop: 46,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
height: "calc(100% - 56px)",
|
||||||
|
position: "relative",
|
||||||
opacity: open ? 1 : 0,
|
opacity: open ? 1 : 0,
|
||||||
transition: "opacity 0.25s ease",
|
transition: "opacity 0.25s ease",
|
||||||
pointerEvents: open ? "auto" : "none",
|
pointerEvents: open ? "auto" : "none",
|
||||||
display: open ? "flex" : "none",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
{/* Герб — .side-menu-crest */}
|
||||||
<Button
|
<div
|
||||||
onClick={handleBack}
|
style={{
|
||||||
variant="contained"
|
width: 170,
|
||||||
color="primary"
|
height: 170,
|
||||||
sx={{
|
alignSelf: "flex-start",
|
||||||
backgroundColor: "#222",
|
marginLeft: 20,
|
||||||
color: "#fff",
|
backgroundColor: "rgba(255,255,255,0.15)",
|
||||||
borderRadius: 1.5,
|
borderRadius: 8,
|
||||||
px: 2,
|
display: "flex",
|
||||||
py: 1,
|
alignItems: "center",
|
||||||
marginBottom: 10,
|
justifyContent: "center",
|
||||||
"&:hover": {
|
color: "rgba(255,255,255,0.5)",
|
||||||
backgroundColor: "#2d2d2d",
|
fontSize: 14,
|
||||||
},
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Герб
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Слоган — .side-menu-label */}
|
||||||
|
{carrierSlogan && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 10,
|
||||||
|
textAlign: "left",
|
||||||
|
fontSize: 15,
|
||||||
|
padding: "0 20px",
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: "150%",
|
||||||
}}
|
}}
|
||||||
fullWidth
|
|
||||||
startIcon={<ArrowBackIcon />}
|
|
||||||
>
|
>
|
||||||
Назад
|
{carrierSlogan}
|
||||||
</Button>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Stack
|
{/* Кнопки — .side-menu-buttons */}
|
||||||
direction="column"
|
<div style={{ width: 220, marginTop: 260 }}>
|
||||||
alignItems="center"
|
<div
|
||||||
justifyContent="center"
|
style={{
|
||||||
spacing={3}
|
backgroundColor: "#fff",
|
||||||
|
color: "#000",
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "8px 16px",
|
||||||
|
marginBottom: 16,
|
||||||
|
borderRadius: 10,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
Достопримечательности
|
||||||
style={{
|
</div>
|
||||||
maxWidth: 150,
|
<div
|
||||||
display: "flex",
|
style={{
|
||||||
flexDirection: "column",
|
backgroundColor: "#fff",
|
||||||
alignItems: "center",
|
color: "#000",
|
||||||
gap: 10,
|
textAlign: "center",
|
||||||
}}
|
padding: "8px 16px",
|
||||||
>
|
marginBottom: 16,
|
||||||
{carrierThumbnail && !isMediaIdEmpty(carrierThumbnail) && (
|
borderRadius: 10,
|
||||||
<MediaViewer
|
}}
|
||||||
media={{
|
>
|
||||||
id: carrierThumbnail,
|
Остановки
|
||||||
media_type: 1, // Тип "Фото" для логотипа
|
|
||||||
filename: "route_thumbnail",
|
|
||||||
}}
|
|
||||||
fullWidth
|
|
||||||
fullHeight
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Typography sx={{ color: "#fff" }} textAlign="center">
|
|
||||||
При поддержке Правительства
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center justify-center gap-2 mt-10">
|
|
||||||
<button className="bg-[#fcd500] text-black px-4 py-2 rounded-md w-full font-medium my-10">
|
|
||||||
Обращение губернатора
|
|
||||||
</button>
|
|
||||||
<button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
|
|
||||||
Достопримечательности
|
|
||||||
</button>
|
|
||||||
<button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
|
|
||||||
Остановки
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Stack
|
{/* Нижняя секция — .side-menu-bottom-section */}
|
||||||
direction="column"
|
<div
|
||||||
alignItems="center"
|
style={{
|
||||||
maxHeight={150}
|
position: "absolute",
|
||||||
justifyContent="center"
|
bottom: 0,
|
||||||
flexGrow={1}
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{carrierLogo && !isMediaIdEmpty(carrierLogo) && (
|
{/* .side-menu-carrier-block */}
|
||||||
<MediaViewer
|
<div style={{ padding: "0 20px" }}>
|
||||||
media={{
|
{carrierLogo && !isMediaIdEmpty(carrierLogo) && (
|
||||||
id: carrierLogo,
|
<div style={{ width: 170 }}>
|
||||||
media_type: 1, // Тип "Фото" для логотипа
|
<MediaViewer
|
||||||
filename: "route_thumbnail_logo",
|
media={{ id: carrierLogo, media_type: 1, filename: "carrier_logo" }}
|
||||||
}}
|
fullWidth
|
||||||
fullHeight
|
/>
|
||||||
/>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
{carrierShortName && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 4,
|
||||||
|
textAlign: "left",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: "150%",
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{carrierShortName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Typography
|
{/* .side-menu-bottom-photo */}
|
||||||
variant="h6"
|
<img
|
||||||
textAlign="center"
|
src="/side-menu-photo.png"
|
||||||
sx={{ color: "#fff", marginTop: "auto" }}
|
alt=""
|
||||||
>
|
style={{ width: "100%", marginTop: 32, display: "block", pointerEvents: "none" }}
|
||||||
#ВсемПоПути
|
/>
|
||||||
</Typography>
|
</div>
|
||||||
</Stack>
|
</div>
|
||||||
|
|
||||||
<div className="absolute bottom-[20px] -right-[520px] z-10">
|
<div className="absolute bottom-[20px] -right-[520px] z-10">
|
||||||
<LanguageSelector onBack={onToggle} isSidebarOpen={open} />
|
<LanguageSelector onBack={onToggle} isSidebarOpen={open} />
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
rgba(255, 255, 255, 0.2) 0%,
|
rgba(255, 255, 255, 0.2) 0%,
|
||||||
rgba(255, 255, 255, 0) 100%
|
rgba(255, 255, 255, 0) 100%
|
||||||
),
|
),
|
||||||
rgba(179, 165, 152, 0.4);
|
rgba(var(--carrier-main-rgb, 0, 111, 58), 0.4);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
z-index: 10000001;
|
z-index: 10000001;
|
||||||
|
|||||||
@@ -121,8 +121,11 @@ export const SightListPage = observer(() => {
|
|||||||
}] : []),
|
}] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { selectedCityId } = selectedCityStore;
|
||||||
const filteredSights = useMemo(() => {
|
const filteredSights = useMemo(() => {
|
||||||
const { selectedCityId } = selectedCityStore;
|
if (!selectedCityId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const allowedCityIds = canReadCities
|
const allowedCityIds = canReadCities
|
||||||
? null
|
? null
|
||||||
: authStore.meCities["ru"].map((c) => c.city_id);
|
: authStore.meCities["ru"].map((c) => c.city_id);
|
||||||
@@ -131,12 +134,12 @@ export const SightListPage = observer(() => {
|
|||||||
if (allowedCityIds && !allowedCityIds.includes(sight.city_id)) {
|
if (allowedCityIds && !allowedCityIds.includes(sight.city_id)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (selectedCityId && sight.city_id !== selectedCityId) {
|
if (sight.city_id !== selectedCityId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [sights, selectedCityStore.selectedCityId, canReadCities, authStore.meCities]);
|
}, [sights, selectedCityId, canReadCities, authStore.meCities]);
|
||||||
|
|
||||||
const query = searchQuery.trim().toLowerCase();
|
const query = searchQuery.trim().toLowerCase();
|
||||||
const rows = filteredSights
|
const rows = filteredSights
|
||||||
@@ -161,6 +164,7 @@ export const SightListPage = observer(() => {
|
|||||||
<CreateButton
|
<CreateButton
|
||||||
label="Создать достопримечательность"
|
label="Создать достопримечательность"
|
||||||
path="/sight/create"
|
path="/sight/create"
|
||||||
|
disabled={!selectedCityStore.selectedCityId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -216,6 +220,8 @@ export const SightListPage = observer(() => {
|
|||||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<CircularProgress size={20} />
|
<CircularProgress size={20} />
|
||||||
|
) : !selectedCityId ? (
|
||||||
|
"Выберите город"
|
||||||
) : (
|
) : (
|
||||||
"Нет достопримечательностей"
|
"Нет достопримечательностей"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -76,6 +76,26 @@ export const SnapshotListPage = observer(() => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
|
{
|
||||||
|
field: "color",
|
||||||
|
headerName: "",
|
||||||
|
width: 28,
|
||||||
|
sortable: false,
|
||||||
|
disableColumnMenu: true,
|
||||||
|
renderCell: (params: GridRenderCellParams) => (
|
||||||
|
<div className="flex items-center justify-center h-full w-full">
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
backgroundColor: params.value,
|
||||||
|
borderRadius: "50%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
field: "name",
|
field: "name",
|
||||||
headerName: "Название",
|
headerName: "Название",
|
||||||
@@ -150,12 +170,13 @@ export const SnapshotListPage = observer(() => {
|
|||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(query),
|
.includes(query),
|
||||||
)
|
)
|
||||||
.map((snapshot) => ({
|
.map((snapshot, index) => ({
|
||||||
id: snapshot.ID,
|
id: snapshot.ID,
|
||||||
name: snapshot.Name,
|
name: snapshot.Name,
|
||||||
parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name,
|
parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name,
|
||||||
created_at: formatCreationTime(snapshot.CreationTime),
|
created_at: formatCreationTime(snapshot.CreationTime),
|
||||||
occupied_disk_space_gb: snapshot.occupied_disk_space_gb,
|
occupied_disk_space_gb: snapshot.occupied_disk_space_gb,
|
||||||
|
color: SEGMENT_COLORS[index % SEGMENT_COLORS.length],
|
||||||
}));
|
}));
|
||||||
}, [snapshots, searchQuery]);
|
}, [snapshots, searchQuery]);
|
||||||
|
|
||||||
@@ -181,7 +202,7 @@ export const SnapshotListPage = observer(() => {
|
|||||||
setIsEmptySnapshotModalOpen(true);
|
setIsEmptySnapshotModalOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Создать пустой снапшот
|
Создать пустой экспорт
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{canCreateSnapshot && (
|
{canCreateSnapshot && (
|
||||||
@@ -203,7 +224,7 @@ export const SnapshotListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full h-3 rounded-lg overflow-hidden bg-gray-100">
|
<div className="flex w-full h-3 rounded-lg overflow-hidden bg-gray-100">
|
||||||
{rows.map((row, i) => {
|
{rows.map((row) => {
|
||||||
const pct =
|
const pct =
|
||||||
row.occupied_disk_space_gb != null && totalGB > 0
|
row.occupied_disk_space_gb != null && totalGB > 0
|
||||||
? (row.occupied_disk_space_gb / totalGB) * 100
|
? (row.occupied_disk_space_gb / totalGB) * 100
|
||||||
@@ -214,8 +235,7 @@ export const SnapshotListPage = observer(() => {
|
|||||||
key={row.id}
|
key={row.id}
|
||||||
style={{
|
style={{
|
||||||
width: `${pct}%`,
|
width: `${pct}%`,
|
||||||
backgroundColor:
|
backgroundColor: row.color,
|
||||||
SEGMENT_COLORS[i % SEGMENT_COLORS.length],
|
|
||||||
}}
|
}}
|
||||||
title={`${row.name}: ${row.occupied_disk_space_gb?.toFixed(1)} ГБ`}
|
title={`${row.name}: ${row.occupied_disk_space_gb?.toFixed(1)} ГБ`}
|
||||||
/>
|
/>
|
||||||
@@ -233,7 +253,7 @@ export const SnapshotListPage = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-x-5 gap-y-1 mt-3">
|
<div className="flex flex-wrap gap-x-5 gap-y-1 mt-3">
|
||||||
{rows.map((row, i) => {
|
{rows.map((row) => {
|
||||||
if (row.occupied_disk_space_gb == null || row.occupied_disk_space_gb <= 0)
|
if (row.occupied_disk_space_gb == null || row.occupied_disk_space_gb <= 0)
|
||||||
return null;
|
return null;
|
||||||
return (
|
return (
|
||||||
@@ -243,10 +263,7 @@ export const SnapshotListPage = observer(() => {
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="inline-block w-2.5 h-2.5 rounded-full"
|
className="inline-block w-2.5 h-2.5 rounded-full"
|
||||||
style={{
|
style={{ backgroundColor: row.color }}
|
||||||
backgroundColor:
|
|
||||||
SEGMENT_COLORS[i % SEGMENT_COLORS.length],
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{row.name}
|
{row.name}
|
||||||
</div>
|
</div>
|
||||||
@@ -325,7 +342,7 @@ export const SnapshotListPage = observer(() => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
maxWidth="xs"
|
maxWidth="xs"
|
||||||
>
|
>
|
||||||
<DialogTitle>Создать пустой снапшот</DialogTitle>
|
<DialogTitle>Создать пустой экспорт</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<TextField
|
<TextField
|
||||||
autoFocus
|
autoFocus
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
mediaStore,
|
mediaStore,
|
||||||
isMediaIdEmpty,
|
isMediaIdEmpty,
|
||||||
useSelectedCity,
|
useSelectedCity,
|
||||||
|
selectedCityStore,
|
||||||
SelectMediaDialog,
|
SelectMediaDialog,
|
||||||
UploadMediaDialog,
|
UploadMediaDialog,
|
||||||
PreviewMediaDialog,
|
PreviewMediaDialog,
|
||||||
@@ -32,6 +33,12 @@ import {
|
|||||||
|
|
||||||
export const StationCreatePage = observer(() => {
|
export const StationCreatePage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedCityStore.setIsLocked(true);
|
||||||
|
return () => selectedCityStore.setIsLocked(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
mediaStore,
|
mediaStore,
|
||||||
isMediaIdEmpty,
|
isMediaIdEmpty,
|
||||||
LoadingSpinner,
|
LoadingSpinner,
|
||||||
|
selectedCityStore,
|
||||||
SelectMediaDialog,
|
SelectMediaDialog,
|
||||||
PreviewMediaDialog,
|
PreviewMediaDialog,
|
||||||
UploadMediaDialog,
|
UploadMediaDialog,
|
||||||
@@ -34,6 +35,12 @@ import { LinkedSights } from "../LinkedSights";
|
|||||||
|
|
||||||
export const StationEditPage = observer(() => {
|
export const StationEditPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedCityStore.setIsLocked(true);
|
||||||
|
return () => selectedCityStore.setIsLocked(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
const { language } = languageStore;
|
const { language } = languageStore;
|
||||||
|
|||||||
@@ -86,13 +86,13 @@ export const StationListPage = observer(() => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "sightCount",
|
field: "sightCount",
|
||||||
headerName: "Достопримечательности",
|
headerName: "Привязки",
|
||||||
width: 180,
|
width: 180,
|
||||||
align: "center" as const,
|
align: "center" as const,
|
||||||
headerAlign: "center" as const,
|
headerAlign: "center" as const,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
renderHeader: (params) => (
|
renderHeader: (params) => (
|
||||||
<Tooltip title="Количество привязанных достопримечательностей">
|
<Tooltip title="Отображает количество привязок">
|
||||||
<span>{params.colDef.headerName}</span>
|
<span>{params.colDef.headerName}</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
@@ -114,7 +114,7 @@ export const StationListPage = observer(() => {
|
|||||||
headerAlign: "center" as const,
|
headerAlign: "center" as const,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
renderHeader: (params) => (
|
renderHeader: (params) => (
|
||||||
<Tooltip title="Подтверждение добавленных пересадок">
|
<Tooltip title="Отображает подтверждение добавленных пересадок">
|
||||||
<span>{params.colDef.headerName}</span>
|
<span>{params.colDef.headerName}</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
@@ -174,9 +174,12 @@ export const StationListPage = observer(() => {
|
|||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const { selectedCityId } = selectedCityStore;
|
const { selectedCityId } = selectedCityStore;
|
||||||
|
if (!selectedCityId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const query = searchQuery.trim().toLowerCase();
|
const query = searchQuery.trim().toLowerCase();
|
||||||
return stationLists[language].data
|
return stationLists[language].data
|
||||||
.filter((station: any) => !selectedCityId || station.city_id === selectedCityId)
|
.filter((station: any) => station.city_id === selectedCityId)
|
||||||
.filter(
|
.filter(
|
||||||
(station: any) =>
|
(station: any) =>
|
||||||
!query ||
|
!query ||
|
||||||
@@ -202,7 +205,11 @@ export const StationListPage = observer(() => {
|
|||||||
<div className="flex justify-between items-center mb-10">
|
<div className="flex justify-between items-center mb-10">
|
||||||
<h1 className="text-2xl">Остановки</h1>
|
<h1 className="text-2xl">Остановки</h1>
|
||||||
{canWriteStations && (
|
{canWriteStations && (
|
||||||
<CreateButton label="Создать остановку" path="/station/create" />
|
<CreateButton
|
||||||
|
label="Создать остановку"
|
||||||
|
path="/station/create"
|
||||||
|
disabled={!selectedCityStore.selectedCityId}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -277,7 +284,13 @@ export const StationListPage = observer(() => {
|
|||||||
slots={{
|
slots={{
|
||||||
noRowsOverlay: () => (
|
noRowsOverlay: () => (
|
||||||
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
|
||||||
{isLoading ? <CircularProgress size={20} /> : "Нет остановок"}
|
{isLoading ? (
|
||||||
|
<CircularProgress size={20} />
|
||||||
|
) : !selectedCityStore.selectedCityId ? (
|
||||||
|
"Выберите город"
|
||||||
|
) : (
|
||||||
|
"Нет остановок"
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,4 +1,19 @@
|
|||||||
import { Button, Paper, TextField } from "@mui/material";
|
import {
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Checkbox,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Radio,
|
||||||
|
RadioGroup,
|
||||||
|
Divider,
|
||||||
|
} from "@mui/material";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
@@ -14,6 +29,40 @@ import {
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { ImageUploadCard } from "@widgets";
|
import { ImageUploadCard } from "@widgets";
|
||||||
|
|
||||||
|
const ROLE_RESOURCES = [
|
||||||
|
{ key: "snapshot", label: "Экспорт" },
|
||||||
|
{ key: "devices", label: "Устройства" },
|
||||||
|
{ key: "vehicles", label: "Транспорт" },
|
||||||
|
{ key: "users", label: "Пользователи" },
|
||||||
|
{ key: "sights", label: "Достопримечательности" },
|
||||||
|
{ key: "stations", label: "Остановки" },
|
||||||
|
{ key: "routes", label: "Маршруты" },
|
||||||
|
{ key: "countries", label: "Страны" },
|
||||||
|
{ key: "cities", label: "Города" },
|
||||||
|
{ key: "carriers", label: "Перевозчики" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type PermissionLevel = "none" | "ro" | "rw";
|
||||||
|
|
||||||
|
function getPermissionLevel(roles: string[], resource: string): PermissionLevel {
|
||||||
|
if (roles.includes(`${resource}_rw`)) return "rw";
|
||||||
|
if (roles.includes(`${resource}_ro`)) return "ro";
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPermissionChange(
|
||||||
|
roles: string[],
|
||||||
|
resource: string,
|
||||||
|
level: PermissionLevel,
|
||||||
|
): string[] {
|
||||||
|
const filtered = roles.filter(
|
||||||
|
(r) => r !== `${resource}_ro` && r !== `${resource}_rw`,
|
||||||
|
);
|
||||||
|
if (level === "ro") return [...filtered, `${resource}_ro`];
|
||||||
|
if (level === "rw") return [...filtered, `${resource}_rw`];
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
export const UserCreatePage = observer(() => {
|
export const UserCreatePage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { createUserData, setCreateUserData, createUser } = userStore;
|
const { createUserData, setCreateUserData, createUser } = userStore;
|
||||||
@@ -26,13 +75,33 @@ export const UserCreatePage = observer(() => {
|
|||||||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
|
const [localRoles, setLocalRoles] = useState<string[]>(
|
||||||
|
createUserData.roles ?? ["articles_ro", "articles_rw", "media_ro", "media_rw"]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mediaStore.getMedia();
|
mediaStore.getMedia();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const allRw = ROLE_RESOURCES.every(({ key }) => localRoles.includes(`${key}_rw`));
|
||||||
|
const isAdmin = allRw && !localRoles.includes("devices_maintenance_rw");
|
||||||
|
if (isAdmin !== createUserData.is_admin) {
|
||||||
|
setCreateUserData(
|
||||||
|
createUserData.name || "",
|
||||||
|
createUserData.email || "",
|
||||||
|
createUserData.password || "",
|
||||||
|
isAdmin,
|
||||||
|
createUserData.icon
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [localRoles]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
// Убеждаемся, что роли в сторе обновлены перед созданием
|
||||||
|
userStore.createUserData.roles = localRoles;
|
||||||
await createUser();
|
await createUser();
|
||||||
toast.success("Пользователь успешно создан");
|
toast.success("Пользователь успешно создан");
|
||||||
navigate("/user");
|
navigate("/user");
|
||||||
@@ -67,18 +136,15 @@ export const UserCreatePage = observer(() => {
|
|||||||
: selectedMedia?.id ?? createUserData.icon ?? null;
|
: selectedMedia?.id ?? createUserData.icon ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
<Paper className="w-full p-6 flex flex-col gap-8">
|
||||||
<div className="flex items-center gap-4">
|
<button className="flex items-center gap-2 self-start" onClick={() => navigate(-1)}>
|
||||||
<button
|
<ArrowLeft size={20} />
|
||||||
className="flex items-center gap-2"
|
Назад
|
||||||
onClick={() => navigate(-1)}
|
</button>
|
||||||
>
|
|
||||||
<ArrowLeft size={20} />
|
<section className="flex flex-col gap-6">
|
||||||
Назад
|
<Typography variant="h6">Основные данные</Typography>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-10 w-full items-end">
|
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Имя"
|
label="Имя"
|
||||||
@@ -116,6 +182,7 @@ export const UserCreatePage = observer(() => {
|
|||||||
label="Пароль"
|
label="Пароль"
|
||||||
value={createUserData.password || ""}
|
value={createUserData.password || ""}
|
||||||
required
|
required
|
||||||
|
type="password"
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setCreateUserData(
|
setCreateUserData(
|
||||||
createUserData.name || "",
|
createUserData.name || "",
|
||||||
@@ -127,7 +194,7 @@ export const UserCreatePage = observer(() => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
|
<div className="w-full flex flex-col gap-4 max-w-[300px]">
|
||||||
<ImageUploadCard
|
<ImageUploadCard
|
||||||
title="Аватар"
|
title="Аватар"
|
||||||
imageKey="thumbnail"
|
imageKey="thumbnail"
|
||||||
@@ -156,23 +223,197 @@ export const UserCreatePage = observer(() => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<Button
|
<Divider />
|
||||||
variant="contained"
|
|
||||||
className="w-min flex gap-2 items-center"
|
<section className="flex flex-col gap-4">
|
||||||
startIcon={<Save size={20} />}
|
<Typography variant="h6">Права доступа</Typography>
|
||||||
onClick={handleCreate}
|
|
||||||
disabled={
|
<Box sx={{ display: "flex", gap: 1 }}>
|
||||||
isLoading || !createUserData.name || !createUserData.password
|
<Button
|
||||||
}
|
variant="outlined"
|
||||||
>
|
size="small"
|
||||||
{isLoading ? (
|
onClick={() => {
|
||||||
<Loader2 size={20} className="animate-spin" />
|
setCreateUserData(
|
||||||
) : (
|
createUserData.name || "",
|
||||||
"Создать"
|
createUserData.email || "",
|
||||||
)}
|
createUserData.password || "",
|
||||||
</Button>
|
true,
|
||||||
</div>
|
createUserData.icon
|
||||||
|
);
|
||||||
|
const next: string[] = [];
|
||||||
|
for (const { key } of ROLE_RESOURCES) {
|
||||||
|
next.push(`${key}_rw`);
|
||||||
|
}
|
||||||
|
next.push("snapshot_create");
|
||||||
|
setLocalRoles(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Полный доступ (admin)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
setCreateUserData(
|
||||||
|
createUserData.name || "",
|
||||||
|
createUserData.email || "",
|
||||||
|
createUserData.password || "",
|
||||||
|
false,
|
||||||
|
createUserData.icon
|
||||||
|
);
|
||||||
|
setLocalRoles(["devices_ro", "vehicles_ro", "devices_maintenance_rw"]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Администратор ТО
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow sx={{ bgcolor: "action.hover" }}>
|
||||||
|
<TableCell sx={{ fontWeight: 600, width: 220 }}>Ресурс</TableCell>
|
||||||
|
<TableCell align="center" sx={{ fontWeight: 600 }}>Нет доступа</TableCell>
|
||||||
|
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение</TableCell>
|
||||||
|
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение/Запись</TableCell>
|
||||||
|
<TableCell align="center" sx={{ fontWeight: 600 }}>
|
||||||
|
Доп. права
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{ROLE_RESOURCES.map(({ key, label }) => {
|
||||||
|
const level = getPermissionLevel(localRoles, key);
|
||||||
|
const isSnapshotResource = key === "snapshot";
|
||||||
|
|
||||||
|
const handleChange = (val: string) => {
|
||||||
|
setLocalRoles((prev) => {
|
||||||
|
let updated = applyPermissionChange(prev, key, val as PermissionLevel);
|
||||||
|
|
||||||
|
if (key === "devices") {
|
||||||
|
updated = applyPermissionChange(
|
||||||
|
updated,
|
||||||
|
"vehicles",
|
||||||
|
val as PermissionLevel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDevicesResource = key === "devices";
|
||||||
|
|
||||||
|
const handleSnapshotCreateChange = (checked: boolean) => {
|
||||||
|
if (!isSnapshotResource) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLocalRoles((prev) => {
|
||||||
|
const withoutSnapshotCreate = prev.filter(
|
||||||
|
(role) => role !== "snapshot_create"
|
||||||
|
);
|
||||||
|
return checked
|
||||||
|
? [...withoutSnapshotCreate, "snapshot_create"]
|
||||||
|
: withoutSnapshotCreate;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMaintenanceChange = (checked: boolean) => {
|
||||||
|
setLocalRoles((prev) => {
|
||||||
|
const without = prev.filter((r) => r !== "devices_maintenance_rw");
|
||||||
|
return checked ? [...without, "devices_maintenance_rw"] : without;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={key} hover>
|
||||||
|
<TableCell>{label}</TableCell>
|
||||||
|
<TableCell align="center" padding="checkbox">
|
||||||
|
<RadioGroup
|
||||||
|
row
|
||||||
|
value={level}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
|
||||||
|
>
|
||||||
|
<Radio value="none" size="small" />
|
||||||
|
</RadioGroup>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center" padding="checkbox">
|
||||||
|
{isSnapshotResource ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
-
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<RadioGroup
|
||||||
|
row
|
||||||
|
value={level}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
|
||||||
|
>
|
||||||
|
<Radio value="ro" size="small" />
|
||||||
|
</RadioGroup>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center" padding="checkbox">
|
||||||
|
<RadioGroup
|
||||||
|
row
|
||||||
|
value={level}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
|
||||||
|
>
|
||||||
|
<Radio value="rw" size="small" />
|
||||||
|
</RadioGroup>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center" padding="checkbox">
|
||||||
|
{isSnapshotResource ? (
|
||||||
|
<Checkbox
|
||||||
|
checked={localRoles.includes("snapshot_create")}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleSnapshotCreateChange(e.target.checked)
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
title="Разрешает создавать новые снапшоты"
|
||||||
|
/>
|
||||||
|
) : isDevicesResource ? (
|
||||||
|
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "center" }}>
|
||||||
|
<Checkbox
|
||||||
|
checked={localRoles.includes("devices_maintenance_rw")}
|
||||||
|
onChange={(e) => handleMaintenanceChange(e.target.checked)}
|
||||||
|
size="small"
|
||||||
|
title="Техническое обслуживание (ТО)"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
-
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
className="self-end w-min flex gap-2 items-center"
|
||||||
|
startIcon={<Save size={20} />}
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={
|
||||||
|
isLoading || !createUserData.name || !createUserData.password || !createUserData.email
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 size={20} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Создать"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<SelectMediaDialog
|
<SelectMediaDialog
|
||||||
open={isSelectMediaOpen}
|
open={isSelectMediaOpen}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
FormControlLabel,
|
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Paper,
|
Paper,
|
||||||
TextField,
|
TextField,
|
||||||
@@ -97,6 +96,20 @@ export const UserEditPage = observer(() => {
|
|||||||
languageStore.setLanguage("ru");
|
languageStore.setLanguage("ru");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const allRw = ROLE_RESOURCES.every(({ key }) => localRoles.includes(`${key}_rw`));
|
||||||
|
const isAdmin = allRw && !localRoles.includes("devices_maintenance_rw");
|
||||||
|
if (isAdmin !== editUserData.is_admin) {
|
||||||
|
setEditUserData(
|
||||||
|
editUserData.name || "",
|
||||||
|
editUserData.email || "",
|
||||||
|
editUserData.password || "",
|
||||||
|
isAdmin,
|
||||||
|
editUserData.icon || ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [localRoles]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (id) {
|
if (id) {
|
||||||
@@ -311,35 +324,33 @@ export const UserEditPage = observer(() => {
|
|||||||
<section className="flex flex-col gap-4">
|
<section className="flex flex-col gap-4">
|
||||||
<Typography variant="h6">Права доступа</Typography>
|
<Typography variant="h6">Права доступа</Typography>
|
||||||
|
|
||||||
<FormControlLabel
|
<Box sx={{ display: "flex", gap: 1 }}>
|
||||||
control={
|
<Button
|
||||||
<Checkbox
|
variant="outlined"
|
||||||
checked={localRoles.includes("admin")}
|
size="small"
|
||||||
onChange={(e) => {
|
onClick={() => {
|
||||||
if (e.target.checked) {
|
setEditUserData(editUserData.name || "", editUserData.email || "", editUserData.password || "", true, editUserData.icon || "");
|
||||||
setLocalRoles((prev) => {
|
const next: string[] = [];
|
||||||
let next = prev.filter((r) => r !== "admin");
|
for (const { key } of ROLE_RESOURCES) {
|
||||||
for (const { key } of ROLE_RESOURCES) {
|
next.push(`${key}_rw`);
|
||||||
next = next.filter((r) => r !== `${key}_ro` && r !== `${key}_rw`);
|
}
|
||||||
next.push(`${key}_rw`);
|
next.push("snapshot_create");
|
||||||
}
|
setLocalRoles(next);
|
||||||
if (!next.includes("snapshot_create")) {
|
}}
|
||||||
next.push("snapshot_create");
|
>
|
||||||
}
|
Полный доступ (admin)
|
||||||
if (!next.includes("devices_maintenance_rw")) {
|
</Button>
|
||||||
next.push("devices_maintenance_rw");
|
<Button
|
||||||
}
|
variant="outlined"
|
||||||
next.push("admin");
|
size="small"
|
||||||
return next;
|
onClick={() => {
|
||||||
});
|
setEditUserData(editUserData.name || "", editUserData.email || "", editUserData.password || "", false, editUserData.icon || "");
|
||||||
} else {
|
setLocalRoles(["devices_ro", "vehicles_ro", "devices_maintenance_rw"]);
|
||||||
setLocalRoles((prev) => prev.filter((r) => r !== "admin"));
|
}}
|
||||||
}
|
>
|
||||||
}}
|
Администратор ТО
|
||||||
/>
|
</Button>
|
||||||
}
|
</Box>
|
||||||
label="Полный доступ (admin)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
|
<Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
|
||||||
<Table size="small">
|
<Table size="small">
|
||||||
@@ -371,20 +382,6 @@ export const UserEditPage = observer(() => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allRw = ROLE_RESOURCES.every(({ key: k }) =>
|
|
||||||
updated.includes(`${k}_rw`),
|
|
||||||
);
|
|
||||||
if (allRw && !updated.includes("admin")) {
|
|
||||||
const next = [...updated];
|
|
||||||
if (!next.includes("snapshot_create")) {
|
|
||||||
next.push("snapshot_create");
|
|
||||||
}
|
|
||||||
next.push("admin");
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
if (!allRw) {
|
|
||||||
return updated.filter((r) => r !== "admin");
|
|
||||||
}
|
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -462,12 +459,14 @@ export const UserEditPage = observer(() => {
|
|||||||
title="Разрешает создавать новые снапшоты"
|
title="Разрешает создавать новые снапшоты"
|
||||||
/>
|
/>
|
||||||
) : isDevicesResource ? (
|
) : isDevicesResource ? (
|
||||||
<Checkbox
|
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "center" }}>
|
||||||
checked={localRoles.includes("devices_maintenance_rw")}
|
<Checkbox
|
||||||
onChange={(e) => handleMaintenanceChange(e.target.checked)}
|
checked={localRoles.includes("devices_maintenance_rw")}
|
||||||
size="small"
|
onChange={(e) => handleMaintenanceChange(e.target.checked)}
|
||||||
title="Разрешает переводить устройства в режим технического обслуживания"
|
size="small"
|
||||||
/>
|
title="Техническое обслуживание (ТО)"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
-
|
-
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ export type Carrier = {
|
|||||||
city: string;
|
city: string;
|
||||||
city_id: number;
|
city_id: number;
|
||||||
logo: string;
|
logo: string;
|
||||||
// main_color: string;
|
main_color: string;
|
||||||
// left_color: string;
|
left_color: string;
|
||||||
// right_color: string;
|
right_color: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CarrierData = {
|
type CarrierData = {
|
||||||
@@ -112,6 +112,9 @@ class CarrierStore {
|
|||||||
createCarrierData = {
|
createCarrierData = {
|
||||||
city_id: 0,
|
city_id: 0,
|
||||||
logo: "",
|
logo: "",
|
||||||
|
main_color: "",
|
||||||
|
left_color: "",
|
||||||
|
right_color: "",
|
||||||
ru: {
|
ru: {
|
||||||
full_name: "",
|
full_name: "",
|
||||||
short_name: "",
|
short_name: "",
|
||||||
@@ -135,10 +138,16 @@ class CarrierStore {
|
|||||||
cityId: number,
|
cityId: number,
|
||||||
slogan: string,
|
slogan: string,
|
||||||
logoId: string,
|
logoId: string,
|
||||||
language: Language
|
language: Language,
|
||||||
|
colors?: { main_color?: string; left_color?: string; right_color?: string }
|
||||||
) => {
|
) => {
|
||||||
this.createCarrierData.city_id = cityId;
|
this.createCarrierData.city_id = cityId;
|
||||||
this.createCarrierData.logo = logoId;
|
this.createCarrierData.logo = logoId;
|
||||||
|
if (colors) {
|
||||||
|
if (colors.main_color !== undefined) this.createCarrierData.main_color = colors.main_color;
|
||||||
|
if (colors.left_color !== undefined) this.createCarrierData.left_color = colors.left_color;
|
||||||
|
if (colors.right_color !== undefined) this.createCarrierData.right_color = colors.right_color;
|
||||||
|
}
|
||||||
this.createCarrierData[language] = {
|
this.createCarrierData[language] = {
|
||||||
full_name: fullName,
|
full_name: fullName,
|
||||||
short_name: shortName,
|
short_name: shortName,
|
||||||
@@ -198,9 +207,10 @@ class CarrierStore {
|
|||||||
city: cityName,
|
city: cityName,
|
||||||
city_id: this.createCarrierData.city_id,
|
city_id: this.createCarrierData.city_id,
|
||||||
slogan: (this.createCarrierData[language].slogan || "").trim(),
|
slogan: (this.createCarrierData[language].slogan || "").trim(),
|
||||||
...(this.createCarrierData.logo
|
...(this.createCarrierData.logo ? { logo: this.createCarrierData.logo } : {}),
|
||||||
? { logo: this.createCarrierData.logo }
|
...(this.createCarrierData.main_color ? { main_color: this.createCarrierData.main_color } : {}),
|
||||||
: {}),
|
...(this.createCarrierData.left_color ? { left_color: this.createCarrierData.left_color } : {}),
|
||||||
|
...(this.createCarrierData.right_color ? { right_color: this.createCarrierData.right_color } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await languageInstance(language).post("/carrier", payload);
|
const response = await languageInstance(language).post("/carrier", payload);
|
||||||
@@ -243,6 +253,9 @@ class CarrierStore {
|
|||||||
this.createCarrierData = {
|
this.createCarrierData = {
|
||||||
city_id: 0,
|
city_id: 0,
|
||||||
logo: "",
|
logo: "",
|
||||||
|
main_color: "",
|
||||||
|
left_color: "",
|
||||||
|
right_color: "",
|
||||||
ru: {
|
ru: {
|
||||||
full_name: "",
|
full_name: "",
|
||||||
short_name: "",
|
short_name: "",
|
||||||
@@ -265,53 +278,44 @@ class CarrierStore {
|
|||||||
ru: {
|
ru: {
|
||||||
full_name: "",
|
full_name: "",
|
||||||
short_name: "",
|
short_name: "",
|
||||||
|
|
||||||
// main_color: "",
|
|
||||||
// left_color: "",
|
|
||||||
// right_color: "",
|
|
||||||
slogan: "",
|
slogan: "",
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
full_name: "",
|
full_name: "",
|
||||||
short_name: "",
|
short_name: "",
|
||||||
|
slogan: "",
|
||||||
// main_color: "",
|
},
|
||||||
// left_color: "",
|
zh: {
|
||||||
// right_color: "",
|
full_name: "",
|
||||||
|
short_name: "",
|
||||||
slogan: "",
|
slogan: "",
|
||||||
},
|
},
|
||||||
city_id: 0,
|
city_id: 0,
|
||||||
logo: "",
|
logo: "",
|
||||||
zh: {
|
main_color: "",
|
||||||
full_name: "",
|
left_color: "",
|
||||||
short_name: "",
|
right_color: "",
|
||||||
|
|
||||||
// main_color: "",
|
|
||||||
// left_color: "",
|
|
||||||
// right_color: "",
|
|
||||||
slogan: "",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setEditCarrierData = (
|
setEditCarrierData = (
|
||||||
fullName: string,
|
fullName: string,
|
||||||
shortName: string,
|
shortName: string,
|
||||||
cityId: number,
|
cityId: number,
|
||||||
// main_color: string,
|
|
||||||
// left_color: string,
|
|
||||||
// right_color: string,
|
|
||||||
slogan: string,
|
slogan: string,
|
||||||
logoId: string,
|
logoId: string,
|
||||||
language: Language
|
language: Language,
|
||||||
|
colors?: { main_color?: string; left_color?: string; right_color?: string }
|
||||||
) => {
|
) => {
|
||||||
this.editCarrierData.city_id = cityId;
|
this.editCarrierData.city_id = cityId;
|
||||||
this.editCarrierData.logo = logoId;
|
this.editCarrierData.logo = logoId;
|
||||||
|
if (colors) {
|
||||||
|
if (colors.main_color !== undefined) this.editCarrierData.main_color = colors.main_color;
|
||||||
|
if (colors.left_color !== undefined) this.editCarrierData.left_color = colors.left_color;
|
||||||
|
if (colors.right_color !== undefined) this.editCarrierData.right_color = colors.right_color;
|
||||||
|
}
|
||||||
this.editCarrierData[language] = {
|
this.editCarrierData[language] = {
|
||||||
full_name: fullName,
|
full_name: fullName,
|
||||||
short_name: shortName,
|
short_name: shortName,
|
||||||
// main_color: main_color,
|
|
||||||
// left_color: left_color,
|
|
||||||
// right_color: right_color,
|
|
||||||
slogan: slogan,
|
slogan: slogan,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -326,9 +330,10 @@ class CarrierStore {
|
|||||||
slogan: (this.editCarrierData[lang].slogan || "").trim(),
|
slogan: (this.editCarrierData[lang].slogan || "").trim(),
|
||||||
city: cityName,
|
city: cityName,
|
||||||
city_id: this.editCarrierData.city_id,
|
city_id: this.editCarrierData.city_id,
|
||||||
...(this.editCarrierData.logo
|
...(this.editCarrierData.logo ? { logo: this.editCarrierData.logo } : {}),
|
||||||
? { logo: this.editCarrierData.logo }
|
...(this.editCarrierData.main_color ? { main_color: this.editCarrierData.main_color } : {}),
|
||||||
: {}),
|
...(this.editCarrierData.left_color ? { left_color: this.editCarrierData.left_color } : {}),
|
||||||
|
...(this.editCarrierData.right_color ? { right_color: this.editCarrierData.right_color } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ class RouteStore {
|
|||||||
scale_max: 0,
|
scale_max: 0,
|
||||||
scale_min: 0,
|
scale_min: 0,
|
||||||
video_preview: "" as string | undefined,
|
video_preview: "" as string | undefined,
|
||||||
video_timer: 60,
|
video_timer: 420,
|
||||||
};
|
};
|
||||||
|
|
||||||
setEditRouteData = (data: any) => {
|
setEditRouteData = (data: any) => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { City } from "../CityStore";
|
|||||||
|
|
||||||
class SelectedCityStore {
|
class SelectedCityStore {
|
||||||
selectedCity: City | null = null;
|
selectedCity: City | null = null;
|
||||||
|
isLocked: boolean = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
makeAutoObservable(this);
|
makeAutoObservable(this);
|
||||||
@@ -32,6 +33,12 @@ class SelectedCityStore {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
setIsLocked = (locked: boolean) => {
|
||||||
|
runInAction(() => {
|
||||||
|
this.isLocked = locked;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
clearSelectedCity = () => {
|
clearSelectedCity = () => {
|
||||||
this.setSelectedCity(null);
|
this.setSelectedCity(null);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { authStore, cityStore, selectedCityStore, type City } from "@shared";
|
|||||||
import { MapPin } from "lucide-react";
|
import { MapPin } from "lucide-react";
|
||||||
|
|
||||||
export const CitySelector: React.FC = observer(() => {
|
export const CitySelector: React.FC = observer(() => {
|
||||||
const { selectedCity, setSelectedCity } = selectedCityStore;
|
const { selectedCity, setSelectedCity, isLocked } = selectedCityStore;
|
||||||
const canLoadAllCities = authStore.isAdmin && authStore.canRead("cities");
|
const canLoadAllCities = authStore.isAdmin && authStore.canRead("cities");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -58,26 +58,35 @@ export const CitySelector: React.FC = observer(() => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex items-center gap-2">
|
<Box className="flex items-center gap-2">
|
||||||
<MapPin size={16} className="text-white" />
|
<MapPin size={16} className={isLocked ? "text-gray-400" : "text-white"} />
|
||||||
<FormControl size="medium" sx={{ minWidth: 120 }}>
|
<FormControl size="medium" sx={{ minWidth: 120 }}>
|
||||||
<Select
|
<Select
|
||||||
value={selectedCity?.id?.toString() || ""}
|
value={selectedCity?.id?.toString() || ""}
|
||||||
onChange={handleCityChange}
|
onChange={handleCityChange}
|
||||||
displayEmpty
|
displayEmpty
|
||||||
|
disabled={isLocked}
|
||||||
sx={{
|
sx={{
|
||||||
height: "40px",
|
height: "40px",
|
||||||
color: "white",
|
color: "white",
|
||||||
|
"&.Mui-disabled": {
|
||||||
|
color: "rgba(255, 255, 255, 0.5)",
|
||||||
|
WebkitTextFillColor: "rgba(255, 255, 255, 0.5)",
|
||||||
|
},
|
||||||
"& .MuiOutlinedInput-notchedOutline": {
|
"& .MuiOutlinedInput-notchedOutline": {
|
||||||
borderColor: "rgba(255, 255, 255, 0.3)",
|
borderColor: isLocked
|
||||||
|
? "rgba(255, 255, 255, 0.1)"
|
||||||
|
: "rgba(255, 255, 255, 0.3)",
|
||||||
},
|
},
|
||||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||||
borderColor: "rgba(255, 255, 255, 0.5)",
|
borderColor: isLocked
|
||||||
|
? "rgba(255, 255, 255, 0.1)"
|
||||||
|
: "rgba(255, 255, 255, 0.5)",
|
||||||
},
|
},
|
||||||
"&.Mui.focused .MuiOutlinedInput-notchedOutline": {
|
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
|
||||||
borderColor: "white",
|
borderColor: "white",
|
||||||
},
|
},
|
||||||
"& .MuiSvgIcon-root": {
|
"& .MuiSvgIcon-root": {
|
||||||
color: "white",
|
color: isLocked ? "rgba(255, 255, 255, 0.3)" : "white",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -628,41 +628,57 @@ export const DevicesTable = observer(() => {
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{canWriteDevices && (
|
{!isMaintenanceOnly && (
|
||||||
<button
|
<>
|
||||||
onClick={(e) => {
|
{canWriteDevices && (
|
||||||
e.stopPropagation();
|
<button
|
||||||
navigate(`/vehicle/${row.vehicle_id}/edit`);
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
title="Редактировать транспорт"
|
navigate(`/vehicle/${row.vehicle_id}/edit`);
|
||||||
>
|
}}
|
||||||
<Pencil size={16} />
|
title="Редактировать транспорт"
|
||||||
</button>
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleReloadStatus();
|
||||||
|
}}
|
||||||
|
title="Перезапросить статус"
|
||||||
|
disabled={
|
||||||
|
!row.device_uuid || !devices.includes(row.device_uuid)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RotateCcw size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (row.device_uuid) {
|
||||||
|
navigator.clipboard.writeText(row.device_uuid);
|
||||||
|
toast.success("UUID скопирован");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Копировать UUID"
|
||||||
|
>
|
||||||
|
<Copy size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (row.device_uuid) {
|
||||||
|
setLogsModalDeviceUuid(row.device_uuid);
|
||||||
|
setLogsModalOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Логи устройства"
|
||||||
|
>
|
||||||
|
<ScrollText size={16} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleReloadStatus();
|
|
||||||
}}
|
|
||||||
title="Перезапросить статус"
|
|
||||||
disabled={
|
|
||||||
!row.device_uuid || !devices.includes(row.device_uuid)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<RotateCcw size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (row.device_uuid) {
|
|
||||||
navigator.clipboard.writeText(row.device_uuid);
|
|
||||||
toast.success("UUID скопирован");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title="Копировать UUID"
|
|
||||||
>
|
|
||||||
<Copy size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -674,18 +690,6 @@ export const DevicesTable = observer(() => {
|
|||||||
>
|
>
|
||||||
<Wrench size={16} />
|
<Wrench size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (row.device_uuid) {
|
|
||||||
setLogsModalDeviceUuid(row.device_uuid);
|
|
||||||
setLogsModalOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title="Логи устройства"
|
|
||||||
>
|
|
||||||
<ScrollText size={16} />
|
|
||||||
</button>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -714,9 +718,11 @@ export const DevicesTable = observer(() => {
|
|||||||
|
|
||||||
const visibleColumns = useMemo(() => {
|
const visibleColumns = useMemo(() => {
|
||||||
if (isMaintenanceOnly) {
|
if (isMaintenanceOnly) {
|
||||||
return columns.filter((c) =>
|
return columns
|
||||||
["model", "tail_number", "maintenance_mode_on"].includes(c.field),
|
.filter((c) =>
|
||||||
);
|
["model", "tail_number", "maintenance_mode_on", "actions"].includes(c.field),
|
||||||
|
)
|
||||||
|
.map((c) => ({ ...c, flex: 1, width: undefined, minWidth: undefined }));
|
||||||
}
|
}
|
||||||
if (!canWriteDevices) {
|
if (!canWriteDevices) {
|
||||||
return columns.filter(
|
return columns.filter(
|
||||||
@@ -729,20 +735,26 @@ export const DevicesTable = observer(() => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await Promise.all([
|
if (isMaintenanceOnly) {
|
||||||
getVehicles(),
|
await Promise.all([getVehicles(), getDevices()]);
|
||||||
getDevices(),
|
} else {
|
||||||
getSnapshots(),
|
await Promise.all([
|
||||||
getRoutes(),
|
getVehicles(),
|
||||||
]);
|
getDevices(),
|
||||||
|
getSnapshots(),
|
||||||
|
getRoutes(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [getDevices, getSnapshots, getVehicles, getRoutes]);
|
}, [getDevices, getSnapshots, getVehicles, getRoutes, isMaintenanceOnly]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
carrierStore.getCarriers("ru");
|
if (!isMaintenanceOnly) {
|
||||||
}, []);
|
carrierStore.getCarriers("ru");
|
||||||
|
}
|
||||||
|
}, [isMaintenanceOnly]);
|
||||||
|
|
||||||
const handleOpenSendSnapshotModal = () => {
|
const handleOpenSendSnapshotModal = () => {
|
||||||
if (!canWriteDevices) {
|
if (!canWriteDevices) {
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ export const ReactMarkdownEditor = ({
|
|||||||
"table",
|
"table",
|
||||||
"horizontal-rule",
|
"horizontal-rule",
|
||||||
"preview",
|
"preview",
|
||||||
"fullscreen",
|
|
||||||
"guide",
|
"guide",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -243,149 +243,123 @@ export const LeftWidgetTab = observer(
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
maxWidth: "320px",
|
maxWidth: "316px",
|
||||||
gap: 0.5,
|
gap: 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Paper
|
<Box
|
||||||
elevation={3}
|
|
||||||
sx={{
|
sx={{
|
||||||
width: "100%",
|
width: "316px",
|
||||||
minWidth: 320,
|
|
||||||
background:
|
|
||||||
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
|
|
||||||
overflowY: "auto",
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
borderRadius: "10px",
|
borderRadius: "10px",
|
||||||
|
background:
|
||||||
|
"linear-gradient(114deg, rgba(255,255,255,0) 8.71%, rgba(255,255,255,0.16) 69.69%), #006F3A",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
{data.left.media.length > 0 ? (
|
||||||
sx={{
|
<Box
|
||||||
overflow: "hidden",
|
|
||||||
width: "100%",
|
|
||||||
minHeight: 100,
|
|
||||||
padding: "3px",
|
|
||||||
position: "relative",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
|
|
||||||
"& img": {
|
|
||||||
borderTopLeftRadius: "10px",
|
|
||||||
borderTopRightRadius: "10px",
|
|
||||||
width: "100%",
|
|
||||||
height: "auto",
|
|
||||||
objectFit: "contain",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{data.left.media.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<MediaViewer
|
|
||||||
media={{
|
|
||||||
id: data.left.media[0].id,
|
|
||||||
media_type: data.left.media[0].media_type,
|
|
||||||
filename: data.left.media[0].filename,
|
|
||||||
}}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
{sight.common.watermark_lu && (
|
|
||||||
<img
|
|
||||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
|
||||||
sight.common.watermark_lu
|
|
||||||
}/download?token=${token}`}
|
|
||||||
alt="preview"
|
|
||||||
className="absolute top-4 left-4 z-10"
|
|
||||||
style={{
|
|
||||||
width: "30px",
|
|
||||||
height: "30px",
|
|
||||||
objectFit: "contain",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{sight.common.watermark_rd && (
|
|
||||||
<img
|
|
||||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
|
||||||
sight.common.watermark_rd
|
|
||||||
}/download?token=${token}`}
|
|
||||||
alt="preview"
|
|
||||||
className="absolute bottom-4 right-4 z-10"
|
|
||||||
style={{
|
|
||||||
width: "30px",
|
|
||||||
height: "30px",
|
|
||||||
objectFit: "contain",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<ImagePlus size={48} color="white" />
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
background:
|
|
||||||
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
|
|
||||||
color: "white",
|
|
||||||
margin: "5px 0px 5px 0px",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 1,
|
|
||||||
padding: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="h5"
|
|
||||||
component="h2"
|
|
||||||
sx={{
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
width: "312px",
|
||||||
|
height: "175px",
|
||||||
|
margin: "2px 0px 2px 0px",
|
||||||
|
borderRadius: "10px 10px 0 0",
|
||||||
|
overflow: "hidden",
|
||||||
|
flexShrink: 0,
|
||||||
|
"& img, & video": {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
|
borderRadius: "10px 10px 0 0",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MediaViewer
|
||||||
|
media={{
|
||||||
|
id: data.left.media[0].id,
|
||||||
|
media_type: data.left.media[0].media_type,
|
||||||
|
filename: data.left.media[0].filename,
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
{sight.common.watermark_lu && (
|
||||||
|
<img
|
||||||
|
src={`${import.meta.env.VITE_KRBL_MEDIA}${sight.common.watermark_lu}/download?token=${token}`}
|
||||||
|
alt="preview"
|
||||||
|
className="absolute top-4 left-4 z-10"
|
||||||
|
style={{ width: "30px", height: "30px", objectFit: "contain" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{sight.common.watermark_rd && (
|
||||||
|
<img
|
||||||
|
src={`${import.meta.env.VITE_KRBL_MEDIA}${sight.common.watermark_rd}/download?token=${token}`}
|
||||||
|
alt="preview"
|
||||||
|
className="absolute bottom-4 right-4 z-10"
|
||||||
|
style={{ width: "30px", height: "30px", objectFit: "contain" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", height: "175px" }}>
|
||||||
|
<ImagePlus size={48} color="white" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ padding: "0px 10px 20px 10px", width: "100%" }}>
|
||||||
|
<Box
|
||||||
|
component="div"
|
||||||
|
sx={{
|
||||||
|
color: "#fff",
|
||||||
|
fontFamily: "Roboto",
|
||||||
|
fontSize: "20px",
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: "150%",
|
||||||
wordBreak: "break-word",
|
wordBreak: "break-word",
|
||||||
fontSize: "24px",
|
|
||||||
fontWeight: 700,
|
|
||||||
lineHeight: "120%",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data?.left?.heading || "Название информации"}
|
{data?.left?.heading || "Название информации"}
|
||||||
</Typography>
|
</Box>
|
||||||
<Typography
|
<Box
|
||||||
variant="h6"
|
component="div"
|
||||||
component="h2"
|
|
||||||
sx={{
|
sx={{
|
||||||
|
marginTop: "2px",
|
||||||
|
color: "#fff",
|
||||||
|
fontFamily: "Roboto",
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: "150%",
|
||||||
wordBreak: "break-word",
|
wordBreak: "break-word",
|
||||||
fontSize: "18px",
|
|
||||||
|
|
||||||
lineHeight: "120%",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{sight[language as Language].address}
|
{sight[language as Language].address}
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{data?.left?.body && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
padding: 1,
|
|
||||||
maxHeight: "300px",
|
|
||||||
overflowY: "auto",
|
|
||||||
width: "100%",
|
|
||||||
"&::-webkit-scrollbar": {
|
|
||||||
display: "none",
|
|
||||||
},
|
|
||||||
"&": {
|
|
||||||
scrollbarWidth: "none",
|
|
||||||
},
|
|
||||||
background:
|
|
||||||
"#806c59 linear-gradient(90deg, rgba(255, 255, 255, 0.2) 12.5%, rgba(255, 255, 255, 0.2) 100%)",
|
|
||||||
flexGrow: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ReactMarkdownComponent value={data?.left?.body} />
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
|
||||||
</Paper>
|
{data?.left?.body && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
marginTop: "15px",
|
||||||
|
maxHeight: "200px",
|
||||||
|
overflowY: "auto",
|
||||||
|
"&::-webkit-scrollbar": { display: "none" },
|
||||||
|
scrollbarWidth: "none",
|
||||||
|
"& p, & li, & h1, & h2, & h3": {
|
||||||
|
color: "#fff !important",
|
||||||
|
fontFamily: "Roboto, sans-serif !important",
|
||||||
|
fontSize: "16px !important",
|
||||||
|
fontWeight: "300 !important",
|
||||||
|
lineHeight: "135% !important",
|
||||||
|
marginTop: "0 !important",
|
||||||
|
marginBottom: "0 !important",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ReactMarkdownComponent value={data?.left?.body} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
rgba(255, 255, 255, 0) 8.71%,
|
rgba(255, 255, 255, 0) 8.71%,
|
||||||
rgba(255, 255, 255, 0.16) 69.69%
|
rgba(255, 255, 255, 0.16) 69.69%
|
||||||
),
|
),
|
||||||
#806c59;
|
#006f3a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sfp-sight-frame-media-stack {
|
.sfp-sight-frame-media-stack {
|
||||||
@@ -22,7 +22,6 @@
|
|||||||
width: calc(100% - 4px);
|
width: calc(100% - 4px);
|
||||||
height: 300px;
|
height: 300px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #111;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sfp-sight-frame-media-item {
|
.sfp-sight-frame-media-item {
|
||||||
@@ -67,21 +66,21 @@
|
|||||||
.sfp-sight-frame-title {
|
.sfp-sight-frame-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 7px 16px;
|
padding: 10px 20px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-family: "Roboto";
|
font-family: "Roboto";
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 120%;
|
line-height: 120%;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.8);
|
border-bottom: 1px solid var(--Glass-stroke, rgba(255, 255, 255, 0.8));
|
||||||
background:
|
background:
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
rgba(255, 255, 255, 0.22) 0%,
|
rgba(255, 255, 255, 0.22) 0%,
|
||||||
rgba(255, 255, 255, 0.04) 100%
|
rgba(255, 255, 255, 0.04) 100%
|
||||||
),
|
),
|
||||||
rgba(179, 165, 152, 0.72);
|
rgba(0, 111, 58, 0.72);
|
||||||
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -118,7 +117,7 @@
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
max-height: calc(80vh - 354px);
|
max-height: calc(80vh - 354px);
|
||||||
min-height: 80px;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -156,6 +155,9 @@
|
|||||||
.sfp-sight-frame-text h3,
|
.sfp-sight-frame-text h3,
|
||||||
.sfp-sight-frame-text li {
|
.sfp-sight-frame-text li {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 150%;
|
||||||
|
font-family: "Roboto";
|
||||||
}
|
}
|
||||||
|
|
||||||
.sfp-sight-frame-menu {
|
.sfp-sight-frame-menu {
|
||||||
@@ -173,7 +175,7 @@
|
|||||||
rgba(255, 255, 255, 0.2) 0%,
|
rgba(255, 255, 255, 0.2) 0%,
|
||||||
rgba(255, 255, 255, 0) 100%
|
rgba(255, 255, 255, 0) 100%
|
||||||
),
|
),
|
||||||
rgba(179, 165, 152, 0.4);
|
rgba(0, 111, 58, 0.4);
|
||||||
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -265,3 +267,4 @@
|
|||||||
.sfp-sight-frame-media-stack.three-d-view {
|
.sfp-sight-frame-media-stack.three-d-view {
|
||||||
background-color: #111 !important;
|
background-color: #111 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user