added route preview
24
package.json
@ -6,37 +6,55 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.8.2",
|
"@emotion/react": "^11.8.2",
|
||||||
"@emotion/styled": "^11.8.1",
|
"@emotion/styled": "^11.8.1",
|
||||||
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@mui/icons-material": "^6.1.6",
|
"@mui/icons-material": "^6.1.6",
|
||||||
"@mui/lab": "^6.0.0-beta.14",
|
"@mui/lab": "^6.0.0-beta.14",
|
||||||
"@mui/material": "^6.1.7",
|
"@mui/material": "^6.1.7",
|
||||||
"@mui/x-data-grid": "^7.22.2",
|
"@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/cli": "^2.16.21",
|
||||||
"@refinedev/core": "^4.47.1",
|
"@refinedev/core": "^4.57.9",
|
||||||
"@refinedev/devtools": "^1.1.32",
|
"@refinedev/devtools": "^1.1.32",
|
||||||
"@refinedev/kbar": "^1.3.6",
|
"@refinedev/kbar": "^1.3.16",
|
||||||
"@refinedev/mui": "^6.0.0",
|
"@refinedev/mui": "^6.0.0",
|
||||||
"@refinedev/react-hook-form": "^4.8.14",
|
"@refinedev/react-hook-form": "^4.8.14",
|
||||||
"@refinedev/react-router": "^1.0.0",
|
"@refinedev/react-router": "^1.0.0",
|
||||||
"@refinedev/simple-rest": "^5.0.1",
|
"@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",
|
"axios": "^1.7.9",
|
||||||
|
"classnames": "^2.5.1",
|
||||||
|
"d3-geo": "^3.1.1",
|
||||||
"easymde": "^2.19.0",
|
"easymde": "^2.19.0",
|
||||||
"i18next": "^24.2.2",
|
"i18next": "^24.2.2",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"react": "^18.0.0",
|
"react": "^18.0.0",
|
||||||
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
"react-dom": "^18.0.0",
|
"react-dom": "^18.0.0",
|
||||||
|
"react-draggable": "^4.4.6",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"react-hook-form": "^7.30.0",
|
"react-hook-form": "^7.30.0",
|
||||||
"react-i18next": "^15.4.1",
|
"react-i18next": "^15.4.1",
|
||||||
|
"react-intl": "^7.1.10",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-photo-sphere-viewer": "^6.2.2",
|
||||||
"react-router": "^7.0.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": {
|
"devDependencies": {
|
||||||
|
"@types/d3-geo": "^3.1.0",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/node": "^18.16.2",
|
"@types/node": "^18.16.2",
|
||||||
"@types/react": "^18.0.0",
|
"@types/react": "^18.0.0",
|
||||||
"@types/react-dom": "^18.0.0",
|
"@types/react-dom": "^18.0.0",
|
||||||
|
"@types/three": "^0.175.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.57.1",
|
"@typescript-eslint/eslint-plugin": "^5.57.1",
|
||||||
"@typescript-eslint/parser": "^5.57.1",
|
"@typescript-eslint/parser": "^5.57.1",
|
||||||
"@vitejs/plugin-react": "^4.0.0",
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
|
2664
pnpm-lock.yaml
248
src/App.tsx
@ -1,42 +1,87 @@
|
|||||||
import {Refine, Authenticated} from '@refinedev/core'
|
import { Refine, Authenticated } from "@refinedev/core";
|
||||||
import {DevtoolsPanel, DevtoolsProvider} from '@refinedev/devtools'
|
import { DevtoolsPanel, DevtoolsProvider } from "@refinedev/devtools";
|
||||||
import {RefineKbar, RefineKbarProvider} from '@refinedev/kbar'
|
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 { customDataProvider } from "./providers/data";
|
||||||
import CssBaseline from '@mui/material/CssBaseline'
|
import CssBaseline from "@mui/material/CssBaseline";
|
||||||
import GlobalStyles from '@mui/material/GlobalStyles'
|
import GlobalStyles from "@mui/material/GlobalStyles";
|
||||||
import {BrowserRouter, Route, Routes, Outlet} from 'react-router'
|
import { BrowserRouter, Route, Routes, Outlet } from "react-router";
|
||||||
import routerBindings, {NavigateToResource, CatchAllNavigate, UnsavedChangesNotifier, DocumentTitleHandler} from '@refinedev/react-router'
|
import routerBindings, {
|
||||||
import {ColorModeContextProvider} from './contexts/color-mode'
|
NavigateToResource,
|
||||||
import {Header} from './components/header'
|
CatchAllNavigate,
|
||||||
import {Login} from './pages/login'
|
UnsavedChangesNotifier,
|
||||||
import {authProvider} from './authProvider'
|
DocumentTitleHandler,
|
||||||
import {i18nProvider} from './i18nProvider'
|
} 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 {
|
||||||
import {CityList, CityCreate, CityEdit, CityShow} from './pages/city'
|
CountryList,
|
||||||
import {CarrierList, CarrierCreate, CarrierEdit, CarrierShow} from './pages/carrier'
|
CountryCreate,
|
||||||
import {MediaList, MediaCreate, MediaEdit, MediaShow} from './pages/media'
|
CountryEdit,
|
||||||
import {ArticleList, ArticleCreate, ArticleEdit, ArticleShow} from './pages/article'
|
CountryShow,
|
||||||
import {SightList, SightCreate, SightEdit, SightShow} from './pages/sight'
|
} from "./pages/country";
|
||||||
import {StationList, StationCreate, StationEdit, StationShow} from './pages/station'
|
import { CityList, CityCreate, CityEdit, CityShow } from "./pages/city";
|
||||||
import {VehicleList, VehicleCreate, VehicleEdit, VehicleShow} from './pages/vehicle'
|
import {
|
||||||
import {RouteList, RouteCreate, RouteEdit, RouteShow} from './pages/route'
|
CarrierList,
|
||||||
import {UserList, UserCreate, UserEdit, UserShow} from './pages/user'
|
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 {
|
||||||
import SidebarTitle from './components/ui/SidebarTitle'
|
CountryIcon,
|
||||||
import {AdminOnly} from './components/AdminOnly'
|
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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<RefineKbarProvider>
|
|
||||||
<ColorModeContextProvider>
|
<ColorModeContextProvider>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<GlobalStyles styles={{html: {WebkitFontSmoothing: 'auto'}}} />
|
<GlobalStyles styles={{ html: { WebkitFontSmoothing: "auto" } }} />
|
||||||
<RefineSnackbarProvider>
|
<RefineSnackbarProvider>
|
||||||
<DevtoolsProvider>
|
<DevtoolsProvider>
|
||||||
<Refine
|
<Refine
|
||||||
@ -47,122 +92,122 @@ function App() {
|
|||||||
i18nProvider={i18nProvider}
|
i18nProvider={i18nProvider}
|
||||||
resources={[
|
resources={[
|
||||||
{
|
{
|
||||||
name: 'country',
|
name: "country",
|
||||||
list: '/country',
|
list: "/country",
|
||||||
create: '/country/create',
|
create: "/country/create",
|
||||||
edit: '/country/edit/:id',
|
edit: "/country/edit/:id",
|
||||||
show: '/country/show/:id',
|
show: "/country/show/:id",
|
||||||
meta: {
|
meta: {
|
||||||
canDelete: true,
|
canDelete: true,
|
||||||
label: 'Страны',
|
label: "Страны",
|
||||||
icon: <CountryIcon />,
|
icon: <CountryIcon />,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'city',
|
name: "city",
|
||||||
list: '/city',
|
list: "/city",
|
||||||
create: '/city/create',
|
create: "/city/create",
|
||||||
edit: '/city/edit/:id',
|
edit: "/city/edit/:id",
|
||||||
show: '/city/show/:id',
|
show: "/city/show/:id",
|
||||||
meta: {
|
meta: {
|
||||||
canDelete: true,
|
canDelete: true,
|
||||||
label: 'Города',
|
label: "Города",
|
||||||
icon: <CityIcon />,
|
icon: <CityIcon />,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'carrier',
|
name: "carrier",
|
||||||
list: '/carrier',
|
list: "/carrier",
|
||||||
create: '/carrier/create',
|
create: "/carrier/create",
|
||||||
edit: '/carrier/edit/:id',
|
edit: "/carrier/edit/:id",
|
||||||
show: '/carrier/show/:id',
|
show: "/carrier/show/:id",
|
||||||
meta: {
|
meta: {
|
||||||
canDelete: true,
|
canDelete: true,
|
||||||
label: 'Перевозчики',
|
label: "Перевозчики",
|
||||||
icon: <CarrierIcon />,
|
icon: <CarrierIcon />,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'media',
|
name: "media",
|
||||||
list: '/media',
|
list: "/media",
|
||||||
create: '/media/create',
|
create: "/media/create",
|
||||||
edit: '/media/edit/:id',
|
edit: "/media/edit/:id",
|
||||||
show: '/media/show/:id',
|
show: "/media/show/:id",
|
||||||
meta: {
|
meta: {
|
||||||
canDelete: true,
|
canDelete: true,
|
||||||
label: 'Медиа',
|
label: "Медиа",
|
||||||
icon: <MediaIcon />,
|
icon: <MediaIcon />,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'article',
|
name: "article",
|
||||||
list: '/article',
|
list: "/article",
|
||||||
create: '/article/create',
|
create: "/article/create",
|
||||||
edit: '/article/edit/:id',
|
edit: "/article/edit/:id",
|
||||||
show: '/article/show/:id',
|
show: "/article/show/:id",
|
||||||
meta: {
|
meta: {
|
||||||
canDelete: true,
|
canDelete: true,
|
||||||
label: 'Статьи',
|
label: "Статьи",
|
||||||
icon: <ArticleIcon />,
|
icon: <ArticleIcon />,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'sight',
|
name: "sight",
|
||||||
list: '/sight',
|
list: "/sight",
|
||||||
create: '/sight/create',
|
create: "/sight/create",
|
||||||
edit: '/sight/edit/:id',
|
edit: "/sight/edit/:id",
|
||||||
show: '/sight/show/:id',
|
show: "/sight/show/:id",
|
||||||
meta: {
|
meta: {
|
||||||
canDelete: true,
|
canDelete: true,
|
||||||
label: 'Достопримечательности',
|
label: "Достопримечательности",
|
||||||
icon: <SightIcon />,
|
icon: <SightIcon />,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'station',
|
name: "station",
|
||||||
list: '/station',
|
list: "/station",
|
||||||
create: '/station/create',
|
create: "/station/create",
|
||||||
edit: '/station/edit/:id',
|
edit: "/station/edit/:id",
|
||||||
show: '/station/show/:id',
|
show: "/station/show/:id",
|
||||||
meta: {
|
meta: {
|
||||||
canDelete: true,
|
canDelete: true,
|
||||||
label: 'Остановки',
|
label: "Остановки",
|
||||||
icon: <StationIcon />,
|
icon: <StationIcon />,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'vehicle',
|
name: "vehicle",
|
||||||
list: '/vehicle',
|
list: "/vehicle",
|
||||||
create: '/vehicle/create',
|
create: "/vehicle/create",
|
||||||
edit: '/vehicle/edit/:id',
|
edit: "/vehicle/edit/:id",
|
||||||
show: '/vehicle/show/:id',
|
show: "/vehicle/show/:id",
|
||||||
meta: {
|
meta: {
|
||||||
canDelete: true,
|
canDelete: true,
|
||||||
label: 'Транспорт',
|
label: "Транспорт",
|
||||||
icon: <VehicleIcon />,
|
icon: <VehicleIcon />,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'route',
|
name: "route",
|
||||||
list: '/route',
|
list: "/route",
|
||||||
create: '/route/create',
|
create: "/route/create",
|
||||||
edit: '/route/edit/:id',
|
edit: "/route/edit/:id",
|
||||||
show: '/route/show/:id',
|
show: "/route/show/:id",
|
||||||
meta: {
|
meta: {
|
||||||
canDelete: true,
|
canDelete: true,
|
||||||
label: 'Маршруты',
|
label: "Маршруты",
|
||||||
icon: <RouteIcon />,
|
icon: <RouteIcon />,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'user',
|
name: "user",
|
||||||
list: '/user',
|
list: "/user",
|
||||||
create: '/user/create',
|
create: "/user/create",
|
||||||
edit: '/user/edit/:id',
|
edit: "/user/edit/:id",
|
||||||
show: '/user/show/:id',
|
show: "/user/show/:id",
|
||||||
meta: {
|
meta: {
|
||||||
canDelete: true,
|
canDelete: true,
|
||||||
label: 'Пользователи',
|
label: "Пользователи",
|
||||||
icon: <UsersIcon />,
|
icon: <UsersIcon />,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -171,20 +216,26 @@ function App() {
|
|||||||
syncWithLocation: true,
|
syncWithLocation: true,
|
||||||
warnWhenUnsavedChanges: true, // Включаем глобально
|
warnWhenUnsavedChanges: true, // Включаем глобально
|
||||||
useNewQueryKeys: true,
|
useNewQueryKeys: true,
|
||||||
projectId: 'Wv044J-t53S3s-PcbJGe',
|
projectId: "Wv044J-t53S3s-PcbJGe",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<Authenticated key="authenticated-inner" fallback={<CatchAllNavigate to="/login" />}>
|
<Authenticated
|
||||||
|
key="authenticated-inner"
|
||||||
|
fallback={<CatchAllNavigate to="/login" />}
|
||||||
|
>
|
||||||
<ThemedLayoutV2 Header={Header} Title={SidebarTitle}>
|
<ThemedLayoutV2 Header={Header} Title={SidebarTitle}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</ThemedLayoutV2>
|
</ThemedLayoutV2>
|
||||||
</Authenticated>
|
</Authenticated>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<NavigateToResource resource="country" />} />
|
<Route
|
||||||
|
index
|
||||||
|
element={<NavigateToResource resource="country" />}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path="/country">
|
<Route path="/country">
|
||||||
<Route index element={<CountryList />} />
|
<Route index element={<CountryList />} />
|
||||||
@ -344,7 +395,10 @@ function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<Authenticated key="authenticated-outer" fallback={<Outlet />}>
|
<Authenticated
|
||||||
|
key="authenticated-outer"
|
||||||
|
fallback={<Outlet />}
|
||||||
|
>
|
||||||
<NavigateToResource />
|
<NavigateToResource />
|
||||||
</Authenticated>
|
</Authenticated>
|
||||||
}
|
}
|
||||||
@ -353,13 +407,12 @@ function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
||||||
<RefineKbar />
|
|
||||||
<UnsavedChangesNotifier />
|
<UnsavedChangesNotifier />
|
||||||
<DocumentTitleHandler
|
<DocumentTitleHandler
|
||||||
handler={() => {
|
handler={() => {
|
||||||
// const cleanedTitle = title.autoGeneratedTitle.split('|')[0].trim()
|
// const cleanedTitle = title.autoGeneratedTitle.split('|')[0].trim()
|
||||||
// return `${cleanedTitle} — Белые ночи`
|
// return `${cleanedTitle} — Белые ночи`
|
||||||
return 'Белые ночи'
|
return "Белые ночи";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Refine>
|
</Refine>
|
||||||
@ -367,9 +420,8 @@ function App() {
|
|||||||
</DevtoolsProvider>
|
</DevtoolsProvider>
|
||||||
</RefineSnackbarProvider>
|
</RefineSnackbarProvider>
|
||||||
</ColorModeContextProvider>
|
</ColorModeContextProvider>
|
||||||
</RefineKbarProvider>
|
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App;
|
||||||
|
@ -12,12 +12,22 @@ import {
|
|||||||
useTheme,
|
useTheme,
|
||||||
TextField,
|
TextField,
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
Table,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Paper,
|
||||||
|
TableBody,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
import { axiosInstance } from "../providers/data";
|
import { axiosInstance } from "../providers/data";
|
||||||
|
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { TOKEN_KEY } from "../authProvider";
|
import { TOKEN_KEY } from "../authProvider";
|
||||||
|
import { Droppable, Draggable, DragDropContext } from "@hello-pangea/dnd";
|
||||||
|
|
||||||
|
// TODO: ДОДЕЛАТЬ
|
||||||
|
|
||||||
type Field<T> = {
|
type Field<T> = {
|
||||||
label: string;
|
label: string;
|
||||||
@ -42,6 +52,14 @@ type LinkedItemsProps<T> = {
|
|||||||
extraField?: ExtraFieldConfig;
|
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 }>({
|
export const LinkedItems = <T extends { id: number; [key: string]: any }>({
|
||||||
parentId,
|
parentId,
|
||||||
parentResource,
|
parentResource,
|
||||||
@ -58,6 +76,20 @@ export const LinkedItems = <T extends { id: number; [key: string]: any }>({
|
|||||||
const [mediaOrder, setMediaOrder] = useState<number>(1);
|
const [mediaOrder, setMediaOrder] = useState<number>(1);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const onDragEnd = (result) => {
|
||||||
|
// ドロップ先がない
|
||||||
|
if (!result.destination) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 配列の順序を入れ替える
|
||||||
|
let movedItems = reorder(
|
||||||
|
linkedItems, // 順序を入れ変えたい配列
|
||||||
|
result.source.index, // 元の配列の位置
|
||||||
|
result.destination.index // 移動先の配列の位置
|
||||||
|
);
|
||||||
|
setLinkedItems(movedItems);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
axiosInstance
|
axiosInstance
|
||||||
@ -180,7 +212,70 @@ export const LinkedItems = <T extends { id: number; [key: string]: any }>({
|
|||||||
</AccordionSummary>
|
</AccordionSummary>
|
||||||
|
|
||||||
<AccordionDetails sx={{ background: theme.palette.background.paper }}>
|
<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}>
|
<Grid container gap={1.25}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Typography>Загрузка...</Typography>
|
<Typography>Загрузка...</Typography>
|
||||||
@ -208,7 +303,8 @@ export const LinkedItems = <T extends { id: number; [key: string]: any }>({
|
|||||||
{childResource === "media" && item.id && (
|
{childResource === "media" && item.id && (
|
||||||
<img
|
<img
|
||||||
src={`${import.meta.env.VITE_KRBL_MEDIA}/${
|
src={`${import.meta.env.VITE_KRBL_MEDIA}/${
|
||||||
item.id
|
item.iimport { DragDropContext } from 'react-beautiful-dnd';
|
||||||
|
d
|
||||||
}/download?token=${localStorage.getItem(TOKEN_KEY)}`}
|
}/download?token=${localStorage.getItem(TOKEN_KEY)}`}
|
||||||
alt={String(item.media_name)}
|
alt={String(item.media_name)}
|
||||||
style={{
|
style={{
|
||||||
@ -338,7 +434,7 @@ export const LinkedItems = <T extends { id: number; [key: string]: any }>({
|
|||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack> */}
|
||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
);
|
);
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
@ -7,8 +6,4 @@ import "./globals.css";
|
|||||||
const container = document.getElementById("root") as HTMLElement;
|
const container = document.getElementById("root") as HTMLElement;
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
|
|
||||||
root.render(
|
root.render(<App />);
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
|
@ -130,12 +130,27 @@ export const RouteEdit = () => {
|
|||||||
(Прямой / Обратный)
|
(Прямой / Обратный)
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Controller
|
<TextField
|
||||||
name="path"
|
{...register("path", {
|
||||||
control={control}
|
|
||||||
defaultValue={[]}
|
|
||||||
rules={{
|
|
||||||
required: "Это поле является обязательным",
|
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) => {
|
validate: (value: unknown) => {
|
||||||
if (!Array.isArray(value)) return "Неверный формат";
|
if (!Array.isArray(value)) return "Неверный формат";
|
||||||
if (value.length === 0)
|
if (value.length === 0)
|
||||||
@ -159,40 +174,15 @@ export const RouteEdit = () => {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
}}
|
})}
|
||||||
render={({ field, fieldState: { error } }) => (
|
error={!!(errors as any)?.path}
|
||||||
<TextField
|
helperText={(errors as any)?.path?.message}
|
||||||
{...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"
|
margin="normal"
|
||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="text"
|
type="text"
|
||||||
label={"Координаты маршрута *"}
|
label={"Координаты маршрута *"}
|
||||||
|
name="path"
|
||||||
placeholder="55.7558 37.6173
|
placeholder="55.7558 37.6173
|
||||||
55.7539 37.6208"
|
55.7539 37.6208"
|
||||||
multiline
|
multiline
|
||||||
@ -201,8 +191,6 @@ export const RouteEdit = () => {
|
|||||||
marginBottom: 2,
|
marginBottom: 2,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register("route_sys_number", {
|
{...register("route_sys_number", {
|
||||||
|
@ -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,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
@ -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;
|
|
||||||
};
|
|
@ -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;
|
|
||||||
}
|
|
@ -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 };
|
|
||||||
});
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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} />;
|
|
||||||
});
|
|
@ -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";
|
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 8.3 MiB After Width: | Height: | Size: 8.3 MiB |
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -1,13 +1,12 @@
|
|||||||
import React, { HTMLAttributes, useEffect } from 'react';
|
import React, { HTMLAttributes, useEffect, useState } from "react";
|
||||||
import { useServerLocalization } from '@mt/i18n';
|
import { useServerLocalization } from "@mt/i18n";
|
||||||
import cn from 'classnames';
|
import cn from "classnames";
|
||||||
import { useSwipeable } from 'react-swipeable';
|
import { useSwipeable } from "react-swipeable";
|
||||||
import { ArticleBase } from '@mt/common-types';
|
import { ArticleBase } from "@mt/common-types";
|
||||||
import './AttractionWidget.css';
|
import "./AttractionWidget.css";
|
||||||
import { usePrevious } from '@mt/utils';
|
import { usePrevious } from "@mt/utils";
|
||||||
import { AttractionMedia } from './media/AttractionMedia';
|
import { AttractionMedia } from "./media/AttractionMedia";
|
||||||
import { useStore } from 'react-admin';
|
import { TouchScrollWrapper } from "../TouchScrollWrapper/TouchScrollWrapper";
|
||||||
import { TouchScrollWrapper } from '../TouchScrollWrapper/TouchScrollWrapper';
|
|
||||||
|
|
||||||
export interface AttractionsWidgetProps extends HTMLAttributes<HTMLElement> {
|
export interface AttractionsWidgetProps extends HTMLAttributes<HTMLElement> {
|
||||||
articles: ArticleBase[];
|
articles: ArticleBase[];
|
||||||
@ -15,15 +14,13 @@ export interface AttractionsWidgetProps extends HTMLAttributes<HTMLElement> {
|
|||||||
isPreviewOnly?: boolean;
|
isPreviewOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ATTRACTION_WIDGET_TABINDEX_STORE_KEY = 'attractions.widget.tabindex';
|
|
||||||
|
|
||||||
export function AttractionWidget({
|
export function AttractionWidget({
|
||||||
articles,
|
articles,
|
||||||
isIdleMode,
|
isIdleMode,
|
||||||
isPreviewOnly = false,
|
isPreviewOnly = false,
|
||||||
...props
|
...props
|
||||||
}: AttractionsWidgetProps) {
|
}: AttractionsWidgetProps) {
|
||||||
const [activeIndex, setActiveIndex] = useStore(ATTRACTION_WIDGET_TABINDEX_STORE_KEY, 0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const prevArticles = usePrevious<ArticleBase[]>(articles) || [];
|
const prevArticles = usePrevious<ArticleBase[]>(articles) || [];
|
||||||
const localizeText = useServerLocalization();
|
const localizeText = useServerLocalization();
|
||||||
|
|
||||||
@ -34,7 +31,9 @@ export function AttractionWidget({
|
|||||||
},
|
},
|
||||||
onSwipedRight: ({ event }) => {
|
onSwipedRight: ({ event }) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setActiveIndex((activeIndex) => (activeIndex - 1 + articles.length) % articles.length);
|
setActiveIndex(
|
||||||
|
(activeIndex) => (activeIndex - 1 + articles.length) % articles.length
|
||||||
|
);
|
||||||
},
|
},
|
||||||
swipeDuration: 500,
|
swipeDuration: 500,
|
||||||
preventScrollOnSwipe: true,
|
preventScrollOnSwipe: true,
|
||||||
@ -43,7 +42,7 @@ export function AttractionWidget({
|
|||||||
|
|
||||||
const handleClick = (index: number) => {
|
const handleClick = (index: number) => {
|
||||||
setActiveIndex(index);
|
setActiveIndex(index);
|
||||||
document.querySelector('.widget-text.active')!.scrollTop = 0;
|
document.querySelector(".widget-text.active")!.scrollTop = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => setActiveIndex(activeIndex), [activeIndex]);
|
useEffect(() => setActiveIndex(activeIndex), [activeIndex]);
|
||||||
@ -68,17 +67,21 @@ export function AttractionWidget({
|
|||||||
{articles?.map((article, index) => (
|
{articles?.map((article, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={`widget-slide ${index === activeIndex ? 'active' : ''}`}
|
className={`widget-slide ${index === activeIndex ? "active" : ""}`}
|
||||||
onPointerUp={() => handleClick(index)}
|
onPointerUp={() => handleClick(index)}
|
||||||
>
|
>
|
||||||
<div className="widget-media">
|
<div className="widget-media">
|
||||||
<AttractionMedia media={article.media} />
|
<AttractionMedia media={article.media} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{index !== 0 && <div className="widget-header">{localizeText(articles[0].text)}</div>}
|
{index !== 0 && (
|
||||||
|
<div className="widget-header">
|
||||||
|
{localizeText(articles[0].text)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<TouchScrollWrapper
|
<TouchScrollWrapper
|
||||||
className={cn('widget-text', {
|
className={cn("widget-text", {
|
||||||
active: index === activeIndex,
|
active: index === activeIndex,
|
||||||
preview: article.isPreview,
|
preview: article.isPreview,
|
||||||
})}
|
})}
|
||||||
@ -95,7 +98,7 @@ export function AttractionWidget({
|
|||||||
{articles?.map((article, index) => (
|
{articles?.map((article, index) => (
|
||||||
<div
|
<div
|
||||||
key={`title-${index}`}
|
key={`title-${index}`}
|
||||||
className={cn('widget-title', {
|
className={cn("widget-title", {
|
||||||
active: index === activeIndex,
|
active: index === activeIndex,
|
||||||
preview: article.isPreview,
|
preview: article.isPreview,
|
||||||
})}
|
})}
|
@ -1,9 +1,9 @@
|
|||||||
import { Media } from '@mt/common-types';
|
import { Media } from "@mt/common-types";
|
||||||
import { ImageMedia } from './ImageMedia';
|
import { ImageMedia } from "./ImageMedia";
|
||||||
import { VideoMedia } from './VideoMedia';
|
import { VideoMedia } from "./VideoMedia";
|
||||||
import { PhotoSphereMedia } from './PhotoSphereMedia';
|
import { PhotoSphereMedia } from "./PhotoSphereMedia";
|
||||||
import { Object3DMedia } from './Object3DMedia';
|
import { Object3DMedia } from "./Object3DMedia";
|
||||||
import { memo } from 'react';
|
import { memo } from "react";
|
||||||
|
|
||||||
export const AttractionMedia = memo(
|
export const AttractionMedia = memo(
|
||||||
({ media }: { media: Media }) => {
|
({ media }: { media: Media }) => {
|
||||||
@ -12,13 +12,15 @@ export const AttractionMedia = memo(
|
|||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'IMAGE':
|
case "IMAGE":
|
||||||
return <ImageMedia url={url} alt={media.url} watermarkUrl={watermarkUrl} />;
|
return (
|
||||||
case 'VIDEO':
|
<ImageMedia url={url} alt={media.url} watermarkUrl={watermarkUrl} />
|
||||||
|
);
|
||||||
|
case "VIDEO":
|
||||||
return <VideoMedia url={url} watermarkUrl={watermarkUrl} />;
|
return <VideoMedia url={url} watermarkUrl={watermarkUrl} />;
|
||||||
case 'PHOTO_SPHERE':
|
case "PHOTO_SPHERE":
|
||||||
return <PhotoSphereMedia url={url} watermarkUrl={watermarkUrl} />;
|
return <PhotoSphereMedia url={url} watermarkUrl={watermarkUrl} />;
|
||||||
case 'OBJECT_3D':
|
case "OBJECT_3D":
|
||||||
return <Object3DMedia url={url} watermarkUrl={watermarkUrl} />;
|
return <Object3DMedia url={url} watermarkUrl={watermarkUrl} />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
@ -1,28 +1,31 @@
|
|||||||
import cn from 'classnames';
|
import cn from "classnames";
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef } from "react";
|
||||||
import { ReactPhotoSphereViewer } from 'react-photo-sphere-viewer';
|
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
|
||||||
|
|
||||||
import { PhotoSphereLightboxData } from '@mt/common-types';
|
import { PhotoSphereLightboxData } from "@mt/common-types";
|
||||||
|
|
||||||
import './AttractionMedia.css';
|
import "./AttractionMedia.css";
|
||||||
import { useLightboxContext } from '../../lightbox';
|
import { useLightboxContext } from "../../lightbox";
|
||||||
import { Icons } from '@mt/components';
|
import { Icons } from "@mt/components";
|
||||||
|
|
||||||
interface PhotoSphereMediaProps {
|
interface PhotoSphereMediaProps {
|
||||||
url: string;
|
url: string;
|
||||||
watermarkUrl?: string;
|
watermarkUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PhotoSphereMedia = ({ url, watermarkUrl }: PhotoSphereMediaProps) => {
|
export const PhotoSphereMedia = ({
|
||||||
|
url,
|
||||||
|
watermarkUrl,
|
||||||
|
}: PhotoSphereMediaProps) => {
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
const { setData, openLightbox } = useLightboxContext<PhotoSphereLightboxData>();
|
const { setData, openLightbox } = useLightboxContext<PhotoSphereLightboxData>();
|
||||||
// react-photo-sphere-viewer doesn't have exported types, so here's a bit of a hardcoded piece
|
// 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 = () => {
|
const handlePhotoSphereFullscreenOpen = () => {
|
||||||
photoSphereRef.current?.stopAutoRotate();
|
photoSphereRef.current?.stopAutoRotate();
|
||||||
setData({
|
setData({
|
||||||
type: 'PHOTO_SPHERE',
|
type: "PHOTO_SPHERE",
|
||||||
imageUrl: url,
|
imageUrl: url,
|
||||||
watermarkUrl,
|
watermarkUrl,
|
||||||
});
|
});
|
||||||
@ -35,18 +38,20 @@ export const PhotoSphereMedia = ({ url, watermarkUrl }: PhotoSphereMediaProps) =
|
|||||||
ref={photoSphereRef}
|
ref={photoSphereRef}
|
||||||
key={url}
|
key={url}
|
||||||
src={url}
|
src={url}
|
||||||
height={'350px'}
|
height={"350px"}
|
||||||
width={'100%'}
|
width={"100%"}
|
||||||
container={cn('widget-media', {
|
container={cn("widget-media", {
|
||||||
'media-with-watermark': watermarkUrl !== null,
|
"media-with-watermark": watermarkUrl !== null,
|
||||||
})}
|
})}
|
||||||
moveInertia={false}
|
moveInertia={false}
|
||||||
mousemove={true}
|
mousemove={true}
|
||||||
navbar={['autorotate', 'zoom']}
|
navbar={["autorotate", "zoom"]}
|
||||||
keyboard={false}
|
keyboard={false}
|
||||||
loadingTxt="Загрузка..."
|
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 */}
|
{/* the following is a workaround to open lightbox-like preview in the middle of the screen instead of the real fullscreen */}
|
||||||
<Icons.FullscreenIcon
|
<Icons.FullscreenIcon
|
||||||
className="fullscreen-photo-sphere-btn"
|
className="fullscreen-photo-sphere-btn"
|
@ -1,7 +1,7 @@
|
|||||||
import cn from 'classnames';
|
import cn from "classnames";
|
||||||
import React from 'react';
|
import React from "react";
|
||||||
|
|
||||||
import './AttractionMedia.css';
|
import "./AttractionMedia.css";
|
||||||
|
|
||||||
interface VideoMediaProps {
|
interface VideoMediaProps {
|
||||||
url: string;
|
url: string;
|
||||||
@ -12,8 +12,8 @@ export const VideoMedia = ({ url, watermarkUrl }: VideoMediaProps) => (
|
|||||||
<>
|
<>
|
||||||
<video
|
<video
|
||||||
src={url}
|
src={url}
|
||||||
className={cn('widget-video', {
|
className={cn("widget-video", {
|
||||||
'media-with-watermark': watermarkUrl !== null,
|
"media-with-watermark": watermarkUrl !== null,
|
||||||
})}
|
})}
|
||||||
autoPlay
|
autoPlay
|
||||||
loop
|
loop
|
@ -1,17 +1,27 @@
|
|||||||
import React, { createContext, useState, useContext, ReactNode, useMemo, useEffect } from 'react';
|
import React, {
|
||||||
import { geoMercator } from 'd3-geo';
|
createContext,
|
||||||
import { Coordinates, Track, uuid } from '@mt/common-types';
|
useState,
|
||||||
import { useNearStation, usePassedTrackIndex } from './hooks';
|
useContext,
|
||||||
import { AttractionGroup, MapData, StationOnMap } from './map-widget.interface';
|
ReactNode,
|
||||||
import { getMapPoint } from './utils';
|
useMemo,
|
||||||
import { EMPTY_SETTING_VALUE, zeroCoordinates } from './map-widget.constant';
|
useEffect,
|
||||||
import { MapSettings, MapWidgetContextType } from './map-widget-context.interface';
|
} from "react";
|
||||||
import { useForm } from 'react-hook-form';
|
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 = {
|
export const mapCanvasProps = {
|
||||||
style: {
|
style: {
|
||||||
width: '100%',
|
width: "100%",
|
||||||
height: '100%',
|
height: "100%",
|
||||||
},
|
},
|
||||||
width: 500,
|
width: 500,
|
||||||
height: 400,
|
height: 400,
|
||||||
@ -24,7 +34,9 @@ export const MapWidgetProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
const [track, setTrack] = useState<Track | null>(null);
|
const [track, setTrack] = useState<Track | null>(null);
|
||||||
const [stations, setStations] = useState<StationOnMap[]>([]);
|
const [stations, setStations] = useState<StationOnMap[]>([]);
|
||||||
const [updatedStationIds, setUpdatedStationIds] = useState<uuid[]>([]);
|
const [updatedStationIds, setUpdatedStationIds] = useState<uuid[]>([]);
|
||||||
const [attractionGroups, setAttractionGroups] = useState<AttractionGroup[]>([]);
|
const [attractionGroups, setAttractionGroups] = useState<AttractionGroup[]>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
const [rotateAngle, setRotateAngle] = useState<number>(0);
|
const [rotateAngle, setRotateAngle] = useState<number>(0);
|
||||||
|
|
||||||
const [scale, setScale] = 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 [center, setCenter] = useState(zeroCoordinates);
|
||||||
const [baseCenter, setBaseCenter] = 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 [isEditMode, setIsEditMode] = useState<boolean>(false);
|
||||||
const [isDragMode, setIsDragMode] = useState<boolean>(false);
|
const [isDragMode, setIsDragMode] = useState<boolean>(false);
|
||||||
|
|
||||||
const [initialSettingsData, setInitialSettingsData] = useState<MapSettings>(EMPTY_SETTING_VALUE);
|
const [initialSettingsData, setInitialSettingsData] =
|
||||||
const [isSettingsDataChanged, setIsSettingsDataChanged] = useState<boolean>(false);
|
useState<MapSettings>(EMPTY_SETTING_VALUE);
|
||||||
|
const [isSettingsDataChanged, setIsSettingsDataChanged] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
|
||||||
const isMapDataChanged = useMemo(
|
const isMapDataChanged = useMemo(
|
||||||
() => isSettingsDataChanged || updatedStationIds.length > 0,
|
() => isSettingsDataChanged || updatedStationIds.length > 0,
|
||||||
@ -63,11 +79,14 @@ export const MapWidgetProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
}, [track]);
|
}, [track]);
|
||||||
|
|
||||||
const settingsForm = useForm({
|
const settingsForm = useForm({
|
||||||
mode: 'onChange',
|
mode: "onChange",
|
||||||
reValidateMode: 'onChange',
|
reValidateMode: "onChange",
|
||||||
defaultValues: initialSettingsData,
|
defaultValues: initialSettingsData,
|
||||||
});
|
});
|
||||||
useEffect(() => settingsForm.reset(initialSettingsData), [initialSettingsData]);
|
useEffect(
|
||||||
|
() => settingsForm.reset(initialSettingsData),
|
||||||
|
[initialSettingsData]
|
||||||
|
);
|
||||||
|
|
||||||
const onMapDataFetched = (data: MapData) => {
|
const onMapDataFetched = (data: MapData) => {
|
||||||
setTrack(data.trackPoints);
|
setTrack(data.trackPoints);
|
||||||
@ -95,7 +114,8 @@ export const MapWidgetProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
|
|
||||||
const onSettingsFormChange = () => {
|
const onSettingsFormChange = () => {
|
||||||
const formData = settingsForm.getValues();
|
const formData = settingsForm.getValues();
|
||||||
const { center, rotateAngle, fullScale, zoomedScale, currentStationId } = formData;
|
const { center, rotateAngle, fullScale, zoomedScale, currentStationId } =
|
||||||
|
formData;
|
||||||
|
|
||||||
setBaseCenter(center);
|
setBaseCenter(center);
|
||||||
setRotateAngle(rotateAngle);
|
setRotateAngle(rotateAngle);
|
||||||
@ -120,7 +140,7 @@ export const MapWidgetProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
const onMapCenterMoved = (center: Coordinates) => {
|
const onMapCenterMoved = (center: Coordinates) => {
|
||||||
setBaseCenter(center);
|
setBaseCenter(center);
|
||||||
setCenter(center);
|
setCenter(center);
|
||||||
settingsForm.setValue('center', center, { shouldDirty: true });
|
settingsForm.setValue("center", center, { shouldDirty: true });
|
||||||
|
|
||||||
updateMapDataChanged(settingsForm.getValues());
|
updateMapDataChanged(settingsForm.getValues());
|
||||||
};
|
};
|
||||||
@ -138,7 +158,7 @@ export const MapWidgetProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onStationUpdate: MapWidgetContextType['onStationUpdate'] = (
|
const onStationUpdate: MapWidgetContextType["onStationUpdate"] = (
|
||||||
stationId,
|
stationId,
|
||||||
{ labelOffset, labelAlignment }
|
{ labelOffset, labelAlignment }
|
||||||
) => {
|
) => {
|
||||||
@ -149,14 +169,18 @@ export const MapWidgetProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
setStations((stations) =>
|
setStations((stations) =>
|
||||||
stations.map((station) => (station.id === stationId ? updatedStation : station))
|
stations.map((station) =>
|
||||||
|
station.id === stationId ? updatedStation : station
|
||||||
|
)
|
||||||
);
|
);
|
||||||
setUpdatedStationIds((ids) => [...ids, stationId]);
|
setUpdatedStationIds((ids) => [...ids, stationId]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getUpdatedStations = () => {
|
const getUpdatedStations = () => {
|
||||||
return updatedStationIds.reduce((acc: Record<uuid, any>, id: uuid) => {
|
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] = {
|
acc[id] = {
|
||||||
textAlignment: labelAlignment,
|
textAlignment: labelAlignment,
|
||||||
@ -230,14 +254,20 @@ export const MapWidgetProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
isMapDataChanged,
|
isMapDataChanged,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <MapWidgetContext.Provider value={contextValue}>{children}</MapWidgetContext.Provider>;
|
return (
|
||||||
|
<MapWidgetContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</MapWidgetContext.Provider>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useMapWidgetContext = function (): MapWidgetContextType {
|
export const useMapWidgetContext = function (): MapWidgetContextType {
|
||||||
const context = useContext(MapWidgetContext);
|
const context = useContext(MapWidgetContext);
|
||||||
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('useMapWidgetContext must be used within a MapWidgetProvider');
|
throw new Error(
|
||||||
|
"useMapWidgetContext must be used within a MapWidgetProvider"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return context;
|
return context;
|
@ -1,15 +1,20 @@
|
|||||||
import { ComposableMap, ZoomableGroup, ZoomableGroupProps } from 'react-simple-maps';
|
import {
|
||||||
import styles from './MapWidget.module.css';
|
ComposableMap,
|
||||||
import { mapCanvasProps, useMapWidgetContext } from '../../MapWidgetContext';
|
ZoomableGroup,
|
||||||
import { useState } from 'react';
|
ZoomableGroupProps,
|
||||||
import { MapContent } from './MapContent';
|
} 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
|
// default coordinates for 3a route: 59.943, 30.331
|
||||||
export const MapWidget = () => {
|
export const MapWidget = () => {
|
||||||
const { onMapCenterMoved, projection, isDragMode, rotateAngle } = useMapWidgetContext();
|
const { onMapCenterMoved, projection, isDragMode, rotateAngle } =
|
||||||
|
useMapWidgetContext();
|
||||||
const [key, setKey] = useState(42);
|
const [key, setKey] = useState(42);
|
||||||
|
|
||||||
const handleMoveEnd: ZoomableGroupProps['onMoveEnd'] = (e, d3Zoom) => {
|
const handleMoveEnd: ZoomableGroupProps["onMoveEnd"] = (e, d3Zoom) => {
|
||||||
const { PI, cos, sin } = Math;
|
const { PI, cos, sin } = Math;
|
||||||
const { x, y } = d3Zoom.transform;
|
const { x, y } = d3Zoom.transform;
|
||||||
const { width, height } = mapCanvasProps;
|
const { width, height } = mapCanvasProps;
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -1,6 +1,6 @@
|
|||||||
import { AttractionMarker } from './AttractionMarker';
|
import { AttractionMarker } from "./AttractionMarker";
|
||||||
import { getMapPoint } from '../../utils';
|
import { getMapPoint } from "../../utils";
|
||||||
import { useMapWidgetContext } from '../../MapWidgetContext';
|
import { useMapWidgetContext } from "../../MapWidgetContext";
|
||||||
|
|
||||||
export const TrackAttractions = () => {
|
export const TrackAttractions = () => {
|
||||||
const { attractionGroups, rotateAngle } = useMapWidgetContext();
|
const { attractionGroups, rotateAngle } = useMapWidgetContext();
|
@ -1,11 +1,11 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from "react";
|
||||||
import { Line, Point } from 'react-simple-maps';
|
import { Line, Point } from "react-simple-maps";
|
||||||
import { getMapPoint } from '../utils';
|
import { getMapPoint } from "../utils";
|
||||||
import { useMapWidgetContext } from '../MapWidgetContext';
|
import { useMapWidgetContext } from "../MapWidgetContext";
|
||||||
import { zeroCoordinates } from '../map-widget.constant';
|
import { zeroCoordinates } from "../map-widget.constant";
|
||||||
|
|
||||||
const passedTrackColor = '#ed1c24';
|
const passedTrackColor = "#ed1c24";
|
||||||
const trackColor = '#cccccc';
|
const trackColor = "#cccccc";
|
||||||
|
|
||||||
export const TrackLine = () => {
|
export const TrackLine = () => {
|
||||||
const { track, passedTrackIndex, currentPosition } = useMapWidgetContext();
|
const { track, passedTrackIndex, currentPosition } = useMapWidgetContext();
|
||||||
@ -16,7 +16,12 @@ export const TrackLine = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Line coordinates={mappedTrack} strokeWidth={2.5} strokeLinecap="round" stroke={trackColor} />
|
<Line
|
||||||
|
coordinates={mappedTrack}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
stroke={trackColor}
|
||||||
|
/>
|
||||||
|
|
||||||
<Line
|
<Line
|
||||||
coordinates={[
|
coordinates={[
|
@ -1,27 +1,37 @@
|
|||||||
import { Marker } from 'react-simple-maps';
|
import { Marker } from "react-simple-maps";
|
||||||
// TODO: resolve circular deps
|
// 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 "./TrackStations.css";
|
||||||
import { StationLabelEdit } from './StationLabelEdit';
|
import { StationLabelEdit } from "./StationLabelEdit";
|
||||||
import { useMapWidgetContext } from '../../MapWidgetContext';
|
import { useMapWidgetContext } from "../../MapWidgetContext";
|
||||||
import { StationLabel } from './StationLabel';
|
import { StationLabel } from "./StationLabel";
|
||||||
|
|
||||||
const colors = {
|
const colors = {
|
||||||
black: '#000000',
|
black: "#000000",
|
||||||
red: '#ed1c24',
|
red: "#ed1c24",
|
||||||
grey: '#cccccc',
|
grey: "#cccccc",
|
||||||
yellow: '#fcd500',
|
yellow: "#fcd500",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TrackStations = () => {
|
export const TrackStations = () => {
|
||||||
const { stations, currentStation, passedTrackIndex, isOnStation, isEditMode } =
|
const {
|
||||||
useMapWidgetContext();
|
stations,
|
||||||
const isTerminalStation = (index: number) => index === 0 || index === stations.length - 1;
|
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) {
|
if (isOnStation && currentStation?.id === id) {
|
||||||
return colors.yellow;
|
return colors.yellow;
|
||||||
}
|
}
|
||||||
@ -49,7 +59,11 @@ export const TrackStations = () => {
|
|||||||
r={3.5}
|
r={3.5}
|
||||||
strokeWidth={isTerminalStation(index) ? 2 : 1.5}
|
strokeWidth={isTerminalStation(index) ? 2 : 1.5}
|
||||||
/>
|
/>
|
||||||
{isEditMode ? <StationLabelEdit station={it} /> : <StationLabel station={it} />}
|
{isEditMode ? (
|
||||||
|
<StationLabelEdit station={it} />
|
||||||
|
) : (
|
||||||
|
<StationLabel station={it} />
|
||||||
|
)}
|
||||||
</Marker>
|
</Marker>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
import { getIntersection, getIntersectionArea } from '../../utils';
|
import { getIntersection, getIntersectionArea } from "../../utils";
|
||||||
import { Marker, Point } from 'react-simple-maps';
|
import { Marker, Point } from "react-simple-maps";
|
||||||
import { Coordinates } from '@mt/common-types';
|
import { Coordinates } from "@mt/common-types";
|
||||||
import cn from 'classnames';
|
import cn from "classnames";
|
||||||
import styles from './TramMarker.module.css';
|
import styles from "./TramMarker.module.css";
|
||||||
import { Icons, useMapWidgetContext } from '@mt/components';
|
import { Icons, useMapWidgetContext } from "@mt/components";
|
||||||
|
|
||||||
interface TramMarkerProps {
|
interface TramMarkerProps {
|
||||||
coordinates: Point;
|
coordinates: Point;
|
||||||
@ -17,7 +17,9 @@ export const TramMarker = ({ coordinates, nextStopPoint }: TramMarkerProps) => {
|
|||||||
const { rotateAngle } = useMapWidgetContext();
|
const { rotateAngle } = useMapWidgetContext();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tramRect = document.getElementById('tram-marker')?.getBoundingClientRect();
|
const tramRect = document
|
||||||
|
.getElementById("tram-marker")
|
||||||
|
?.getBoundingClientRect();
|
||||||
const nextStopRect = document
|
const nextStopRect = document
|
||||||
.getElementById(`${nextStopPoint.lat}:${nextStopPoint.lon}`)
|
.getElementById(`${nextStopPoint.lat}:${nextStopPoint.lon}`)
|
||||||
?.getBoundingClientRect();
|
?.getBoundingClientRect();
|
||||||
@ -36,7 +38,9 @@ export const TramMarker = ({ coordinates, nextStopPoint }: TramMarkerProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Marker coordinates={coordinates} id="tram-marker">
|
<Marker coordinates={coordinates} id="tram-marker">
|
||||||
<foreignObject className={cn(styles.iconContainer, { [styles.flipped]: flipped })}>
|
<foreignObject
|
||||||
|
className={cn(styles.iconContainer, { [styles.flipped]: flipped })}
|
||||||
|
>
|
||||||
<Icons.TramMarkerIcon
|
<Icons.TramMarkerIcon
|
||||||
className={`${styles.icon} g-transform-origin__center`}
|
className={`${styles.icon} g-transform-origin__center`}
|
||||||
// Inverse angle to compensate map rotation
|
// Inverse angle to compensate map rotation
|
@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
import { StationOnMap } from '../map-widget.interface';
|
import { StationOnMap } from "../map-widget.interface";
|
||||||
import { getDistance } from '../utils';
|
import { getDistance } from "../utils";
|
||||||
import { Coordinates } from '@mt/common-types';
|
import { Coordinates } from "@mt/common-types";
|
||||||
|
|
||||||
const ZOOM_DISTANCE = 100;
|
const ZOOM_DISTANCE = 100;
|
||||||
const ON_STATION_DISTANCE = 15;
|
const ON_STATION_DISTANCE = 15;
|
||||||
@ -12,7 +12,9 @@ export function useNearStation(
|
|||||||
passedTrackIndex: number
|
passedTrackIndex: number
|
||||||
) {
|
) {
|
||||||
const [nextStation, setNextStation] = useState<StationOnMap | null>(null);
|
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);
|
const [isOnStation, setIsOnStation] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
@ -1,11 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
import { Coordinates, Track } from '@mt/common-types';
|
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)
|
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);
|
const [passedTrackIndex, setPassedTrackIndex] = useState<number>(0);
|
||||||
|
|
||||||
useEffect(() => {
|
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
|
* 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
|
* 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 prevIndex = Math.max(newPassedIndex - 1, 0);
|
||||||
const nextIndex = Math.min(newPassedIndex + 1, track.length - 1);
|
const nextIndex = Math.min(newPassedIndex + 1, track.length - 1);
|
||||||
|
|
@ -1,16 +1,16 @@
|
|||||||
import { Coordinates, SetState, uuid } from '@mt/common-types';
|
import { Coordinates, SetState, uuid } from "@mt/common-types";
|
||||||
import { MapData, StationOnMap } from '@mt/components';
|
import { MapData, StationOnMap } from "@mt/components";
|
||||||
import { GeoProjection } from 'd3-geo';
|
|
||||||
import { Point } from 'react-simple-maps';
|
import { UseFormReturn } from "react-hook-form";
|
||||||
import { UseFormReturn } from 'react-hook-form';
|
import { RouteStation } from "@mt/common-types";
|
||||||
import { RouteStation } from '@admin/types';
|
import { GeoProjection } from "d3-geo";
|
||||||
|
|
||||||
export interface MapWidgetContextType {
|
export interface MapWidgetContextType {
|
||||||
// External data
|
// External data
|
||||||
track: MapData['trackPoints'] | null;
|
track: MapData["trackPoints"] | null;
|
||||||
stations: MapData['stationsOnMap'];
|
stations: MapData["stationsOnMap"];
|
||||||
attractionGroups: MapData['touristAttractionGroupsOnMap'];
|
attractionGroups: MapData["touristAttractionGroupsOnMap"];
|
||||||
rotateAngle: MapData['mapRotateAngle'];
|
rotateAngle: MapData["mapRotateAngle"];
|
||||||
center: Coordinates;
|
center: Coordinates;
|
||||||
projection: GeoProjection;
|
projection: GeoProjection;
|
||||||
currentPosition: Coordinates | null;
|
currentPosition: Coordinates | null;
|
||||||
@ -24,6 +24,7 @@ export interface MapWidgetContextType {
|
|||||||
setCurrentPosition: SetState<Coordinates | null>;
|
setCurrentPosition: SetState<Coordinates | null>;
|
||||||
|
|
||||||
isDragMode: boolean;
|
isDragMode: boolean;
|
||||||
|
|
||||||
setIsDragMode: SetState<boolean>;
|
setIsDragMode: SetState<boolean>;
|
||||||
isEditMode: boolean;
|
isEditMode: boolean;
|
||||||
setIsEditMode: SetState<boolean>;
|
setIsEditMode: SetState<boolean>;
|
||||||
@ -34,7 +35,7 @@ export interface MapWidgetContextType {
|
|||||||
onMapCenterMoved: (center: Coordinates) => void;
|
onMapCenterMoved: (center: Coordinates) => void;
|
||||||
onStationUpdate: (
|
onStationUpdate: (
|
||||||
stationId: uuid,
|
stationId: uuid,
|
||||||
data: Partial<Pick<StationOnMap, 'labelAlignment' | 'labelOffset'>>
|
data: Partial<Pick<StationOnMap, "labelAlignment" | "labelOffset">>
|
||||||
) => void;
|
) => void;
|
||||||
getUpdatedStations: () => Partial<RouteStation>;
|
getUpdatedStations: () => Partial<RouteStation>;
|
||||||
isMapDataChanged: boolean;
|
isMapDataChanged: boolean;
|
@ -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 };
|
export const zeroCoordinates = { lat: 0, lon: 0 };
|
||||||
|
|
@ -4,8 +4,8 @@ import {
|
|||||||
StationOnMap as StationOnMapBase,
|
StationOnMap as StationOnMapBase,
|
||||||
Track,
|
Track,
|
||||||
uuid,
|
uuid,
|
||||||
} from '@mt/common-types';
|
Transfer,
|
||||||
import { Transfer } from '@front/types';
|
} from "@mt/common-types";
|
||||||
|
|
||||||
export type PointOnTrack = Coordinates & {
|
export type PointOnTrack = Coordinates & {
|
||||||
trackIndex: number;
|
trackIndex: number;
|
@ -1,8 +1,8 @@
|
|||||||
import React, { HTMLAttributes, useContext, useEffect, useRef } from 'react';
|
import React, { HTMLAttributes, useContext, useEffect, useRef } from "react";
|
||||||
import { LocalizationContext, LocalizedString } from '@mt/i18n';
|
import { LocalizationContext, LocalizedString } from "@mt/i18n";
|
||||||
|
|
||||||
import styles from './RouteInfoWidget.module.css';
|
import styles from "./RouteInfoWidget.module.css";
|
||||||
import cn from 'classnames';
|
import cn from "classnames";
|
||||||
|
|
||||||
export interface RouteInfoData {
|
export interface RouteInfoData {
|
||||||
routeNumber: string;
|
routeNumber: string;
|
||||||
@ -13,7 +13,11 @@ interface RouteInfoWidgetProps extends HTMLAttributes<HTMLDivElement> {
|
|||||||
routeInfo?: RouteInfoData;
|
routeInfo?: RouteInfoData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RouteInfoWidget({ routeInfo, className, ...props }: RouteInfoWidgetProps) {
|
export function RouteInfoWidget({
|
||||||
|
routeInfo,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: RouteInfoWidgetProps) {
|
||||||
const contentContainerRef = useRef<HTMLDivElement>(null);
|
const contentContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const titleRefs = useRef<Array<HTMLSpanElement>>([]);
|
const titleRefs = useRef<Array<HTMLSpanElement>>([]);
|
||||||
|
|
||||||
@ -42,7 +46,7 @@ export function RouteInfoWidget({ routeInfo, className, ...props }: RouteInfoWid
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(styles.root, className)} {...props}>
|
<div className={cn(styles.root, className)} {...props}>
|
||||||
<div className={styles.number}>{routeInfo?.routeNumber || '--'}</div>
|
<div className={styles.number}>{routeInfo?.routeNumber || "--"}</div>
|
||||||
|
|
||||||
{routeInfo ? (
|
{routeInfo ? (
|
||||||
<div className={styles.content} ref={contentContainerRef}>
|
<div className={styles.content} ref={contentContainerRef}>
|
||||||
@ -52,11 +56,13 @@ export function RouteInfoWidget({ routeInfo, className, ...props }: RouteInfoWid
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={cn(styles.title, styles.titleEnd)}>
|
<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>
|
||||||
<div className={cn(styles.title, styles.titleTranslation)}>
|
<div className={cn(styles.title, styles.titleTranslation)}>
|
||||||
<span ref={(ref) => (titleRefs.current[2] = ref!)}>
|
<span ref={(ref) => (titleRefs.current[2] = ref!)}>
|
||||||
{locale === 'zh'
|
{locale === "zh"
|
||||||
? `${routeInfo.firstStationName.zh} – ${routeInfo.lastStationName.zh}`
|
? `${routeInfo.firstStationName.zh} – ${routeInfo.lastStationName.zh}`
|
||||||
: `${routeInfo.firstStationName.en} – ${routeInfo.lastStationName.en}`}
|
: `${routeInfo.firstStationName.en} – ${routeInfo.lastStationName.en}`}
|
||||||
</span>
|
</span>
|
@ -1,9 +1,17 @@
|
|||||||
import { HTMLAttributes, PointerEvent, WheelEvent, useEffect, useRef, useState } from 'react';
|
import {
|
||||||
import cn from 'classnames';
|
HTMLAttributes,
|
||||||
import styles from './TouchScrollWrapper.module.css';
|
PointerEvent,
|
||||||
import { useCssProperty } from '@mt/utils';
|
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 setNumberPxFormatter = (number: number) => `${number}px`;
|
||||||
|
|
||||||
const { abs, min } = Math;
|
const { abs, min } = Math;
|
||||||
@ -18,14 +26,17 @@ export const TouchScrollWrapper = ({
|
|||||||
const scrollbarRef = useRef<HTMLDivElement>(null);
|
const scrollbarRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const scrollbarHeight = useCssProperty<number>(
|
const scrollbarHeight = useCssProperty<number>(
|
||||||
'--scrollbar-height',
|
"--scrollbar-height",
|
||||||
scrollbarRef,
|
scrollbarRef,
|
||||||
setNumberPxFormatter,
|
setNumberPxFormatter,
|
||||||
getNumberPxFormatter
|
getNumberPxFormatter
|
||||||
);
|
);
|
||||||
const scrollbarVisibility = useCssProperty<string>('--scrollbar-visibility', scrollbarRef);
|
const scrollbarVisibility = useCssProperty<string>(
|
||||||
|
"--scrollbar-visibility",
|
||||||
|
scrollbarRef
|
||||||
|
);
|
||||||
const scrollbarOffset = useCssProperty<number>(
|
const scrollbarOffset = useCssProperty<number>(
|
||||||
'--scrollbar-offset',
|
"--scrollbar-offset",
|
||||||
scrollbarRef,
|
scrollbarRef,
|
||||||
setNumberPxFormatter
|
setNumberPxFormatter
|
||||||
);
|
);
|
||||||
@ -57,10 +68,11 @@ export const TouchScrollWrapper = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (containerHeight >= contentHeight) {
|
if (containerHeight >= contentHeight) {
|
||||||
scrollbarVisibility.value = 'hidden';
|
scrollbarVisibility.value = "hidden";
|
||||||
} else {
|
} else {
|
||||||
scrollbarHeight.value = (containerHeight / contentHeight) * containerHeight + 1;
|
scrollbarHeight.value =
|
||||||
scrollbarVisibility.value = 'visible';
|
(containerHeight / contentHeight) * containerHeight + 1;
|
||||||
|
scrollbarVisibility.value = "visible";
|
||||||
}
|
}
|
||||||
}, [contentHeight, containerHeight]);
|
}, [contentHeight, containerHeight]);
|
||||||
|
|
||||||
@ -73,7 +85,7 @@ export const TouchScrollWrapper = ({
|
|||||||
const swipeDistance = startSwipeY - e.clientY;
|
const swipeDistance = startSwipeY - e.clientY;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
e.pointerType !== 'touch' ||
|
e.pointerType !== "touch" ||
|
||||||
containerHeight >= contentHeight ||
|
containerHeight >= contentHeight ||
|
||||||
(pointerId && pointerId !== e.pointerId) ||
|
(pointerId && pointerId !== e.pointerId) ||
|
||||||
abs(swipeDistance) < 2
|
abs(swipeDistance) < 2
|
Before Width: | Height: | Size: 422 B After Width: | Height: | Size: 422 B |
Before Width: | Height: | Size: 714 B After Width: | Height: | Size: 714 B |
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 556 B After Width: | Height: | Size: 556 B |
Before Width: | Height: | Size: 599 B After Width: | Height: | Size: 599 B |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
@ -1,11 +1,11 @@
|
|||||||
import { createElement } from 'react';
|
import { createElement } from "react";
|
||||||
import { ReactComponent as IconCloudy } from './icons/cond_cloudy.svg';
|
import IconCloudy from "./icons/cond_cloudy.svg";
|
||||||
import { ReactComponent as IconRainy } from './icons/cond_rainy.svg';
|
import IconRainy from "./icons/cond_rainy.svg";
|
||||||
import { ReactComponent as IconPartlyCloudy } from './icons/cond_partlycloudy.svg';
|
import IconPartlyCloudy from "./icons/cond_partlycloudy.svg";
|
||||||
import { ReactComponent as IconSnow } from './icons/cond_snow.svg';
|
import IconSnow from "./icons/cond_snow.svg";
|
||||||
import { ReactComponent as IconSnowy } from './icons/cond_snowy.svg';
|
import IconSnowy from "./icons/cond_snowy.svg";
|
||||||
import { ReactComponent as IconSunny } from './icons/cond_sunny.svg';
|
import IconSunny from "./icons/cond_sunny.svg";
|
||||||
import { ReactComponent as IconThunder } from './icons/cond_thunder.svg';
|
import IconThunder from "./icons/cond_thunder.svg";
|
||||||
|
|
||||||
interface WeatherWidgetIconProps {
|
interface WeatherWidgetIconProps {
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
@ -30,11 +30,11 @@ export function WeatherWidgetIcon({ icon, size = 16 }: WeatherWidgetIconProps) {
|
|||||||
style={{
|
style={{
|
||||||
width: `${size}px`,
|
width: `${size}px`,
|
||||||
height: `${size}px`,
|
height: `${size}px`,
|
||||||
textAlign: 'center',
|
textAlign: "center",
|
||||||
margin: '0 auto',
|
margin: "0 auto",
|
||||||
fontSize: `${size}px`,
|
fontSize: `${size}px`,
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
overflow: 'hidden',
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
children="--"
|
children="--"
|
||||||
/>
|
/>
|
||||||
@ -43,6 +43,6 @@ export function WeatherWidgetIcon({ icon, size = 16 }: WeatherWidgetIconProps) {
|
|||||||
return createElement(svg, {
|
return createElement(svg, {
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
style: { margin: '0 auto', display: 'block' },
|
style: { margin: "0 auto", display: "block" },
|
||||||
});
|
});
|
||||||
}
|
}
|
@ -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 IconHumidity from "./icons/det_humidity.svg";
|
||||||
import { ReactComponent as IconWind } from './icons/det_wind.svg';
|
import IconWind from "./icons/det_wind.svg";
|
||||||
import { WeatherDayRow, WeatherWidgetData } from './weather.interface';
|
import { WeatherDayRow, WeatherWidgetData } from "./weather.interface";
|
||||||
|
|
||||||
const Weekdays = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
const Weekdays = ["Вс", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
|
||||||
|
|
||||||
const WRow = ({ temperature, weekday, condition }: WeatherDayRow) => (
|
const WRow = ({ temperature, weekday, condition }: WeatherDayRow) => (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
marginBottom: '8px',
|
marginBottom: "8px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@ -22,26 +22,29 @@ const WRow = ({ temperature, weekday, condition }: WeatherDayRow) => (
|
|||||||
<WeatherWidgetIcon icon={condition} size={16} />
|
<WeatherWidgetIcon icon={condition} size={16} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginLeft: 8, minWidth: 22 }} children={Weekdays[weekday]} />
|
<div style={{ marginLeft: 8, minWidth: 22 }} children={Weekdays[weekday]} />
|
||||||
<div style={{ marginLeft: 8 }} children={`${temperature ?? '--'}°`} />
|
<div style={{ marginLeft: 8 }} children={`${temperature ?? "--"}°`} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export function WeatherWidgetRight({ forecasts, weatherInfo }: WeatherWidgetData) {
|
export function WeatherWidgetRight({
|
||||||
|
forecasts,
|
||||||
|
weatherInfo,
|
||||||
|
}: WeatherWidgetData) {
|
||||||
const wd = new Date().getDay();
|
const wd = new Date().getDay();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
textAlign: 'center',
|
textAlign: "center",
|
||||||
width: '50%',
|
width: "50%",
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
lineHeight: '21px',
|
lineHeight: "21px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
borderBottom: '1px solid #999',
|
borderBottom: "1px solid #999",
|
||||||
marginBottom: '8px',
|
marginBottom: "8px",
|
||||||
marginTop: '8px',
|
marginTop: "8px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[...forecasts].slice(0, 3).map((d, idx) => (
|
{[...forecasts].slice(0, 3).map((d, idx) => (
|
||||||
@ -51,22 +54,22 @@ export function WeatherWidgetRight({ forecasts, weatherInfo }: WeatherWidgetData
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconHumidity width={16} height={16} style={{ marginRight: 8 }} />
|
<IconHumidity width={16} height={16} style={{ marginRight: 8 }} />
|
||||||
<b children={weatherInfo?.humidity ?? '--'} />%
|
<b children={weatherInfo?.humidity ?? "--"} />%
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconWind width={16} height={16} style={{ marginRight: 8 }} />
|
<IconWind width={16} height={16} style={{ marginRight: 8 }} />
|
||||||
<b children={weatherInfo?.windSpeed ?? '--'} />
|
<b children={weatherInfo?.windSpeed ?? "--"} />
|
||||||
м/с
|
м/с
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|