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": { "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",

File diff suppressed because it is too large Load Diff

View File

@ -1,375 +1,427 @@
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 dataProvider={customDataProvider}
dataProvider={customDataProvider} notificationProvider={useNotificationProvider}
notificationProvider={useNotificationProvider} routerProvider={routerBindings}
routerProvider={routerBindings} authProvider={authProvider}
authProvider={authProvider} 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', {
list: '/city', name: "city",
create: '/city/create', list: "/city",
edit: '/city/edit/:id', create: "/city/create",
show: '/city/show/:id', edit: "/city/edit/:id",
meta: { show: "/city/show/:id",
canDelete: true, meta: {
label: 'Города', canDelete: true,
icon: <CityIcon />, label: "Города",
}, icon: <CityIcon />,
}, },
{ },
name: 'carrier', {
list: '/carrier', name: "carrier",
create: '/carrier/create', list: "/carrier",
edit: '/carrier/edit/:id', create: "/carrier/create",
show: '/carrier/show/:id', edit: "/carrier/edit/:id",
meta: { show: "/carrier/show/:id",
canDelete: true, meta: {
label: 'Перевозчики', canDelete: true,
icon: <CarrierIcon />, label: "Перевозчики",
}, icon: <CarrierIcon />,
}, },
{ },
name: 'media', {
list: '/media', name: "media",
create: '/media/create', list: "/media",
edit: '/media/edit/:id', create: "/media/create",
show: '/media/show/:id', edit: "/media/edit/:id",
meta: { show: "/media/show/:id",
canDelete: true, meta: {
label: 'Медиа', canDelete: true,
icon: <MediaIcon />, label: "Медиа",
}, icon: <MediaIcon />,
}, },
{ },
name: 'article', {
list: '/article', name: "article",
create: '/article/create', list: "/article",
edit: '/article/edit/:id', create: "/article/create",
show: '/article/show/:id', edit: "/article/edit/:id",
meta: { show: "/article/show/:id",
canDelete: true, meta: {
label: 'Статьи', canDelete: true,
icon: <ArticleIcon />, label: "Статьи",
}, icon: <ArticleIcon />,
}, },
{ },
name: 'sight', {
list: '/sight', name: "sight",
create: '/sight/create', list: "/sight",
edit: '/sight/edit/:id', create: "/sight/create",
show: '/sight/show/:id', edit: "/sight/edit/:id",
meta: { show: "/sight/show/:id",
canDelete: true, meta: {
label: 'Достопримечательности', canDelete: true,
icon: <SightIcon />, label: "Достопримечательности",
}, icon: <SightIcon />,
}, },
{ },
name: 'station', {
list: '/station', name: "station",
create: '/station/create', list: "/station",
edit: '/station/edit/:id', create: "/station/create",
show: '/station/show/:id', edit: "/station/edit/:id",
meta: { show: "/station/show/:id",
canDelete: true, meta: {
label: 'Остановки', canDelete: true,
icon: <StationIcon />, label: "Остановки",
}, icon: <StationIcon />,
}, },
{ },
name: 'vehicle', {
list: '/vehicle', name: "vehicle",
create: '/vehicle/create', list: "/vehicle",
edit: '/vehicle/edit/:id', create: "/vehicle/create",
show: '/vehicle/show/:id', edit: "/vehicle/edit/:id",
meta: { show: "/vehicle/show/:id",
canDelete: true, meta: {
label: 'Транспорт', canDelete: true,
icon: <VehicleIcon />, label: "Транспорт",
}, icon: <VehicleIcon />,
}, },
{ },
name: 'route', {
list: '/route', name: "route",
create: '/route/create', list: "/route",
edit: '/route/edit/:id', create: "/route/create",
show: '/route/show/:id', edit: "/route/edit/:id",
meta: { show: "/route/show/:id",
canDelete: true, meta: {
label: 'Маршруты', canDelete: true,
icon: <RouteIcon />, label: "Маршруты",
}, icon: <RouteIcon />,
}, },
{ },
name: 'user', {
list: '/user', name: "user",
create: '/user/create', list: "/user",
edit: '/user/edit/:id', create: "/user/create",
show: '/user/show/:id', edit: "/user/edit/:id",
meta: { show: "/user/show/:id",
canDelete: true, meta: {
label: 'Пользователи', canDelete: true,
icon: <UsersIcon />, label: "Пользователи",
}, icon: <UsersIcon />,
}, },
]} },
options={{ ]}
syncWithLocation: true, options={{
warnWhenUnsavedChanges: true, // Включаем глобально syncWithLocation: true,
useNewQueryKeys: true, warnWhenUnsavedChanges: true, // Включаем глобально
projectId: 'Wv044J-t53S3s-PcbJGe', 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> </Refine>
<Route <DevtoolsPanel />
element={ </DevtoolsProvider>
<Authenticated key="authenticated-inner" fallback={<CatchAllNavigate to="/login" />}> </RefineSnackbarProvider>
<ThemedLayoutV2 Header={Header} Title={SidebarTitle}> </ColorModeContextProvider>
<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>
</BrowserRouter> </BrowserRouter>
) );
} }
export default App export default App;

View File

@ -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>
); );

View File

@ -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>
);

View File

@ -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,49 +174,22 @@ export const RouteEdit = () => {
} }
return true; return true;
}, },
}} })}
render={({ field, fieldState: { error } }) => ( error={!!(errors as any)?.path}
<TextField helperText={(errors as any)?.path?.message}
{...field} margin="normal"
value={ fullWidth
Array.isArray(field.value) InputLabelProps={{ shrink: true }}
? field.value.map((point) => point.join(" ")).join("\n") type="text"
: "" label={"Координаты маршрута *"}
} name="path"
onChange={(e) => { placeholder="55.7558 37.6173
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
55.7539 37.6208" 55.7539 37.6208"
multiline multiline
rows={4} rows={4}
sx={{ sx={{
marginBottom: 2, marginBottom: 2,
}} }}
/>
)}
/> />
<TextField <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 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,
})} })}

View File

@ -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;

View File

@ -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"

View File

@ -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

View File

@ -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;

View File

@ -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;

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 { 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();

View File

@ -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={[

View File

@ -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>
))} ))}
</> </>

View File

@ -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

View File

@ -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(() => {

View File

@ -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);

View File

@ -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;

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 }; export const zeroCoordinates = { lat: 0, lon: 0 };

View File

@ -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;

View File

@ -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>

View File

@ -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

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 { 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" },
}); });
} }

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 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 ?? "--"} />
&nbsp;м/с &nbsp;м/с
</div> </div>
</div> </div>

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