added route preview

This commit is contained in:
Илья Куприец 2025-04-15 21:12:43 +03:00
parent b6449b02c0
commit 4dd149f2af
193 changed files with 3568 additions and 1692 deletions

View File

@ -6,37 +6,55 @@
"dependencies": {
"@emotion/react": "^11.8.2",
"@emotion/styled": "^11.8.1",
"@hello-pangea/dnd": "^18.0.1",
"@mui/icons-material": "^6.1.6",
"@mui/lab": "^6.0.0-beta.14",
"@mui/material": "^6.1.7",
"@mui/x-data-grid": "^7.22.2",
"@react-three/drei": "^10.0.6",
"@react-three/fiber": "^9.1.2",
"@refinedev/cli": "^2.16.21",
"@refinedev/core": "^4.47.1",
"@refinedev/core": "^4.57.9",
"@refinedev/devtools": "^1.1.32",
"@refinedev/kbar": "^1.3.6",
"@refinedev/kbar": "^1.3.16",
"@refinedev/mui": "^6.0.0",
"@refinedev/react-hook-form": "^4.8.14",
"@refinedev/react-router": "^1.0.0",
"@refinedev/simple-rest": "^5.0.1",
"@tanstack/react-query": "^5.74.3",
"@types/react-beautiful-dnd": "^13.1.8",
"@types/react-simple-maps": "^3.0.6",
"axios": "^1.7.9",
"classnames": "^2.5.1",
"d3-geo": "^3.1.1",
"easymde": "^2.19.0",
"i18next": "^24.2.2",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"react": "^18.0.0",
"react-beautiful-dnd": "^13.1.1",
"react-dom": "^18.0.0",
"react-draggable": "^4.4.6",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.30.0",
"react-i18next": "^15.4.1",
"react-intl": "^7.1.10",
"react-markdown": "^10.1.0",
"react-photo-sphere-viewer": "^6.2.2",
"react-router": "^7.0.2",
"react-simplemde-editor": "^5.2.0"
"react-simple-maps": "^3.0.0",
"react-simplemde-editor": "^5.2.0",
"react-swipeable": "^7.0.2",
"three": "^0.175.0",
"vite-plugin-svgr": "^4.3.0"
},
"devDependencies": {
"@types/d3-geo": "^3.1.0",
"@types/js-cookie": "^3.0.6",
"@types/node": "^18.16.2",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@types/three": "^0.175.0",
"@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.1",
"@vitejs/plugin-react": "^4.0.0",

File diff suppressed because it is too large Load Diff

View File

@ -1,375 +1,427 @@
import {Refine, Authenticated} from '@refinedev/core'
import {DevtoolsPanel, DevtoolsProvider} from '@refinedev/devtools'
import {RefineKbar, RefineKbarProvider} from '@refinedev/kbar'
import { Refine, Authenticated } from "@refinedev/core";
import { DevtoolsPanel, DevtoolsProvider } from "@refinedev/devtools";
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
import {ErrorComponent, useNotificationProvider, RefineSnackbarProvider, ThemedLayoutV2} from '@refinedev/mui'
import {
ErrorComponent,
useNotificationProvider,
RefineSnackbarProvider,
ThemedLayoutV2,
} from "@refinedev/mui";
import {customDataProvider} from './providers/data'
import CssBaseline from '@mui/material/CssBaseline'
import GlobalStyles from '@mui/material/GlobalStyles'
import {BrowserRouter, Route, Routes, Outlet} from 'react-router'
import routerBindings, {NavigateToResource, CatchAllNavigate, UnsavedChangesNotifier, DocumentTitleHandler} from '@refinedev/react-router'
import {ColorModeContextProvider} from './contexts/color-mode'
import {Header} from './components/header'
import {Login} from './pages/login'
import {authProvider} from './authProvider'
import {i18nProvider} from './i18nProvider'
import { customDataProvider } from "./providers/data";
import CssBaseline from "@mui/material/CssBaseline";
import GlobalStyles from "@mui/material/GlobalStyles";
import { BrowserRouter, Route, Routes, Outlet } from "react-router";
import routerBindings, {
NavigateToResource,
CatchAllNavigate,
UnsavedChangesNotifier,
DocumentTitleHandler,
} from "@refinedev/react-router";
import { ColorModeContextProvider } from "./contexts/color-mode";
import { Header } from "./components/header";
import { Login } from "./pages/login";
import { authProvider } from "./authProvider";
import { i18nProvider } from "./i18nProvider";
import {CountryList, CountryCreate, CountryEdit, CountryShow} from './pages/country'
import {CityList, CityCreate, CityEdit, CityShow} from './pages/city'
import {CarrierList, CarrierCreate, CarrierEdit, CarrierShow} from './pages/carrier'
import {MediaList, MediaCreate, MediaEdit, MediaShow} from './pages/media'
import {ArticleList, ArticleCreate, ArticleEdit, ArticleShow} from './pages/article'
import {SightList, SightCreate, SightEdit, SightShow} from './pages/sight'
import {StationList, StationCreate, StationEdit, StationShow} from './pages/station'
import {VehicleList, VehicleCreate, VehicleEdit, VehicleShow} from './pages/vehicle'
import {RouteList, RouteCreate, RouteEdit, RouteShow} from './pages/route'
import {UserList, UserCreate, UserEdit, UserShow} from './pages/user'
import {
CountryList,
CountryCreate,
CountryEdit,
CountryShow,
} from "./pages/country";
import { CityList, CityCreate, CityEdit, CityShow } from "./pages/city";
import {
CarrierList,
CarrierCreate,
CarrierEdit,
CarrierShow,
} from "./pages/carrier";
import { MediaList, MediaCreate, MediaEdit, MediaShow } from "./pages/media";
import {
ArticleList,
ArticleCreate,
ArticleEdit,
ArticleShow,
} from "./pages/article";
import { SightList, SightCreate, SightEdit, SightShow } from "./pages/sight";
import {
StationList,
StationCreate,
StationEdit,
StationShow,
} from "./pages/station";
import {
VehicleList,
VehicleCreate,
VehicleEdit,
VehicleShow,
} from "./pages/vehicle";
import { RouteList, RouteCreate, RouteEdit, RouteShow } from "./pages/route";
import { UserList, UserCreate, UserEdit, UserShow } from "./pages/user";
import {CountryIcon, CityIcon, CarrierIcon, MediaIcon, ArticleIcon, SightIcon, StationIcon, VehicleIcon, RouteIcon, UsersIcon} from './components/ui/Icons'
import SidebarTitle from './components/ui/SidebarTitle'
import {AdminOnly} from './components/AdminOnly'
import {
CountryIcon,
CityIcon,
CarrierIcon,
MediaIcon,
ArticleIcon,
SightIcon,
StationIcon,
VehicleIcon,
RouteIcon,
UsersIcon,
} from "./components/ui/Icons";
import SidebarTitle from "./components/ui/SidebarTitle";
import { AdminOnly } from "./components/AdminOnly";
function App() {
return (
<BrowserRouter>
<RefineKbarProvider>
<ColorModeContextProvider>
<CssBaseline />
<GlobalStyles styles={{html: {WebkitFontSmoothing: 'auto'}}} />
<RefineSnackbarProvider>
<DevtoolsProvider>
<Refine
dataProvider={customDataProvider}
notificationProvider={useNotificationProvider}
routerProvider={routerBindings}
authProvider={authProvider}
i18nProvider={i18nProvider}
resources={[
{
name: 'country',
list: '/country',
create: '/country/create',
edit: '/country/edit/:id',
show: '/country/show/:id',
meta: {
canDelete: true,
label: 'Страны',
icon: <CountryIcon />,
},
<ColorModeContextProvider>
<CssBaseline />
<GlobalStyles styles={{ html: { WebkitFontSmoothing: "auto" } }} />
<RefineSnackbarProvider>
<DevtoolsProvider>
<Refine
dataProvider={customDataProvider}
notificationProvider={useNotificationProvider}
routerProvider={routerBindings}
authProvider={authProvider}
i18nProvider={i18nProvider}
resources={[
{
name: "country",
list: "/country",
create: "/country/create",
edit: "/country/edit/:id",
show: "/country/show/:id",
meta: {
canDelete: true,
label: "Страны",
icon: <CountryIcon />,
},
{
name: 'city',
list: '/city',
create: '/city/create',
edit: '/city/edit/:id',
show: '/city/show/:id',
meta: {
canDelete: true,
label: 'Города',
icon: <CityIcon />,
},
},
{
name: "city",
list: "/city",
create: "/city/create",
edit: "/city/edit/:id",
show: "/city/show/:id",
meta: {
canDelete: true,
label: "Города",
icon: <CityIcon />,
},
{
name: 'carrier',
list: '/carrier',
create: '/carrier/create',
edit: '/carrier/edit/:id',
show: '/carrier/show/:id',
meta: {
canDelete: true,
label: 'Перевозчики',
icon: <CarrierIcon />,
},
},
{
name: "carrier",
list: "/carrier",
create: "/carrier/create",
edit: "/carrier/edit/:id",
show: "/carrier/show/:id",
meta: {
canDelete: true,
label: "Перевозчики",
icon: <CarrierIcon />,
},
{
name: 'media',
list: '/media',
create: '/media/create',
edit: '/media/edit/:id',
show: '/media/show/:id',
meta: {
canDelete: true,
label: 'Медиа',
icon: <MediaIcon />,
},
},
{
name: "media",
list: "/media",
create: "/media/create",
edit: "/media/edit/:id",
show: "/media/show/:id",
meta: {
canDelete: true,
label: "Медиа",
icon: <MediaIcon />,
},
{
name: 'article',
list: '/article',
create: '/article/create',
edit: '/article/edit/:id',
show: '/article/show/:id',
meta: {
canDelete: true,
label: 'Статьи',
icon: <ArticleIcon />,
},
},
{
name: "article",
list: "/article",
create: "/article/create",
edit: "/article/edit/:id",
show: "/article/show/:id",
meta: {
canDelete: true,
label: "Статьи",
icon: <ArticleIcon />,
},
{
name: 'sight',
list: '/sight',
create: '/sight/create',
edit: '/sight/edit/:id',
show: '/sight/show/:id',
meta: {
canDelete: true,
label: 'Достопримечательности',
icon: <SightIcon />,
},
},
{
name: "sight",
list: "/sight",
create: "/sight/create",
edit: "/sight/edit/:id",
show: "/sight/show/:id",
meta: {
canDelete: true,
label: "Достопримечательности",
icon: <SightIcon />,
},
{
name: 'station',
list: '/station',
create: '/station/create',
edit: '/station/edit/:id',
show: '/station/show/:id',
meta: {
canDelete: true,
label: 'Остановки',
icon: <StationIcon />,
},
},
{
name: "station",
list: "/station",
create: "/station/create",
edit: "/station/edit/:id",
show: "/station/show/:id",
meta: {
canDelete: true,
label: "Остановки",
icon: <StationIcon />,
},
{
name: 'vehicle',
list: '/vehicle',
create: '/vehicle/create',
edit: '/vehicle/edit/:id',
show: '/vehicle/show/:id',
meta: {
canDelete: true,
label: 'Транспорт',
icon: <VehicleIcon />,
},
},
{
name: "vehicle",
list: "/vehicle",
create: "/vehicle/create",
edit: "/vehicle/edit/:id",
show: "/vehicle/show/:id",
meta: {
canDelete: true,
label: "Транспорт",
icon: <VehicleIcon />,
},
{
name: 'route',
list: '/route',
create: '/route/create',
edit: '/route/edit/:id',
show: '/route/show/:id',
meta: {
canDelete: true,
label: 'Маршруты',
icon: <RouteIcon />,
},
},
{
name: "route",
list: "/route",
create: "/route/create",
edit: "/route/edit/:id",
show: "/route/show/:id",
meta: {
canDelete: true,
label: "Маршруты",
icon: <RouteIcon />,
},
{
name: 'user',
list: '/user',
create: '/user/create',
edit: '/user/edit/:id',
show: '/user/show/:id',
meta: {
canDelete: true,
label: 'Пользователи',
icon: <UsersIcon />,
},
},
{
name: "user",
list: "/user",
create: "/user/create",
edit: "/user/edit/:id",
show: "/user/show/:id",
meta: {
canDelete: true,
label: "Пользователи",
icon: <UsersIcon />,
},
]}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true, // Включаем глобально
useNewQueryKeys: true,
projectId: 'Wv044J-t53S3s-PcbJGe',
},
]}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true, // Включаем глобально
useNewQueryKeys: true,
projectId: "Wv044J-t53S3s-PcbJGe",
}}
>
<Routes>
<Route
element={
<Authenticated
key="authenticated-inner"
fallback={<CatchAllNavigate to="/login" />}
>
<ThemedLayoutV2 Header={Header} Title={SidebarTitle}>
<Outlet />
</ThemedLayoutV2>
</Authenticated>
}
>
<Route
index
element={<NavigateToResource resource="country" />}
/>
<Route path="/country">
<Route index element={<CountryList />} />
<Route
path="create"
element={
<AdminOnly>
<CountryCreate />
</AdminOnly>
}
/>
<Route
path="edit/:id"
element={
<AdminOnly>
<CountryEdit />
</AdminOnly>
}
/>
<Route path="show/:id" element={<CountryShow />} />
</Route>
<Route path="/city">
<Route index element={<CityList />} />
<Route
path="create"
element={
<AdminOnly>
<CityCreate />
</AdminOnly>
}
/>
<Route
path="edit/:id"
element={
<AdminOnly>
<CityEdit />
</AdminOnly>
}
/>
<Route path="show/:id" element={<CityShow />} />
</Route>
<Route path="/carrier">
<Route index element={<CarrierList />} />
<Route path="create" element={<CarrierCreate />} />
<Route path="edit/:id" element={<CarrierEdit />} />
<Route path="show/:id" element={<CarrierShow />} />
</Route>
<Route path="/media">
<Route index element={<MediaList />} />
<Route path="create" element={<MediaCreate />} />
<Route path="edit/:id" element={<MediaEdit />} />
<Route path="show/:id" element={<MediaShow />} />
</Route>
<Route path="/article">
<Route index element={<ArticleList />} />
<Route path="create" element={<ArticleCreate />} />
<Route path="edit/:id" element={<ArticleEdit />} />
<Route path="show/:id" element={<ArticleShow />} />
</Route>
<Route path="/sight">
<Route index element={<SightList />} />
<Route path="create" element={<SightCreate />} />
<Route path="edit/:id" element={<SightEdit />} />
<Route path="show/:id" element={<SightShow />} />
</Route>
<Route path="/station">
<Route index element={<StationList />} />
<Route
path="create"
element={
<AdminOnly>
<StationCreate />
</AdminOnly>
}
/>
<Route
path="edit/:id"
element={
<AdminOnly>
<StationEdit />
</AdminOnly>
}
/>
<Route path="show/:id" element={<StationShow />} />
</Route>
<Route path="/vehicle">
<Route index element={<VehicleList />} />
<Route path="create" element={<VehicleCreate />} />
<Route path="edit/:id" element={<VehicleEdit />} />
<Route path="show/:id" element={<VehicleShow />} />
</Route>
<Route path="/route">
<Route index element={<RouteList />} />
<Route
path="create"
element={
<AdminOnly>
<RouteCreate />
</AdminOnly>
}
/>
<Route
path="edit/:id"
element={
<AdminOnly>
<RouteEdit />
</AdminOnly>
}
/>
<Route path="show/:id" element={<RouteShow />} />
</Route>
<Route path="/user">
<Route
index
element={
<AdminOnly>
<UserList />
</AdminOnly>
}
/>
<Route
path="create"
element={
<AdminOnly>
<UserCreate />
</AdminOnly>
}
/>
<Route
path="edit/:id"
element={
<AdminOnly>
<UserEdit />
</AdminOnly>
}
/>
<Route
path="show/:id"
element={
<AdminOnly>
<UserShow />
</AdminOnly>
}
/>
</Route>
<Route path="*" element={<ErrorComponent />} />
</Route>
<Route
element={
<Authenticated
key="authenticated-outer"
fallback={<Outlet />}
>
<NavigateToResource />
</Authenticated>
}
>
<Route path="/login" element={<Login />} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler
handler={() => {
// const cleanedTitle = title.autoGeneratedTitle.split('|')[0].trim()
// return `${cleanedTitle} — Белые ночи`
return "Белые ночи";
}}
>
<Routes>
<Route
element={
<Authenticated key="authenticated-inner" fallback={<CatchAllNavigate to="/login" />}>
<ThemedLayoutV2 Header={Header} Title={SidebarTitle}>
<Outlet />
</ThemedLayoutV2>
</Authenticated>
}
>
<Route index element={<NavigateToResource resource="country" />} />
<Route path="/country">
<Route index element={<CountryList />} />
<Route
path="create"
element={
<AdminOnly>
<CountryCreate />
</AdminOnly>
}
/>
<Route
path="edit/:id"
element={
<AdminOnly>
<CountryEdit />
</AdminOnly>
}
/>
<Route path="show/:id" element={<CountryShow />} />
</Route>
<Route path="/city">
<Route index element={<CityList />} />
<Route
path="create"
element={
<AdminOnly>
<CityCreate />
</AdminOnly>
}
/>
<Route
path="edit/:id"
element={
<AdminOnly>
<CityEdit />
</AdminOnly>
}
/>
<Route path="show/:id" element={<CityShow />} />
</Route>
<Route path="/carrier">
<Route index element={<CarrierList />} />
<Route path="create" element={<CarrierCreate />} />
<Route path="edit/:id" element={<CarrierEdit />} />
<Route path="show/:id" element={<CarrierShow />} />
</Route>
<Route path="/media">
<Route index element={<MediaList />} />
<Route path="create" element={<MediaCreate />} />
<Route path="edit/:id" element={<MediaEdit />} />
<Route path="show/:id" element={<MediaShow />} />
</Route>
<Route path="/article">
<Route index element={<ArticleList />} />
<Route path="create" element={<ArticleCreate />} />
<Route path="edit/:id" element={<ArticleEdit />} />
<Route path="show/:id" element={<ArticleShow />} />
</Route>
<Route path="/sight">
<Route index element={<SightList />} />
<Route path="create" element={<SightCreate />} />
<Route path="edit/:id" element={<SightEdit />} />
<Route path="show/:id" element={<SightShow />} />
</Route>
<Route path="/station">
<Route index element={<StationList />} />
<Route
path="create"
element={
<AdminOnly>
<StationCreate />
</AdminOnly>
}
/>
<Route
path="edit/:id"
element={
<AdminOnly>
<StationEdit />
</AdminOnly>
}
/>
<Route path="show/:id" element={<StationShow />} />
</Route>
<Route path="/vehicle">
<Route index element={<VehicleList />} />
<Route path="create" element={<VehicleCreate />} />
<Route path="edit/:id" element={<VehicleEdit />} />
<Route path="show/:id" element={<VehicleShow />} />
</Route>
<Route path="/route">
<Route index element={<RouteList />} />
<Route
path="create"
element={
<AdminOnly>
<RouteCreate />
</AdminOnly>
}
/>
<Route
path="edit/:id"
element={
<AdminOnly>
<RouteEdit />
</AdminOnly>
}
/>
<Route path="show/:id" element={<RouteShow />} />
</Route>
<Route path="/user">
<Route
index
element={
<AdminOnly>
<UserList />
</AdminOnly>
}
/>
<Route
path="create"
element={
<AdminOnly>
<UserCreate />
</AdminOnly>
}
/>
<Route
path="edit/:id"
element={
<AdminOnly>
<UserEdit />
</AdminOnly>
}
/>
<Route
path="show/:id"
element={
<AdminOnly>
<UserShow />
</AdminOnly>
}
/>
</Route>
<Route path="*" element={<ErrorComponent />} />
</Route>
<Route
element={
<Authenticated key="authenticated-outer" fallback={<Outlet />}>
<NavigateToResource />
</Authenticated>
}
>
<Route path="/login" element={<Login />} />
</Route>
</Routes>
<RefineKbar />
<UnsavedChangesNotifier />
<DocumentTitleHandler
handler={() => {
// const cleanedTitle = title.autoGeneratedTitle.split('|')[0].trim()
// return `${cleanedTitle} — Белые ночи`
return 'Белые ночи'
}}
/>
</Refine>
<DevtoolsPanel />
</DevtoolsProvider>
</RefineSnackbarProvider>
</ColorModeContextProvider>
</RefineKbarProvider>
/>
</Refine>
<DevtoolsPanel />
</DevtoolsProvider>
</RefineSnackbarProvider>
</ColorModeContextProvider>
</BrowserRouter>
)
);
}
export default App
export default App;

View File

@ -12,12 +12,22 @@ import {
useTheme,
TextField,
Autocomplete,
TableCell,
TableContainer,
Table,
TableHead,
TableRow,
Paper,
TableBody,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { axiosInstance } from "../providers/data";
import { Link } from "react-router";
import { TOKEN_KEY } from "../authProvider";
import { Droppable, Draggable, DragDropContext } from "@hello-pangea/dnd";
// TODO: ДОДЕЛАТЬ
type Field<T> = {
label: string;
@ -42,6 +52,14 @@ type LinkedItemsProps<T> = {
extraField?: ExtraFieldConfig;
};
const reorder = (list, startIndex, endIndex) => {
const result = Array.from(list);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
};
export const LinkedItems = <T extends { id: number; [key: string]: any }>({
parentId,
parentResource,
@ -58,6 +76,20 @@ export const LinkedItems = <T extends { id: number; [key: string]: any }>({
const [mediaOrder, setMediaOrder] = useState<number>(1);
const theme = useTheme();
const onDragEnd = (result) => {
// ドロップ先がない
if (!result.destination) {
return;
}
// 配列の順序を入れ替える
let movedItems = reorder(
linkedItems, // 順序を入れ変えたい配列
result.source.index, // 元の配列の位置
result.destination.index // 移動先の配列の位置
);
setLinkedItems(movedItems);
};
useEffect(() => {
if (parentId) {
axiosInstance
@ -180,7 +212,70 @@ export const LinkedItems = <T extends { id: number; [key: string]: any }>({
</AccordionSummary>
<AccordionDetails sx={{ background: theme.palette.background.paper }}>
<Stack gap={2}>
<TableContainer component={Paper}>
<Table aria-label="simple table">
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Name</TableCell>
<TableCell>Действие</TableCell>
</TableRow>
</TableHead>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="droppable">
{(provided) => (
<TableBody
ref={provided.innerRef}
{...provided.droppableProps}
>
{linkedItems.length > 0 &&
linkedItems.map((item, index) => (
<Draggable
key={item.id}
draggableId={"q" + item.id.toString()}
index={index}
>
{(provided) => (
<TableRow
ref={provided.innerRef}
{...provided.dragHandleProps}
{...provided.draggableProps}
style={{
...provided.draggableProps.style,
}}
>
<TableCell style={{ flex: 1, minWidth: "100px" }}>
{item.id}
</TableCell>
<TableCell style={{ flex: 1, minWidth: "100px" }}>
{item.name}
</TableCell>
<TableCell style={{ flex: 1, minWidth: "100px" }}>
<Button
variant="outlined"
color="error"
size="small"
onClick={(e) => {
e.preventDefault();
deleteItem(item.id);
}}
>
Отвязать
</Button>
</TableCell>
</TableRow>
)}
</Draggable>
))}
{provided.placeholder}
</TableBody>
)}
</Droppable>
</DragDropContext>
</Table>
</TableContainer>
{/* <Stack gap={2}>
<Grid container gap={1.25}>
{isLoading ? (
<Typography>Загрузка...</Typography>
@ -208,7 +303,8 @@ export const LinkedItems = <T extends { id: number; [key: string]: any }>({
{childResource === "media" && item.id && (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}/${
item.id
item.iimport { DragDropContext } from 'react-beautiful-dnd';
d
}/download?token=${localStorage.getItem(TOKEN_KEY)}`}
alt={String(item.media_name)}
style={{
@ -338,7 +434,7 @@ export const LinkedItems = <T extends { id: number; [key: string]: any }>({
</Button>
</Stack>
)}
</Stack>
</Stack> */}
</AccordionDetails>
</Accordion>
);

View File

@ -1,4 +1,3 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
@ -7,8 +6,4 @@ import "./globals.css";
const container = document.getElementById("root") as HTMLElement;
const root = createRoot(container);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
root.render(<App />);

View File

@ -130,12 +130,27 @@ export const RouteEdit = () => {
(Прямой / Обратный)
</Typography>
<Controller
name="path"
control={control}
defaultValue={[]}
rules={{
<TextField
{...register("path", {
required: "Это поле является обязательным",
setValueAs: (value: string) => {
try {
// Разбиваем строку на строки и парсим каждую строку как пару координат
const lines = value.trim().split("\n");
return lines.map((line) => {
const [lat, lon] = line
.trim()
.split(/[\s,]+/)
.map(Number);
if (isNaN(lat) || isNaN(lon)) {
throw new Error("Invalid coordinates");
}
return [lat, lon];
});
} catch {
return [];
}
},
validate: (value: unknown) => {
if (!Array.isArray(value)) return "Неверный формат";
if (value.length === 0)
@ -159,49 +174,22 @@ export const RouteEdit = () => {
}
return true;
},
}}
render={({ field, fieldState: { error } }) => (
<TextField
{...field}
value={
Array.isArray(field.value)
? field.value.map((point) => point.join(" ")).join("\n")
: ""
}
onChange={(e) => {
try {
const lines = e.target.value.trim().split("\n");
const parsed = lines.map((line) => {
const [lat, lon] = line
.trim()
.split(/[\s,]+/)
.map(Number);
if (isNaN(lat) || isNaN(lon)) {
throw new Error("Invalid coordinates");
}
return [lat, lon];
});
field.onChange(parsed);
} catch {
field.onChange([]);
}
}}
error={!!error}
helperText={error?.message}
margin="normal"
fullWidth
InputLabelProps={{ shrink: true }}
type="text"
label={"Координаты маршрута *"}
placeholder="55.7558 37.6173
})}
error={!!(errors as any)?.path}
helperText={(errors as any)?.path?.message}
margin="normal"
fullWidth
InputLabelProps={{ shrink: true }}
type="text"
label={"Координаты маршрута *"}
name="path"
placeholder="55.7558 37.6173
55.7539 37.6208"
multiline
rows={4}
sx={{
marginBottom: 2,
}}
/>
)}
multiline
rows={4}
sx={{
marginBottom: 2,
}}
/>
<TextField

View File

@ -1,23 +0,0 @@
import { UseQueryResult, useQuery } from 'react-query';
import { MapData } from '@mt/components';
import { mapStationsFromApi } from './mapStationsFromApi';
export function useGetMapData(): UseQueryResult<MapData> {
return useQuery<MapData>(
'getMapData',
async () => {
const { stationsOnMap, trackPoints, ...rest } = await fetch(
'https://localhost:8443/widgets/route-map/data'
).then((res) => res.json());
return {
trackPoints,
stationsOnMap: mapStationsFromApi(stationsOnMap, trackPoints),
...rest,
};
},
{
refetchOnWindowFocus: false,
}
);
}

View File

@ -1,26 +0,0 @@
import { useQuery } from 'react-query';
import { useEffect } from 'react';
import { useEventQuery } from '@mt/utils';
import { RouteInfoData } from '@mt/components';
export const useGetRouteInfo = () => {
const { data, isSuccess } = useEventQuery(
// 'getRouteInfoEvents',
'/widgets/route-info/events',
['REFRESH_DATA']
);
const routeInfoQuery = useQuery<RouteInfoData>(
'getRouteInfo',
async () =>
await fetch('https://localhost:8443/widgets/route-info/data').then((res) => res.json())
);
useEffect(() => {
if (isSuccess && data.length) {
routeInfoQuery.refetch();
}
}, [data, isSuccess]);
return routeInfoQuery;
};

View File

@ -1,36 +0,0 @@
import { useQuery, UseQueryResult } from 'react-query';
import { LocalizedString } from '@mt/i18n';
import { useEventQuery } from '@mt/utils';
import { useEffect } from 'react';
interface Attraction {
id: string;
name: LocalizedString;
iconUrl: string;
}
export function useGetAttractionList(): UseQueryResult<Attraction[]> {
const { data, isSuccess } = useEventQuery('/widgets/attraction-with-details-list/events', [
'REFRESH_DATA',
]);
const attractionListQuery = useQuery<Attraction[]>(
['getAttractionList'],
async () =>
await fetch('https://localhost:8443/widgets/attraction-with-details-list/data')
.then((res) => res.json())
.then(({ touristAttractions }) => touristAttractions),
{
refetchOnWindowFocus: false,
refetchOnMount: false,
}
);
useEffect(() => {
if (isSuccess && data.length) {
attractionListQuery.refetch();
}
}, [data, isSuccess]);
return attractionListQuery;
}

View File

@ -1,24 +0,0 @@
import { UseQueryResult, useQuery } from 'react-query';
import { uuid } from '@mt/common-types';
import { AttractionDetailsBE } from '../nav-widget.interface';
import { AttractionShortPreviewProps } from '@mt/components';
export function useGetAttractionDetails(
touristAttractionId: uuid
): UseQueryResult<AttractionShortPreviewProps> {
return useQuery(['getAttractionDetails', touristAttractionId], async () => {
const {
touristAttractionAddress: subtitle,
touristAttractionDescription: content,
touristAttractionName: title,
touristAttractionImageUrl: img,
}: AttractionDetailsBE = await fetch(
'https://localhost:8443/widgets/attraction-info/data-by-params?' +
new URLSearchParams({
touristAttractionId: touristAttractionId as string,
})
).then((res) => res.json());
return { img, title, subtitle, content };
});
}

View File

@ -1,28 +0,0 @@
import { UseQueryResult, useQuery } from 'react-query';
import { Attraction } from '../nav-widget.interface';
import { useEventQuery } from '@mt/utils';
import { useEffect } from 'react';
export function useGetAttractions(): UseQueryResult<Attraction[]> {
const { data, isSuccess } = useEventQuery(
// 'getAttractionsEvents',
'/widgets/attraction-list/events',
['REFRESH_DATA']
);
const attractionQuery = useQuery(
'getAttractions',
async () =>
await fetch('https://localhost:8443/widgets/attraction-list/data')
.then((res) => res.json())
.then(({ touristAttractions }) => touristAttractions)
);
useEffect(() => {
if (isSuccess && data.length) {
attractionQuery.refetch();
}
}, [data, isSuccess]);
return attractionQuery;
}

View File

@ -1,28 +0,0 @@
import { UseQueryResult, useQuery } from 'react-query';
import { Station } from '../nav-widget.interface';
import { useEventQuery } from '@mt/utils';
import { useEffect } from 'react';
export function useGetStations(): UseQueryResult<Station[]> {
const { data = [], isSuccess } = useEventQuery(
// 'getStationsEvents',
'/widgets/station-list/events',
['REFRESH_DATA']
);
const stationQuery = useQuery('getStations', async () => {
const { stations } = await fetch('https://localhost:8443/widgets/station-list/data').then(
(res) => res.json()
);
return stations;
});
useEffect(() => {
if (isSuccess && data.length) {
stationQuery.refetch();
}
}, [data, isSuccess]);
return stationQuery;
}

View File

@ -1,43 +0,0 @@
import './AttractionShortPreview.css';
import { LocalizedString, useServerLocalization } from '@mt/i18n';
import classNames from 'classnames';
import { TouchScrollWrapper } from '../TouchScrollWrapper/TouchScrollWrapper';
import { HTMLAttributes } from 'react';
export interface AttractionShortPreviewProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
img: string;
title: LocalizedString;
subtitle: LocalizedString;
content: LocalizedString;
}
export function AttractionShortPreview({
img,
title,
subtitle,
content,
className,
...props
}: AttractionShortPreviewProps) {
const localizeText = useServerLocalization();
return (
<div className={classNames(className, 'attraction-card g-flex-column')} {...props}>
{img && <img className="attraction-card__image" src={img} alt={localizeText(title)} />}
<TouchScrollWrapper className="g-flex-column__item">
<div className="attraction-card__content">
<h4 className="attraction-card__title">{localizeText(title)}</h4>
<h5 className="attraction-card__subtitle">{localizeText(subtitle)}</h5>
<p
className="attraction-card__text"
dangerouslySetInnerHTML={{ __html: localizeText(content) }}
/>
</div>
</TouchScrollWrapper>
</div>
);
}

View File

@ -1,32 +0,0 @@
import { Marker, Point } from 'react-simple-maps';
import { Icons } from '@mt/components';
import { AttractionGroupIconSizeType } from '@mt/common-types';
import styles from './AttractionMarker.module.css';
import cn from 'classnames';
interface Props {
coordinates: Point;
rotate: number;
size: AttractionGroupIconSizeType;
counter?: number;
}
export const AttractionMarker = ({ coordinates, counter = 0, rotate, size }: Props) => {
return (
<Marker coordinates={coordinates}>
<foreignObject
className={cn({
[styles.markerLarge]: size === 'LARGE',
[styles.markerSmall]: size === 'SMALL',
})}
>
<div className="g-transform-origin__center" style={{ transform: `rotate(${rotate}deg)` }}>
<Icons.AttractionIcon className={styles.icon} />
{counter > 1 && <div className={styles.counter}>{counter}</div>}
</div>
</foreignObject>
</Marker>
);
};

View File

@ -1,30 +0,0 @@
import React, { ForwardedRef, forwardRef, useMemo } from 'react';
import { Mesh, MeshPhysicalMaterial, Object3D, PerspectiveCamera } from 'three';
import { useGLTF } from '@react-three/drei';
import * as SkeletonUtils from 'three/examples/jsm/utils/SkeletonUtils';
import { useThree } from '@react-three/fiber';
import { fitCameraToObject } from './fitCameraToObject';
import { BufferGeometry } from 'three/src/core/BufferGeometry';
export const Model = forwardRef(({ url }: { url: string }, ref: ForwardedRef<Object3D>) => {
const { camera } = useThree();
const { scene } = useGLTF(url);
const model = useMemo(() => {
const model = (SkeletonUtils as any).clone(scene);
model.traverse((el: Mesh<BufferGeometry, MeshPhysicalMaterial>) => {
if (el.type === 'Mesh') {
el.material.reflectivity = 0.0;
el.material.fog = false;
el.material.color.setHex(0xffffff);
}
});
fitCameraToObject(camera as PerspectiveCamera, scene);
scene.updateWorldMatrix(true, true);
return model;
}, [scene, camera]);
return <primitive key={url} object={model} ref={ref} />;
});

View File

@ -1,44 +0,0 @@
import {
ArticleBase,
Coordinates,
StationOnMap,
TransportType,
} from "@mt/common-types";
import { LocalizedString } from "@mt/i18n";
// TODO: Rename with BE pointOnMap => coordinates
export interface Station extends Omit<StationOnMap, "coordinates"> {
pointOnMap: Coordinates;
}
export interface Article extends Omit<ArticleBase, "name"> {
title: LocalizedString;
}
export interface Transfer {
type: TransportType;
name: LocalizedString;
}
/**
* TYPES
*/
export * from "./uuid.type";
export * from "./media.type";
export * from "./track.type";
export * from "./coordinates.type";
export * from "./on-map.type";
export * from "./transport.type";
export * from "./option.type";
export * from "./attraction-group-icon-size.type";
export * from "./order.type";
export * from "./set-state.type";
/**
* INTERFACES
*/
export * from "./station.interface";
export * from "./attraction.interface";
export * from "./attraction-widget.interface";
export * from "./article.interface";
export * from "./lightbox.interface";

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 8.3 MiB

After

Width:  |  Height:  |  Size: 8.3 MiB

View File

@ -0,0 +1,55 @@
import "./AttractionShortPreview.css";
import { LocalizedString, useServerLocalization } from "@mt/i18n";
import classNames from "classnames";
import { TouchScrollWrapper } from "../TouchScrollWrapper/TouchScrollWrapper";
import { HTMLAttributes } from "react";
export interface AttractionShortPreviewProps
extends Omit<HTMLAttributes<HTMLElement>, "title" | "content"> {
img: string;
title: LocalizedString;
subtitle: LocalizedString;
content: LocalizedString;
}
export function AttractionShortPreview({
img,
title,
subtitle,
content,
className,
...props
}: AttractionShortPreviewProps) {
const localizeText = useServerLocalization();
return (
<div
className={classNames(className, "attraction-card g-flex-column")}
{...props}
>
{img && (
<img
className="attraction-card__image"
src={img}
alt={localizeText(title)}
/>
)}
<TouchScrollWrapper className="g-flex-column__item">
<div className="attraction-card__content">
<h4 className="attraction-card__title">{localizeText(title)}</h4>
<h5 className="attraction-card__subtitle">
{localizeText(subtitle)}
</h5>
<p
className="attraction-card__text"
dangerouslySetInnerHTML={{ __html: localizeText(content) }}
/>
</div>
</TouchScrollWrapper>
</div>
);
}

View File

@ -1,13 +1,12 @@
import React, { HTMLAttributes, useEffect } from 'react';
import { useServerLocalization } from '@mt/i18n';
import cn from 'classnames';
import { useSwipeable } from 'react-swipeable';
import { ArticleBase } from '@mt/common-types';
import './AttractionWidget.css';
import { usePrevious } from '@mt/utils';
import { AttractionMedia } from './media/AttractionMedia';
import { useStore } from 'react-admin';
import { TouchScrollWrapper } from '../TouchScrollWrapper/TouchScrollWrapper';
import React, { HTMLAttributes, useEffect, useState } from "react";
import { useServerLocalization } from "@mt/i18n";
import cn from "classnames";
import { useSwipeable } from "react-swipeable";
import { ArticleBase } from "@mt/common-types";
import "./AttractionWidget.css";
import { usePrevious } from "@mt/utils";
import { AttractionMedia } from "./media/AttractionMedia";
import { TouchScrollWrapper } from "../TouchScrollWrapper/TouchScrollWrapper";
export interface AttractionsWidgetProps extends HTMLAttributes<HTMLElement> {
articles: ArticleBase[];
@ -15,15 +14,13 @@ export interface AttractionsWidgetProps extends HTMLAttributes<HTMLElement> {
isPreviewOnly?: boolean;
}
export const ATTRACTION_WIDGET_TABINDEX_STORE_KEY = 'attractions.widget.tabindex';
export function AttractionWidget({
articles,
isIdleMode,
isPreviewOnly = false,
...props
}: AttractionsWidgetProps) {
const [activeIndex, setActiveIndex] = useStore(ATTRACTION_WIDGET_TABINDEX_STORE_KEY, 0);
const [activeIndex, setActiveIndex] = useState(0);
const prevArticles = usePrevious<ArticleBase[]>(articles) || [];
const localizeText = useServerLocalization();
@ -34,7 +31,9 @@ export function AttractionWidget({
},
onSwipedRight: ({ event }) => {
event.preventDefault();
setActiveIndex((activeIndex) => (activeIndex - 1 + articles.length) % articles.length);
setActiveIndex(
(activeIndex) => (activeIndex - 1 + articles.length) % articles.length
);
},
swipeDuration: 500,
preventScrollOnSwipe: true,
@ -43,7 +42,7 @@ export function AttractionWidget({
const handleClick = (index: number) => {
setActiveIndex(index);
document.querySelector('.widget-text.active')!.scrollTop = 0;
document.querySelector(".widget-text.active")!.scrollTop = 0;
};
useEffect(() => setActiveIndex(activeIndex), [activeIndex]);
@ -68,17 +67,21 @@ export function AttractionWidget({
{articles?.map((article, index) => (
<div
key={index}
className={`widget-slide ${index === activeIndex ? 'active' : ''}`}
className={`widget-slide ${index === activeIndex ? "active" : ""}`}
onPointerUp={() => handleClick(index)}
>
<div className="widget-media">
<AttractionMedia media={article.media} />
</div>
{index !== 0 && <div className="widget-header">{localizeText(articles[0].text)}</div>}
{index !== 0 && (
<div className="widget-header">
{localizeText(articles[0].text)}
</div>
)}
<TouchScrollWrapper
className={cn('widget-text', {
className={cn("widget-text", {
active: index === activeIndex,
preview: article.isPreview,
})}
@ -95,7 +98,7 @@ export function AttractionWidget({
{articles?.map((article, index) => (
<div
key={`title-${index}`}
className={cn('widget-title', {
className={cn("widget-title", {
active: index === activeIndex,
preview: article.isPreview,
})}

View File

@ -1,9 +1,9 @@
import { Media } from '@mt/common-types';
import { ImageMedia } from './ImageMedia';
import { VideoMedia } from './VideoMedia';
import { PhotoSphereMedia } from './PhotoSphereMedia';
import { Object3DMedia } from './Object3DMedia';
import { memo } from 'react';
import { Media } from "@mt/common-types";
import { ImageMedia } from "./ImageMedia";
import { VideoMedia } from "./VideoMedia";
import { PhotoSphereMedia } from "./PhotoSphereMedia";
import { Object3DMedia } from "./Object3DMedia";
import { memo } from "react";
export const AttractionMedia = memo(
({ media }: { media: Media }) => {
@ -12,13 +12,15 @@ export const AttractionMedia = memo(
if (!url) return null;
switch (type) {
case 'IMAGE':
return <ImageMedia url={url} alt={media.url} watermarkUrl={watermarkUrl} />;
case 'VIDEO':
case "IMAGE":
return (
<ImageMedia url={url} alt={media.url} watermarkUrl={watermarkUrl} />
);
case "VIDEO":
return <VideoMedia url={url} watermarkUrl={watermarkUrl} />;
case 'PHOTO_SPHERE':
case "PHOTO_SPHERE":
return <PhotoSphereMedia url={url} watermarkUrl={watermarkUrl} />;
case 'OBJECT_3D':
case "OBJECT_3D":
return <Object3DMedia url={url} watermarkUrl={watermarkUrl} />;
default:
return null;

View File

@ -1,28 +1,31 @@
import cn from 'classnames';
import React, { useRef } from 'react';
import { ReactPhotoSphereViewer } from 'react-photo-sphere-viewer';
import cn from "classnames";
import React, { useRef } from "react";
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
import { PhotoSphereLightboxData } from '@mt/common-types';
import { PhotoSphereLightboxData } from "@mt/common-types";
import './AttractionMedia.css';
import { useLightboxContext } from '../../lightbox';
import { Icons } from '@mt/components';
import "./AttractionMedia.css";
import { useLightboxContext } from "../../lightbox";
import { Icons } from "@mt/components";
interface PhotoSphereMediaProps {
url: string;
watermarkUrl?: string;
}
export const PhotoSphereMedia = ({ url, watermarkUrl }: PhotoSphereMediaProps) => {
export const PhotoSphereMedia = ({
url,
watermarkUrl,
}: PhotoSphereMediaProps) => {
// prettier-ignore
const { setData, openLightbox } = useLightboxContext<PhotoSphereLightboxData>();
// react-photo-sphere-viewer doesn't have exported types, so here's a bit of a hardcoded piece
const photoSphereRef = useRef<{ stopAutoRotate: () => void }>();
const photoSphereRef = useRef<any>(null);
const handlePhotoSphereFullscreenOpen = () => {
photoSphereRef.current?.stopAutoRotate();
setData({
type: 'PHOTO_SPHERE',
type: "PHOTO_SPHERE",
imageUrl: url,
watermarkUrl,
});
@ -35,18 +38,20 @@ export const PhotoSphereMedia = ({ url, watermarkUrl }: PhotoSphereMediaProps) =
ref={photoSphereRef}
key={url}
src={url}
height={'350px'}
width={'100%'}
container={cn('widget-media', {
'media-with-watermark': watermarkUrl !== null,
height={"350px"}
width={"100%"}
container={cn("widget-media", {
"media-with-watermark": watermarkUrl !== null,
})}
moveInertia={false}
mousemove={true}
navbar={['autorotate', 'zoom']}
navbar={["autorotate", "zoom"]}
keyboard={false}
loadingTxt="Загрузка..."
/>
{watermarkUrl && <img src={watermarkUrl} alt="Watermark" className="watermark" />}
{watermarkUrl && (
<img src={watermarkUrl} alt="Watermark" className="watermark" />
)}
{/* the following is a workaround to open lightbox-like preview in the middle of the screen instead of the real fullscreen */}
<Icons.FullscreenIcon
className="fullscreen-photo-sphere-btn"

View File

@ -1,7 +1,7 @@
import cn from 'classnames';
import React from 'react';
import cn from "classnames";
import React from "react";
import './AttractionMedia.css';
import "./AttractionMedia.css";
interface VideoMediaProps {
url: string;
@ -12,8 +12,8 @@ export const VideoMedia = ({ url, watermarkUrl }: VideoMediaProps) => (
<>
<video
src={url}
className={cn('widget-video', {
'media-with-watermark': watermarkUrl !== null,
className={cn("widget-video", {
"media-with-watermark": watermarkUrl !== null,
})}
autoPlay
loop

View File

@ -1,17 +1,27 @@
import React, { createContext, useState, useContext, ReactNode, useMemo, useEffect } from 'react';
import { geoMercator } from 'd3-geo';
import { Coordinates, Track, uuid } from '@mt/common-types';
import { useNearStation, usePassedTrackIndex } from './hooks';
import { AttractionGroup, MapData, StationOnMap } from './map-widget.interface';
import { getMapPoint } from './utils';
import { EMPTY_SETTING_VALUE, zeroCoordinates } from './map-widget.constant';
import { MapSettings, MapWidgetContextType } from './map-widget-context.interface';
import { useForm } from 'react-hook-form';
import React, {
createContext,
useState,
useContext,
ReactNode,
useMemo,
useEffect,
} from "react";
import { geoMercator } from "d3-geo";
import { Coordinates, Track, uuid } from "@mt/common-types";
import { useNearStation, usePassedTrackIndex } from "./hooks";
import { AttractionGroup, MapData, StationOnMap } from "./map-widget.interface";
import { getMapPoint } from "./utils";
import { EMPTY_SETTING_VALUE, zeroCoordinates } from "./map-widget.constant";
import {
MapSettings,
MapWidgetContextType,
} from "./map-widget-context.interface";
import { useForm } from "react-hook-form";
export const mapCanvasProps = {
style: {
width: '100%',
height: '100%',
width: "100%",
height: "100%",
},
width: 500,
height: 400,
@ -24,7 +34,9 @@ export const MapWidgetProvider = ({ children }: { children: ReactNode }) => {
const [track, setTrack] = useState<Track | null>(null);
const [stations, setStations] = useState<StationOnMap[]>([]);
const [updatedStationIds, setUpdatedStationIds] = useState<uuid[]>([]);
const [attractionGroups, setAttractionGroups] = useState<AttractionGroup[]>([]);
const [attractionGroups, setAttractionGroups] = useState<AttractionGroup[]>(
[]
);
const [rotateAngle, setRotateAngle] = useState<number>(0);
const [scale, setScale] = useState<number>(0);
@ -34,13 +46,17 @@ export const MapWidgetProvider = ({ children }: { children: ReactNode }) => {
const [center, setCenter] = useState(zeroCoordinates);
const [baseCenter, setBaseCenter] = useState(zeroCoordinates);
const [currentPosition, setCurrentPosition] = useState<Coordinates | null>(null);
const [currentPosition, setCurrentPosition] = useState<Coordinates | null>(
null
);
const [isEditMode, setIsEditMode] = useState<boolean>(false);
const [isDragMode, setIsDragMode] = useState<boolean>(false);
const [initialSettingsData, setInitialSettingsData] = useState<MapSettings>(EMPTY_SETTING_VALUE);
const [isSettingsDataChanged, setIsSettingsDataChanged] = useState<boolean>(false);
const [initialSettingsData, setInitialSettingsData] =
useState<MapSettings>(EMPTY_SETTING_VALUE);
const [isSettingsDataChanged, setIsSettingsDataChanged] =
useState<boolean>(false);
const isMapDataChanged = useMemo(
() => isSettingsDataChanged || updatedStationIds.length > 0,
@ -63,11 +79,14 @@ export const MapWidgetProvider = ({ children }: { children: ReactNode }) => {
}, [track]);
const settingsForm = useForm({
mode: 'onChange',
reValidateMode: 'onChange',
mode: "onChange",
reValidateMode: "onChange",
defaultValues: initialSettingsData,
});
useEffect(() => settingsForm.reset(initialSettingsData), [initialSettingsData]);
useEffect(
() => settingsForm.reset(initialSettingsData),
[initialSettingsData]
);
const onMapDataFetched = (data: MapData) => {
setTrack(data.trackPoints);
@ -95,7 +114,8 @@ export const MapWidgetProvider = ({ children }: { children: ReactNode }) => {
const onSettingsFormChange = () => {
const formData = settingsForm.getValues();
const { center, rotateAngle, fullScale, zoomedScale, currentStationId } = formData;
const { center, rotateAngle, fullScale, zoomedScale, currentStationId } =
formData;
setBaseCenter(center);
setRotateAngle(rotateAngle);
@ -120,7 +140,7 @@ export const MapWidgetProvider = ({ children }: { children: ReactNode }) => {
const onMapCenterMoved = (center: Coordinates) => {
setBaseCenter(center);
setCenter(center);
settingsForm.setValue('center', center, { shouldDirty: true });
settingsForm.setValue("center", center, { shouldDirty: true });
updateMapDataChanged(settingsForm.getValues());
};
@ -138,7 +158,7 @@ export const MapWidgetProvider = ({ children }: { children: ReactNode }) => {
);
};
const onStationUpdate: MapWidgetContextType['onStationUpdate'] = (
const onStationUpdate: MapWidgetContextType["onStationUpdate"] = (
stationId,
{ labelOffset, labelAlignment }
) => {
@ -149,14 +169,18 @@ export const MapWidgetProvider = ({ children }: { children: ReactNode }) => {
};
setStations((stations) =>
stations.map((station) => (station.id === stationId ? updatedStation : station))
stations.map((station) =>
station.id === stationId ? updatedStation : station
)
);
setUpdatedStationIds((ids) => [...ids, stationId]);
};
const getUpdatedStations = () => {
return updatedStationIds.reduce((acc: Record<uuid, any>, id: uuid) => {
const { labelAlignment, labelOffset } = stationsMap.get(id) as StationOnMap;
const { labelAlignment, labelOffset } = stationsMap.get(
id
) as StationOnMap;
acc[id] = {
textAlignment: labelAlignment,
@ -230,14 +254,20 @@ export const MapWidgetProvider = ({ children }: { children: ReactNode }) => {
isMapDataChanged,
};
return <MapWidgetContext.Provider value={contextValue}>{children}</MapWidgetContext.Provider>;
return (
<MapWidgetContext.Provider value={contextValue}>
{children}
</MapWidgetContext.Provider>
);
};
export const useMapWidgetContext = function (): MapWidgetContextType {
const context = useContext(MapWidgetContext);
if (!context) {
throw new Error('useMapWidgetContext must be used within a MapWidgetProvider');
throw new Error(
"useMapWidgetContext must be used within a MapWidgetProvider"
);
}
return context;

View File

@ -1,15 +1,20 @@
import { ComposableMap, ZoomableGroup, ZoomableGroupProps } from 'react-simple-maps';
import styles from './MapWidget.module.css';
import { mapCanvasProps, useMapWidgetContext } from '../../MapWidgetContext';
import { useState } from 'react';
import { MapContent } from './MapContent';
import {
ComposableMap,
ZoomableGroup,
ZoomableGroupProps,
} from "react-simple-maps";
import styles from "./MapWidget.module.css";
import { mapCanvasProps, useMapWidgetContext } from "../../MapWidgetContext";
import { useState } from "react";
import { MapContent } from "./MapContent";
// default coordinates for 3a route: 59.943, 30.331
export const MapWidget = () => {
const { onMapCenterMoved, projection, isDragMode, rotateAngle } = useMapWidgetContext();
const { onMapCenterMoved, projection, isDragMode, rotateAngle } =
useMapWidgetContext();
const [key, setKey] = useState(42);
const handleMoveEnd: ZoomableGroupProps['onMoveEnd'] = (e, d3Zoom) => {
const handleMoveEnd: ZoomableGroupProps["onMoveEnd"] = (e, d3Zoom) => {
const { PI, cos, sin } = Math;
const { x, y } = d3Zoom.transform;
const { width, height } = mapCanvasProps;

View File

@ -0,0 +1,40 @@
import { Marker, Point } from "react-simple-maps";
import { Icons } from "@mt/components";
import { AttractionGroupIconSizeType } from "@mt/common-types";
import styles from "./AttractionMarker.module.css";
import cn from "classnames";
interface Props {
coordinates: Point;
rotate: number;
size: AttractionGroupIconSizeType;
counter?: number;
}
export const AttractionMarker = ({
coordinates,
counter = 0,
rotate,
size,
}: Props) => {
return (
<Marker coordinates={coordinates}>
<foreignObject
className={cn({
[styles.markerLarge]: size === "LARGE",
[styles.markerSmall]: size === "SMALL",
})}
>
<div
className="g-transform-origin__center"
style={{ transform: `rotate(${rotate}deg)` }}
>
<Icons.AttractionIcon className={styles.icon} />
{counter > 1 && <div className={styles.counter}>{counter}</div>}
</div>
</foreignObject>
</Marker>
);
};

View File

@ -1,6 +1,6 @@
import { AttractionMarker } from './AttractionMarker';
import { getMapPoint } from '../../utils';
import { useMapWidgetContext } from '../../MapWidgetContext';
import { AttractionMarker } from "./AttractionMarker";
import { getMapPoint } from "../../utils";
import { useMapWidgetContext } from "../../MapWidgetContext";
export const TrackAttractions = () => {
const { attractionGroups, rotateAngle } = useMapWidgetContext();

View File

@ -1,11 +1,11 @@
import { useMemo } from 'react';
import { Line, Point } from 'react-simple-maps';
import { getMapPoint } from '../utils';
import { useMapWidgetContext } from '../MapWidgetContext';
import { zeroCoordinates } from '../map-widget.constant';
import { useMemo } from "react";
import { Line, Point } from "react-simple-maps";
import { getMapPoint } from "../utils";
import { useMapWidgetContext } from "../MapWidgetContext";
import { zeroCoordinates } from "../map-widget.constant";
const passedTrackColor = '#ed1c24';
const trackColor = '#cccccc';
const passedTrackColor = "#ed1c24";
const trackColor = "#cccccc";
export const TrackLine = () => {
const { track, passedTrackIndex, currentPosition } = useMapWidgetContext();
@ -16,7 +16,12 @@ export const TrackLine = () => {
return (
<>
<Line coordinates={mappedTrack} strokeWidth={2.5} strokeLinecap="round" stroke={trackColor} />
<Line
coordinates={mappedTrack}
strokeWidth={2.5}
strokeLinecap="round"
stroke={trackColor}
/>
<Line
coordinates={[

View File

@ -1,27 +1,37 @@
import { Marker } from 'react-simple-maps';
import { Marker } from "react-simple-maps";
// TODO: resolve circular deps
import type { uuid } from '@mt/common-types';
import type { uuid } from "@mt/common-types";
import { getMapPoint } from '../../utils';
import { getMapPoint } from "../../utils";
import './TrackStations.css';
import { StationLabelEdit } from './StationLabelEdit';
import { useMapWidgetContext } from '../../MapWidgetContext';
import { StationLabel } from './StationLabel';
import "./TrackStations.css";
import { StationLabelEdit } from "./StationLabelEdit";
import { useMapWidgetContext } from "../../MapWidgetContext";
import { StationLabel } from "./StationLabel";
const colors = {
black: '#000000',
red: '#ed1c24',
grey: '#cccccc',
yellow: '#fcd500',
black: "#000000",
red: "#ed1c24",
grey: "#cccccc",
yellow: "#fcd500",
};
export const TrackStations = () => {
const { stations, currentStation, passedTrackIndex, isOnStation, isEditMode } =
useMapWidgetContext();
const isTerminalStation = (index: number) => index === 0 || index === stations.length - 1;
const {
stations,
currentStation,
passedTrackIndex,
isOnStation,
isEditMode,
} = useMapWidgetContext();
const isTerminalStation = (index: number) =>
index === 0 || index === stations.length - 1;
const getStationFill = (id: uuid, trackIndex: number, index: number): string => {
const getStationFill = (
id: uuid,
trackIndex: number,
index: number
): string => {
if (isOnStation && currentStation?.id === id) {
return colors.yellow;
}
@ -49,7 +59,11 @@ export const TrackStations = () => {
r={3.5}
strokeWidth={isTerminalStation(index) ? 2 : 1.5}
/>
{isEditMode ? <StationLabelEdit station={it} /> : <StationLabel station={it} />}
{isEditMode ? (
<StationLabelEdit station={it} />
) : (
<StationLabel station={it} />
)}
</Marker>
))}
</>

View File

@ -1,10 +1,10 @@
import { useEffect, useState } from 'react';
import { getIntersection, getIntersectionArea } from '../../utils';
import { Marker, Point } from 'react-simple-maps';
import { Coordinates } from '@mt/common-types';
import cn from 'classnames';
import styles from './TramMarker.module.css';
import { Icons, useMapWidgetContext } from '@mt/components';
import { useEffect, useState } from "react";
import { getIntersection, getIntersectionArea } from "../../utils";
import { Marker, Point } from "react-simple-maps";
import { Coordinates } from "@mt/common-types";
import cn from "classnames";
import styles from "./TramMarker.module.css";
import { Icons, useMapWidgetContext } from "@mt/components";
interface TramMarkerProps {
coordinates: Point;
@ -17,7 +17,9 @@ export const TramMarker = ({ coordinates, nextStopPoint }: TramMarkerProps) => {
const { rotateAngle } = useMapWidgetContext();
useEffect(() => {
const tramRect = document.getElementById('tram-marker')?.getBoundingClientRect();
const tramRect = document
.getElementById("tram-marker")
?.getBoundingClientRect();
const nextStopRect = document
.getElementById(`${nextStopPoint.lat}:${nextStopPoint.lon}`)
?.getBoundingClientRect();
@ -36,7 +38,9 @@ export const TramMarker = ({ coordinates, nextStopPoint }: TramMarkerProps) => {
return (
<Marker coordinates={coordinates} id="tram-marker">
<foreignObject className={cn(styles.iconContainer, { [styles.flipped]: flipped })}>
<foreignObject
className={cn(styles.iconContainer, { [styles.flipped]: flipped })}
>
<Icons.TramMarkerIcon
className={`${styles.icon} g-transform-origin__center`}
// Inverse angle to compensate map rotation

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { StationOnMap } from '../map-widget.interface';
import { getDistance } from '../utils';
import { Coordinates } from '@mt/common-types';
import { useEffect, useState } from "react";
import { StationOnMap } from "../map-widget.interface";
import { getDistance } from "../utils";
import { Coordinates } from "@mt/common-types";
const ZOOM_DISTANCE = 100;
const ON_STATION_DISTANCE = 15;
@ -12,7 +12,9 @@ export function useNearStation(
passedTrackIndex: number
) {
const [nextStation, setNextStation] = useState<StationOnMap | null>(null);
const [currentStation, setCurrentStation] = useState<StationOnMap | null>(null);
const [currentStation, setCurrentStation] = useState<StationOnMap | null>(
null
);
const [isOnStation, setIsOnStation] = useState<boolean>(false);
useEffect(() => {

View File

@ -1,11 +1,14 @@
import { useEffect, useState } from 'react';
import { Coordinates, Track } from '@mt/common-types';
import { useEffect, useState } from "react";
import { Coordinates, Track } from "@mt/common-types";
import { getDistance, getPointDeviation } from '../utils';
import { getDistance, getPointDeviation } from "../utils";
const APPROXIMATE_DISTANCE = 15; // [meters] half of tramway length (~30 meters)
export function usePassedTrackIndex(track: Track | null, currentPosition: Coordinates | null) {
export function usePassedTrackIndex(
track: Track | null,
currentPosition: Coordinates | null
) {
const [passedTrackIndex, setPassedTrackIndex] = useState<number>(0);
useEffect(() => {
@ -29,7 +32,9 @@ export function usePassedTrackIndex(track: Track | null, currentPosition: Coordi
* Is current position more than APPROXIMATE_DISTANCE far from found track point
* we need to check that we really reach newPassedIndex. If not should decrement index
*/
if (getDistance(track[newPassedIndex], currentPosition) > APPROXIMATE_DISTANCE) {
if (
getDistance(track[newPassedIndex], currentPosition) > APPROXIMATE_DISTANCE
) {
const prevIndex = Math.max(newPassedIndex - 1, 0);
const nextIndex = Math.min(newPassedIndex + 1, track.length - 1);

View File

@ -1,16 +1,16 @@
import { Coordinates, SetState, uuid } from '@mt/common-types';
import { MapData, StationOnMap } from '@mt/components';
import { GeoProjection } from 'd3-geo';
import { Point } from 'react-simple-maps';
import { UseFormReturn } from 'react-hook-form';
import { RouteStation } from '@admin/types';
import { Coordinates, SetState, uuid } from "@mt/common-types";
import { MapData, StationOnMap } from "@mt/components";
import { UseFormReturn } from "react-hook-form";
import { RouteStation } from "@mt/common-types";
import { GeoProjection } from "d3-geo";
export interface MapWidgetContextType {
// External data
track: MapData['trackPoints'] | null;
stations: MapData['stationsOnMap'];
attractionGroups: MapData['touristAttractionGroupsOnMap'];
rotateAngle: MapData['mapRotateAngle'];
track: MapData["trackPoints"] | null;
stations: MapData["stationsOnMap"];
attractionGroups: MapData["touristAttractionGroupsOnMap"];
rotateAngle: MapData["mapRotateAngle"];
center: Coordinates;
projection: GeoProjection;
currentPosition: Coordinates | null;
@ -24,6 +24,7 @@ export interface MapWidgetContextType {
setCurrentPosition: SetState<Coordinates | null>;
isDragMode: boolean;
setIsDragMode: SetState<boolean>;
isEditMode: boolean;
setIsEditMode: SetState<boolean>;
@ -34,7 +35,7 @@ export interface MapWidgetContextType {
onMapCenterMoved: (center: Coordinates) => void;
onStationUpdate: (
stationId: uuid,
data: Partial<Pick<StationOnMap, 'labelAlignment' | 'labelOffset'>>
data: Partial<Pick<StationOnMap, "labelAlignment" | "labelOffset">>
) => void;
getUpdatedStations: () => Partial<RouteStation>;
isMapDataChanged: boolean;

View File

@ -1,4 +1,4 @@
import { MapSettings } from './map-widget-context.interface';
import { MapSettings } from "./map-widget-context.interface";
export const zeroCoordinates = { lat: 0, lon: 0 };

View File

@ -4,8 +4,8 @@ import {
StationOnMap as StationOnMapBase,
Track,
uuid,
} from '@mt/common-types';
import { Transfer } from '@front/types';
Transfer,
} from "@mt/common-types";
export type PointOnTrack = Coordinates & {
trackIndex: number;

View File

@ -1,8 +1,8 @@
import React, { HTMLAttributes, useContext, useEffect, useRef } from 'react';
import { LocalizationContext, LocalizedString } from '@mt/i18n';
import React, { HTMLAttributes, useContext, useEffect, useRef } from "react";
import { LocalizationContext, LocalizedString } from "@mt/i18n";
import styles from './RouteInfoWidget.module.css';
import cn from 'classnames';
import styles from "./RouteInfoWidget.module.css";
import cn from "classnames";
export interface RouteInfoData {
routeNumber: string;
@ -13,7 +13,11 @@ interface RouteInfoWidgetProps extends HTMLAttributes<HTMLDivElement> {
routeInfo?: RouteInfoData;
}
export function RouteInfoWidget({ routeInfo, className, ...props }: RouteInfoWidgetProps) {
export function RouteInfoWidget({
routeInfo,
className,
...props
}: RouteInfoWidgetProps) {
const contentContainerRef = useRef<HTMLDivElement>(null);
const titleRefs = useRef<Array<HTMLSpanElement>>([]);
@ -42,7 +46,7 @@ export function RouteInfoWidget({ routeInfo, className, ...props }: RouteInfoWid
return (
<div className={cn(styles.root, className)} {...props}>
<div className={styles.number}>{routeInfo?.routeNumber || '--'}</div>
<div className={styles.number}>{routeInfo?.routeNumber || "--"}</div>
{routeInfo ? (
<div className={styles.content} ref={contentContainerRef}>
@ -52,11 +56,13 @@ export function RouteInfoWidget({ routeInfo, className, ...props }: RouteInfoWid
</span>
</div>
<div className={cn(styles.title, styles.titleEnd)}>
<span ref={(ref) => (titleRefs.current[1] = ref!)}>{routeInfo.lastStationName.ru}</span>
<span ref={(ref) => (titleRefs.current[1] = ref!)}>
{routeInfo.lastStationName.ru}
</span>
</div>
<div className={cn(styles.title, styles.titleTranslation)}>
<span ref={(ref) => (titleRefs.current[2] = ref!)}>
{locale === 'zh'
{locale === "zh"
? `${routeInfo.firstStationName.zh} ${routeInfo.lastStationName.zh}`
: `${routeInfo.firstStationName.en} ${routeInfo.lastStationName.en}`}
</span>

View File

@ -1,9 +1,17 @@
import { HTMLAttributes, PointerEvent, WheelEvent, useEffect, useRef, useState } from 'react';
import cn from 'classnames';
import styles from './TouchScrollWrapper.module.css';
import { useCssProperty } from '@mt/utils';
import {
HTMLAttributes,
PointerEvent,
WheelEvent,
useEffect,
useRef,
useState,
} from "react";
import cn from "classnames";
import styles from "./TouchScrollWrapper.module.css";
import { useCssProperty } from "@mt/utils";
const getNumberPxFormatter = (numberStr: string | null) => Number(numberStr?.replace(/px$/, ''));
const getNumberPxFormatter = (numberStr: string | null) =>
Number(numberStr?.replace(/px$/, ""));
const setNumberPxFormatter = (number: number) => `${number}px`;
const { abs, min } = Math;
@ -18,14 +26,17 @@ export const TouchScrollWrapper = ({
const scrollbarRef = useRef<HTMLDivElement>(null);
const scrollbarHeight = useCssProperty<number>(
'--scrollbar-height',
"--scrollbar-height",
scrollbarRef,
setNumberPxFormatter,
getNumberPxFormatter
);
const scrollbarVisibility = useCssProperty<string>('--scrollbar-visibility', scrollbarRef);
const scrollbarVisibility = useCssProperty<string>(
"--scrollbar-visibility",
scrollbarRef
);
const scrollbarOffset = useCssProperty<number>(
'--scrollbar-offset',
"--scrollbar-offset",
scrollbarRef,
setNumberPxFormatter
);
@ -57,10 +68,11 @@ export const TouchScrollWrapper = ({
useEffect(() => {
if (containerHeight >= contentHeight) {
scrollbarVisibility.value = 'hidden';
scrollbarVisibility.value = "hidden";
} else {
scrollbarHeight.value = (containerHeight / contentHeight) * containerHeight + 1;
scrollbarVisibility.value = 'visible';
scrollbarHeight.value =
(containerHeight / contentHeight) * containerHeight + 1;
scrollbarVisibility.value = "visible";
}
}, [contentHeight, containerHeight]);
@ -73,7 +85,7 @@ export const TouchScrollWrapper = ({
const swipeDistance = startSwipeY - e.clientY;
if (
e.pointerType !== 'touch' ||
e.pointerType !== "touch" ||
containerHeight >= contentHeight ||
(pointerId && pointerId !== e.pointerId) ||
abs(swipeDistance) < 2

View File

Before

Width:  |  Height:  |  Size: 422 B

After

Width:  |  Height:  |  Size: 422 B

View File

Before

Width:  |  Height:  |  Size: 714 B

After

Width:  |  Height:  |  Size: 714 B

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 556 B

After

Width:  |  Height:  |  Size: 556 B

View File

Before

Width:  |  Height:  |  Size: 599 B

After

Width:  |  Height:  |  Size: 599 B

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,11 +1,11 @@
import { createElement } from 'react';
import { ReactComponent as IconCloudy } from './icons/cond_cloudy.svg';
import { ReactComponent as IconRainy } from './icons/cond_rainy.svg';
import { ReactComponent as IconPartlyCloudy } from './icons/cond_partlycloudy.svg';
import { ReactComponent as IconSnow } from './icons/cond_snow.svg';
import { ReactComponent as IconSnowy } from './icons/cond_snowy.svg';
import { ReactComponent as IconSunny } from './icons/cond_sunny.svg';
import { ReactComponent as IconThunder } from './icons/cond_thunder.svg';
import { createElement } from "react";
import IconCloudy from "./icons/cond_cloudy.svg";
import IconRainy from "./icons/cond_rainy.svg";
import IconPartlyCloudy from "./icons/cond_partlycloudy.svg";
import IconSnow from "./icons/cond_snow.svg";
import IconSnowy from "./icons/cond_snowy.svg";
import IconSunny from "./icons/cond_sunny.svg";
import IconThunder from "./icons/cond_thunder.svg";
interface WeatherWidgetIconProps {
icon: string | null;
@ -30,11 +30,11 @@ export function WeatherWidgetIcon({ icon, size = 16 }: WeatherWidgetIconProps) {
style={{
width: `${size}px`,
height: `${size}px`,
textAlign: 'center',
margin: '0 auto',
textAlign: "center",
margin: "0 auto",
fontSize: `${size}px`,
lineHeight: 1,
overflow: 'hidden',
overflow: "hidden",
}}
children="--"
/>
@ -43,6 +43,6 @@ export function WeatherWidgetIcon({ icon, size = 16 }: WeatherWidgetIconProps) {
return createElement(svg, {
width: size,
height: size,
style: { margin: '0 auto', display: 'block' },
style: { margin: "0 auto", display: "block" },
});
}

View File

@ -1,16 +1,16 @@
import { WeatherWidgetIcon } from './weather-widget-icon';
import { WeatherWidgetIcon } from "./weather-widget-icon";
import { ReactComponent as IconHumidity } from './icons/det_humidity.svg';
import { ReactComponent as IconWind } from './icons/det_wind.svg';
import { WeatherDayRow, WeatherWidgetData } from './weather.interface';
import IconHumidity from "./icons/det_humidity.svg";
import IconWind from "./icons/det_wind.svg";
import { WeatherDayRow, WeatherWidgetData } from "./weather.interface";
const Weekdays = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
const Weekdays = ["Вс", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
const WRow = ({ temperature, weekday, condition }: WeatherDayRow) => (
<div
style={{
display: 'flex',
marginBottom: '8px',
display: "flex",
marginBottom: "8px",
}}
>
<div
@ -22,26 +22,29 @@ const WRow = ({ temperature, weekday, condition }: WeatherDayRow) => (
<WeatherWidgetIcon icon={condition} size={16} />
</div>
<div style={{ marginLeft: 8, minWidth: 22 }} children={Weekdays[weekday]} />
<div style={{ marginLeft: 8 }} children={`${temperature ?? '--'}°`} />
<div style={{ marginLeft: 8 }} children={`${temperature ?? "--"}°`} />
</div>
);
export function WeatherWidgetRight({ forecasts, weatherInfo }: WeatherWidgetData) {
export function WeatherWidgetRight({
forecasts,
weatherInfo,
}: WeatherWidgetData) {
const wd = new Date().getDay();
return (
<div
style={{
textAlign: 'center',
width: '50%',
textAlign: "center",
width: "50%",
fontSize: 18,
lineHeight: '21px',
lineHeight: "21px",
}}
>
<div
style={{
borderBottom: '1px solid #999',
marginBottom: '8px',
marginTop: '8px',
borderBottom: "1px solid #999",
marginBottom: "8px",
marginTop: "8px",
}}
>
{[...forecasts].slice(0, 3).map((d, idx) => (
@ -51,22 +54,22 @@ export function WeatherWidgetRight({ forecasts, weatherInfo }: WeatherWidgetData
<div
style={{
display: 'flex',
alignItems: 'center',
display: "flex",
alignItems: "center",
marginBottom: 8,
}}
>
<IconHumidity width={16} height={16} style={{ marginRight: 8 }} />
<b children={weatherInfo?.humidity ?? '--'} />%
<b children={weatherInfo?.humidity ?? "--"} />%
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
display: "flex",
alignItems: "center",
}}
>
<IconWind width={16} height={16} style={{ marginRight: 8 }} />
<b children={weatherInfo?.windSpeed ?? '--'} />
<b children={weatherInfo?.windSpeed ?? "--"} />
&nbsp;м/с
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More