Compare commits
13 Commits
Author | SHA1 | Date | |
---|---|---|---|
65532f7074 | |||
dc483d62de | |||
03829aacc6 | |||
a1a2264758 | |||
0d325a3aa6 | |||
abd054b8d4 | |||
9927c0afd6 | |||
463c593a0e | |||
9e34a71e14 | |||
029a2de97e | |||
4dd149f2af | |||
b6449b02c0 | |||
607012bd47 |
2
.env
Normal file
2
.env
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
VITE_KRBL_MEDIA = "https://wn.krbl.ru/media/"
|
||||||
|
VITE_KRBL_API = "https://wn.krbl.ru"
|
@ -1,7 +1,7 @@
|
|||||||
# This Dockerfile uses `serve` npm package to serve the static files with node process.
|
# This Dockerfile uses `serve` npm package to serve the static files with node process.
|
||||||
# You can find the Dockerfile for nginx in the following link:
|
# You can find the Dockerfile for nginx in the following link:
|
||||||
# https://github.com/refinedev/dockerfiles/blob/main/vite/Dockerfile.nginx
|
# https://github.com/refinedev/dockerfiles/blob/main/vite/Dockerfile.nginx
|
||||||
FROM refinedev/node:18 AS base
|
FROM refinedev/node:20 AS base
|
||||||
|
|
||||||
FROM base as deps
|
FROM base as deps
|
||||||
|
|
||||||
|
5
compose.yaml
Normal file
5
compose.yaml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
services:
|
||||||
|
refine:
|
||||||
|
image: white-nights:latest
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
12782
package-lock.json
generated
Normal file
12782
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
@ -6,37 +6,58 @@
|
|||||||
"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",
|
||||||
|
"@photo-sphere-viewer/core": "^5.13.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",
|
"mobx": "^6.13.7",
|
||||||
"react-dom": "^18.0.0",
|
"mobx-react-lite": "^4.1.0",
|
||||||
|
"react": "19.0.0",
|
||||||
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
|
"react-dom": "19.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.3",
|
||||||
"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",
|
||||||
|
7245
pnpm-lock.yaml
7245
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
254
src/App.tsx
254
src/App.tsx
@ -1,42 +1,89 @@
|
|||||||
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 {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, HashRouter } 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";
|
||||||
|
|
||||||
|
import { LoadingProvider } from "@mt/utils";
|
||||||
|
import { KBarProvider, RefineKbar } from "@refinedev/kbar";
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
|
<LoadingProvider>
|
||||||
<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 +94,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 +218,27 @@ function App() {
|
|||||||
syncWithLocation: true,
|
syncWithLocation: true,
|
||||||
warnWhenUnsavedChanges: true, // Включаем глобально
|
warnWhenUnsavedChanges: true, // Включаем глобально
|
||||||
useNewQueryKeys: true,
|
useNewQueryKeys: true,
|
||||||
projectId: 'Wv044J-t53S3s-PcbJGe',
|
projectId: "Wv044J-t53S3s-PcbJGe",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<KBarProvider>
|
||||||
<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 +398,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,23 +410,24 @@ 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 "Белые ночи";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<RefineKbar />
|
||||||
|
</KBarProvider>
|
||||||
</Refine>
|
</Refine>
|
||||||
<DevtoolsPanel />
|
<DevtoolsPanel />
|
||||||
</DevtoolsProvider>
|
</DevtoolsProvider>
|
||||||
</RefineSnackbarProvider>
|
</RefineSnackbarProvider>
|
||||||
</ColorModeContextProvider>
|
</ColorModeContextProvider>
|
||||||
</RefineKbarProvider>
|
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
</LoadingProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App;
|
||||||
|
@ -1,174 +1,182 @@
|
|||||||
import type {AuthProvider} from '@refinedev/core'
|
import type { AuthProvider } from "@refinedev/core";
|
||||||
import axios, {AxiosError} from 'axios'
|
import axios, { AxiosError } from "axios";
|
||||||
import {BACKEND_URL} from './lib/constants'
|
|
||||||
import {jwtDecode} from 'jwt-decode'
|
|
||||||
|
|
||||||
export const TOKEN_KEY = 'refine-auth'
|
import { jwtDecode } from "jwt-decode";
|
||||||
|
|
||||||
|
export const TOKEN_KEY = "refine-auth";
|
||||||
|
|
||||||
interface AuthResponse {
|
interface AuthResponse {
|
||||||
token: string
|
token: string;
|
||||||
user: {
|
user: {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
email: string
|
email: string;
|
||||||
is_admin: boolean
|
is_admin: boolean;
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ErrorResponse {
|
interface ErrorResponse {
|
||||||
message: string
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class AuthError extends Error {
|
class AuthError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message)
|
super(message);
|
||||||
this.name = 'AuthError'
|
this.name = "AuthError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface JWTPayload {
|
interface JWTPayload {
|
||||||
user_id: number
|
user_id: number;
|
||||||
email: string
|
email: string;
|
||||||
is_admin: boolean
|
is_admin: boolean;
|
||||||
exp: number
|
exp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authProvider: AuthProvider = {
|
export const authProvider: AuthProvider = {
|
||||||
login: async ({ email, password }) => {
|
login: async ({ email, password }) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post<AuthResponse>(`${BACKEND_URL}/auth/login`, {
|
const response = await axios.post<AuthResponse>(
|
||||||
|
`${import.meta.env.VITE_KRBL_API}/auth/login`,
|
||||||
|
{
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
})
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const {token, user} = response.data
|
const { token, user } = response.data;
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
localStorage.setItem(TOKEN_KEY, token)
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
localStorage.setItem('user', JSON.stringify(user))
|
localStorage.setItem("user", JSON.stringify(user));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
redirectTo: '/',
|
redirectTo: "/",
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new AuthError('Неверный email или пароль')
|
throw new AuthError("Неверный email или пароль");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = (error as AxiosError<ErrorResponse>)?.response?.data?.message || 'Неверный email или пароль'
|
const errorMessage =
|
||||||
|
(error as AxiosError<ErrorResponse>)?.response?.data?.message ||
|
||||||
|
"Неверный email или пароль";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: new AuthError(errorMessage),
|
error: new AuthError(errorMessage),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
try {
|
try {
|
||||||
await axios.post(
|
await axios.post(
|
||||||
`${BACKEND_URL}/auth/logout`,
|
`${import.meta.env.VITE_KRBL_API}/auth/logout`,
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${localStorage.getItem(TOKEN_KEY)}`,
|
Authorization: `Bearer ${localStorage.getItem(TOKEN_KEY)}`,
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
)
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при выходе:', error)
|
console.error("Ошибка при выходе:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
localStorage.removeItem('user')
|
localStorage.removeItem("user");
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
redirectTo: '/login',
|
redirectTo: "/login",
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
check: async () => {
|
check: async () => {
|
||||||
const token = localStorage.getItem(TOKEN_KEY)
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return {
|
return {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
redirectTo: '/login',
|
redirectTo: "/login",
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${BACKEND_URL}/auth/me`, {
|
const response = await axios.get(
|
||||||
|
`${import.meta.env.VITE_KRBL_API}/auth/me`,
|
||||||
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
// Обновляем информацию о пользователе
|
// Обновляем информацию о пользователе
|
||||||
localStorage.setItem('user', JSON.stringify(response.data))
|
localStorage.setItem("user", JSON.stringify(response.data));
|
||||||
return {
|
return {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
localStorage.removeItem('user')
|
localStorage.removeItem("user");
|
||||||
return {
|
return {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
redirectTo: '/login',
|
redirectTo: "/login",
|
||||||
error: new AuthError('Сессия истекла, пожалуйста, войдите снова'),
|
error: new AuthError("Сессия истекла, пожалуйста, войдите снова"),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
redirectTo: '/login',
|
redirectTo: "/login",
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
getPermissions: async () => {
|
getPermissions: async () => {
|
||||||
const token = localStorage.getItem(TOKEN_KEY)
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
if (!token) return null
|
if (!token) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwtDecode<JWTPayload>(token)
|
const decoded = jwtDecode<JWTPayload>(token);
|
||||||
if (decoded.is_admin) {
|
if (decoded.is_admin) {
|
||||||
document.body.classList.add('is-admin')
|
document.body.classList.add("is-admin");
|
||||||
} else {
|
} else {
|
||||||
document.body.classList.remove('is-admin')
|
document.body.classList.remove("is-admin");
|
||||||
}
|
}
|
||||||
return decoded.is_admin ? ['admin'] : ['user']
|
return decoded.is_admin ? ["admin"] : ["user"];
|
||||||
} catch {
|
} catch {
|
||||||
document.body.classList.remove('is-admin')
|
document.body.classList.remove("is-admin");
|
||||||
return ['user']
|
return ["user"];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getIdentity: async () => {
|
getIdentity: async () => {
|
||||||
const token = localStorage.getItem(TOKEN_KEY)
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
const user = localStorage.getItem('user')
|
const user = localStorage.getItem("user");
|
||||||
|
|
||||||
if (!token || !user) return null
|
if (!token || !user) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwtDecode<JWTPayload>(token)
|
const decoded = jwtDecode<JWTPayload>(token);
|
||||||
const userData = JSON.parse(user)
|
const userData = JSON.parse(user);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...userData,
|
...userData,
|
||||||
is_admin: decoded.is_admin, // всегда используем значение из токена
|
is_admin: decoded.is_admin, // всегда используем значение из токена
|
||||||
}
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: async (error) => {
|
onError: async (error) => {
|
||||||
const status = (error as AxiosError)?.response?.status
|
const status = (error as AxiosError)?.response?.status;
|
||||||
if (status === 401 || status === 403) {
|
if (status === 401 || status === 403) {
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
localStorage.removeItem('user')
|
localStorage.removeItem("user");
|
||||||
return {
|
return {
|
||||||
logout: true,
|
logout: true,
|
||||||
redirectTo: '/login',
|
redirectTo: "/login",
|
||||||
error: new AuthError('Сессия истекла, пожалуйста, войдите снова'),
|
error: new AuthError("Сессия истекла, пожалуйста, войдите снова"),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
return { error };
|
||||||
return {error}
|
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
@ -1,32 +1,50 @@
|
|||||||
import {Typography, Button, Box, Accordion, AccordionSummary, AccordionDetails, useTheme, TextField} from '@mui/material'
|
import {
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
|
Typography,
|
||||||
import {axiosInstance} from '../providers/data'
|
Button,
|
||||||
import {BACKEND_URL} from '../lib/constants'
|
Box,
|
||||||
import {useForm, Controller} from 'react-hook-form'
|
Accordion,
|
||||||
import {MarkdownEditor} from './MarkdownEditor'
|
AccordionSummary,
|
||||||
import React, {useState, useCallback} from 'react'
|
AccordionDetails,
|
||||||
import {useDropzone} from 'react-dropzone'
|
useTheme,
|
||||||
import {ALLOWED_IMAGE_TYPES, ALLOWED_VIDEO_TYPES} from '../components/media/MediaFormUtils'
|
TextField,
|
||||||
|
} from "@mui/material";
|
||||||
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
|
import { axiosInstance } from "../providers/data";
|
||||||
|
import { useForm, Controller } from "react-hook-form";
|
||||||
|
import { MarkdownEditor } from "./MarkdownEditor";
|
||||||
|
import React, { useState, useCallback } from "react";
|
||||||
|
import { useDropzone } from "react-dropzone";
|
||||||
|
import {
|
||||||
|
ALLOWED_IMAGE_TYPES,
|
||||||
|
ALLOWED_VIDEO_TYPES,
|
||||||
|
} from "../components/media/MediaFormUtils";
|
||||||
|
|
||||||
const MemoizedSimpleMDE = React.memo(MarkdownEditor)
|
const MemoizedSimpleMDE = React.memo(MarkdownEditor);
|
||||||
|
|
||||||
type MediaFile = {
|
type MediaFile = {
|
||||||
file: File
|
file: File;
|
||||||
preview: string
|
preview: string;
|
||||||
uploading: boolean
|
uploading: boolean;
|
||||||
mediaId?: number
|
mediaId?: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
parentId: string | number
|
parentId: string | number;
|
||||||
parentResource: string
|
parentResource: string;
|
||||||
childResource: string
|
childResource: string;
|
||||||
title: string
|
title: string;
|
||||||
}
|
left?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export const CreateSightArticle = ({parentId, parentResource, childResource, title}: Props) => {
|
export const CreateSightArticle = ({
|
||||||
const theme = useTheme()
|
parentId,
|
||||||
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([])
|
parentResource,
|
||||||
|
childResource,
|
||||||
|
title,
|
||||||
|
left,
|
||||||
|
}: Props) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register: registerItem,
|
register: registerItem,
|
||||||
@ -36,98 +54,121 @@ export const CreateSightArticle = ({parentId, parentResource, childResource, tit
|
|||||||
formState: { errors: itemErrors },
|
formState: { errors: itemErrors },
|
||||||
} = useForm({
|
} = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
heading: '',
|
heading: "",
|
||||||
body: '',
|
body: "",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const simpleMDEOptions = React.useMemo(
|
const simpleMDEOptions = React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
placeholder: 'Введите контент в формате Markdown...',
|
placeholder: "Введите контент в формате Markdown...",
|
||||||
spellChecker: false,
|
spellChecker: false,
|
||||||
}),
|
}),
|
||||||
[],
|
[]
|
||||||
)
|
);
|
||||||
|
|
||||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||||
const newFiles = acceptedFiles.map((file) => ({
|
const newFiles = acceptedFiles.map((file) => ({
|
||||||
file,
|
file,
|
||||||
preview: URL.createObjectURL(file),
|
preview: URL.createObjectURL(file),
|
||||||
uploading: false,
|
uploading: false,
|
||||||
}))
|
}));
|
||||||
setMediaFiles((prev) => [...prev, ...newFiles])
|
setMediaFiles((prev) => [...prev, ...newFiles]);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
onDrop,
|
onDrop,
|
||||||
accept: {
|
accept: {
|
||||||
'image/*': ALLOWED_IMAGE_TYPES,
|
"image/*": ALLOWED_IMAGE_TYPES,
|
||||||
'video/*': ALLOWED_VIDEO_TYPES,
|
"video/*": ALLOWED_VIDEO_TYPES,
|
||||||
},
|
},
|
||||||
multiple: true,
|
multiple: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
const uploadMedia = async (mediaFile: MediaFile) => {
|
const uploadMedia = async (mediaFile: MediaFile) => {
|
||||||
const formData = new FormData()
|
const formData = new FormData();
|
||||||
formData.append('media_name', mediaFile.file.name)
|
formData.append("media_name", mediaFile.file.name);
|
||||||
formData.append('filename', mediaFile.file.name)
|
formData.append("filename", mediaFile.file.name);
|
||||||
formData.append('type', mediaFile.file.type.startsWith('image/') ? '1' : '2')
|
formData.append(
|
||||||
formData.append('file', mediaFile.file)
|
"type",
|
||||||
|
mediaFile.file.type.startsWith("image/") ? "1" : "2"
|
||||||
|
);
|
||||||
|
formData.append("file", mediaFile.file);
|
||||||
|
|
||||||
const response = await axiosInstance.post(`${BACKEND_URL}/media`, formData)
|
const response = await axiosInstance.post(
|
||||||
return response.data.id
|
`${import.meta.env.VITE_KRBL_API}/media`,
|
||||||
}
|
formData
|
||||||
|
);
|
||||||
|
return response.data.id;
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreate = async (data: { heading: string; body: string }) => {
|
const handleCreate = async (data: { heading: string; body: string }) => {
|
||||||
try {
|
try {
|
||||||
// Создаем статью
|
// Создаем статью
|
||||||
const response = await axiosInstance.post(`${BACKEND_URL}/${childResource}`, data)
|
const response = await axiosInstance.post(
|
||||||
const itemId = response.data.id
|
`${import.meta.env.VITE_KRBL_API}/${childResource}`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
const itemId = response.data.id;
|
||||||
|
|
||||||
// Получаем существующие статьи для определения порядкового номера
|
// Получаем существующие статьи для определения порядкового номера
|
||||||
const existingItemsResponse = await axiosInstance.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`)
|
const existingItemsResponse = await axiosInstance.get(
|
||||||
const existingItems = existingItemsResponse.data || []
|
`${
|
||||||
const nextPageNum = existingItems.length + 1
|
import.meta.env.VITE_KRBL_API
|
||||||
|
}/${parentResource}/${parentId}/${childResource}`
|
||||||
|
);
|
||||||
|
const existingItems = existingItemsResponse.data || [];
|
||||||
|
const nextPageNum = existingItems.length + 1;
|
||||||
|
|
||||||
// Привязываем статью к достопримечательности
|
if (!left) {
|
||||||
await axiosInstance.post(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}/`, {
|
// Привязываем статью к достопримечательности если она не левая
|
||||||
|
await axiosInstance.post(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_KRBL_API
|
||||||
|
}/${parentResource}/${parentId}/${childResource}/`,
|
||||||
|
{
|
||||||
[`${childResource}_id`]: itemId,
|
[`${childResource}_id`]: itemId,
|
||||||
page_num: nextPageNum,
|
page_num: nextPageNum,
|
||||||
})
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Загружаем все медиа файлы и получаем их ID
|
// Загружаем все медиа файлы и получаем их ID
|
||||||
const mediaIds = await Promise.all(
|
const mediaIds = await Promise.all(
|
||||||
mediaFiles.map(async (mediaFile) => {
|
mediaFiles.map(async (mediaFile) => {
|
||||||
return await uploadMedia(mediaFile)
|
return await uploadMedia(mediaFile);
|
||||||
}),
|
})
|
||||||
)
|
);
|
||||||
|
|
||||||
// Привязываем все медиа к статье
|
// Привязываем все медиа к статье
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
mediaIds.map((mediaId, index) =>
|
mediaIds.map((mediaId, index) =>
|
||||||
axiosInstance.post(`${BACKEND_URL}/article/${itemId}/media/`, {
|
axiosInstance.post(
|
||||||
|
`${import.meta.env.VITE_KRBL_API}/article/${itemId}/media/`,
|
||||||
|
{
|
||||||
media_id: mediaId,
|
media_id: mediaId,
|
||||||
media_order: index + 1,
|
media_order: index + 1,
|
||||||
}),
|
}
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
resetItem()
|
resetItem();
|
||||||
setMediaFiles([])
|
setMediaFiles([]);
|
||||||
window.location.reload()
|
window.location.reload();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Error creating item:', err)
|
console.error("Error creating item:", err);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const removeMedia = (index: number) => {
|
const removeMedia = (index: number) => {
|
||||||
setMediaFiles((prev) => {
|
setMediaFiles((prev) => {
|
||||||
const newFiles = [...prev]
|
const newFiles = [...prev];
|
||||||
URL.revokeObjectURL(newFiles[index].preview)
|
URL.revokeObjectURL(newFiles[index].preview);
|
||||||
newFiles.splice(index, 1)
|
newFiles.splice(index, 1);
|
||||||
return newFiles
|
return newFiles;
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Accordion>
|
<Accordion>
|
||||||
@ -137,6 +178,7 @@ export const CreateSightArticle = ({parentId, parentResource, childResource, tit
|
|||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
background: theme.palette.background.paper,
|
background: theme.palette.background.paper,
|
||||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||||
|
zIndex: 2000,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="subtitle1" fontWeight="bold">
|
<Typography variant="subtitle1" fontWeight="bold">
|
||||||
@ -146,8 +188,8 @@ export const CreateSightArticle = ({parentId, parentResource, childResource, tit
|
|||||||
<AccordionDetails sx={{ background: theme.palette.background.paper }}>
|
<AccordionDetails sx={{ background: theme.palette.background.paper }}>
|
||||||
<Box component="form" onSubmit={handleSubmitItem(handleCreate)}>
|
<Box component="form" onSubmit={handleSubmitItem(handleCreate)}>
|
||||||
<TextField
|
<TextField
|
||||||
{...registerItem('heading', {
|
{...registerItem("heading", {
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
})}
|
})}
|
||||||
error={!!(itemErrors as any)?.heading}
|
error={!!(itemErrors as any)?.heading}
|
||||||
helperText={(itemErrors as any)?.heading?.message}
|
helperText={(itemErrors as any)?.heading?.message}
|
||||||
@ -155,64 +197,87 @@ export const CreateSightArticle = ({parentId, parentResource, childResource, tit
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="text"
|
type="text"
|
||||||
|
sx={{
|
||||||
|
zIndex: 2000,
|
||||||
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
}}
|
||||||
label="Заголовок *"
|
label="Заголовок *"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Controller control={controlItem} name="body" rules={{required: 'Это поле является обязательным'}} defaultValue="" render={({field: {onChange, value}}) => <MemoizedSimpleMDE value={value} onChange={onChange} options={simpleMDEOptions} className="my-markdown-editor" />} />
|
<Controller
|
||||||
|
control={controlItem}
|
||||||
|
name="body"
|
||||||
|
rules={{ required: "Это поле является обязательным" }}
|
||||||
|
defaultValue=""
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<MemoizedSimpleMDE
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
options={simpleMDEOptions}
|
||||||
|
className="my-markdown-editor"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Dropzone для медиа файлов */}
|
{/* Dropzone для медиа файлов */}
|
||||||
<Box sx={{ mt: 2, mb: 2 }}>
|
<Box sx={{ mt: 2, mb: 2 }}>
|
||||||
<Box
|
<Box
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
sx={{
|
sx={{
|
||||||
border: '2px dashed',
|
border: "2px dashed",
|
||||||
borderColor: isDragActive ? 'primary.main' : 'grey.300',
|
borderColor: isDragActive ? "primary.main" : "grey.300",
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
p: 2,
|
p: 2,
|
||||||
textAlign: 'center',
|
textAlign: "center",
|
||||||
cursor: 'pointer',
|
cursor: "pointer",
|
||||||
'&:hover': {
|
"&:hover": {
|
||||||
borderColor: 'primary.main',
|
borderColor: "primary.main",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input {...getInputProps()} />
|
<input {...getInputProps()} />
|
||||||
<Typography>{isDragActive ? 'Перетащите файлы сюда...' : 'Перетащите файлы сюда или кликните для выбора'}</Typography>
|
<Typography>
|
||||||
|
{isDragActive
|
||||||
|
? "Перетащите файлы сюда..."
|
||||||
|
: "Перетащите файлы сюда или кликните для выбора"}
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Превью загруженных файлов */}
|
{/* Превью загруженных файлов */}
|
||||||
<Box sx={{mt: 2, display: 'flex', flexWrap: 'wrap', gap: 1}}>
|
<Box sx={{ mt: 2, display: "flex", flexWrap: "wrap", gap: 1 }}>
|
||||||
{mediaFiles.map((mediaFile, index) => (
|
{mediaFiles.map((mediaFile, index) => (
|
||||||
<Box
|
<Box
|
||||||
key={mediaFile.preview}
|
key={mediaFile.preview}
|
||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
position: "relative",
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 100,
|
height: 100,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{mediaFile.file.type.startsWith('image/') ? (
|
{mediaFile.file.type.startsWith("image/") ? (
|
||||||
<img
|
<img
|
||||||
src={mediaFile.preview}
|
src={mediaFile.preview}
|
||||||
alt={mediaFile.file.name}
|
alt={mediaFile.file.name}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: "100%",
|
||||||
height: '100%',
|
height: "100%",
|
||||||
objectFit: 'cover',
|
objectFit: "cover",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: '100%',
|
width: "100%",
|
||||||
height: '100%',
|
height: "100%",
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
bgcolor: 'grey.200',
|
bgcolor: "grey.200",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="caption">{mediaFile.file.name}</Typography>
|
<Typography variant="caption">
|
||||||
|
{mediaFile.file.name}
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
@ -220,10 +285,10 @@ export const CreateSightArticle = ({parentId, parentResource, childResource, tit
|
|||||||
color="error"
|
color="error"
|
||||||
onClick={() => removeMedia(index)}
|
onClick={() => removeMedia(index)}
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
minWidth: 'auto',
|
minWidth: "auto",
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
p: 0,
|
p: 0,
|
||||||
@ -236,16 +301,16 @@ export const CreateSightArticle = ({parentId, parentResource, childResource, tit
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{mt: 2, display: 'flex', gap: 2}}>
|
<Box sx={{ mt: 2, display: "flex", gap: 2 }}>
|
||||||
<Button variant="contained" color="primary" type="submit">
|
<Button variant="contained" color="primary" type="submit">
|
||||||
Создать
|
Создать
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
resetItem()
|
resetItem();
|
||||||
mediaFiles.forEach((file) => URL.revokeObjectURL(file.preview))
|
mediaFiles.forEach((file) => URL.revokeObjectURL(file.preview));
|
||||||
setMediaFiles([])
|
setMediaFiles([]);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Очистить
|
Очистить
|
||||||
@ -254,5 +319,5 @@ export const CreateSightArticle = ({parentId, parentResource, childResource, tit
|
|||||||
</Box>
|
</Box>
|
||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
@ -1,83 +1,142 @@
|
|||||||
import {DataGrid, type DataGridProps, type GridColumnVisibilityModel} from '@mui/x-data-grid'
|
import {
|
||||||
import {Stack, Button, Typography} from '@mui/material'
|
DataGrid,
|
||||||
import {ExportButton} from '@refinedev/mui'
|
type DataGridProps,
|
||||||
import {useExport} from '@refinedev/core'
|
type GridColumnVisibilityModel,
|
||||||
import React, {useState, useEffect, useMemo} from 'react'
|
} from "@mui/x-data-grid";
|
||||||
import Cookies from 'js-cookie'
|
import { Stack, Button, Typography, Box } from "@mui/material";
|
||||||
|
import { ExportButton } from "@refinedev/mui";
|
||||||
|
import { useExport } from "@refinedev/core";
|
||||||
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
|
||||||
import {localeText} from '../locales/ru/localeText'
|
import { localeText } from "../locales/ru/localeText";
|
||||||
|
import { languageStore } from "../store/LanguageStore";
|
||||||
|
import { LanguageSwitch } from "./LanguageSwitch";
|
||||||
|
|
||||||
interface CustomDataGridProps extends DataGridProps {
|
interface CustomDataGridProps extends DataGridProps {
|
||||||
hasCoordinates?: boolean
|
hasCoordinates?: boolean;
|
||||||
resource?: string // Add this prop
|
resource?: string; // Add this prop
|
||||||
|
languageEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEV_FIELDS = ['id', 'code', 'country_code', 'city_id', 'carrier_id', 'main_color', 'left_color', 'right_color', 'logo', 'slogan', 'filename', 'arms', 'thumbnail', 'route_sys_number', 'governor_appeal', 'scale_min', 'scale_max', 'rotate', 'center_latitude', 'center_longitude', 'watermark_lu', 'watermark_rd', 'left_article', 'preview_article', 'offset_x', 'offset_y'] as const
|
const DEV_FIELDS = [
|
||||||
|
"id",
|
||||||
|
"code",
|
||||||
|
"country_code",
|
||||||
|
"city_id",
|
||||||
|
"carrier_id",
|
||||||
|
"main_color",
|
||||||
|
"left_color",
|
||||||
|
"right_color",
|
||||||
|
"logo",
|
||||||
|
"slogan",
|
||||||
|
"filename",
|
||||||
|
"arms",
|
||||||
|
"thumbnail",
|
||||||
|
"route_sys_number",
|
||||||
|
"governor_appeal",
|
||||||
|
"scale_min",
|
||||||
|
"scale_max",
|
||||||
|
"rotate",
|
||||||
|
"center_latitude",
|
||||||
|
"center_longitude",
|
||||||
|
"watermark_lu",
|
||||||
|
"watermark_rd",
|
||||||
|
"left_article",
|
||||||
|
"preview_article",
|
||||||
|
"offset_x",
|
||||||
|
"offset_y",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const CustomDataGrid = ({hasCoordinates = false, columns = [], resource, ...props}: CustomDataGridProps) => {
|
export const CustomDataGrid = ({
|
||||||
|
languageEnabled = false,
|
||||||
|
hasCoordinates = false,
|
||||||
|
columns = [],
|
||||||
|
resource,
|
||||||
|
...props
|
||||||
|
}: CustomDataGridProps) => {
|
||||||
// const isDev = import.meta.env.DEV
|
// const isDev = import.meta.env.DEV
|
||||||
const { triggerExport, isLoading: exportLoading } = useExport({
|
const { triggerExport, isLoading: exportLoading } = useExport({
|
||||||
resource: resource ?? '',
|
resource: resource ?? "",
|
||||||
// pageSize: 100, #*
|
// pageSize: 100, #*
|
||||||
// maxItemCount: 100, #*
|
// maxItemCount: 100, #*
|
||||||
})
|
});
|
||||||
|
|
||||||
const initialShowCoordinates = Cookies.get('showCoordinates') === 'true'
|
const initialShowCoordinates = Cookies.get("showCoordinates") === "true";
|
||||||
const initialShowDevData = false // Default to false in both prod and dev
|
const initialShowDevData = false; // Default to false in both prod and dev
|
||||||
const [showCoordinates, setShowCoordinates] = useState(initialShowCoordinates)
|
const [showCoordinates, setShowCoordinates] = useState(
|
||||||
const [showDevData, setShowDevData] = useState(Cookies.get('showDevData') === 'true')
|
initialShowCoordinates
|
||||||
|
);
|
||||||
|
const [showDevData, setShowDevData] = useState(
|
||||||
|
Cookies.get("showDevData") === "true"
|
||||||
|
);
|
||||||
|
|
||||||
const availableDevFields = useMemo(() => DEV_FIELDS.filter((field) => columns.some((column) => column.field === field)), [columns])
|
const availableDevFields = useMemo(
|
||||||
|
() =>
|
||||||
|
DEV_FIELDS.filter((field) =>
|
||||||
|
columns.some((column) => column.field === field)
|
||||||
|
),
|
||||||
|
[columns]
|
||||||
|
);
|
||||||
|
|
||||||
const initialVisibilityModel = useMemo(() => {
|
const initialVisibilityModel = useMemo(() => {
|
||||||
const model: GridColumnVisibilityModel = {}
|
const model: GridColumnVisibilityModel = {};
|
||||||
|
|
||||||
availableDevFields.forEach((field) => {
|
availableDevFields.forEach((field) => {
|
||||||
model[field] = initialShowDevData
|
model[field] = initialShowDevData;
|
||||||
})
|
});
|
||||||
|
|
||||||
if (hasCoordinates) {
|
if (hasCoordinates) {
|
||||||
model.latitude = initialShowCoordinates
|
model.latitude = initialShowCoordinates;
|
||||||
model.longitude = initialShowCoordinates
|
model.longitude = initialShowCoordinates;
|
||||||
}
|
}
|
||||||
|
|
||||||
return model
|
return model;
|
||||||
}, [availableDevFields, hasCoordinates, initialShowCoordinates, initialShowDevData])
|
}, [
|
||||||
|
availableDevFields,
|
||||||
|
hasCoordinates,
|
||||||
|
initialShowCoordinates,
|
||||||
|
initialShowDevData,
|
||||||
|
]);
|
||||||
|
|
||||||
const [columnVisibilityModel, setColumnVisibilityModel] = useState<GridColumnVisibilityModel>(initialVisibilityModel)
|
const [columnVisibilityModel, setColumnVisibilityModel] =
|
||||||
|
useState<GridColumnVisibilityModel>(initialVisibilityModel);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setColumnVisibilityModel((prevModel) => {
|
setColumnVisibilityModel((prevModel) => {
|
||||||
const newModel = {...prevModel}
|
const newModel = { ...prevModel };
|
||||||
|
|
||||||
availableDevFields.forEach((field) => {
|
availableDevFields.forEach((field) => {
|
||||||
newModel[field] = showDevData
|
newModel[field] = showDevData;
|
||||||
})
|
});
|
||||||
|
|
||||||
if (hasCoordinates) {
|
if (hasCoordinates) {
|
||||||
newModel.latitude = showCoordinates
|
newModel.latitude = showCoordinates;
|
||||||
newModel.longitude = showCoordinates
|
newModel.longitude = showCoordinates;
|
||||||
}
|
}
|
||||||
|
|
||||||
return newModel
|
return newModel;
|
||||||
})
|
});
|
||||||
|
|
||||||
if (hasCoordinates) {
|
if (hasCoordinates) {
|
||||||
Cookies.set('showCoordinates', String(showCoordinates))
|
Cookies.set("showCoordinates", String(showCoordinates));
|
||||||
}
|
}
|
||||||
Cookies.set('showDevData', String(showDevData))
|
Cookies.set("showDevData", String(showDevData));
|
||||||
}, [showCoordinates, showDevData, hasCoordinates, availableDevFields])
|
}, [showCoordinates, showDevData, hasCoordinates, availableDevFields]);
|
||||||
|
|
||||||
const toggleCoordinates = () => {
|
const toggleCoordinates = () => {
|
||||||
setShowCoordinates((prev) => !prev)
|
setShowCoordinates((prev) => !prev);
|
||||||
}
|
};
|
||||||
|
|
||||||
const toggleDevData = () => {
|
const toggleDevData = () => {
|
||||||
setShowDevData((prev) => !prev)
|
setShowDevData((prev) => !prev);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
|
<Box sx={{ visibility: languageEnabled ? "visible" : "hidden" }}>
|
||||||
|
<LanguageSwitch />
|
||||||
|
</Box>
|
||||||
<DataGrid
|
<DataGrid
|
||||||
{...props}
|
{...props}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@ -92,31 +151,37 @@ export const CustomDataGrid = ({hasCoordinates = false, columns = [], resource,
|
|||||||
// paginationModel: {pageSize: 25, page: 0},
|
// paginationModel: {pageSize: 25, page: 0},
|
||||||
// },
|
// },
|
||||||
sorting: {
|
sorting: {
|
||||||
sortModel: [{field: 'id', sort: 'asc'}],
|
sortModel: [{ field: "id", sort: "asc" }],
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
pageSizeOptions={[10, 25, 50, 100]}
|
pageSizeOptions={[10, 25, 50, 100]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Stack direction="row" spacing={2} justifyContent="space-between" mb={2}>
|
<Stack direction="row" spacing={2} justifyContent="space-between" mb={2}>
|
||||||
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
|
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
|
||||||
{hasCoordinates && (
|
{hasCoordinates && (
|
||||||
<Button variant="contained" onClick={toggleCoordinates}>
|
<Button variant="contained" onClick={toggleCoordinates}>
|
||||||
{showCoordinates ? 'Скрыть координаты' : 'Показать координаты'}
|
{showCoordinates ? "Скрыть координаты" : "Показать координаты"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(import.meta.env.DEV || showDevData) && availableDevFields.length > 0 && (
|
{(import.meta.env.DEV || showDevData) &&
|
||||||
|
availableDevFields.length > 0 && (
|
||||||
<Button variant="contained" onClick={toggleDevData}>
|
<Button variant="contained" onClick={toggleDevData}>
|
||||||
{showDevData ? 'Скрыть служебные данные' : 'Показать служебные данные'}
|
{showDevData
|
||||||
|
? "Скрыть служебные данные"
|
||||||
|
: "Показать служебные данные"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<ExportButton onClick={triggerExport} loading={exportLoading} hideText={false}>
|
<ExportButton
|
||||||
<Typography sx={{marginLeft: '-2px'}}>Экспорт</Typography>
|
onClick={triggerExport}
|
||||||
|
loading={exportLoading}
|
||||||
|
hideText={false}
|
||||||
|
>
|
||||||
|
<Typography sx={{ marginLeft: "-2px" }}>Экспорт</Typography>
|
||||||
</ExportButton>
|
</ExportButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
70
src/components/LanguageSwitch/index.tsx
Normal file
70
src/components/LanguageSwitch/index.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { Box } from "@mui/material";
|
||||||
|
import { languageStore } from "../../store/LanguageStore";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
|
export const LanguageSwitch = observer(({ action }: any) => {
|
||||||
|
const { language, setLanguageAction } = languageStore;
|
||||||
|
|
||||||
|
const handleLanguageChange = (lang: string) => {
|
||||||
|
if (action) {
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
setLanguageAction(lang);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
cursor: "pointer",
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: language === "ru" ? "primary.main" : "transparent",
|
||||||
|
color: language === "ru" ? "white" : "inherit",
|
||||||
|
borderRadius: 1,
|
||||||
|
p: 1,
|
||||||
|
}}
|
||||||
|
onClick={() => handleLanguageChange("ru")}
|
||||||
|
>
|
||||||
|
RU
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
cursor: "pointer",
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: language === "en" ? "primary.main" : "transparent",
|
||||||
|
color: language === "en" ? "white" : "inherit",
|
||||||
|
borderRadius: 1,
|
||||||
|
p: 1,
|
||||||
|
}}
|
||||||
|
onClick={() => handleLanguageChange("en")}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
cursor: "pointer",
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: language === "zh" ? "primary.main" : "transparent",
|
||||||
|
color: language === "zh" ? "white" : "inherit",
|
||||||
|
borderRadius: 1,
|
||||||
|
p: 1,
|
||||||
|
}}
|
||||||
|
onClick={() => handleLanguageChange("zh")}
|
||||||
|
>
|
||||||
|
ZH
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
@ -1,129 +1,258 @@
|
|||||||
import {useState, useEffect} from 'react'
|
import { useState, useEffect } from "react";
|
||||||
import {Stack, Typography, Button, FormControl, Grid, Box, Accordion, AccordionSummary, AccordionDetails, useTheme, TextField, Autocomplete} from '@mui/material'
|
import { languageStore } from "../store/LanguageStore";
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
|
import {
|
||||||
import {axiosInstance} from '../providers/data'
|
Stack,
|
||||||
import {BACKEND_URL} from '../lib/constants'
|
Typography,
|
||||||
import {Link} from 'react-router'
|
Button,
|
||||||
import {TOKEN_KEY} from '../authProvider'
|
FormControl,
|
||||||
|
Grid,
|
||||||
|
Box,
|
||||||
|
Accordion,
|
||||||
|
AccordionSummary,
|
||||||
|
AccordionDetails,
|
||||||
|
useTheme,
|
||||||
|
TextField,
|
||||||
|
Autocomplete,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
Table,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Paper,
|
||||||
|
TableBody,
|
||||||
|
IconButton,
|
||||||
|
Collapse,
|
||||||
|
Modal,
|
||||||
|
} from "@mui/material";
|
||||||
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
|
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||||
|
import { axiosInstance } from "../providers/data";
|
||||||
|
|
||||||
|
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
||||||
|
|
||||||
|
import { articleStore } from "../store/ArticleStore";
|
||||||
|
import { ArticleEditModal } from "./modals/ArticleEditModal";
|
||||||
|
import { StationEditModal } from "./modals/StationEditModal";
|
||||||
|
import { stationStore } from "../store/StationStore";
|
||||||
|
function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] {
|
||||||
|
const index = pos - 1;
|
||||||
|
if (index >= arr.length) {
|
||||||
|
arr.push(value);
|
||||||
|
} else {
|
||||||
|
arr.splice(index, 0, value);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
type Field<T> = {
|
type Field<T> = {
|
||||||
label: string
|
label: string;
|
||||||
data: keyof T
|
data: keyof T;
|
||||||
render?: (value: any) => React.ReactNode
|
render?: (value: any) => React.ReactNode;
|
||||||
}
|
};
|
||||||
|
|
||||||
type ExtraFieldConfig = {
|
type ExtraFieldConfig = {
|
||||||
type: 'number'
|
type: "number";
|
||||||
label: string
|
label: string;
|
||||||
minValue: number
|
minValue: number;
|
||||||
maxValue: (linkedItems: any[]) => number
|
maxValue: (linkedItems: any[]) => number;
|
||||||
}
|
};
|
||||||
|
|
||||||
type LinkedItemsProps<T> = {
|
type LinkedItemsProps<T> = {
|
||||||
parentId: string | number
|
parentId: string | number;
|
||||||
parentResource: string
|
parentResource: string;
|
||||||
childResource: string
|
childResource: string;
|
||||||
fields: Field<T>[]
|
fields: Field<T>[];
|
||||||
title: string
|
setItemsParent?: (items: T[]) => void;
|
||||||
type: 'show' | 'edit'
|
title: string;
|
||||||
extraField?: ExtraFieldConfig
|
type: "show" | "edit";
|
||||||
}
|
extraField?: ExtraFieldConfig;
|
||||||
|
dragAllowed?: boolean;
|
||||||
|
onSave?: (items: T[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentId, parentResource, childResource, fields, title, type}: LinkedItemsProps<T>) => {
|
const reorder = (list: any[], startIndex: number, endIndex: number) => {
|
||||||
const [items, setItems] = useState<T[]>([])
|
const result = Array.from(list);
|
||||||
const [linkedItems, setLinkedItems] = useState<T[]>([])
|
const [removed] = result.splice(startIndex, 1);
|
||||||
const [selectedItemId, setSelectedItemId] = useState<number | null>(null)
|
result.splice(endIndex, 0, removed);
|
||||||
const [pageNum, setPageNum] = useState<number>(1)
|
return result;
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true)
|
};
|
||||||
const [mediaOrder, setMediaOrder] = useState<number>(1)
|
|
||||||
const theme = useTheme()
|
export const LinkedItems = <T extends { id: number; [key: string]: any }>({
|
||||||
|
parentId,
|
||||||
|
parentResource,
|
||||||
|
childResource,
|
||||||
|
setItemsParent,
|
||||||
|
fields,
|
||||||
|
title,
|
||||||
|
dragAllowed = false,
|
||||||
|
type,
|
||||||
|
onSave,
|
||||||
|
}: LinkedItemsProps<T>) => {
|
||||||
|
const { language } = languageStore;
|
||||||
|
const { setArticleModalOpenAction, setArticleIdAction } = articleStore;
|
||||||
|
const { setStationModalOpenAction, setStationIdAction } = stationStore;
|
||||||
|
const [position, setPosition] = useState<number>(1);
|
||||||
|
const [items, setItems] = useState<T[]>([]);
|
||||||
|
const [linkedItems, setLinkedItems] = useState<T[]>([]);
|
||||||
|
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
|
||||||
|
const [pageNum, setPageNum] = useState<number>(1);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [mediaOrder, setMediaOrder] = useState<number>(1);
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
let availableItems = items.filter(
|
||||||
|
(item) => !linkedItems.some((linked) => linked.id === item.id)
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (childResource == "station") {
|
||||||
|
availableItems = availableItems.sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [childResource, availableItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (setItemsParent) {
|
||||||
|
setItemsParent(linkedItems);
|
||||||
|
}
|
||||||
|
}, [linkedItems, setItemsParent]);
|
||||||
|
|
||||||
|
const onDragEnd = (result: any) => {
|
||||||
|
if (!result.destination) return;
|
||||||
|
|
||||||
|
const reorderedItems = reorder(
|
||||||
|
linkedItems,
|
||||||
|
result.source.index,
|
||||||
|
result.destination.index
|
||||||
|
);
|
||||||
|
|
||||||
|
setLinkedItems(reorderedItems);
|
||||||
|
|
||||||
|
axiosInstance.post(
|
||||||
|
`${import.meta.env.VITE_KRBL_API}/route/${parentId}/station`,
|
||||||
|
{
|
||||||
|
stations: reorderedItems.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
axiosInstance
|
axiosInstance
|
||||||
.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`)
|
.get(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_KRBL_API
|
||||||
|
}/${parentResource}/${parentId}/${childResource}`
|
||||||
|
)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
setLinkedItems(response?.data || [])
|
setLinkedItems(response?.data || []);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setLinkedItems([])
|
setLinkedItems([]);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}, [parentId, parentResource, childResource])
|
}, [parentId, parentResource, childResource, language]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (type === 'edit') {
|
if (type === "edit") {
|
||||||
axiosInstance
|
axiosInstance
|
||||||
.get(`${BACKEND_URL}/${childResource}/`)
|
.get(`${import.meta.env.VITE_KRBL_API}/${childResource}/`)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
setItems(response?.data || [])
|
setItems(response?.data || []);
|
||||||
setIsLoading(false)
|
setIsLoading(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setItems([])
|
setItems([]);
|
||||||
setIsLoading(false)
|
setIsLoading(false);
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
setIsLoading(false)
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [childResource, type])
|
}, [childResource, type]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (childResource === 'article' && parentResource === 'sight') {
|
if (childResource === "article" && parentResource === "sight") {
|
||||||
setPageNum(linkedItems.length + 1)
|
setPageNum(linkedItems.length + 1);
|
||||||
}
|
}
|
||||||
}, [linkedItems, childResource, parentResource])
|
}, [linkedItems, childResource, parentResource]);
|
||||||
|
|
||||||
const availableItems = items.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
|
|
||||||
|
|
||||||
const linkItem = () => {
|
const linkItem = () => {
|
||||||
if (selectedItemId !== null) {
|
if (selectedItemId !== null) {
|
||||||
const requestData =
|
const requestData =
|
||||||
childResource === 'article'
|
childResource === "article"
|
||||||
? {
|
? {
|
||||||
[`${childResource}_id`]: selectedItemId,
|
[`${childResource}_id`]: selectedItemId,
|
||||||
page_num: pageNum,
|
page_num: pageNum,
|
||||||
}
|
}
|
||||||
: childResource === 'media'
|
: childResource === "media"
|
||||||
? {
|
? {
|
||||||
[`${childResource}_id`]: selectedItemId,
|
[`${childResource}_id`]: selectedItemId,
|
||||||
media_order: mediaOrder,
|
media_order: mediaOrder,
|
||||||
}
|
}
|
||||||
: {
|
: childResource === "station"
|
||||||
[`${childResource}_id`]: selectedItemId,
|
? {
|
||||||
|
stations: insertAtPosition(
|
||||||
|
linkedItems.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
})),
|
||||||
|
position,
|
||||||
|
{
|
||||||
|
id: selectedItemId,
|
||||||
}
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: { [`${childResource}_id`]: selectedItemId };
|
||||||
|
|
||||||
axiosInstance
|
axiosInstance
|
||||||
.post(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`, requestData)
|
.post(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_KRBL_API
|
||||||
|
}/${parentResource}/${parentId}/${childResource}`,
|
||||||
|
requestData
|
||||||
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
axiosInstance.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`).then((response) => {
|
axiosInstance
|
||||||
setLinkedItems(response?.data || [])
|
.get(
|
||||||
setSelectedItemId(null)
|
`${
|
||||||
if (childResource === 'article') {
|
import.meta.env.VITE_KRBL_API
|
||||||
setPageNum(pageNum + 1)
|
}/${parentResource}/${parentId}/${childResource}`
|
||||||
|
)
|
||||||
|
.then((response) => {
|
||||||
|
setLinkedItems(response?.data || []);
|
||||||
|
setSelectedItemId(null);
|
||||||
|
if (childResource === "article") {
|
||||||
|
setPageNum(pageNum + 1);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Error linking item:', error)
|
console.error("Error linking item:", error);
|
||||||
})
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const deleteItem = (itemId: number) => {
|
const deleteItem = (itemId: number) => {
|
||||||
axiosInstance
|
axiosInstance
|
||||||
.delete(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`, {
|
.delete(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_KRBL_API
|
||||||
|
}/${parentResource}/${parentId}/${childResource}`,
|
||||||
|
{
|
||||||
data: { [`${childResource}_id`]: itemId },
|
data: { [`${childResource}_id`]: itemId },
|
||||||
})
|
}
|
||||||
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setLinkedItems((prev) => prev.filter((item) => item.id !== itemId))
|
setLinkedItems((prev) => prev.filter((item) => item.id !== itemId));
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Error unlinking item:', error)
|
console.error("Error unlinking item:", error);
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Accordion>
|
<Accordion>
|
||||||
<AccordionSummary
|
<AccordionSummary
|
||||||
expandIcon={<ExpandMoreIcon />}
|
expandIcon={<ExpandMoreIcon />}
|
||||||
@ -139,105 +268,179 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI
|
|||||||
|
|
||||||
<AccordionDetails sx={{ background: theme.palette.background.paper }}>
|
<AccordionDetails sx={{ background: theme.palette.background.paper }}>
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<Grid container gap={1.25}>
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
{isLoading ? (
|
<TableContainer component={Paper}>
|
||||||
<Typography>Загрузка...</Typography>
|
<Table>
|
||||||
) : linkedItems.length > 0 ? (
|
<TableHead>
|
||||||
linkedItems.map((item, index) => (
|
<TableRow>
|
||||||
<Box
|
{type === "edit" && dragAllowed && (
|
||||||
component={Link}
|
<TableCell width="40px"></TableCell>
|
||||||
to={`/${childResource}/show/${item.id}`}
|
|
||||||
key={index}
|
|
||||||
sx={{
|
|
||||||
marginTop: '8px',
|
|
||||||
padding: '14px',
|
|
||||||
borderRadius: 2,
|
|
||||||
border: `2px solid ${theme.palette.divider}`,
|
|
||||||
width: childResource === 'article' ? '100%' : 'auto',
|
|
||||||
textDecoration: 'none',
|
|
||||||
color: 'inherit',
|
|
||||||
display: 'block',
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: theme.palette.action.hover,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack gap={0.25}>
|
|
||||||
{childResource === 'media' && item.id && (
|
|
||||||
<img
|
|
||||||
src={`https://wn.krbl.ru/media/${item.id}/download?token=${localStorage.getItem(TOKEN_KEY)}`}
|
|
||||||
alt={String(item.media_name)}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '120px',
|
|
||||||
objectFit: 'contain',
|
|
||||||
marginBottom: '8px',
|
|
||||||
borderRadius: 4,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{fields.map(({label, data, render}) => (
|
<TableCell key="id">№</TableCell>
|
||||||
<Typography variant="body2" color="textSecondary" key={String(data)}>
|
{fields.map((field) => (
|
||||||
<strong>{label}:</strong> {render ? render(item[data]) : item[data]}
|
<TableCell key={String(field.data)}>
|
||||||
</Typography>
|
{field.label}
|
||||||
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
{type === 'edit' && (
|
|
||||||
|
{type === "edit" && (
|
||||||
|
<TableCell width="120px">Действие</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
|
||||||
|
<Droppable
|
||||||
|
droppableId="droppable"
|
||||||
|
isDropDisabled={type !== "edit" || !dragAllowed}
|
||||||
|
>
|
||||||
|
{(provided) => (
|
||||||
|
<TableBody
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.droppableProps}
|
||||||
|
>
|
||||||
|
{linkedItems.map((item, index) => (
|
||||||
|
<Draggable
|
||||||
|
key={item.id}
|
||||||
|
draggableId={"q" + String(item.id)}
|
||||||
|
index={index}
|
||||||
|
isDragDisabled={type !== "edit" || !dragAllowed}
|
||||||
|
>
|
||||||
|
{(provided) => (
|
||||||
|
<>
|
||||||
|
<TableRow
|
||||||
|
sx={{
|
||||||
|
cursor:
|
||||||
|
childResource === "article"
|
||||||
|
? "pointer"
|
||||||
|
: "default",
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (childResource === "article") {
|
||||||
|
setArticleModalOpenAction(true);
|
||||||
|
setArticleIdAction(item.id);
|
||||||
|
}
|
||||||
|
if (childResource === "station") {
|
||||||
|
setStationModalOpenAction(true);
|
||||||
|
setStationIdAction(item.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
hover
|
||||||
|
>
|
||||||
|
{type === "edit" && dragAllowed && (
|
||||||
|
<TableCell {...provided.dragHandleProps}>
|
||||||
|
<IconButton size="small">
|
||||||
|
<DragIndicatorIcon />
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
<TableCell key={String(item.id)}>
|
||||||
|
{index + 1}
|
||||||
|
</TableCell>
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<TableCell
|
||||||
|
key={String(field.data) + String(index)}
|
||||||
|
>
|
||||||
|
{field.render
|
||||||
|
? field.render(item[field.data])
|
||||||
|
: item[field.data]}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{type === "edit" && (
|
||||||
|
<TableCell>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="error"
|
color="error"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault()
|
e.stopPropagation();
|
||||||
deleteItem(item.id)
|
deleteItem(item.id);
|
||||||
}}
|
}}
|
||||||
sx={{mt: 1.5}}
|
|
||||||
>
|
>
|
||||||
Отвязать
|
Отвязать
|
||||||
</Button>
|
</Button>
|
||||||
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</TableRow>
|
||||||
</Box>
|
</>
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<Typography color="textSecondary">{title} не найдены</Typography>
|
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Draggable>
|
||||||
|
))}
|
||||||
|
|
||||||
{type === 'edit' && (
|
{provided.placeholder}
|
||||||
<Stack gap={2}>
|
</TableBody>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</DragDropContext>
|
||||||
|
|
||||||
|
{linkedItems.length === 0 && !isLoading && (
|
||||||
|
<Typography color="textSecondary" textAlign="center" py={2}>
|
||||||
|
{title} не найдены
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === "edit" && (
|
||||||
|
<Stack gap={2} mt={2}>
|
||||||
<Typography variant="subtitle1">Добавить {title}</Typography>
|
<Typography variant="subtitle1">Добавить {title}</Typography>
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
fullWidth
|
fullWidth
|
||||||
value={availableItems.find((item) => item.id === selectedItemId) || null}
|
value={
|
||||||
onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)}
|
availableItems?.find(
|
||||||
|
(item) => item.id === selectedItemId
|
||||||
|
) || null
|
||||||
|
}
|
||||||
|
onChange={(_, newValue) =>
|
||||||
|
setSelectedItemId(newValue?.id || null)
|
||||||
|
}
|
||||||
options={availableItems}
|
options={availableItems}
|
||||||
getOptionLabel={(item) => String(item[fields[0].data])}
|
getOptionLabel={(item) => String(item[fields[0].data])}
|
||||||
renderInput={(params) => <TextField {...params} label={`Выберите ${title}`} fullWidth />}
|
renderInput={(params) => (
|
||||||
isOptionEqualToValue={(option, value) => option.id === value?.id}
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label={`Выберите ${title}`}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
isOptionEqualToValue={(option, value) =>
|
||||||
|
option.id === value?.id
|
||||||
|
}
|
||||||
filterOptions={(options, { inputValue }) => {
|
filterOptions={(options, { inputValue }) => {
|
||||||
// return options.filter((option) => String(option[fields[0].data]).toLowerCase().includes(inputValue.toLowerCase()))
|
|
||||||
const searchWords = inputValue
|
const searchWords = inputValue
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.split(' ')
|
.split(" ")
|
||||||
.filter((word) => word.length > 0)
|
.filter((word) => word.length > 0);
|
||||||
return options.filter((option) => {
|
return options.filter((option) => {
|
||||||
const optionWords = String(option[fields[0].data]).toLowerCase().split(' ')
|
const optionWords = String(option[fields[0].data])
|
||||||
return searchWords.every((searchWord) => optionWords.some((word) => word.startsWith(searchWord)))
|
.toLowerCase()
|
||||||
})
|
.split(" ");
|
||||||
|
return searchWords.every((searchWord) =>
|
||||||
|
optionWords.some((word) => word.startsWith(searchWord))
|
||||||
|
);
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
|
renderOption={(props, option) => (
|
||||||
|
<li {...props} key={option.id}>
|
||||||
|
{String(option[fields[0].data])}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{childResource === 'article' && (
|
{childResource === "article" && (
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
<TextField
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
label="Номер страницы"
|
label="Позиция добавляемой статьи"
|
||||||
name="page_num"
|
name="page_num"
|
||||||
value={pageNum}
|
value={pageNum}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newValue = Number(e.target.value)
|
const newValue = Number(e.target.value);
|
||||||
const minValue = linkedItems.length + 1 // page number on articles lenght
|
const minValue = linkedItems.length + 1;
|
||||||
setPageNum(newValue < minValue ? minValue : newValue)
|
setPageNum(newValue < minValue ? minValue : newValue);
|
||||||
}}
|
}}
|
||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
@ -245,17 +448,17 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{childResource === 'media' && type === 'edit' && (
|
{childResource === "media" && (
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
<TextField
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
label="Порядок отображения медиа"
|
label="Порядок отображения медиа"
|
||||||
value={mediaOrder}
|
value={mediaOrder}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newValue = Number(e.target.value)
|
const newValue = Number(e.target.value);
|
||||||
const maxValue = linkedItems.length + 1
|
const maxValue = linkedItems.length + 1;
|
||||||
const value = Math.max(1, Math.min(newValue, maxValue))
|
const value = Math.max(1, Math.min(newValue, maxValue));
|
||||||
setMediaOrder(value)
|
setMediaOrder(value);
|
||||||
}}
|
}}
|
||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
@ -263,13 +466,36 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button variant="contained" onClick={linkItem} disabled={!selectedItemId}>
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={linkItem}
|
||||||
|
disabled={!selectedItemId}
|
||||||
|
sx={{ alignSelf: "flex-start" }}
|
||||||
|
>
|
||||||
Добавить
|
Добавить
|
||||||
</Button>
|
</Button>
|
||||||
|
{childResource == "station" && (
|
||||||
|
<TextField
|
||||||
|
type="text"
|
||||||
|
label="Позиция добавляемой остановки к маршруту"
|
||||||
|
value={position}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = Number(e.target.value);
|
||||||
|
setPosition(
|
||||||
|
newValue > linkedItems.length + 1
|
||||||
|
? linkedItems.length + 1
|
||||||
|
: newValue
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
></TextField>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
)
|
<ArticleEditModal />
|
||||||
}
|
<StationEditModal />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
@ -1,150 +1,194 @@
|
|||||||
import DarkModeOutlined from '@mui/icons-material/DarkModeOutlined'
|
import DarkModeOutlined from "@mui/icons-material/DarkModeOutlined";
|
||||||
import LightModeOutlined from '@mui/icons-material/LightModeOutlined'
|
import LightModeOutlined from "@mui/icons-material/LightModeOutlined";
|
||||||
import AppBar from '@mui/material/AppBar'
|
import AppBar from "@mui/material/AppBar";
|
||||||
import Avatar from '@mui/material/Avatar'
|
import Avatar from "@mui/material/Avatar";
|
||||||
import IconButton from '@mui/material/IconButton'
|
import IconButton from "@mui/material/IconButton";
|
||||||
import Stack from '@mui/material/Stack'
|
import Stack from "@mui/material/Stack";
|
||||||
import Toolbar from '@mui/material/Toolbar'
|
import Toolbar from "@mui/material/Toolbar";
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from "@mui/material/Typography";
|
||||||
import {useGetIdentity, usePermissions, useWarnAboutChange} from '@refinedev/core'
|
import { languageStore } from "../../store/LanguageStore";
|
||||||
import {HamburgerMenu, RefineThemedLayoutV2HeaderProps} from '@refinedev/mui'
|
import {
|
||||||
import React, {useContext, useEffect} from 'react'
|
useGetIdentity,
|
||||||
import {ColorModeContext} from '../../contexts/color-mode'
|
useList,
|
||||||
import Cookies from 'js-cookie'
|
usePermissions,
|
||||||
import {useTranslation} from 'react-i18next'
|
useWarnAboutChange,
|
||||||
import {Button} from '@mui/material'
|
} from "@refinedev/core";
|
||||||
import {useNavigate} from 'react-router'
|
import { HamburgerMenu, RefineThemedLayoutV2HeaderProps } from "@refinedev/mui";
|
||||||
|
import React, { useContext, useEffect, useState } from "react";
|
||||||
|
import { ColorModeContext } from "../../contexts/color-mode";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
FormControl,
|
||||||
|
SelectChangeEvent,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { cityStore } from "../../store/CityStore";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
type IUser = {
|
type IUser = {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
avatar: string
|
avatar: string;
|
||||||
is_admin: boolean
|
is_admin: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = ({sticky = true}) => {
|
export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = observer(
|
||||||
const {mode, setMode} = useContext(ColorModeContext)
|
({ sticky }) => {
|
||||||
const {data: user} = useGetIdentity<IUser>()
|
const { city_id, setCityIdAction } = cityStore;
|
||||||
const {data: permissions} = usePermissions<string[]>()
|
const { language } = languageStore;
|
||||||
const isAdmin = permissions?.includes('admin')
|
const { data: cities } = useList({
|
||||||
const {i18n} = useTranslation()
|
resource: "city",
|
||||||
const {setWarnWhen, warnWhen} = useWarnAboutChange()
|
});
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const { mode, setMode } = useContext(ColorModeContext);
|
||||||
|
const { data: user } = useGetIdentity<IUser>();
|
||||||
|
const { data: permissions } = usePermissions<string[]>();
|
||||||
|
const isAdmin = permissions?.includes("admin");
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const { setWarnWhen, warnWhen } = useWarnAboutChange();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleChange = (event: SelectChangeEvent<string>) => {
|
||||||
|
setCityIdAction(event.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
const handleLanguageChange = async (lang: string) => {
|
const handleLanguageChange = async (lang: string) => {
|
||||||
// console.log('Language change requested:', lang)
|
// console.log('Language change requested:', lang)
|
||||||
// console.log('Current warnWhen state:', warnWhen)
|
// console.log('Current warnWhen state:', warnWhen)
|
||||||
|
|
||||||
const form = document.querySelector('form')
|
const form = document.querySelector("form");
|
||||||
const inputs = form?.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>('input, textarea, select')
|
const inputs = form?.querySelectorAll<
|
||||||
const saveButton = document.querySelector('.refine-save-button') as HTMLButtonElement
|
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||||
|
>("input, textarea, select");
|
||||||
|
const saveButton = document.querySelector(
|
||||||
|
".refine-save-button"
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
|
||||||
// Сохраняем текущий URL перед любыми действиями
|
// Сохраняем текущий URL перед любыми действиями
|
||||||
const currentLocation = window.location.pathname + window.location.search
|
const currentLocation = window.location.pathname + window.location.search;
|
||||||
|
|
||||||
if (form && saveButton) {
|
if (form && saveButton) {
|
||||||
const hasChanges = Array.from(inputs || []).some((input) => {
|
const hasChanges = Array.from(inputs || []).some((input) => {
|
||||||
if (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) {
|
if (
|
||||||
return input.value !== input.defaultValue
|
input instanceof HTMLInputElement ||
|
||||||
|
input instanceof HTMLTextAreaElement
|
||||||
|
) {
|
||||||
|
return input.value !== input.defaultValue;
|
||||||
}
|
}
|
||||||
if (input instanceof HTMLSelectElement) {
|
if (input instanceof HTMLSelectElement) {
|
||||||
return input.value !== input.options[input.selectedIndex].defaultSelected.toString()
|
return (
|
||||||
|
input.value !==
|
||||||
|
input.options[input.selectedIndex].defaultSelected.toString()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return false
|
return false;
|
||||||
})
|
});
|
||||||
|
|
||||||
if (hasChanges || warnWhen) {
|
if (hasChanges || warnWhen) {
|
||||||
try {
|
try {
|
||||||
// console.log('Attempting to save changes...')
|
// console.log('Attempting to save changes...')
|
||||||
setWarnWhen(false)
|
setWarnWhen(false);
|
||||||
saveButton.click()
|
saveButton.click();
|
||||||
// console.log('Save button clicked')
|
// console.log('Save button clicked')
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
// После сохранения меняем язык и возвращаемся на ту же страницу
|
// После сохранения меняем язык и возвращаемся на ту же страницу
|
||||||
Cookies.set('lang', lang)
|
Cookies.set("lang", lang);
|
||||||
i18n.changeLanguage(lang)
|
|
||||||
navigate(currentLocation)
|
i18n.changeLanguage(lang);
|
||||||
return
|
navigate(currentLocation);
|
||||||
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save form:', error)
|
console.error("Failed to save form:", error);
|
||||||
setWarnWhen(true)
|
setWarnWhen(true);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если нет формы или изменений, просто меняем язык
|
// Если нет формы или изменений, просто меняем язык
|
||||||
// console.log('Setting language cookie:', lang)
|
// console.log('Setting language cookie:', lang)
|
||||||
Cookies.set('lang', lang)
|
Cookies.set("lang", lang);
|
||||||
|
|
||||||
// console.log('Changing i18n language')
|
// console.log('Changing i18n language')
|
||||||
i18n.changeLanguage(lang)
|
i18n.changeLanguage(lang);
|
||||||
|
|
||||||
// Используем текущий URL для навигации
|
// Используем текущий URL для навигации
|
||||||
navigate(0)
|
navigate(0);
|
||||||
}
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedLang = Cookies.get('lang') || 'ru'
|
const savedLang = Cookies.get("lang") || "ru";
|
||||||
i18n.changeLanguage(savedLang)
|
i18n.changeLanguage(savedLang);
|
||||||
}, [i18n])
|
}, [i18n]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBar position={sticky ? 'sticky' : 'relative'}>
|
<AppBar position={sticky ? "sticky" : "relative"}>
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<Stack direction="row" width="100%" justifyContent="flex-end" alignItems="center">
|
|
||||||
<HamburgerMenu />
|
|
||||||
<Stack direction="row" width="100%" justifyContent="flex-end" alignItems="center" spacing={2}>
|
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
spacing={1}
|
width="100%"
|
||||||
sx={{
|
justifyContent="flex-end"
|
||||||
backgroundColor: 'background.paper',
|
alignItems="center"
|
||||||
padding: '4px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{['ru', 'en', 'zh'].map((lang) => (
|
<HamburgerMenu />
|
||||||
<Button
|
|
||||||
key={lang}
|
<Stack
|
||||||
onClick={() => handleLanguageChange(lang)}
|
direction="row"
|
||||||
variant={i18n.language === lang ? 'contained' : 'outlined'}
|
width="100%"
|
||||||
size="small"
|
justifyContent="flex-end"
|
||||||
sx={{
|
alignItems="center"
|
||||||
minWidth: '30px',
|
spacing={2}
|
||||||
padding: '2px 0px',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{lang}
|
<FormControl variant="standard" sx={{ width: "min-content" }}>
|
||||||
</Button>
|
{city_id && cities && (
|
||||||
|
<Select
|
||||||
|
defaultValue={city_id}
|
||||||
|
value={city_id}
|
||||||
|
onChange={handleChange}
|
||||||
|
>
|
||||||
|
<MenuItem value={String(0)} key={0}>
|
||||||
|
Все города
|
||||||
|
</MenuItem>
|
||||||
|
{cities.data?.map((city) => (
|
||||||
|
<MenuItem value={String(city.id)} key={city.id}>
|
||||||
|
{city.name}
|
||||||
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Select>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMode()
|
setMode();
|
||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
marginRight: '2px',
|
marginRight: "2px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{mode === 'dark' ? <LightModeOutlined /> : <DarkModeOutlined />}
|
{mode === "dark" ? <LightModeOutlined /> : <DarkModeOutlined />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
{(user?.avatar || user?.name) && (
|
{(user?.avatar || user?.name) && (
|
||||||
<Stack direction="row" gap="16px" alignItems="center" justifyContent="center">
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
gap="16px"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
>
|
||||||
{user?.name && (
|
{user?.name && (
|
||||||
<Stack direction="column" alignItems="start" gap="0px">
|
<Stack direction="column" alignItems="start" gap="0px">
|
||||||
<Typography
|
<Typography
|
||||||
sx={{
|
sx={{
|
||||||
display: {
|
display: {
|
||||||
xs: 'none',
|
xs: "none",
|
||||||
sm: 'inline-block',
|
sm: "inline-block",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
variant="subtitle2"
|
variant="subtitle2"
|
||||||
@ -155,18 +199,18 @@ export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = ({sticky = true
|
|||||||
<Typography
|
<Typography
|
||||||
sx={{
|
sx={{
|
||||||
display: {
|
display: {
|
||||||
xs: 'none',
|
xs: "none",
|
||||||
sm: 'inline-block',
|
sm: "inline-block",
|
||||||
},
|
},
|
||||||
backgroundColor: 'primary.main',
|
backgroundColor: "primary.main",
|
||||||
color: 'rgba(255, 255, 255, 0.7)',
|
color: "rgba(255, 255, 255, 0.7)",
|
||||||
padding: '1px 4px',
|
padding: "1px 4px",
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
fontSize: '0.6rem',
|
fontSize: "0.6rem",
|
||||||
}}
|
}}
|
||||||
variant="subtitle2"
|
variant="subtitle2"
|
||||||
>
|
>
|
||||||
{isAdmin ? 'Администратор' : 'Пользователь'}
|
{isAdmin ? "Администратор" : "Пользователь"}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
@ -177,5 +221,6 @@ export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = ({sticky = true
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
@ -1 +0,0 @@
|
|||||||
export {Header} from './header'
|
|
@ -1,72 +1,142 @@
|
|||||||
import {useState} from 'react'
|
import { useState } from "react";
|
||||||
import {UseFormSetError, UseFormClearErrors, UseFormSetValue} from 'react-hook-form'
|
import {
|
||||||
|
UseFormSetError,
|
||||||
|
UseFormClearErrors,
|
||||||
|
UseFormSetValue,
|
||||||
|
} from "react-hook-form";
|
||||||
|
|
||||||
export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
export const ALLOWED_IMAGE_TYPES = [
|
||||||
export const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/ogg']
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ALLOWED_VIDEO_TYPES = ["video/mp4", "video/webm", "video/ogg"];
|
||||||
|
|
||||||
|
export const ALLOWED_PANORAMA_TYPES = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ALLOWED_ICON_TYPES = [
|
||||||
|
"image/svg+xml",
|
||||||
|
"image/png",
|
||||||
|
"image/jpg",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/webp",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ALLOWED_WATERMARK_TYPES = [
|
||||||
|
"image/svg+xml",
|
||||||
|
"image/png",
|
||||||
|
"image/jpg",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/webp",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ALLOWED_3D_MODEL_TYPES = [
|
||||||
|
".glb",
|
||||||
|
"glb",
|
||||||
|
".gltf",
|
||||||
|
"gltf",
|
||||||
|
"model/gltf-binary",
|
||||||
|
".vnd.ms-3d",
|
||||||
|
];
|
||||||
|
|
||||||
export const validateFileType = (file: File, mediaType: number) => {
|
export const validateFileType = (file: File, mediaType: number) => {
|
||||||
if (mediaType === 1 && !ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
if (mediaType === 1 && !ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||||
return 'Для типа "Фото" разрешены только форматы: JPG, PNG, GIF, WEBP'
|
return 'Для типа "Фото" разрешены только форматы: JPG, PNG, GIF, WEBP';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mediaType === 2 && !ALLOWED_VIDEO_TYPES.includes(file.type)) {
|
if (mediaType === 2 && !ALLOWED_VIDEO_TYPES.includes(file.type)) {
|
||||||
return 'Для типа "Видео" разрешены только форматы: MP4, WEBM, OGG'
|
return 'Для типа "Видео" разрешены только форматы: MP4, WEBM, OGG';
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
if (mediaType === 3 && !ALLOWED_ICON_TYPES.includes(file.type)) {
|
||||||
|
return 'Для типа "Иконка" разрешены только форматы: SVG, PNG, JPG, JPEG, WEBP';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mediaType === 4 && !ALLOWED_WATERMARK_TYPES.includes(file.type)) {
|
||||||
|
return 'Для типа "Водяной знак" разрешены только форматы: SVG, PNG, JPG, JPEG, WEBP';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaType === 5 && !ALLOWED_PANORAMA_TYPES.includes(file.type)) {
|
||||||
|
return 'Для типа "Панорама" разрешены только форматы: JPG, PNG, GIF, WEBP';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaType === 6 && !ALLOWED_3D_MODEL_TYPES.includes(file.type)) {
|
||||||
|
const extension = file.name.split(".").pop();
|
||||||
|
const isMimeTypeValid = ["model/gltf-binary"].includes(file.type);
|
||||||
|
const isExtensionValid =
|
||||||
|
extension && ALLOWED_3D_MODEL_TYPES.includes(extension);
|
||||||
|
if (!isMimeTypeValid && !isExtensionValid) {
|
||||||
|
return 'Для типа "3D-модель" разрешены только форматы: GLB, GLTF';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
type UseMediaFileUploadProps = {
|
type UseMediaFileUploadProps = {
|
||||||
selectedMediaType: number
|
selectedMediaType: number;
|
||||||
setError: UseFormSetError<any>
|
setError: UseFormSetError<any>;
|
||||||
clearErrors: UseFormClearErrors<any>
|
clearErrors: UseFormClearErrors<any>;
|
||||||
setValue: UseFormSetValue<any>
|
setValue: UseFormSetValue<any>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const useMediaFileUpload = ({selectedMediaType, setError, clearErrors, setValue}: UseMediaFileUploadProps) => {
|
export const useMediaFileUpload = ({
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
selectedMediaType,
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
setError,
|
||||||
|
clearErrors,
|
||||||
|
setValue,
|
||||||
|
}: UseMediaFileUploadProps) => {
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0]
|
const file = event.target.files?.[0];
|
||||||
if (!file) return
|
if (!file) return;
|
||||||
|
|
||||||
if (selectedMediaType) {
|
if (selectedMediaType) {
|
||||||
const error = validateFileType(file, selectedMediaType)
|
const error = validateFileType(file, selectedMediaType);
|
||||||
if (error) {
|
if (error) {
|
||||||
setError('file', {type: 'manual', message: error})
|
setError("file", { type: "manual", message: error });
|
||||||
event.target.value = ''
|
event.target.value = "";
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearErrors('file')
|
clearErrors("file");
|
||||||
setValue('file', file)
|
setValue("file", file);
|
||||||
setSelectedFile(file)
|
setSelectedFile(file);
|
||||||
|
|
||||||
if (file.type.startsWith('image/')) {
|
if (file.type.startsWith("image/")) {
|
||||||
const url = URL.createObjectURL(file)
|
const url = URL.createObjectURL(file);
|
||||||
setPreviewUrl(url)
|
setPreviewUrl(url);
|
||||||
} else {
|
} else {
|
||||||
setPreviewUrl(null)
|
setPreviewUrl(null);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleMediaTypeChange = (newMediaType: number | null) => {
|
const handleMediaTypeChange = (newMediaType: number | null) => {
|
||||||
setValue('media_type', newMediaType || null)
|
setValue("media_type", newMediaType || null);
|
||||||
|
|
||||||
if (selectedFile && newMediaType) {
|
if (selectedFile && newMediaType) {
|
||||||
const error = validateFileType(selectedFile, newMediaType)
|
const error = validateFileType(selectedFile, newMediaType);
|
||||||
if (error) {
|
if (error) {
|
||||||
setError('file', {type: 'manual', message: error})
|
setError("file", { type: "manual", message: error });
|
||||||
setValue('file', null)
|
setValue("file", null);
|
||||||
setSelectedFile(null)
|
setSelectedFile(null);
|
||||||
setPreviewUrl(null)
|
setPreviewUrl(null);
|
||||||
} else {
|
} else {
|
||||||
clearErrors('file')
|
clearErrors("file");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selectedFile,
|
selectedFile,
|
||||||
@ -75,5 +145,5 @@ export const useMediaFileUpload = ({selectedMediaType, setError, clearErrors, se
|
|||||||
setPreviewUrl,
|
setPreviewUrl,
|
||||||
handleFileChange,
|
handleFileChange,
|
||||||
handleMediaTypeChange,
|
handleMediaTypeChange,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
399
src/components/modals/ArticleEditModal/index.tsx
Normal file
399
src/components/modals/ArticleEditModal/index.tsx
Normal file
@ -0,0 +1,399 @@
|
|||||||
|
import { Modal, Box, Button, TextField, Typography } from "@mui/material";
|
||||||
|
import { articleStore } from "../../../store/ArticleStore";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useForm } from "@refinedev/react-hook-form";
|
||||||
|
import { Controller } from "react-hook-form";
|
||||||
|
import "easymde/dist/easymde.min.css";
|
||||||
|
import { memo, useMemo, useEffect, useCallback } from "react";
|
||||||
|
import { MarkdownEditor } from "../../MarkdownEditor";
|
||||||
|
import { Edit } from "@refinedev/mui";
|
||||||
|
import { languageStore } from "../../../store/LanguageStore";
|
||||||
|
import { LanguageSwitch } from "../../LanguageSwitch/index";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useDropzone } from "react-dropzone";
|
||||||
|
import {
|
||||||
|
ALLOWED_IMAGE_TYPES,
|
||||||
|
ALLOWED_VIDEO_TYPES,
|
||||||
|
} from "../../media/MediaFormUtils";
|
||||||
|
import { axiosInstance } from "../../../providers/data";
|
||||||
|
import { TOKEN_KEY } from "../../../authProvider";
|
||||||
|
|
||||||
|
const MemoizedSimpleMDE = memo(MarkdownEditor);
|
||||||
|
|
||||||
|
type MediaFile = {
|
||||||
|
file: File;
|
||||||
|
preview: string;
|
||||||
|
uploading: boolean;
|
||||||
|
mediaId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
width: "60%",
|
||||||
|
bgcolor: "background.paper",
|
||||||
|
border: "2px solid #000",
|
||||||
|
boxShadow: 24,
|
||||||
|
p: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ArticleEditModal = observer(() => {
|
||||||
|
const [articleData, setArticleData] = useState({
|
||||||
|
ru: {
|
||||||
|
heading: "",
|
||||||
|
body: "",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
heading: "",
|
||||||
|
body: "",
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
heading: "",
|
||||||
|
body: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { articleModalOpen, setArticleModalOpenAction, selectedArticleId } =
|
||||||
|
articleStore;
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
|
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setArticleModalOpenAction(false);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load existing media files when editing an article
|
||||||
|
useEffect(() => {
|
||||||
|
const loadExistingMedia = async () => {
|
||||||
|
if (selectedArticleId) {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_KRBL_API
|
||||||
|
}/article/${selectedArticleId}/media`
|
||||||
|
);
|
||||||
|
const existingMedia = response.data;
|
||||||
|
|
||||||
|
// Convert existing media to MediaFile format
|
||||||
|
const mediaFiles = await Promise.all(
|
||||||
|
existingMedia.map(async (media: any) => {
|
||||||
|
const response = await fetch(
|
||||||
|
`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
|
media.id
|
||||||
|
}/download?token=${localStorage.getItem(TOKEN_KEY)}`
|
||||||
|
);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const file = new File([blob], media.filename, {
|
||||||
|
type: media.media_type === 1 ? "image/jpeg" : "video/mp4",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
preview: URL.createObjectURL(blob),
|
||||||
|
uploading: false,
|
||||||
|
mediaId: media.id,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
setMediaFiles(mediaFiles);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading existing media:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadExistingMedia();
|
||||||
|
}, [selectedArticleId]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
saveButtonProps,
|
||||||
|
reset,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
} = useForm({
|
||||||
|
refineCoreProps: {
|
||||||
|
resource: "article",
|
||||||
|
id: selectedArticleId ?? undefined,
|
||||||
|
action: "edit",
|
||||||
|
redirect: false,
|
||||||
|
|
||||||
|
onMutationSuccess: async () => {
|
||||||
|
try {
|
||||||
|
// Upload new media files
|
||||||
|
const newMediaFiles = mediaFiles.filter((file) => !file.mediaId);
|
||||||
|
const mediaIds = await Promise.all(
|
||||||
|
newMediaFiles.map(async (mediaFile) => {
|
||||||
|
return await uploadMedia(mediaFile);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Associate all media with the article
|
||||||
|
await Promise.all(
|
||||||
|
mediaIds.map((mediaId, index) =>
|
||||||
|
axiosInstance.post(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_KRBL_API
|
||||||
|
}/article/${selectedArticleId}/media/`,
|
||||||
|
{
|
||||||
|
media_id: mediaId,
|
||||||
|
media_order: index + 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
setArticleModalOpenAction(false);
|
||||||
|
reset();
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error handling media:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headers: {
|
||||||
|
"Accept-Language": language,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (articleData[language as keyof typeof articleData]?.heading) {
|
||||||
|
setValue(
|
||||||
|
"heading",
|
||||||
|
articleData[language as keyof typeof articleData]?.heading || ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (articleData[language as keyof typeof articleData]?.body) {
|
||||||
|
setValue(
|
||||||
|
"body",
|
||||||
|
articleData[language as keyof typeof articleData]?.body || ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [language, articleData, setValue]);
|
||||||
|
|
||||||
|
const handleLanguageChange = () => {
|
||||||
|
setArticleData((prevData) => ({
|
||||||
|
...prevData,
|
||||||
|
[language]: {
|
||||||
|
heading: watch("heading") || "",
|
||||||
|
body: watch("body") || "",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const simpleMDEOptions = useMemo(
|
||||||
|
() => ({
|
||||||
|
placeholder: "Введите контент в формате Markdown...",
|
||||||
|
spellChecker: false,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||||
|
const newFiles = acceptedFiles.map((file) => ({
|
||||||
|
file,
|
||||||
|
preview: URL.createObjectURL(file),
|
||||||
|
uploading: false,
|
||||||
|
}));
|
||||||
|
setMediaFiles((prev) => [...prev, ...newFiles]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
|
onDrop,
|
||||||
|
accept: {
|
||||||
|
"image/*": ALLOWED_IMAGE_TYPES,
|
||||||
|
"video/*": ALLOWED_VIDEO_TYPES,
|
||||||
|
},
|
||||||
|
multiple: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadMedia = async (mediaFile: MediaFile) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("media_name", mediaFile.file.name);
|
||||||
|
formData.append("filename", mediaFile.file.name);
|
||||||
|
formData.append(
|
||||||
|
"type",
|
||||||
|
mediaFile.file.type.startsWith("image/") ? "1" : "2"
|
||||||
|
);
|
||||||
|
formData.append("file", mediaFile.file);
|
||||||
|
|
||||||
|
const response = await axiosInstance.post(
|
||||||
|
`${import.meta.env.VITE_KRBL_API}/media`,
|
||||||
|
formData
|
||||||
|
);
|
||||||
|
return response.data.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMedia = async (index: number) => {
|
||||||
|
const mediaFile = mediaFiles[index];
|
||||||
|
|
||||||
|
// If it's an existing media file (has mediaId), delete it from the server
|
||||||
|
if (mediaFile.mediaId) {
|
||||||
|
try {
|
||||||
|
await axiosInstance.delete(
|
||||||
|
`${import.meta.env.VITE_KRBL_API}/media/${mediaFile.mediaId}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting media:", error);
|
||||||
|
return; // Don't remove from UI if server deletion failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from UI and cleanup
|
||||||
|
setMediaFiles((prev) => {
|
||||||
|
const newFiles = [...prev];
|
||||||
|
URL.revokeObjectURL(newFiles[index].preview);
|
||||||
|
newFiles.splice(index, 1);
|
||||||
|
return newFiles;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={articleModalOpen}
|
||||||
|
onClose={() => setArticleModalOpenAction(false)}
|
||||||
|
aria-labelledby="modal-modal-title"
|
||||||
|
aria-describedby="modal-modal-description"
|
||||||
|
>
|
||||||
|
<Box sx={style}>
|
||||||
|
<Edit
|
||||||
|
title={<Typography variant="h5">Редактирование статьи</Typography>}
|
||||||
|
headerProps={{
|
||||||
|
sx: {
|
||||||
|
fontSize: "50px",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
saveButtonProps={saveButtonProps}
|
||||||
|
>
|
||||||
|
<LanguageSwitch action={handleLanguageChange} />
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
sx={{ display: "flex", flexDirection: "column" }}
|
||||||
|
autoComplete="off"
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
{...register("heading", {
|
||||||
|
required: "Это поле является обязательным",
|
||||||
|
})}
|
||||||
|
error={!!errors.heading}
|
||||||
|
helperText={errors.heading?.message as string}
|
||||||
|
margin="normal"
|
||||||
|
fullWidth
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
type="text"
|
||||||
|
name="heading"
|
||||||
|
label="Заголовок *"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="body"
|
||||||
|
rules={{ required: "Это поле является обязательным" }}
|
||||||
|
defaultValue=""
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<MemoizedSimpleMDE
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
options={simpleMDEOptions}
|
||||||
|
className="my-markdown-editor"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Dropzone для медиа файлов */}
|
||||||
|
<Box sx={{ mt: 2, mb: 2 }}>
|
||||||
|
<Box
|
||||||
|
{...getRootProps()}
|
||||||
|
sx={{
|
||||||
|
border: "2px dashed",
|
||||||
|
borderColor: isDragActive ? "primary.main" : "grey.300",
|
||||||
|
borderRadius: 1,
|
||||||
|
p: 2,
|
||||||
|
textAlign: "center",
|
||||||
|
cursor: "pointer",
|
||||||
|
"&:hover": {
|
||||||
|
borderColor: "primary.main",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
<Typography>
|
||||||
|
{isDragActive
|
||||||
|
? "Перетащите файлы сюда..."
|
||||||
|
: "Перетащите файлы сюда или кликните для выбора"}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Превью загруженных файлов */}
|
||||||
|
<Box sx={{ mt: 2, display: "flex", flexWrap: "wrap", gap: 1 }}>
|
||||||
|
{mediaFiles.map((mediaFile, index) => (
|
||||||
|
<Box
|
||||||
|
key={mediaFile.preview}
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mediaFile.file.type.startsWith("image/") ? (
|
||||||
|
<img
|
||||||
|
src={mediaFile.preview}
|
||||||
|
alt={mediaFile.file.name}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: "grey.200",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption">
|
||||||
|
{mediaFile.file.name}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => removeMedia(index)}
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
minWidth: "auto",
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
p: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Edit>
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
});
|
164
src/components/modals/StationEditModal/index.tsx
Normal file
164
src/components/modals/StationEditModal/index.tsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
Grid,
|
||||||
|
Paper,
|
||||||
|
} from "@mui/material";
|
||||||
|
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useForm } from "@refinedev/react-hook-form";
|
||||||
|
import { Controller } from "react-hook-form";
|
||||||
|
import "easymde/dist/easymde.min.css";
|
||||||
|
import { memo, useMemo, useEffect } from "react";
|
||||||
|
import { MarkdownEditor } from "../../MarkdownEditor";
|
||||||
|
import { Edit } from "@refinedev/mui";
|
||||||
|
import { languageStore } from "../../../store/LanguageStore";
|
||||||
|
import { LanguageSwitch } from "../../LanguageSwitch/index";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { stationStore } from "../../../store/StationStore";
|
||||||
|
const MemoizedSimpleMDE = memo(MarkdownEditor);
|
||||||
|
|
||||||
|
const TRANSFER_FIELDS = [
|
||||||
|
{ name: "bus", label: "Автобус" },
|
||||||
|
{ name: "metro_blue", label: "Метро (синяя)" },
|
||||||
|
{ name: "metro_green", label: "Метро (зеленая)" },
|
||||||
|
{ name: "metro_orange", label: "Метро (оранжевая)" },
|
||||||
|
{ name: "metro_purple", label: "Метро (фиолетовая)" },
|
||||||
|
{ name: "metro_red", label: "Метро (красная)" },
|
||||||
|
{ name: "train", label: "Электричка" },
|
||||||
|
{ name: "tram", label: "Трамвай" },
|
||||||
|
{ name: "trolleybus", label: "Троллейбус" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
width: "60%",
|
||||||
|
bgcolor: "background.paper",
|
||||||
|
border: "2px solid #000",
|
||||||
|
boxShadow: 24,
|
||||||
|
p: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StationEditModal = observer(() => {
|
||||||
|
const { stationModalOpen, setStationModalOpenAction, selectedStationId } =
|
||||||
|
stationStore;
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setStationModalOpenAction(false);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
saveButtonProps,
|
||||||
|
reset,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
} = useForm({
|
||||||
|
refineCoreProps: {
|
||||||
|
resource: "station",
|
||||||
|
id: selectedStationId ?? undefined,
|
||||||
|
action: "edit",
|
||||||
|
redirect: false,
|
||||||
|
|
||||||
|
onMutationSuccess: () => {
|
||||||
|
setStationModalOpenAction(false);
|
||||||
|
reset();
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headers: {
|
||||||
|
"Accept-Language": language,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={stationModalOpen}
|
||||||
|
onClose={() => setStationModalOpenAction(false)}
|
||||||
|
aria-labelledby="modal-modal-title"
|
||||||
|
aria-describedby="modal-modal-description"
|
||||||
|
>
|
||||||
|
<Box sx={style}>
|
||||||
|
<Edit
|
||||||
|
title={<Typography variant="h5">Редактирование станции</Typography>}
|
||||||
|
saveButtonProps={saveButtonProps}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
sx={{ display: "flex", flexDirection: "column" }}
|
||||||
|
autoComplete="off"
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
{...register("offset_x", {
|
||||||
|
setValueAs: (value) => parseFloat(value),
|
||||||
|
})}
|
||||||
|
error={!!(errors as any)?.offset_x}
|
||||||
|
helperText={(errors as any)?.offset_x?.message}
|
||||||
|
margin="normal"
|
||||||
|
fullWidth
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
type="number"
|
||||||
|
label={"Смещение (X)"}
|
||||||
|
name="offset_x"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
{...register("offset_y", {
|
||||||
|
required: "Это поле является обязательным",
|
||||||
|
setValueAs: (value) => parseFloat(value),
|
||||||
|
})}
|
||||||
|
error={!!(errors as any)?.offset_y}
|
||||||
|
helperText={(errors as any)?.offset_y?.message}
|
||||||
|
margin="normal"
|
||||||
|
fullWidth
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
type="number"
|
||||||
|
label={"Смещение (Y)"}
|
||||||
|
name="offset_y"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Группа полей пересадок */}
|
||||||
|
<Paper sx={{ p: 2, mt: 2 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Пересадки
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{TRANSFER_FIELDS.map((field) => (
|
||||||
|
<Grid item xs={12} sm={6} md={4} key={field.name}>
|
||||||
|
<TextField
|
||||||
|
{...register(`transfers.${field.name}`)}
|
||||||
|
error={!!(errors as any)?.transfers?.[field.name]}
|
||||||
|
helperText={
|
||||||
|
(errors as any)?.transfers?.[field.name]?.message
|
||||||
|
}
|
||||||
|
margin="normal"
|
||||||
|
fullWidth
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
type="text"
|
||||||
|
label={field.label}
|
||||||
|
name={`transfers.${field.name}`}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
</Edit>
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
});
|
@ -1,14 +1,9 @@
|
|||||||
import React from 'react'
|
import { createRoot } from "react-dom/client";
|
||||||
import {createRoot} from 'react-dom/client'
|
|
||||||
|
|
||||||
import App from './App'
|
import App from "./App";
|
||||||
import './globals.css'
|
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>,
|
|
||||||
)
|
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
export const BACKEND_URL = 'https://wn.krbl.ru'
|
|
||||||
|
|
||||||
export const MEDIA_TYPES = [
|
export const MEDIA_TYPES = [
|
||||||
{label: 'Фото', value: 1},
|
{ label: "Фото", value: 1 },
|
||||||
{label: 'Видео', value: 2},
|
{ label: "Видео", value: 2 },
|
||||||
]
|
{ label: "Иконка", value: 3 },
|
||||||
|
{ label: "Водяной знак", value: 4 },
|
||||||
|
{ label: "Панорама", value: 5 },
|
||||||
|
{ label: "3Д-модель", value: 6 },
|
||||||
|
];
|
||||||
|
|
||||||
export const VEHICLE_TYPES = [
|
export const VEHICLE_TYPES = [
|
||||||
{label: 'Трамвай', value: 1},
|
{ label: "Трамвай", value: 1 },
|
||||||
{label: 'Троллейбус', value: 2},
|
{ label: "Троллейбус", value: 2 },
|
||||||
]
|
];
|
||||||
|
@ -1,16 +1,27 @@
|
|||||||
import {Box, TextField, Typography, Paper} from '@mui/material'
|
import { Box, TextField, Typography, Paper } from "@mui/material";
|
||||||
import {Create} from '@refinedev/mui'
|
import { Create } from "@refinedev/mui";
|
||||||
import {useForm} from '@refinedev/react-hook-form'
|
import { useForm } from "@refinedev/react-hook-form";
|
||||||
import {Controller} from 'react-hook-form'
|
import { Controller } from "react-hook-form";
|
||||||
import React, {useState, useEffect} from 'react'
|
import React, { useState, useEffect } from "react";
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { MarkdownEditor } from "../../components/MarkdownEditor";
|
||||||
|
import "easymde/dist/easymde.min.css";
|
||||||
|
|
||||||
import {MarkdownEditor} from '../../components/MarkdownEditor'
|
const MemoizedSimpleMDE = React.memo(MarkdownEditor);
|
||||||
import 'easymde/dist/easymde.min.css'
|
|
||||||
|
|
||||||
const MemoizedSimpleMDE = React.memo(MarkdownEditor)
|
|
||||||
|
|
||||||
export const ArticleCreate = () => {
|
export const ArticleCreate = () => {
|
||||||
|
const [language, setLanguage] = useState(Cookies.get("lang")!);
|
||||||
|
const [articleData, setArticleData] = useState<{
|
||||||
|
ru: { heading: string; body: string };
|
||||||
|
en: { heading: string; body: string };
|
||||||
|
zh: { heading: string; body: string };
|
||||||
|
}>({
|
||||||
|
ru: { heading: "", body: "" },
|
||||||
|
en: { heading: "", body: "" },
|
||||||
|
zh: { heading: "", body: "" },
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
saveButtonProps,
|
saveButtonProps,
|
||||||
refineCore: { formLoading },
|
refineCore: { formLoading },
|
||||||
@ -18,43 +29,142 @@ export const ArticleCreate = () => {
|
|||||||
control,
|
control,
|
||||||
watch,
|
watch,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
|
setValue,
|
||||||
} = useForm({
|
} = useForm({
|
||||||
refineCoreProps: {
|
refineCoreProps: {
|
||||||
resource: 'article/',
|
resource: "article/",
|
||||||
|
meta: {
|
||||||
|
headers: {
|
||||||
|
"Accept-Language": language,
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const [preview, setPreview] = useState('')
|
useEffect(() => {
|
||||||
const [headingPreview, setHeadingPreview] = useState('')
|
const lang = Cookies.get("lang")!;
|
||||||
|
Cookies.set("lang", language);
|
||||||
|
return () => {
|
||||||
|
Cookies.set("lang", lang);
|
||||||
|
};
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(
|
||||||
|
"heading",
|
||||||
|
articleData[language as keyof typeof articleData]?.heading || ""
|
||||||
|
);
|
||||||
|
setValue(
|
||||||
|
"body",
|
||||||
|
articleData[language as keyof typeof articleData]?.body || ""
|
||||||
|
);
|
||||||
|
setPreview(articleData[language as keyof typeof articleData]?.body || "");
|
||||||
|
setHeadingPreview(
|
||||||
|
articleData[language as keyof typeof articleData]?.heading || ""
|
||||||
|
);
|
||||||
|
}, [language, articleData, setValue]);
|
||||||
|
|
||||||
|
const handleLanguageChange = (lang: string) => {
|
||||||
|
setArticleData((prevData) => ({
|
||||||
|
...prevData,
|
||||||
|
[language]: {
|
||||||
|
heading: watch("heading") || "",
|
||||||
|
body: watch("body") || "",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
setLanguage(lang);
|
||||||
|
Cookies.set("lang", lang);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [preview, setPreview] = useState("");
|
||||||
|
const [headingPreview, setHeadingPreview] = useState("");
|
||||||
|
|
||||||
// Следим за изменениями в полях body и heading
|
// Следим за изменениями в полях body и heading
|
||||||
const bodyContent = watch('body')
|
const bodyContent = watch("body");
|
||||||
const headingContent = watch('heading')
|
const headingContent = watch("heading");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPreview(bodyContent || '')
|
setPreview(bodyContent || "");
|
||||||
}, [bodyContent])
|
}, [bodyContent]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHeadingPreview(headingContent || '')
|
setHeadingPreview(headingContent || "");
|
||||||
}, [headingContent])
|
}, [headingContent]);
|
||||||
|
|
||||||
const simpleMDEOptions = React.useMemo(
|
const simpleMDEOptions = React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
placeholder: 'Введите контент в формате Markdown...',
|
placeholder: "Введите контент в формате Markdown...",
|
||||||
spellChecker: false,
|
spellChecker: false,
|
||||||
}),
|
}),
|
||||||
[],
|
[]
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
|
<Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
|
||||||
<Box sx={{display: 'flex', gap: 2}}>
|
<Box sx={{ display: "flex", flex: 1, gap: 2 }}>
|
||||||
{/* Форма создания */}
|
{/* Форма создания */}
|
||||||
<Box component="form" sx={{flex: 1, display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
<Box sx={{ display: "flex", flex: 1, flexDirection: "column", gap: 2 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
cursor: "pointer",
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: language === "ru" ? "primary.main" : "transparent",
|
||||||
|
color: language === "ru" ? "white" : "inherit",
|
||||||
|
p: 1,
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
onClick={() => handleLanguageChange("ru")}
|
||||||
|
>
|
||||||
|
RU
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
cursor: "pointer",
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: language === "en" ? "primary.main" : "transparent",
|
||||||
|
color: language === "en" ? "white" : "inherit",
|
||||||
|
p: 1,
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
onClick={() => handleLanguageChange("en")}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
cursor: "pointer",
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: language === "zh" ? "primary.main" : "transparent",
|
||||||
|
color: language === "zh" ? "white" : "inherit",
|
||||||
|
p: 1,
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
onClick={() => handleLanguageChange("zh")}
|
||||||
|
>
|
||||||
|
ZH
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
sx={{ flex: 1, display: "flex", flexDirection: "column" }}
|
||||||
|
autoComplete="off"
|
||||||
|
>
|
||||||
<TextField
|
<TextField
|
||||||
{...register('heading', {
|
{...register("heading", {
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.heading}
|
error={!!(errors as any)?.heading}
|
||||||
helperText={(errors as any)?.heading?.message}
|
helperText={(errors as any)?.heading?.message}
|
||||||
@ -66,7 +176,21 @@ export const ArticleCreate = () => {
|
|||||||
name="heading"
|
name="heading"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Controller control={control} name="body" rules={{required: 'Это поле является обязательным'}} defaultValue="" render={({field: {onChange, value}}) => <MemoizedSimpleMDE value={value} onChange={onChange} options={simpleMDEOptions} className="my-markdown-editor" />} />
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="body"
|
||||||
|
rules={{ required: "Это поле является обязательным" }}
|
||||||
|
defaultValue=""
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<MemoizedSimpleMDE
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
options={simpleMDEOptions}
|
||||||
|
className="my-markdown-editor"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Блок предпросмотра */}
|
{/* Блок предпросмотра */}
|
||||||
@ -74,14 +198,15 @@ export const ArticleCreate = () => {
|
|||||||
sx={{
|
sx={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
p: 2,
|
p: 2,
|
||||||
maxHeight: 'calc(100vh - 200px)',
|
maxHeight: "calc(100vh - 200px)",
|
||||||
overflowY: 'auto',
|
overflowY: "auto",
|
||||||
position: 'sticky',
|
position: "sticky",
|
||||||
top: 16,
|
top: 16,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
border: '1px solid',
|
border: "1px solid",
|
||||||
borderColor: 'primary.main',
|
borderColor: "primary.main",
|
||||||
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : '#fff'),
|
bgcolor: (theme) =>
|
||||||
|
theme.palette.mode === "dark" ? "background.paper" : "#fff",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="h6" gutterBottom color="primary">
|
<Typography variant="h6" gutterBottom color="primary">
|
||||||
@ -93,7 +218,8 @@ export const ArticleCreate = () => {
|
|||||||
variant="h4"
|
variant="h4"
|
||||||
gutterBottom
|
gutterBottom
|
||||||
sx={{
|
sx={{
|
||||||
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'),
|
color: (theme) =>
|
||||||
|
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
|
||||||
mb: 3,
|
mb: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -103,39 +229,41 @@ export const ArticleCreate = () => {
|
|||||||
{/* Markdown контент */}
|
{/* Markdown контент */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
'& img': {
|
"& img": {
|
||||||
maxWidth: '100%',
|
maxWidth: "100%",
|
||||||
height: 'auto',
|
height: "auto",
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
},
|
},
|
||||||
'& h1, & h2, & h3, & h4, & h5, & h6': {
|
"& h1, & h2, & h3, & h4, & h5, & h6": {
|
||||||
color: 'primary.main',
|
color: "primary.main",
|
||||||
mt: 2,
|
mt: 2,
|
||||||
mb: 1,
|
mb: 1,
|
||||||
},
|
},
|
||||||
'& p': {
|
"& p": {
|
||||||
mb: 2,
|
mb: 2,
|
||||||
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'),
|
color: (theme) =>
|
||||||
|
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
|
||||||
},
|
},
|
||||||
'& a': {
|
"& a": {
|
||||||
color: 'primary.main',
|
color: "primary.main",
|
||||||
textDecoration: 'none',
|
textDecoration: "none",
|
||||||
'&:hover': {
|
"&:hover": {
|
||||||
textDecoration: 'underline',
|
textDecoration: "underline",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'& blockquote': {
|
"& blockquote": {
|
||||||
borderLeft: '4px solid',
|
borderLeft: "4px solid",
|
||||||
borderColor: 'primary.main',
|
borderColor: "primary.main",
|
||||||
pl: 2,
|
pl: 2,
|
||||||
my: 2,
|
my: 2,
|
||||||
color: 'text.secondary',
|
color: "text.secondary",
|
||||||
},
|
},
|
||||||
'& code': {
|
"& code": {
|
||||||
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100'),
|
bgcolor: (theme) =>
|
||||||
|
theme.palette.mode === "dark" ? "grey.900" : "grey.100",
|
||||||
p: 0.5,
|
p: 0.5,
|
||||||
borderRadius: 0.5,
|
borderRadius: 0.5,
|
||||||
color: 'primary.main',
|
color: "primary.main",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -144,5 +272,5 @@ export const ArticleCreate = () => {
|
|||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
</Create>
|
</Create>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
@ -1,72 +1,183 @@
|
|||||||
import {Box, TextField, Typography, Paper} from '@mui/material'
|
import { Box, TextField, Typography, Paper } from "@mui/material";
|
||||||
import {Edit} from '@refinedev/mui'
|
import { Edit } from "@refinedev/mui";
|
||||||
import {useForm} from '@refinedev/react-hook-form'
|
import { useForm } from "@refinedev/react-hook-form";
|
||||||
import {Controller} from 'react-hook-form'
|
import { Controller } from "react-hook-form";
|
||||||
import {useParams} from 'react-router'
|
import { useParams } from "react-router";
|
||||||
import React, {useState, useEffect} from 'react'
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from "react-markdown";
|
||||||
import {useList} from '@refinedev/core'
|
import { useList } from "@refinedev/core";
|
||||||
|
|
||||||
import {MarkdownEditor} from '../../components/MarkdownEditor'
|
import { MarkdownEditor } from "../../components/MarkdownEditor";
|
||||||
import {LinkedItems} from '../../components/LinkedItems'
|
import { LinkedItems } from "../../components/LinkedItems";
|
||||||
import {MediaItem, mediaFields} from './types'
|
import { MediaItem, mediaFields } from "./types";
|
||||||
import {TOKEN_KEY} from '../../authProvider'
|
import { TOKEN_KEY } from "../../authProvider";
|
||||||
import 'easymde/dist/easymde.min.css'
|
import "easymde/dist/easymde.min.css";
|
||||||
|
import { languageStore } from "../../store/LanguageStore";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
const MemoizedSimpleMDE = React.memo(MarkdownEditor);
|
||||||
|
|
||||||
const MemoizedSimpleMDE = React.memo(MarkdownEditor)
|
export const ArticleEdit = observer(() => {
|
||||||
|
const { language, setLanguageAction } = languageStore;
|
||||||
|
|
||||||
|
const [articleData, setArticleData] = useState<{
|
||||||
|
ru: { heading: string; body: string };
|
||||||
|
en: { heading: string; body: string };
|
||||||
|
zh: { heading: string; body: string };
|
||||||
|
}>({
|
||||||
|
ru: { heading: "", body: "" },
|
||||||
|
en: { heading: "", body: "" },
|
||||||
|
zh: { heading: "", body: "" },
|
||||||
|
});
|
||||||
|
const { id: articleId } = useParams<{ id: string }>();
|
||||||
|
const [preview, setPreview] = useState("");
|
||||||
|
const [headingPreview, setHeadingPreview] = useState("");
|
||||||
|
const simpleMDEOptions = useMemo(
|
||||||
|
() => ({
|
||||||
|
placeholder: "Введите контент в формате Markdown...",
|
||||||
|
spellChecker: false,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
export const ArticleEdit = () => {
|
|
||||||
const {
|
const {
|
||||||
saveButtonProps,
|
saveButtonProps,
|
||||||
register,
|
register,
|
||||||
control,
|
control,
|
||||||
|
handleSubmit,
|
||||||
watch,
|
watch,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm()
|
setValue,
|
||||||
|
} = useForm<{ heading: string; body: string }>({
|
||||||
|
refineCoreProps: {
|
||||||
|
meta: {
|
||||||
|
headers: {
|
||||||
|
"Accept-Language": language,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const {id: articleId} = useParams<{id: string}>()
|
useEffect(() => {
|
||||||
const [preview, setPreview] = useState('')
|
if (articleData[language as keyof typeof articleData]?.heading) {
|
||||||
const [headingPreview, setHeadingPreview] = useState('')
|
setValue(
|
||||||
|
"heading",
|
||||||
|
articleData[language as keyof typeof articleData]?.heading
|
||||||
|
);
|
||||||
|
setHeadingPreview(
|
||||||
|
articleData[language as keyof typeof articleData]?.heading || ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (articleData[language as keyof typeof articleData]?.body) {
|
||||||
|
setValue(
|
||||||
|
"body",
|
||||||
|
articleData[language as keyof typeof articleData]?.body || ""
|
||||||
|
);
|
||||||
|
setPreview(articleData[language as keyof typeof articleData]?.body || "");
|
||||||
|
}
|
||||||
|
}, [language, articleData, setValue]);
|
||||||
|
|
||||||
|
const handleLanguageChange = (lang: string) => {
|
||||||
|
setArticleData((prevData) => ({
|
||||||
|
...prevData,
|
||||||
|
[language]: {
|
||||||
|
heading: watch("heading") ?? "",
|
||||||
|
body: watch("body") ?? "",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
setLanguageAction(lang);
|
||||||
|
};
|
||||||
|
|
||||||
|
const bodyContent = watch("body");
|
||||||
|
const headingContent = watch("heading");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPreview(bodyContent || "");
|
||||||
|
}, [bodyContent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHeadingPreview(headingContent || "");
|
||||||
|
}, [headingContent]);
|
||||||
|
|
||||||
// Получаем привязанные медиа
|
|
||||||
const { data: mediaData } = useList<MediaItem>({
|
const { data: mediaData } = useList<MediaItem>({
|
||||||
resource: `article/${articleId}/media`,
|
resource: `article/${articleId}/media`,
|
||||||
queryOptions: {
|
});
|
||||||
enabled: !!articleId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Следим за изменениями в полях body и heading
|
|
||||||
const bodyContent = watch('body')
|
|
||||||
const headingContent = watch('heading')
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPreview(bodyContent || '')
|
return () => {
|
||||||
}, [bodyContent])
|
setLanguageAction("ru");
|
||||||
|
};
|
||||||
useEffect(() => {
|
}, [setLanguageAction]);
|
||||||
setHeadingPreview(headingContent || '')
|
|
||||||
}, [headingContent])
|
|
||||||
|
|
||||||
const simpleMDEOptions = React.useMemo(
|
|
||||||
() => ({
|
|
||||||
placeholder: 'Введите контент в формате Markdown...',
|
|
||||||
spellChecker: false,
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Edit saveButtonProps={saveButtonProps}>
|
<Edit saveButtonProps={saveButtonProps}>
|
||||||
<Box sx={{display: 'flex', gap: 2}}>
|
<Box sx={{ display: "flex", gap: 2 }}>
|
||||||
{/* Форма редактирования */}
|
{/* Форма редактирования */}
|
||||||
<Box component="form" sx={{flex: 1, display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
{/* Форма создания */}
|
||||||
|
<Box sx={{ display: "flex", flex: 1, flexDirection: "column", gap: 2 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
cursor: "pointer",
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: language === "ru" ? "primary.main" : "transparent",
|
||||||
|
color: language === "ru" ? "white" : "inherit",
|
||||||
|
p: 1,
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
onClick={() => handleLanguageChange("ru")}
|
||||||
|
>
|
||||||
|
RU
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
cursor: "pointer",
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: language === "en" ? "primary.main" : "transparent",
|
||||||
|
color: language === "en" ? "white" : "inherit",
|
||||||
|
p: 1,
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
onClick={() => handleLanguageChange("en")}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
cursor: "pointer",
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: language === "zh" ? "primary.main" : "transparent",
|
||||||
|
color: language === "zh" ? "white" : "inherit",
|
||||||
|
p: 1,
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
onClick={() => handleLanguageChange("zh")}
|
||||||
|
>
|
||||||
|
ZH
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
sx={{ flex: 1, display: "flex", flexDirection: "column" }}
|
||||||
|
autoComplete="off"
|
||||||
|
>
|
||||||
<TextField
|
<TextField
|
||||||
{...register('heading', {
|
{...register("heading", {
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.heading}
|
error={!!errors?.heading}
|
||||||
helperText={(errors as any)?.heading?.message}
|
helperText={errors?.heading?.message as string}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
@ -78,7 +189,7 @@ export const ArticleEdit = () => {
|
|||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="body"
|
name="body"
|
||||||
rules={{required: 'Это поле является обязательным'}}
|
rules={{ required: "Это поле является обязательным" }}
|
||||||
defaultValue=""
|
defaultValue=""
|
||||||
render={({ field: { onChange, value } }) => (
|
render={({ field: { onChange, value } }) => (
|
||||||
<MemoizedSimpleMDE
|
<MemoizedSimpleMDE
|
||||||
@ -90,7 +201,17 @@ export const ArticleEdit = () => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{articleId && <LinkedItems<MediaItem> type="edit" parentId={articleId} parentResource="article" childResource="media" fields={mediaFields} title="медиа" />}
|
{articleId && (
|
||||||
|
<LinkedItems<MediaItem>
|
||||||
|
type="edit"
|
||||||
|
parentId={articleId}
|
||||||
|
parentResource="article"
|
||||||
|
childResource="media"
|
||||||
|
fields={mediaFields}
|
||||||
|
title="медиа"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Блок предпросмотра */}
|
{/* Блок предпросмотра */}
|
||||||
@ -98,14 +219,15 @@ export const ArticleEdit = () => {
|
|||||||
sx={{
|
sx={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
p: 2,
|
p: 2,
|
||||||
maxHeight: 'calc(100vh - 200px)',
|
maxHeight: "calc(100vh - 200px)",
|
||||||
overflowY: 'auto',
|
overflowY: "auto",
|
||||||
position: 'sticky',
|
position: "sticky",
|
||||||
top: 16,
|
top: 16,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
border: '1px solid',
|
border: "1px solid",
|
||||||
borderColor: 'primary.main',
|
borderColor: "primary.main",
|
||||||
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : '#fff'),
|
bgcolor: (theme) =>
|
||||||
|
theme.palette.mode === "dark" ? "background.paper" : "#fff",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="h6" gutterBottom color="primary">
|
<Typography variant="h6" gutterBottom color="primary">
|
||||||
@ -117,7 +239,8 @@ export const ArticleEdit = () => {
|
|||||||
variant="h4"
|
variant="h4"
|
||||||
gutterBottom
|
gutterBottom
|
||||||
sx={{
|
sx={{
|
||||||
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'),
|
color: (theme) =>
|
||||||
|
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
|
||||||
mb: 3,
|
mb: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -127,39 +250,41 @@ export const ArticleEdit = () => {
|
|||||||
{/* Markdown контент */}
|
{/* Markdown контент */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
'& img': {
|
"& img": {
|
||||||
maxWidth: '100%',
|
maxWidth: "100%",
|
||||||
height: 'auto',
|
height: "auto",
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
},
|
},
|
||||||
'& h1, & h2, & h3, & h4, & h5, & h6': {
|
"& h1, & h2, & h3, & h4, & h5, & h6": {
|
||||||
color: 'primary.main',
|
color: "primary.main",
|
||||||
mt: 2,
|
mt: 2,
|
||||||
mb: 1,
|
mb: 1,
|
||||||
},
|
},
|
||||||
'& p': {
|
"& p": {
|
||||||
mb: 2,
|
mb: 2,
|
||||||
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'),
|
color: (theme) =>
|
||||||
|
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
|
||||||
},
|
},
|
||||||
'& a': {
|
"& a": {
|
||||||
color: 'primary.main',
|
color: "primary.main",
|
||||||
textDecoration: 'none',
|
textDecoration: "none",
|
||||||
'&:hover': {
|
"&:hover": {
|
||||||
textDecoration: 'underline',
|
textDecoration: "underline",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'& blockquote': {
|
"& blockquote": {
|
||||||
borderLeft: '4px solid',
|
borderLeft: "4px solid",
|
||||||
borderColor: 'primary.main',
|
borderColor: "primary.main",
|
||||||
pl: 2,
|
pl: 2,
|
||||||
my: 2,
|
my: 2,
|
||||||
color: 'text.secondary',
|
color: "text.secondary",
|
||||||
},
|
},
|
||||||
'& code': {
|
"& code": {
|
||||||
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100'),
|
bgcolor: (theme) =>
|
||||||
|
theme.palette.mode === "dark" ? "grey.900" : "grey.100",
|
||||||
p: 0.5,
|
p: 0.5,
|
||||||
borderRadius: 0.5,
|
borderRadius: 0.5,
|
||||||
color: 'primary.main',
|
color: "primary.main",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -174,9 +299,9 @@ export const ArticleEdit = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
gap: 1,
|
gap: 1,
|
||||||
flexWrap: 'wrap',
|
flexWrap: "wrap",
|
||||||
mb: 2,
|
mb: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -187,18 +312,20 @@ export const ArticleEdit = () => {
|
|||||||
width: 120,
|
width: 120,
|
||||||
height: 120,
|
height: 120,
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
overflow: 'hidden',
|
overflow: "hidden",
|
||||||
border: '1px solid',
|
border: "1px solid",
|
||||||
borderColor: 'primary.main',
|
borderColor: "primary.main",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`https://wn.krbl.ru/media/${media.id}/download?token=${localStorage.getItem(TOKEN_KEY)}`}
|
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
|
media.id
|
||||||
|
}/download?token=${localStorage.getItem(TOKEN_KEY)}`}
|
||||||
alt={media.media_name}
|
alt={media.media_name}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: "100%",
|
||||||
height: '100%',
|
height: "100%",
|
||||||
objectFit: 'cover',
|
objectFit: "cover",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@ -209,5 +336,5 @@ export const ArticleEdit = () => {
|
|||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
</Edit>
|
</Edit>
|
||||||
)
|
);
|
||||||
}
|
});
|
||||||
|
@ -1,34 +1,49 @@
|
|||||||
import {type GridColDef} from '@mui/x-data-grid'
|
import { type GridColDef } from "@mui/x-data-grid";
|
||||||
import {CustomDataGrid} from '../../components/CustomDataGrid'
|
import { CustomDataGrid } from "../../components/CustomDataGrid";
|
||||||
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
|
import {
|
||||||
import React from 'react'
|
DeleteButton,
|
||||||
|
EditButton,
|
||||||
|
List,
|
||||||
|
ShowButton,
|
||||||
|
useDataGrid,
|
||||||
|
} from "@refinedev/mui";
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
|
||||||
import {localeText} from '../../locales/ru/localeText'
|
import { localeText } from "../../locales/ru/localeText";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { languageStore } from "../../store/LanguageStore";
|
||||||
|
|
||||||
|
export const ArticleList = observer(() => {
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
export const ArticleList = () => {
|
|
||||||
const { dataGridProps } = useDataGrid({
|
const { dataGridProps } = useDataGrid({
|
||||||
resource: 'article/',
|
resource: "article/",
|
||||||
})
|
meta: {
|
||||||
|
headers: {
|
||||||
|
"Accept-Language": language,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const columns = React.useMemo<GridColDef[]>(
|
const columns = React.useMemo<GridColDef[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
field: 'id',
|
field: "id",
|
||||||
headerName: 'ID',
|
headerName: "ID",
|
||||||
type: 'number',
|
type: "number",
|
||||||
minWidth: 70,
|
minWidth: 70,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'heading',
|
field: "heading",
|
||||||
headerName: 'Заголовок',
|
headerName: "Заголовок",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 300,
|
minWidth: 300,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
@ -41,12 +56,12 @@ export const ArticleList = () => {
|
|||||||
// flex: 1,
|
// flex: 1,
|
||||||
// },
|
// },
|
||||||
{
|
{
|
||||||
field: 'actions',
|
field: "actions",
|
||||||
headerName: 'Действия',
|
headerName: "Действия",
|
||||||
align: 'right',
|
align: "right",
|
||||||
headerAlign: 'center',
|
headerAlign: "center",
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
sortable: false,
|
sortable: false,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
disableColumnMenu: true,
|
disableColumnMenu: true,
|
||||||
@ -57,16 +72,22 @@ export const ArticleList = () => {
|
|||||||
<ShowButton hideText recordItemId={row.id} />
|
<ShowButton hideText recordItemId={row.id} />
|
||||||
<DeleteButton hideText recordItemId={row.id} />
|
<DeleteButton hideText recordItemId={row.id} />
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[]
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List>
|
<List>
|
||||||
<CustomDataGrid {...dataGridProps} columns={columns} localeText={localeText} getRowId={(row: any) => row.id} />
|
<CustomDataGrid
|
||||||
|
{...dataGridProps}
|
||||||
|
languageEnabled
|
||||||
|
columns={columns}
|
||||||
|
localeText={localeText}
|
||||||
|
getRowId={(row: any) => row.id}
|
||||||
|
/>
|
||||||
</List>
|
</List>
|
||||||
)
|
);
|
||||||
}
|
});
|
||||||
|
@ -1,71 +1,101 @@
|
|||||||
import {Autocomplete, Box, TextField} from '@mui/material'
|
import { Autocomplete, Box, TextField } from "@mui/material";
|
||||||
import {Create, useAutocomplete} from '@refinedev/mui'
|
import { Create, useAutocomplete } from "@refinedev/mui";
|
||||||
import {useForm} from '@refinedev/react-hook-form'
|
import { useForm } from "@refinedev/react-hook-form";
|
||||||
import {Controller} from 'react-hook-form'
|
import { Controller } from "react-hook-form";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
export const CarrierCreate = () => {
|
import { languageStore } from "../../store/LanguageStore";
|
||||||
|
export const CarrierCreate = observer(() => {
|
||||||
|
const { language } = languageStore;
|
||||||
const {
|
const {
|
||||||
saveButtonProps,
|
saveButtonProps,
|
||||||
refineCore: { formLoading },
|
refineCore: { formLoading },
|
||||||
register,
|
register,
|
||||||
control,
|
control,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm({})
|
} = useForm({
|
||||||
|
refineCoreProps: {
|
||||||
|
meta: {
|
||||||
|
headers: {
|
||||||
|
"Accept-Language": language,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({
|
const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({
|
||||||
resource: 'city',
|
resource: "city",
|
||||||
onSearch: (value) => [
|
onSearch: (value) => [
|
||||||
{
|
{
|
||||||
field: 'name',
|
field: "name",
|
||||||
operator: 'contains',
|
operator: "contains",
|
||||||
value,
|
value,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
|
|
||||||
const { autocompleteProps: mediaAutocompleteProps } = useAutocomplete({
|
const { autocompleteProps: mediaAutocompleteProps } = useAutocomplete({
|
||||||
resource: 'media',
|
resource: "media",
|
||||||
onSearch: (value) => [
|
onSearch: (value) => [
|
||||||
{
|
{
|
||||||
field: 'media_name',
|
field: "media_name",
|
||||||
operator: 'contains',
|
operator: "contains",
|
||||||
value,
|
value,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
|
<Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
|
||||||
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
<Box
|
||||||
|
component="form"
|
||||||
|
sx={{ display: "flex", flexDirection: "column" }}
|
||||||
|
autoComplete="off"
|
||||||
|
>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="city_id"
|
name="city_id"
|
||||||
rules={{required: 'Это поле является обязательным'}}
|
rules={{ required: "Это поле является обязательным" }}
|
||||||
defaultValue={null}
|
defaultValue={null}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
{...cityAutocompleteProps}
|
{...cityAutocompleteProps}
|
||||||
value={cityAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
value={
|
||||||
|
cityAutocompleteProps.options.find(
|
||||||
|
(option) => option.id === field.value
|
||||||
|
) || null
|
||||||
|
}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
field.onChange(value?.id || '')
|
field.onChange(value?.id || "");
|
||||||
}}
|
}}
|
||||||
getOptionLabel={(item) => {
|
getOptionLabel={(item) => {
|
||||||
return item ? item.name : ''
|
return item ? item.name : "";
|
||||||
}}
|
}}
|
||||||
isOptionEqualToValue={(option, value) => {
|
isOptionEqualToValue={(option, value) => {
|
||||||
return option.id === value?.id
|
return option.id === value?.id;
|
||||||
}}
|
}}
|
||||||
filterOptions={(options, { inputValue }) => {
|
filterOptions={(options, { inputValue }) => {
|
||||||
return options.filter((option) => option.name.toLowerCase().includes(inputValue.toLowerCase()))
|
return options.filter((option) =>
|
||||||
|
option.name.toLowerCase().includes(inputValue.toLowerCase())
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
renderInput={(params) => <TextField {...params} label="Выберите город" margin="normal" variant="outlined" error={!!errors.city_id} helperText={(errors as any)?.city_id?.message} required />}
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Выберите город"
|
||||||
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
error={!!errors.city_id}
|
||||||
|
helperText={(errors as any)?.city_id?.message}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('full_name', {
|
{...register("full_name", {
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.full_name}
|
error={!!(errors as any)?.full_name}
|
||||||
helperText={(errors as any)?.full_name?.message}
|
helperText={(errors as any)?.full_name?.message}
|
||||||
@ -73,13 +103,13 @@ export const CarrierCreate = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="text"
|
type="text"
|
||||||
label={'Полное имя *'}
|
label={"Полное имя *"}
|
||||||
name="full_name"
|
name="full_name"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('short_name', {
|
{...register("short_name", {
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.short_name}
|
error={!!(errors as any)?.short_name}
|
||||||
helperText={(errors as any)?.short_name?.message}
|
helperText={(errors as any)?.short_name?.message}
|
||||||
@ -87,12 +117,12 @@ export const CarrierCreate = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="text"
|
type="text"
|
||||||
label={'Короткое имя *'}
|
label={"Короткое имя *"}
|
||||||
name="short_name"
|
name="short_name"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('main_color', {
|
{...register("main_color", {
|
||||||
// required: 'Это поле является обязательным',
|
// required: 'Это поле является обязательным',
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.main_color}
|
error={!!(errors as any)?.main_color}
|
||||||
@ -101,20 +131,20 @@ export const CarrierCreate = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="color"
|
type="color"
|
||||||
label={'Основной цвет'}
|
label={"Основной цвет"}
|
||||||
name="main_color"
|
name="main_color"
|
||||||
sx={{
|
sx={{
|
||||||
'& input': {
|
"& input": {
|
||||||
height: '50px',
|
height: "50px",
|
||||||
paddingBlock: '14px',
|
paddingBlock: "14px",
|
||||||
paddingInline: '14px',
|
paddingInline: "14px",
|
||||||
cursor: 'pointer',
|
cursor: "pointer",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('left_color', {
|
{...register("left_color", {
|
||||||
// required: 'Это поле является обязательным',
|
// required: 'Это поле является обязательным',
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.left_color}
|
error={!!(errors as any)?.left_color}
|
||||||
@ -123,19 +153,19 @@ export const CarrierCreate = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="color"
|
type="color"
|
||||||
label={'Цвет левого виджета'}
|
label={"Цвет левого виджета"}
|
||||||
name="left_color"
|
name="left_color"
|
||||||
sx={{
|
sx={{
|
||||||
'& input': {
|
"& input": {
|
||||||
height: '50px',
|
height: "50px",
|
||||||
paddingBlock: '14px',
|
paddingBlock: "14px",
|
||||||
paddingInline: '14px',
|
paddingInline: "14px",
|
||||||
cursor: 'pointer',
|
cursor: "pointer",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
{...register('right_color', {
|
{...register("right_color", {
|
||||||
// required: 'Это поле является обязательным',
|
// required: 'Это поле является обязательным',
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.right_color}
|
error={!!(errors as any)?.right_color}
|
||||||
@ -144,20 +174,20 @@ export const CarrierCreate = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="color"
|
type="color"
|
||||||
label={'Цвет правого виджета'}
|
label={"Цвет правого виджета"}
|
||||||
name="right_color"
|
name="right_color"
|
||||||
sx={{
|
sx={{
|
||||||
'& input': {
|
"& input": {
|
||||||
height: '50px',
|
height: "50px",
|
||||||
paddingBlock: '14px',
|
paddingBlock: "14px",
|
||||||
paddingInline: '14px',
|
paddingInline: "14px",
|
||||||
cursor: 'pointer',
|
cursor: "pointer",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('slogan', {
|
{...register("slogan", {
|
||||||
// required: 'Это поле является обязательным',
|
// required: 'Это поле является обязательным',
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.slogan}
|
error={!!(errors as any)?.slogan}
|
||||||
@ -166,7 +196,7 @@ export const CarrierCreate = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="text"
|
type="text"
|
||||||
label={'Слоган'}
|
label={"Слоган"}
|
||||||
name="slogan"
|
name="slogan"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -178,24 +208,41 @@ export const CarrierCreate = () => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
{...mediaAutocompleteProps}
|
{...mediaAutocompleteProps}
|
||||||
value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
value={
|
||||||
|
mediaAutocompleteProps.options.find(
|
||||||
|
(option) => option.id === field.value
|
||||||
|
) || null
|
||||||
|
}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
field.onChange(value?.id || '')
|
field.onChange(value?.id || "");
|
||||||
}}
|
}}
|
||||||
getOptionLabel={(item) => {
|
getOptionLabel={(item) => {
|
||||||
return item ? item.media_name : ''
|
return item ? item.media_name : "";
|
||||||
}}
|
}}
|
||||||
isOptionEqualToValue={(option, value) => {
|
isOptionEqualToValue={(option, value) => {
|
||||||
return option.id === value?.id
|
return option.id === value?.id;
|
||||||
}}
|
}}
|
||||||
filterOptions={(options, { inputValue }) => {
|
filterOptions={(options, { inputValue }) => {
|
||||||
return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase()))
|
return options.filter((option) =>
|
||||||
|
option.media_name
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(inputValue.toLowerCase())
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
renderInput={(params) => <TextField {...params} label="Выберите логотип" margin="normal" variant="outlined" error={!!errors.logo} helperText={(errors as any)?.logo?.message} />}
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Выберите логотип"
|
||||||
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
error={!!errors.logo}
|
||||||
|
helperText={(errors as any)?.logo?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Create>
|
</Create>
|
||||||
)
|
);
|
||||||
}
|
});
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import {Autocomplete, Box, TextField} from '@mui/material'
|
import { Autocomplete, Box, TextField } from "@mui/material";
|
||||||
import {Edit, useAutocomplete} from '@refinedev/mui'
|
import { Edit, useAutocomplete } from "@refinedev/mui";
|
||||||
import {useForm} from '@refinedev/react-hook-form'
|
import { useForm } from "@refinedev/react-hook-form";
|
||||||
import {Controller} from 'react-hook-form'
|
import { Controller } from "react-hook-form";
|
||||||
|
|
||||||
export const CarrierEdit = () => {
|
export const CarrierEdit = () => {
|
||||||
const {
|
const {
|
||||||
@ -9,62 +9,82 @@ export const CarrierEdit = () => {
|
|||||||
register,
|
register,
|
||||||
control,
|
control,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm()
|
} = useForm();
|
||||||
|
|
||||||
const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({
|
const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({
|
||||||
resource: 'city',
|
resource: "city",
|
||||||
onSearch: (value) => [
|
onSearch: (value) => [
|
||||||
{
|
{
|
||||||
field: 'name',
|
field: "name",
|
||||||
operator: 'contains',
|
operator: "contains",
|
||||||
value,
|
value,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
|
|
||||||
const { autocompleteProps: mediaAutocompleteProps } = useAutocomplete({
|
const { autocompleteProps: mediaAutocompleteProps } = useAutocomplete({
|
||||||
resource: 'media',
|
resource: "media",
|
||||||
onSearch: (value) => [
|
onSearch: (value) => [
|
||||||
{
|
{
|
||||||
field: 'media_name',
|
field: "media_name",
|
||||||
operator: 'contains',
|
operator: "contains",
|
||||||
value,
|
value,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Edit saveButtonProps={saveButtonProps}>
|
<Edit saveButtonProps={saveButtonProps}>
|
||||||
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
<Box
|
||||||
|
component="form"
|
||||||
|
sx={{ display: "flex", flexDirection: "column" }}
|
||||||
|
autoComplete="off"
|
||||||
|
>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="city_id"
|
name="city_id"
|
||||||
rules={{required: 'Это поле является обязательным'}}
|
rules={{ required: "Это поле является обязательным" }}
|
||||||
defaultValue={null}
|
defaultValue={null}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
{...cityAutocompleteProps}
|
{...cityAutocompleteProps}
|
||||||
value={cityAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
value={
|
||||||
|
cityAutocompleteProps.options.find(
|
||||||
|
(option) => option.id === field.value
|
||||||
|
) || null
|
||||||
|
}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
field.onChange(value?.id || '')
|
field.onChange(value?.id || "");
|
||||||
}}
|
}}
|
||||||
getOptionLabel={(item) => {
|
getOptionLabel={(item) => {
|
||||||
return item ? item.name : ''
|
return item ? item.name : "";
|
||||||
}}
|
}}
|
||||||
isOptionEqualToValue={(option, value) => {
|
isOptionEqualToValue={(option, value) => {
|
||||||
return option.id === value?.id
|
return option.id === value?.id;
|
||||||
}}
|
}}
|
||||||
filterOptions={(options, { inputValue }) => {
|
filterOptions={(options, { inputValue }) => {
|
||||||
return options.filter((option) => option.name.toLowerCase().includes(inputValue.toLowerCase()))
|
return options.filter((option) =>
|
||||||
|
option.name.toLowerCase().includes(inputValue.toLowerCase())
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
renderInput={(params) => <TextField {...params} label="Выберите город" margin="normal" variant="outlined" error={!!errors.city_id} helperText={(errors as any)?.city_id?.message} required />}
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Выберите город"
|
||||||
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
error={!!errors.city_id}
|
||||||
|
helperText={(errors as any)?.city_id?.message}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('full_name', {
|
{...register("full_name", {
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.full_name}
|
error={!!(errors as any)?.full_name}
|
||||||
helperText={(errors as any)?.full_name?.message}
|
helperText={(errors as any)?.full_name?.message}
|
||||||
@ -72,13 +92,13 @@ export const CarrierEdit = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="text"
|
type="text"
|
||||||
label={'Полное имя *'}
|
label={"Полное имя *"}
|
||||||
name="full_name"
|
name="full_name"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('short_name', {
|
{...register("short_name", {
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.short_name}
|
error={!!(errors as any)?.short_name}
|
||||||
helperText={(errors as any)?.short_name?.message}
|
helperText={(errors as any)?.short_name?.message}
|
||||||
@ -86,12 +106,12 @@ export const CarrierEdit = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="text"
|
type="text"
|
||||||
label={'Короткое имя *'}
|
label={"Короткое имя *"}
|
||||||
name="short_name"
|
name="short_name"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('main_color', {
|
{...register("main_color", {
|
||||||
// required: 'Это поле является обязательным',
|
// required: 'Это поле является обязательным',
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.main_color}
|
error={!!(errors as any)?.main_color}
|
||||||
@ -100,20 +120,20 @@ export const CarrierEdit = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="color"
|
type="color"
|
||||||
label={'Основной цвет'}
|
label={"Основной цвет"}
|
||||||
name="main_color"
|
name="main_color"
|
||||||
sx={{
|
sx={{
|
||||||
'& input': {
|
"& input": {
|
||||||
height: '50px',
|
height: "50px",
|
||||||
paddingBlock: '14px',
|
paddingBlock: "14px",
|
||||||
paddingInline: '14px',
|
paddingInline: "14px",
|
||||||
cursor: 'pointer',
|
cursor: "pointer",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('left_color', {
|
{...register("left_color", {
|
||||||
// required: 'Это поле является обязательным',
|
// required: 'Это поле является обязательным',
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.left_color}
|
error={!!(errors as any)?.left_color}
|
||||||
@ -122,19 +142,19 @@ export const CarrierEdit = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="color"
|
type="color"
|
||||||
label={'Цвет левого виджета'}
|
label={"Цвет левого виджета"}
|
||||||
name="left_color"
|
name="left_color"
|
||||||
sx={{
|
sx={{
|
||||||
'& input': {
|
"& input": {
|
||||||
height: '50px',
|
height: "50px",
|
||||||
paddingBlock: '14px',
|
paddingBlock: "14px",
|
||||||
paddingInline: '14px',
|
paddingInline: "14px",
|
||||||
cursor: 'pointer',
|
cursor: "pointer",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
{...register('right_color', {
|
{...register("right_color", {
|
||||||
// required: 'Это поле является обязательным',
|
// required: 'Это поле является обязательным',
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.right_color}
|
error={!!(errors as any)?.right_color}
|
||||||
@ -143,20 +163,20 @@ export const CarrierEdit = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="color"
|
type="color"
|
||||||
label={'Цвет правого виджета'}
|
label={"Цвет правого виджета"}
|
||||||
name="right_color"
|
name="right_color"
|
||||||
sx={{
|
sx={{
|
||||||
'& input': {
|
"& input": {
|
||||||
height: '50px',
|
height: "50px",
|
||||||
paddingBlock: '14px',
|
paddingBlock: "14px",
|
||||||
paddingInline: '14px',
|
paddingInline: "14px",
|
||||||
cursor: 'pointer',
|
cursor: "pointer",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('slogan', {
|
{...register("slogan", {
|
||||||
// required: 'Это поле является обязательным',
|
// required: 'Это поле является обязательным',
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.slogan}
|
error={!!(errors as any)?.slogan}
|
||||||
@ -165,7 +185,7 @@ export const CarrierEdit = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="text"
|
type="text"
|
||||||
label={'Слоган'}
|
label={"Слоган"}
|
||||||
name="slogan"
|
name="slogan"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -177,24 +197,41 @@ export const CarrierEdit = () => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
{...mediaAutocompleteProps}
|
{...mediaAutocompleteProps}
|
||||||
value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
value={
|
||||||
|
mediaAutocompleteProps.options.find(
|
||||||
|
(option) => option.id === field.value
|
||||||
|
) || null
|
||||||
|
}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
field.onChange(value?.id || '')
|
field.onChange(value?.id || "");
|
||||||
}}
|
}}
|
||||||
getOptionLabel={(item) => {
|
getOptionLabel={(item) => {
|
||||||
return item ? item.media_name : ''
|
return item ? item.media_name : "";
|
||||||
}}
|
}}
|
||||||
isOptionEqualToValue={(option, value) => {
|
isOptionEqualToValue={(option, value) => {
|
||||||
return option.id === value?.id
|
return option.id === value?.id;
|
||||||
}}
|
}}
|
||||||
filterOptions={(options, { inputValue }) => {
|
filterOptions={(options, { inputValue }) => {
|
||||||
return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase()))
|
return options.filter((option) =>
|
||||||
|
option.media_name
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(inputValue.toLowerCase())
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
renderInput={(params) => <TextField {...params} label="Выберите логотип" margin="normal" variant="outlined" error={!!errors.logo} helperText={(errors as any)?.logo?.message} />}
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Выберите логотип"
|
||||||
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
error={!!errors.logo}
|
||||||
|
helperText={(errors as any)?.logo?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Edit>
|
</Edit>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
@ -1,90 +1,157 @@
|
|||||||
import {type GridColDef} from '@mui/x-data-grid'
|
import { type GridColDef } from "@mui/x-data-grid";
|
||||||
import {CustomDataGrid} from '../../components/CustomDataGrid'
|
import { CustomDataGrid } from "../../components/CustomDataGrid";
|
||||||
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
|
import {
|
||||||
import React from 'react'
|
DeleteButton,
|
||||||
|
EditButton,
|
||||||
|
List,
|
||||||
|
ShowButton,
|
||||||
|
useDataGrid,
|
||||||
|
} from "@refinedev/mui";
|
||||||
|
import React from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { cityStore } from "../../store/CityStore";
|
||||||
|
import { languageStore } from "../../store/LanguageStore";
|
||||||
|
|
||||||
export const CarrierList = () => {
|
export const CarrierList = observer(() => {
|
||||||
const {dataGridProps} = useDataGrid({})
|
const { city_id } = cityStore;
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
|
const { dataGridProps } = useDataGrid({
|
||||||
|
resource: "carrier",
|
||||||
|
meta: {
|
||||||
|
headers: {
|
||||||
|
"Accept-Language": language,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
permanent: [
|
||||||
|
{
|
||||||
|
field: "cityID",
|
||||||
|
operator: "eq",
|
||||||
|
value: city_id === "0" ? null : city_id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const columns = React.useMemo<GridColDef[]>(
|
const columns = React.useMemo<GridColDef[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
field: 'id',
|
field: "id",
|
||||||
headerName: 'ID',
|
headerName: "ID",
|
||||||
type: 'number',
|
type: "number",
|
||||||
minWidth: 50,
|
minWidth: 50,
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'city_id',
|
field: "city_id",
|
||||||
headerName: 'ID Города',
|
headerName: "ID Города",
|
||||||
type: 'number',
|
type: "number",
|
||||||
minWidth: 100,
|
minWidth: 100,
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'full_name',
|
field: "full_name",
|
||||||
headerName: 'Полное имя',
|
headerName: "Полное имя",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 200,
|
minWidth: 200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'short_name',
|
field: "short_name",
|
||||||
headerName: 'Короткое имя',
|
headerName: "Короткое имя",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 125,
|
minWidth: 125,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'city',
|
field: "city",
|
||||||
headerName: 'Город',
|
headerName: "Город",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 125,
|
minWidth: 125,
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'main_color',
|
field: "main_color",
|
||||||
headerName: 'Основной цвет',
|
headerName: "Основной цвет",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
renderCell: ({value}) => <div style={{display: 'grid', placeItems: 'center', width: '100%', height: '100%', backgroundColor: `${value}10`, borderRadius: 10}}>{value}</div>,
|
renderCell: ({ value }) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: `${value}10`,
|
||||||
|
borderRadius: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'left_color',
|
field: "left_color",
|
||||||
headerName: 'Цвет левого виджета',
|
headerName: "Цвет левого виджета",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
renderCell: ({value}) => <div style={{display: 'grid', placeItems: 'center', width: '100%', height: '100%', backgroundColor: `${value}10`, borderRadius: 10}}>{value}</div>,
|
renderCell: ({ value }) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: `${value}10`,
|
||||||
|
borderRadius: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'right_color',
|
field: "right_color",
|
||||||
headerName: 'Цвет правого виджета',
|
headerName: "Цвет правого виджета",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
renderCell: ({value}) => <div style={{display: 'grid', placeItems: 'center', width: '100%', height: '100%', backgroundColor: `${value}10`, borderRadius: 10}}>{value}</div>,
|
renderCell: ({ value }) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: `${value}10`,
|
||||||
|
borderRadius: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'logo',
|
field: "logo",
|
||||||
headerName: 'Лого',
|
headerName: "Лого",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'slogan',
|
field: "slogan",
|
||||||
headerName: 'Слоган',
|
headerName: "Слоган",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'actions',
|
field: "actions",
|
||||||
headerName: 'Действия',
|
headerName: "Действия",
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'right',
|
align: "right",
|
||||||
headerAlign: 'center',
|
headerAlign: "center",
|
||||||
sortable: false,
|
sortable: false,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
disableColumnMenu: true,
|
disableColumnMenu: true,
|
||||||
@ -93,18 +160,22 @@ export const CarrierList = () => {
|
|||||||
<>
|
<>
|
||||||
<EditButton hideText recordItemId={row.id} />
|
<EditButton hideText recordItemId={row.id} />
|
||||||
<ShowButton hideText recordItemId={row.id} />
|
<ShowButton hideText recordItemId={row.id} />
|
||||||
<DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} />
|
<DeleteButton
|
||||||
|
hideText
|
||||||
|
confirmTitle="Вы уверены?"
|
||||||
|
recordItemId={row.id}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[]
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List>
|
<List>
|
||||||
<CustomDataGrid {...dataGridProps} columns={columns} />
|
<CustomDataGrid {...dataGridProps} languageEnabled columns={columns} />
|
||||||
</List>
|
</List>
|
||||||
)
|
);
|
||||||
}
|
});
|
||||||
|
@ -1,46 +1,96 @@
|
|||||||
import {Box, Stack, Typography} from '@mui/material'
|
import { Box, Stack, Typography } from "@mui/material";
|
||||||
import {useShow} from '@refinedev/core'
|
import { useShow } from "@refinedev/core";
|
||||||
import {Show, TextFieldComponent as TextField} from '@refinedev/mui'
|
import { Show, TextFieldComponent as TextField } from "@refinedev/mui";
|
||||||
import {TOKEN_KEY} from '../../authProvider'
|
import { TOKEN_KEY } from "../../authProvider";
|
||||||
|
|
||||||
export type FieldType = {
|
export type FieldType = {
|
||||||
label: string
|
label: string;
|
||||||
data: any
|
data: any;
|
||||||
render?: (value: any) => React.ReactNode
|
render?: (value: any) => React.ReactNode;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const CarrierShow = () => {
|
export const CarrierShow = () => {
|
||||||
const {query} = useShow({})
|
const { query } = useShow({});
|
||||||
const {data, isLoading} = query
|
const { data, isLoading } = query;
|
||||||
|
|
||||||
const record = data?.data
|
const record = data?.data;
|
||||||
|
|
||||||
const fields: FieldType[] = [
|
const fields: FieldType[] = [
|
||||||
{label: 'Полное имя', data: 'full_name'},
|
{ label: "Полное имя", data: "full_name" },
|
||||||
{label: 'Короткое имя', data: 'short_name'},
|
{ label: "Короткое имя", data: "short_name" },
|
||||||
{label: 'Город', data: 'city'},
|
{ label: "Город", data: "city" },
|
||||||
{
|
{
|
||||||
label: 'Основной цвет',
|
label: "Основной цвет",
|
||||||
data: 'main_color',
|
data: "main_color",
|
||||||
render: (value: string) => <Box sx={{display: 'grid', placeItems: 'center', width: 'fit-content', paddingInline: '6px', height: '100%', backgroundColor: `${value}20`, borderRadius: 1}}>{value}</Box>,
|
render: (value: string) => (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
width: "fit-content",
|
||||||
|
paddingInline: "6px",
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: `${value}20`,
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Цвет левого виджета',
|
label: "Цвет левого виджета",
|
||||||
data: 'left_color',
|
data: "left_color",
|
||||||
render: (value: string) => <Box sx={{display: 'grid', placeItems: 'center', width: 'fit-content', paddingInline: '6px', height: '100%', backgroundColor: `${value}20`, borderRadius: 1}}>{value}</Box>,
|
render: (value: string) => (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
width: "fit-content",
|
||||||
|
paddingInline: "6px",
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: `${value}20`,
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Цвет правого виджета',
|
label: "Цвет правого виджета",
|
||||||
data: 'right_color',
|
data: "right_color",
|
||||||
render: (value: string) => <Box sx={{display: 'grid', placeItems: 'center', width: 'fit-content', paddingInline: '6px', height: '100%', backgroundColor: `${value}20`, borderRadius: 1}}>{value}</Box>,
|
render: (value: string) => (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
width: "fit-content",
|
||||||
|
paddingInline: "6px",
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: `${value}20`,
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{label: 'Слоган', data: 'slogan'},
|
{ label: "Слоган", data: "slogan" },
|
||||||
{
|
{
|
||||||
label: 'Логотип',
|
label: "Логотип",
|
||||||
data: 'logo',
|
data: "logo",
|
||||||
render: (value: number) => <img src={`https://wn.krbl.ru/media/${value}/download?token=${localStorage.getItem(TOKEN_KEY)}`} alt={String(value)} style={{maxWidth: '10%', objectFit: 'contain', borderRadius: 8}} />,
|
render: (value: number) => (
|
||||||
|
<img
|
||||||
|
src={`${
|
||||||
|
import.meta.env.VITE_KRBL_MEDIA
|
||||||
|
}${value}/download?token=${localStorage.getItem(TOKEN_KEY)}`}
|
||||||
|
alt={String(value)}
|
||||||
|
style={{ maxWidth: "10%", objectFit: "contain", borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show isLoading={isLoading}>
|
<Show isLoading={isLoading}>
|
||||||
@ -51,10 +101,14 @@ export const CarrierShow = () => {
|
|||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{render ? render(record?.[data]) : <TextField value={record?.[data]} />}
|
{render ? (
|
||||||
|
render(record?.[data])
|
||||||
|
) : (
|
||||||
|
<TextField value={record?.[data]} />
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
@ -1,58 +1,76 @@
|
|||||||
import {type GridColDef} from '@mui/x-data-grid'
|
import { type GridColDef } from "@mui/x-data-grid";
|
||||||
import {CustomDataGrid} from '../../components/CustomDataGrid'
|
import { CustomDataGrid } from "../../components/CustomDataGrid";
|
||||||
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
|
import {
|
||||||
import React from 'react'
|
DeleteButton,
|
||||||
|
EditButton,
|
||||||
|
List,
|
||||||
|
ShowButton,
|
||||||
|
useDataGrid,
|
||||||
|
} from "@refinedev/mui";
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
|
||||||
export const CityList = () => {
|
import { observer } from "mobx-react-lite";
|
||||||
const {dataGridProps} = useDataGrid({})
|
import { languageStore } from "../../store/LanguageStore";
|
||||||
|
|
||||||
|
export const CityList = observer(() => {
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
|
const { dataGridProps } = useDataGrid({
|
||||||
|
resource: "city",
|
||||||
|
meta: {
|
||||||
|
headers: {
|
||||||
|
"Accept-Language": language,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const columns = React.useMemo<GridColDef[]>(
|
const columns = React.useMemo<GridColDef[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
field: 'id',
|
field: "id",
|
||||||
headerName: 'ID',
|
headerName: "ID",
|
||||||
type: 'number',
|
type: "number",
|
||||||
minWidth: 50,
|
minWidth: 50,
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'country_code',
|
field: "country_code",
|
||||||
headerName: 'Код страны',
|
headerName: "Код страны",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'country',
|
field: "country",
|
||||||
headerName: 'Cтрана',
|
headerName: "Cтрана",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'name',
|
field: "name",
|
||||||
headerName: 'Название',
|
headerName: "Название",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'arms',
|
field: "arms",
|
||||||
headerName: 'Герб',
|
headerName: "Герб",
|
||||||
type: 'string',
|
type: "string",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'actions',
|
field: "actions",
|
||||||
headerName: 'Действия',
|
headerName: "Действия",
|
||||||
cellClassName: 'city-actions',
|
cellClassName: "city-actions",
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'right',
|
align: "right",
|
||||||
headerAlign: 'center',
|
headerAlign: "center",
|
||||||
sortable: false,
|
sortable: false,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
disableColumnMenu: true,
|
disableColumnMenu: true,
|
||||||
@ -61,18 +79,22 @@ export const CityList = () => {
|
|||||||
<>
|
<>
|
||||||
<EditButton hideText recordItemId={row.id} />
|
<EditButton hideText recordItemId={row.id} />
|
||||||
<ShowButton hideText recordItemId={row.id} />
|
<ShowButton hideText recordItemId={row.id} />
|
||||||
<DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} />
|
<DeleteButton
|
||||||
|
hideText
|
||||||
|
confirmTitle="Вы уверены?"
|
||||||
|
recordItemId={row.id}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[]
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List>
|
<List>
|
||||||
<CustomDataGrid {...dataGridProps} columns={columns} />
|
<CustomDataGrid {...dataGridProps} columns={columns} languageEnabled />
|
||||||
</List>
|
</List>
|
||||||
)
|
);
|
||||||
}
|
});
|
||||||
|
@ -1,21 +1,33 @@
|
|||||||
import {Stack, Typography} from '@mui/material'
|
import { Stack, Typography } from "@mui/material";
|
||||||
import {useShow} from '@refinedev/core'
|
import { useShow } from "@refinedev/core";
|
||||||
import {Show, TextFieldComponent as TextField} from '@refinedev/mui'
|
import { Show, TextFieldComponent as TextField } from "@refinedev/mui";
|
||||||
import {TOKEN_KEY} from '../../authProvider'
|
import { TOKEN_KEY } from "../../authProvider";
|
||||||
|
|
||||||
export const CityShow = () => {
|
export const CityShow = () => {
|
||||||
const {query} = useShow({})
|
const { query } = useShow({});
|
||||||
const {data, isLoading} = query
|
const { data, isLoading } = query;
|
||||||
|
|
||||||
const record = data?.data
|
const record = data?.data;
|
||||||
|
|
||||||
const fields = [
|
const fields = [
|
||||||
// {label: 'ID', data: 'id'},
|
// {label: 'ID', data: 'id'},
|
||||||
{label: 'Название', data: 'name'},
|
{ label: "Название", data: "name" },
|
||||||
// {label: 'Код страны', data: 'country_code'},
|
// {label: 'Код страны', data: 'country_code'},
|
||||||
{label: 'Страна', data: 'country'},
|
{ label: "Страна", data: "country" },
|
||||||
{label: 'Герб', data: 'arms', render: (value: number) => <img src={`https://wn.krbl.ru/media/${value}/download?token=${localStorage.getItem(TOKEN_KEY)}`} alt={String(value)} style={{maxWidth: '10%', objectFit: 'contain', borderRadius: 8}} />},
|
{
|
||||||
]
|
label: "Герб",
|
||||||
|
data: "arms",
|
||||||
|
render: (value: number) => (
|
||||||
|
<img
|
||||||
|
src={`${
|
||||||
|
import.meta.env.VITE_KRBL_MEDIA
|
||||||
|
}${value}/download?token=${localStorage.getItem(TOKEN_KEY)}`}
|
||||||
|
alt={String(value)}
|
||||||
|
style={{ maxWidth: "10%", objectFit: "contain", borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show isLoading={isLoading}>
|
<Show isLoading={isLoading}>
|
||||||
@ -26,10 +38,14 @@ export const CityShow = () => {
|
|||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{render ? render(record?.[data]) : <TextField value={record?.[data]} />}
|
{render ? (
|
||||||
|
render(record?.[data])
|
||||||
|
) : (
|
||||||
|
<TextField value={record?.[data]} />
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
@ -1,20 +1,24 @@
|
|||||||
import {Box, TextField} from '@mui/material'
|
import { Box, TextField } from "@mui/material";
|
||||||
import {Edit} from '@refinedev/mui'
|
import { Edit } from "@refinedev/mui";
|
||||||
import {useForm} from '@refinedev/react-hook-form'
|
import { useForm } from "@refinedev/react-hook-form";
|
||||||
|
|
||||||
export const CountryEdit = () => {
|
export const CountryEdit = () => {
|
||||||
const {
|
const {
|
||||||
saveButtonProps,
|
saveButtonProps,
|
||||||
register,
|
register,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm({})
|
} = useForm({});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Edit saveButtonProps={saveButtonProps}>
|
<Edit saveButtonProps={saveButtonProps}>
|
||||||
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
<Box
|
||||||
|
component="form"
|
||||||
|
sx={{ display: "flex", flexDirection: "column" }}
|
||||||
|
autoComplete="off"
|
||||||
|
>
|
||||||
<TextField
|
<TextField
|
||||||
{...register('code', {
|
{...register("code", {
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.code}
|
error={!!(errors as any)?.code}
|
||||||
helperText={(errors as any)?.code?.message}
|
helperText={(errors as any)?.code?.message}
|
||||||
@ -22,12 +26,12 @@ export const CountryEdit = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="text"
|
type="text"
|
||||||
label={'Код *'}
|
label={"Код *"}
|
||||||
name="code"
|
name="code"
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
{...register('name', {
|
{...register("name", {
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.name}
|
error={!!(errors as any)?.name}
|
||||||
helperText={(errors as any)?.name?.message}
|
helperText={(errors as any)?.name?.message}
|
||||||
@ -35,10 +39,10 @@ export const CountryEdit = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="text"
|
type="text"
|
||||||
label={'Название *'}
|
label={"Название *"}
|
||||||
name="name"
|
name="name"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Edit>
|
</Edit>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
@ -1,36 +1,53 @@
|
|||||||
import {type GridColDef} from '@mui/x-data-grid'
|
import { type GridColDef } from "@mui/x-data-grid";
|
||||||
import {CustomDataGrid} from '../../components/CustomDataGrid'
|
import { CustomDataGrid } from "../../components/CustomDataGrid";
|
||||||
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
|
import {
|
||||||
import React from 'react'
|
DeleteButton,
|
||||||
|
EditButton,
|
||||||
|
List,
|
||||||
|
ShowButton,
|
||||||
|
useDataGrid,
|
||||||
|
} from "@refinedev/mui";
|
||||||
|
import React from "react";
|
||||||
|
import { languageStore } from "../../store/LanguageStore";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
export const CountryList = () => {
|
export const CountryList = observer(() => {
|
||||||
const {dataGridProps} = useDataGrid({})
|
const { language } = languageStore;
|
||||||
|
|
||||||
|
const { dataGridProps } = useDataGrid({
|
||||||
|
resource: "country",
|
||||||
|
meta: {
|
||||||
|
headers: {
|
||||||
|
"Accept-Language": language,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const columns = React.useMemo<GridColDef[]>(
|
const columns = React.useMemo<GridColDef[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
field: 'code',
|
field: "code",
|
||||||
headerName: 'Код',
|
headerName: "Код",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'name',
|
field: "name",
|
||||||
headerName: 'Название',
|
headerName: "Название",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'actions',
|
field: "actions",
|
||||||
headerName: 'Действия',
|
headerName: "Действия",
|
||||||
cellClassName: 'country-actions',
|
cellClassName: "country-actions",
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'right',
|
align: "right",
|
||||||
headerAlign: 'center',
|
headerAlign: "center",
|
||||||
sortable: false,
|
sortable: false,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
disableColumnMenu: true,
|
disableColumnMenu: true,
|
||||||
@ -39,18 +56,27 @@ export const CountryList = () => {
|
|||||||
<>
|
<>
|
||||||
<EditButton hideText recordItemId={row.code} />
|
<EditButton hideText recordItemId={row.code} />
|
||||||
<ShowButton hideText recordItemId={row.code} />
|
<ShowButton hideText recordItemId={row.code} />
|
||||||
<DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.code} />
|
<DeleteButton
|
||||||
|
hideText
|
||||||
|
confirmTitle="Вы уверены?"
|
||||||
|
recordItemId={row.code}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[]
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List>
|
<List>
|
||||||
<CustomDataGrid {...dataGridProps} columns={columns} getRowId={(row: any) => row.code} />
|
<CustomDataGrid
|
||||||
|
{...dataGridProps}
|
||||||
|
languageEnabled
|
||||||
|
columns={columns}
|
||||||
|
getRowId={(row: any) => row.code}
|
||||||
|
/>
|
||||||
</List>
|
</List>
|
||||||
)
|
);
|
||||||
}
|
});
|
||||||
|
22
src/pages/media/ModelViewer/index.tsx
Normal file
22
src/pages/media/ModelViewer/index.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { Canvas } from "@react-three/fiber";
|
||||||
|
import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
|
||||||
|
|
||||||
|
type ModelViewerProps = {
|
||||||
|
fileUrl: string;
|
||||||
|
height?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ModelViewer = ({ fileUrl, height = "80vh" }: ModelViewerProps) => {
|
||||||
|
const { scene } = useGLTF(fileUrl);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Canvas style={{ width: "100%", height: height }}>
|
||||||
|
<ambientLight />
|
||||||
|
<directionalLight />
|
||||||
|
<Stage environment="city" intensity={0.6}>
|
||||||
|
<primitive object={scene} />
|
||||||
|
</Stage>
|
||||||
|
<OrbitControls />
|
||||||
|
</Canvas>
|
||||||
|
);
|
||||||
|
};
|
@ -1,16 +1,32 @@
|
|||||||
import {Box, TextField, Button, Typography, Autocomplete} from '@mui/material'
|
import {
|
||||||
import {Create} from '@refinedev/mui'
|
Box,
|
||||||
import {useForm} from '@refinedev/react-hook-form'
|
TextField,
|
||||||
import {Controller} from 'react-hook-form'
|
Button,
|
||||||
|
Typography,
|
||||||
|
Autocomplete,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { Create } from "@refinedev/mui";
|
||||||
|
import { useForm } from "@refinedev/react-hook-form";
|
||||||
|
import { Controller } from "react-hook-form";
|
||||||
|
|
||||||
import {MEDIA_TYPES} from '../../lib/constants'
|
import { MEDIA_TYPES } from "../../lib/constants";
|
||||||
import {ALLOWED_IMAGE_TYPES, ALLOWED_VIDEO_TYPES, useMediaFileUpload} from '../../components/media/MediaFormUtils'
|
import {
|
||||||
|
ALLOWED_IMAGE_TYPES,
|
||||||
|
ALLOWED_ICON_TYPES,
|
||||||
|
ALLOWED_PANORAMA_TYPES,
|
||||||
|
ALLOWED_VIDEO_TYPES,
|
||||||
|
ALLOWED_WATERMARK_TYPES,
|
||||||
|
ALLOWED_3D_MODEL_TYPES,
|
||||||
|
useMediaFileUpload,
|
||||||
|
} from "../../components/media/MediaFormUtils";
|
||||||
|
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
|
||||||
|
import { ModelViewer } from "./ModelViewer/index";
|
||||||
|
|
||||||
type MediaFormValues = {
|
type MediaFormValues = {
|
||||||
media_name: string
|
media_name: string;
|
||||||
media_type: number
|
media_type: number;
|
||||||
file?: File
|
file?: File;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const MediaCreate = () => {
|
export const MediaCreate = () => {
|
||||||
const {
|
const {
|
||||||
@ -24,16 +40,19 @@ export const MediaCreate = () => {
|
|||||||
watch,
|
watch,
|
||||||
setError,
|
setError,
|
||||||
clearErrors,
|
clearErrors,
|
||||||
} = useForm<MediaFormValues>({})
|
getValues,
|
||||||
|
} = useForm<MediaFormValues>({});
|
||||||
|
|
||||||
const selectedMediaType = watch('media_type')
|
const selectedMediaType = watch("media_type");
|
||||||
|
const file = getValues("file");
|
||||||
|
|
||||||
const {selectedFile, previewUrl, handleFileChange, handleMediaTypeChange} = useMediaFileUpload({
|
const { selectedFile, previewUrl, handleFileChange, handleMediaTypeChange } =
|
||||||
|
useMediaFileUpload({
|
||||||
selectedMediaType,
|
selectedMediaType,
|
||||||
setError,
|
setError,
|
||||||
clearErrors,
|
clearErrors,
|
||||||
setValue,
|
setValue,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Create
|
<Create
|
||||||
@ -42,19 +61,20 @@ export const MediaCreate = () => {
|
|||||||
...saveButtonProps,
|
...saveButtonProps,
|
||||||
disabled: !!errors.file || !selectedFile,
|
disabled: !!errors.file || !selectedFile,
|
||||||
onClick: handleSubmit((data) => {
|
onClick: handleSubmit((data) => {
|
||||||
|
console.log(data);
|
||||||
if (data.file) {
|
if (data.file) {
|
||||||
const formData = new FormData()
|
const formData = new FormData();
|
||||||
formData.append('media_name', data.media_name)
|
formData.append("media_name", data.media_name);
|
||||||
formData.append('filename', data.file.name)
|
formData.append("filename", data.file.name);
|
||||||
formData.append('type', String(data.media_type))
|
formData.append("type", String(data.media_type));
|
||||||
formData.append('file', data.file)
|
formData.append("file", data.file);
|
||||||
|
|
||||||
console.log('Отправляемые данные:')
|
console.log("Отправляемые данные:");
|
||||||
for (const pair of formData.entries()) {
|
for (const pair of formData.entries()) {
|
||||||
console.log(pair[0] + ': ' + pair[1])
|
console.log(pair[0] + ": " + pair[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
onFinish(formData)
|
onFinish(formData);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
@ -63,30 +83,42 @@ export const MediaCreate = () => {
|
|||||||
control={control}
|
control={control}
|
||||||
name="media_type"
|
name="media_type"
|
||||||
rules={{
|
rules={{
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
}}
|
}}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
options={MEDIA_TYPES}
|
options={MEDIA_TYPES}
|
||||||
value={MEDIA_TYPES.find((option) => option.value === field.value) || null}
|
value={
|
||||||
|
MEDIA_TYPES.find((option) => option.value === field.value) || null
|
||||||
|
}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
field.onChange(value?.value || null)
|
field.onChange(value?.value || null);
|
||||||
handleMediaTypeChange(value?.value || null)
|
handleMediaTypeChange(value?.value || null);
|
||||||
}}
|
}}
|
||||||
getOptionLabel={(item) => {
|
getOptionLabel={(item) => {
|
||||||
return item ? item.label : ''
|
return item ? item.label : "";
|
||||||
}}
|
}}
|
||||||
isOptionEqualToValue={(option, value) => {
|
isOptionEqualToValue={(option, value) => {
|
||||||
return option.value === value?.value
|
return option.value === value?.value;
|
||||||
}}
|
}}
|
||||||
renderInput={(params) => <TextField {...params} label="Тип" margin="normal" variant="outlined" error={!!errors.media_type} helperText={(errors as any)?.media_type?.message} required />}
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Тип"
|
||||||
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
error={!!errors.media_type}
|
||||||
|
helperText={(errors as any)?.media_type?.message}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('media_name', {
|
{...register("media_name", {
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.media_name}
|
error={!!(errors as any)?.media_name}
|
||||||
helperText={(errors as any)?.media_name?.message}
|
helperText={(errors as any)?.media_name?.message}
|
||||||
@ -98,12 +130,50 @@ export const MediaCreate = () => {
|
|||||||
name="media_name"
|
name="media_name"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off" style={{marginTop: 10}}>
|
<Box
|
||||||
<Box display="flex" flexDirection="column-reverse" alignItems="center" gap={6}>
|
component="form"
|
||||||
<Box display="flex" flexDirection="column" alignItems="center" gap={2}>
|
sx={{ display: "flex", flexDirection: "column" }}
|
||||||
<Button variant="contained" component="label" disabled={!selectedMediaType}>
|
autoComplete="off"
|
||||||
{selectedFile ? 'Изменить файл' : 'Загрузить файл'}
|
style={{ marginTop: 10 }}
|
||||||
<input type="file" hidden onChange={handleFileChange} accept={selectedMediaType === 1 ? ALLOWED_IMAGE_TYPES.join(',') : ALLOWED_VIDEO_TYPES.join(',')} />
|
>
|
||||||
|
<Box
|
||||||
|
display="flex"
|
||||||
|
flexDirection="column-reverse"
|
||||||
|
alignItems="center"
|
||||||
|
gap={6}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
display="flex"
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
gap={2}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
component="label"
|
||||||
|
disabled={!selectedMediaType}
|
||||||
|
>
|
||||||
|
{selectedFile ? "Изменить файл" : "Загрузить файл"}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
hidden
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept={
|
||||||
|
selectedMediaType === 6
|
||||||
|
? ALLOWED_3D_MODEL_TYPES.join(",")
|
||||||
|
: selectedMediaType === 1
|
||||||
|
? ALLOWED_IMAGE_TYPES.join(",")
|
||||||
|
: selectedMediaType === 2
|
||||||
|
? ALLOWED_VIDEO_TYPES.join(",")
|
||||||
|
: selectedMediaType === 3
|
||||||
|
? ALLOWED_ICON_TYPES.join(",")
|
||||||
|
: selectedMediaType === 4
|
||||||
|
? ALLOWED_WATERMARK_TYPES.join(",")
|
||||||
|
: selectedMediaType === 5
|
||||||
|
? ALLOWED_PANORAMA_TYPES.join(",")
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{selectedFile && (
|
{selectedFile && (
|
||||||
@ -121,11 +191,53 @@ export const MediaCreate = () => {
|
|||||||
|
|
||||||
{previewUrl && selectedMediaType === 1 && (
|
{previewUrl && selectedMediaType === 1 && (
|
||||||
<Box mt={2} display="flex" justifyContent="center">
|
<Box mt={2} display="flex" justifyContent="center">
|
||||||
<img src={previewUrl} alt="Preview" style={{maxWidth: '200px', borderRadius: 8}} />
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Preview"
|
||||||
|
style={{ maxWidth: "200px", borderRadius: 8 }}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{file && selectedMediaType === 2 && (
|
||||||
|
<Box mt={2} display="flex" justifyContent="center">
|
||||||
|
<video src={URL.createObjectURL(file)} autoPlay controls />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{previewUrl && selectedMediaType === 3 && (
|
||||||
|
<Box mt={2} display="flex" justifyContent="center">
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Preview"
|
||||||
|
style={{ maxWidth: "200px", borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{previewUrl && selectedMediaType === 4 && (
|
||||||
|
<Box mt={2} display="flex" justifyContent="center">
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Preview"
|
||||||
|
style={{ maxWidth: "200px", borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{file && selectedMediaType === 5 && (
|
||||||
|
<ReactPhotoSphereViewer
|
||||||
|
src={URL.createObjectURL(file)}
|
||||||
|
width={"100%"}
|
||||||
|
height={"80vh"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{file && previewUrl && selectedMediaType === 6 && (
|
||||||
|
<ModelViewer fileUrl={URL.createObjectURL(file)} />
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Create>
|
</Create>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
@ -1,19 +1,33 @@
|
|||||||
import {Box, TextField, Button, Typography, Autocomplete} from '@mui/material'
|
import {
|
||||||
import {Edit} from '@refinedev/mui'
|
Box,
|
||||||
import {useForm} from '@refinedev/react-hook-form'
|
TextField,
|
||||||
import {useEffect} from 'react'
|
Button,
|
||||||
import {useShow} from '@refinedev/core'
|
Typography,
|
||||||
import {Controller} from 'react-hook-form'
|
Autocomplete,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { Edit } from "@refinedev/mui";
|
||||||
|
import { useForm } from "@refinedev/react-hook-form";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useShow } from "@refinedev/core";
|
||||||
|
import { Controller } from "react-hook-form";
|
||||||
|
|
||||||
import {MEDIA_TYPES} from '../../lib/constants'
|
import { TOKEN_KEY } from "../../authProvider";
|
||||||
import {ALLOWED_IMAGE_TYPES, ALLOWED_VIDEO_TYPES, useMediaFileUpload} from '../../components/media/MediaFormUtils'
|
import { MEDIA_TYPES } from "../../lib/constants";
|
||||||
import {TOKEN_KEY} from '../../authProvider'
|
import {
|
||||||
|
ALLOWED_IMAGE_TYPES,
|
||||||
|
ALLOWED_VIDEO_TYPES,
|
||||||
|
ALLOWED_ICON_TYPES,
|
||||||
|
ALLOWED_WATERMARK_TYPES,
|
||||||
|
ALLOWED_PANORAMA_TYPES,
|
||||||
|
ALLOWED_3D_MODEL_TYPES,
|
||||||
|
useMediaFileUpload,
|
||||||
|
} from "../../components/media/MediaFormUtils";
|
||||||
|
|
||||||
type MediaFormValues = {
|
type MediaFormValues = {
|
||||||
media_name: string
|
media_name: string;
|
||||||
media_type: number
|
media_type: number;
|
||||||
file?: File
|
file?: File;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const MediaEdit = () => {
|
export const MediaEdit = () => {
|
||||||
const {
|
const {
|
||||||
@ -29,32 +43,42 @@ export const MediaEdit = () => {
|
|||||||
control,
|
control,
|
||||||
} = useForm<MediaFormValues>({
|
} = useForm<MediaFormValues>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
media_name: '',
|
media_name: "",
|
||||||
media_type: '',
|
media_type: "",
|
||||||
file: undefined,
|
file: undefined,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const {query} = useShow()
|
const { query } = useShow();
|
||||||
const {data} = query
|
const { data } = query;
|
||||||
const record = data?.data
|
const record = data?.data;
|
||||||
|
|
||||||
const selectedMediaType = watch('media_type')
|
const selectedMediaType = watch("media_type");
|
||||||
|
|
||||||
const {selectedFile, previewUrl, setPreviewUrl, handleFileChange, handleMediaTypeChange} = useMediaFileUpload({
|
const {
|
||||||
|
selectedFile,
|
||||||
|
previewUrl,
|
||||||
|
setPreviewUrl,
|
||||||
|
handleFileChange,
|
||||||
|
handleMediaTypeChange,
|
||||||
|
} = useMediaFileUpload({
|
||||||
selectedMediaType,
|
selectedMediaType,
|
||||||
setError,
|
setError,
|
||||||
clearErrors,
|
clearErrors,
|
||||||
setValue,
|
setValue,
|
||||||
})
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (record?.id) {
|
if (record?.id) {
|
||||||
setPreviewUrl(`https://wn.krbl.ru/media/${record.id}/download?token=${localStorage.getItem(TOKEN_KEY)}`)
|
setPreviewUrl(
|
||||||
setValue('media_name', record?.media_name || '')
|
`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
setValue('media_type', record?.media_type)
|
record.id
|
||||||
|
}/download?token=${localStorage.getItem(TOKEN_KEY)}`
|
||||||
|
);
|
||||||
|
setValue("media_name", record?.media_name || "");
|
||||||
|
setValue("media_type", record?.media_type);
|
||||||
}
|
}
|
||||||
}, [record, setValue, setPreviewUrl])
|
}, [record, setValue, setPreviewUrl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Edit
|
<Edit
|
||||||
@ -66,41 +90,58 @@ export const MediaEdit = () => {
|
|||||||
media_name: data.media_name,
|
media_name: data.media_name,
|
||||||
filename: selectedFile?.name || record?.filename,
|
filename: selectedFile?.name || record?.filename,
|
||||||
type: Number(data.media_type),
|
type: Number(data.media_type),
|
||||||
}
|
};
|
||||||
onFinish(formData)
|
onFinish(formData);
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
<Box
|
||||||
|
component="form"
|
||||||
|
sx={{ display: "flex", flexDirection: "column" }}
|
||||||
|
autoComplete="off"
|
||||||
|
>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="media_type"
|
name="media_type"
|
||||||
rules={{
|
rules={{
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
}}
|
}}
|
||||||
defaultValue={null}
|
defaultValue={null}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
options={MEDIA_TYPES}
|
options={MEDIA_TYPES}
|
||||||
value={MEDIA_TYPES.find((option) => option.value === field.value) || null}
|
value={
|
||||||
|
MEDIA_TYPES.find((option) => option.value === field.value) ||
|
||||||
|
null
|
||||||
|
}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
field.onChange(value?.value || null)
|
field.onChange(value?.value || null);
|
||||||
handleMediaTypeChange(value?.value || null)
|
handleMediaTypeChange(value?.value || null);
|
||||||
}}
|
}}
|
||||||
getOptionLabel={(item) => {
|
getOptionLabel={(item) => {
|
||||||
return item ? item.label : ''
|
return item ? item.label : "";
|
||||||
}}
|
}}
|
||||||
isOptionEqualToValue={(option, value) => {
|
isOptionEqualToValue={(option, value) => {
|
||||||
return option.value === value?.value
|
return option.value === value?.value;
|
||||||
}}
|
}}
|
||||||
renderInput={(params) => <TextField {...params} label="Тип" margin="normal" variant="outlined" error={!!errors.media_type} helperText={(errors as any)?.media_type?.message} required />}
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Тип"
|
||||||
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
error={!!errors.media_type}
|
||||||
|
helperText={(errors as any)?.media_type?.message}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('media_name', {
|
{...register("media_name", {
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.media_name}
|
error={!!(errors as any)?.media_name}
|
||||||
helperText={(errors as any)?.media_name?.message}
|
helperText={(errors as any)?.media_name?.message}
|
||||||
@ -112,11 +153,45 @@ export const MediaEdit = () => {
|
|||||||
name="media_name"
|
name="media_name"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Box display="flex" flexDirection="column-reverse" alignItems="center" gap={4} style={{marginTop: 10}}>
|
<Box
|
||||||
<Box display="flex" flexDirection="column" alignItems="center" gap={2}>
|
display="flex"
|
||||||
<Button variant="contained" component="label" disabled={!selectedMediaType}>
|
flexDirection="column-reverse"
|
||||||
{selectedFile ? 'Изменить файл' : 'Загрузить файл'}
|
alignItems="center"
|
||||||
<input type="file" hidden onChange={handleFileChange} accept={selectedMediaType === 1 ? ALLOWED_IMAGE_TYPES.join(',') : ALLOWED_VIDEO_TYPES.join(',')} />
|
gap={4}
|
||||||
|
style={{ marginTop: 10 }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
display="flex"
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
gap={2}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
component="label"
|
||||||
|
disabled={!selectedMediaType}
|
||||||
|
>
|
||||||
|
{selectedFile ? "Изменить файл" : "Загрузить файл"}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
hidden
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept={
|
||||||
|
selectedMediaType === 1
|
||||||
|
? ALLOWED_IMAGE_TYPES.join(",")
|
||||||
|
: selectedMediaType === 2
|
||||||
|
? ALLOWED_VIDEO_TYPES.join(",")
|
||||||
|
: selectedMediaType === 3
|
||||||
|
? ALLOWED_ICON_TYPES.join(",")
|
||||||
|
: selectedMediaType === 4
|
||||||
|
? ALLOWED_WATERMARK_TYPES.join(",")
|
||||||
|
: selectedMediaType === 5
|
||||||
|
? ALLOWED_PANORAMA_TYPES.join(",")
|
||||||
|
: selectedMediaType === 6
|
||||||
|
? ALLOWED_3D_MODEL_TYPES.join(",")
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{selectedFile && (
|
{selectedFile && (
|
||||||
@ -134,11 +209,15 @@ export const MediaEdit = () => {
|
|||||||
|
|
||||||
{previewUrl && selectedMediaType === 1 && (
|
{previewUrl && selectedMediaType === 1 && (
|
||||||
<Box mt={2} display="flex" justifyContent="center">
|
<Box mt={2} display="flex" justifyContent="center">
|
||||||
<img src={previewUrl} alt="Preview" style={{maxWidth: '200px', borderRadius: 8}} />
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Preview"
|
||||||
|
style={{ maxWidth: "200px", borderRadius: 8 }}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Edit>
|
</Edit>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
@ -1,78 +1,151 @@
|
|||||||
import {Stack, Typography, Box, Button} from '@mui/material'
|
import { Stack, Typography, Box, Button } from "@mui/material";
|
||||||
import {useShow} from '@refinedev/core'
|
import { useShow } from "@refinedev/core";
|
||||||
import {Show, TextFieldComponent as TextField} from '@refinedev/mui'
|
import { Show, TextFieldComponent as TextField } from "@refinedev/mui";
|
||||||
|
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
|
||||||
import {MEDIA_TYPES} from '../../lib/constants'
|
import sky from "./12414.jpg";
|
||||||
import {TOKEN_KEY} from '../../authProvider'
|
import { MEDIA_TYPES } from "../../lib/constants";
|
||||||
|
import { TOKEN_KEY } from "../../authProvider";
|
||||||
|
import { ModelViewer } from "./ModelViewer/index";
|
||||||
|
|
||||||
export const MediaShow = () => {
|
export const MediaShow = () => {
|
||||||
const {query} = useShow({})
|
const { query } = useShow({});
|
||||||
const {data, isLoading} = query
|
const { data, isLoading } = query;
|
||||||
|
|
||||||
const record = data?.data
|
const record = data?.data;
|
||||||
const token = localStorage.getItem(TOKEN_KEY)
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
|
|
||||||
const fields = [
|
const fields = [
|
||||||
// {label: 'Название файла', data: 'filename'},
|
// {label: 'Название файла', data: 'filename'},
|
||||||
{label: 'Название', data: 'media_name'},
|
{ label: "Название", data: "media_name" },
|
||||||
{
|
{
|
||||||
label: 'Тип',
|
label: "Тип",
|
||||||
data: 'media_type',
|
data: "media_type",
|
||||||
render: (value: number) => MEDIA_TYPES.find((type) => type.value === value)?.label || value,
|
render: (value: number) =>
|
||||||
|
MEDIA_TYPES.find((type) => type.value === value)?.label || value,
|
||||||
},
|
},
|
||||||
// {label: 'ID', data: 'id'},
|
// {label: 'ID', data: 'id'},
|
||||||
]
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show isLoading={isLoading}>
|
<Show isLoading={isLoading}>
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
{record && record.media_type === 1 && (
|
{record && record.media_type === 1 && (
|
||||||
<img
|
<img
|
||||||
src={`https://wn.krbl.ru/media/${record?.id}/download?token=${token}`}
|
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
|
record?.id
|
||||||
|
}/download?token=${token}`}
|
||||||
alt={record?.filename}
|
alt={record?.filename}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100%',
|
maxWidth: "100%",
|
||||||
height: '40vh',
|
height: "40vh",
|
||||||
objectFit: 'contain',
|
objectFit: "contain",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{record && record.media_type === 2 && (
|
{record && record.media_type === 2 && (
|
||||||
|
<video
|
||||||
|
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
|
record?.id
|
||||||
|
}/download?token=${token}`}
|
||||||
|
style={{
|
||||||
|
maxWidth: "50%",
|
||||||
|
|
||||||
|
objectFit: "contain",
|
||||||
|
borderRadius: 30,
|
||||||
|
}}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
muted
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{record && record.media_type === 3 && (
|
||||||
|
<img
|
||||||
|
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
|
record?.id
|
||||||
|
}/download?token=${token}`}
|
||||||
|
alt={record?.filename}
|
||||||
|
style={{
|
||||||
|
maxWidth: "100%",
|
||||||
|
height: "40vh",
|
||||||
|
objectFit: "contain",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{record && record.media_type === 4 && (
|
||||||
|
<img
|
||||||
|
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
|
record?.id
|
||||||
|
}/download?token=${token}`}
|
||||||
|
alt={record?.filename}
|
||||||
|
style={{
|
||||||
|
maxWidth: "100%",
|
||||||
|
height: "40vh",
|
||||||
|
objectFit: "contain",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{record && record.media_type === 5 && (
|
||||||
|
<ReactPhotoSphereViewer
|
||||||
|
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
|
record?.id
|
||||||
|
}/download?token=${token}`}
|
||||||
|
width={"100%"}
|
||||||
|
height={"80vh"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{record && record.media_type === 6 && (
|
||||||
|
<ModelViewer
|
||||||
|
fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
|
record?.id
|
||||||
|
}/download?token=${token}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{fields.map(({ label, data, render }) => (
|
||||||
|
<Stack key={data} gap={1}>
|
||||||
|
<Typography variant="body1" fontWeight="bold">
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
value={render ? render(record?.[data]) : record?.[data]}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
p: 2,
|
p: 2,
|
||||||
border: '1px solid text.pimary',
|
border: "1px solid text.pimary",
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
bgcolor: 'primary.light',
|
bgcolor: "primary.light",
|
||||||
width: 'fit-content',
|
width: "fit-content",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body1"
|
variant="body1"
|
||||||
gutterBottom
|
gutterBottom
|
||||||
sx={{
|
sx={{
|
||||||
color: '#FFFFFF',
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Видео доступно для скачивания по ссылке:
|
Доступно для скачивания по ссылке:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button variant="contained" href={`https://wn.krbl.ru/media/${record?.id}/download?token=${token}`} target="_blank" sx={{mt: 1, width: '100%'}}>
|
<Button
|
||||||
Скачать видео
|
variant="contained"
|
||||||
|
href={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
|
record?.id
|
||||||
|
}/download?token=${token}`}
|
||||||
|
target="_blank"
|
||||||
|
sx={{ mt: 1, width: "100%" }}
|
||||||
|
>
|
||||||
|
Скачать медиа
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
|
||||||
|
|
||||||
{fields.map(({label, data, render}) => (
|
|
||||||
<Stack key={data} gap={1}>
|
|
||||||
<Typography variant="body1" fontWeight="bold">
|
|
||||||
{label}
|
|
||||||
</Typography>
|
|
||||||
<TextField value={render ? render(record?.[data]) : record?.[data]} />
|
|
||||||
</Stack>
|
|
||||||
))}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
@ -1,7 +1,14 @@
|
|||||||
import {Autocomplete, Box, TextField, FormControlLabel, Checkbox, Typography} from '@mui/material'
|
import {
|
||||||
import {Create, useAutocomplete} from '@refinedev/mui'
|
Autocomplete,
|
||||||
import {useForm} from '@refinedev/react-hook-form'
|
Box,
|
||||||
import {Controller} from 'react-hook-form'
|
TextField,
|
||||||
|
FormControlLabel,
|
||||||
|
Checkbox,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { Create, useAutocomplete } from "@refinedev/mui";
|
||||||
|
import { useForm } from "@refinedev/react-hook-form";
|
||||||
|
import { Controller } from "react-hook-form";
|
||||||
|
|
||||||
export const RouteCreate = () => {
|
export const RouteCreate = () => {
|
||||||
const {
|
const {
|
||||||
@ -12,53 +19,75 @@ export const RouteCreate = () => {
|
|||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm({
|
} = useForm({
|
||||||
refineCoreProps: {
|
refineCoreProps: {
|
||||||
resource: 'route/',
|
resource: "route/",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const { autocompleteProps: carrierAutocompleteProps } = useAutocomplete({
|
const { autocompleteProps: carrierAutocompleteProps } = useAutocomplete({
|
||||||
resource: 'carrier',
|
resource: "carrier",
|
||||||
onSearch: (value) => [
|
onSearch: (value) => [
|
||||||
{
|
{
|
||||||
field: 'short_name',
|
field: "short_name",
|
||||||
operator: 'contains',
|
operator: "contains",
|
||||||
value,
|
value,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
|
<Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
|
||||||
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
<Box
|
||||||
|
component="form"
|
||||||
|
sx={{ display: "flex", flexDirection: "column" }}
|
||||||
|
autoComplete="off"
|
||||||
|
>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="carrier_id"
|
name="carrier_id"
|
||||||
rules={{required: 'Это поле является обязательным'}}
|
rules={{ required: "Это поле является обязательным" }}
|
||||||
defaultValue={null}
|
defaultValue={null}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
{...carrierAutocompleteProps}
|
{...carrierAutocompleteProps}
|
||||||
value={carrierAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
value={
|
||||||
|
carrierAutocompleteProps.options.find(
|
||||||
|
(option) => option.id === field.value
|
||||||
|
) || null
|
||||||
|
}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
field.onChange(value?.id || '')
|
field.onChange(value?.id || "");
|
||||||
}}
|
}}
|
||||||
getOptionLabel={(item) => {
|
getOptionLabel={(item) => {
|
||||||
return item ? item.short_name : ''
|
return item ? item.short_name : "";
|
||||||
}}
|
}}
|
||||||
isOptionEqualToValue={(option, value) => {
|
isOptionEqualToValue={(option, value) => {
|
||||||
return option.id === value?.id
|
return option.id === value?.id;
|
||||||
}}
|
}}
|
||||||
filterOptions={(options, { inputValue }) => {
|
filterOptions={(options, { inputValue }) => {
|
||||||
return options.filter((option) => option.short_name.toLowerCase().includes(inputValue.toLowerCase()))
|
return options.filter((option) =>
|
||||||
|
option.short_name
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(inputValue.toLowerCase())
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
renderInput={(params) => <TextField {...params} label="Выберите перевозчика" margin="normal" variant="outlined" error={!!errors.carrier_id} helperText={(errors as any)?.carrier_id?.message} required />}
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Выберите перевозчика"
|
||||||
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
error={!!errors.carrier_id}
|
||||||
|
helperText={(errors as any)?.carrier_id?.message}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('route_number', {
|
{...register("route_number", {
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
setValueAs: (value) => String(value),
|
setValueAs: (value) => String(value),
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.route_number}
|
error={!!(errors as any)?.route_number}
|
||||||
@ -67,7 +96,7 @@ export const RouteCreate = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="text"
|
type="text"
|
||||||
label={'Номер маршрута *'}
|
label={"Номер маршрута *"}
|
||||||
name="route_number"
|
name="route_number"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -75,48 +104,89 @@ export const RouteCreate = () => {
|
|||||||
name="route_direction" // boolean
|
name="route_direction" // boolean
|
||||||
control={control}
|
control={control}
|
||||||
defaultValue={false}
|
defaultValue={false}
|
||||||
render={({field}: {field: any}) => <FormControlLabel label="Прямой маршрут? *" control={<Checkbox {...field} checked={field.value} onChange={(e) => field.onChange(e.target.checked)} />} />}
|
render={({ field }: { field: any }) => (
|
||||||
|
<FormControlLabel
|
||||||
|
label="Прямой маршрут? *"
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
{...field}
|
||||||
|
checked={field.value}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
<Typography variant="caption" color="textSecondary" sx={{mt: 0, mb: 1}}>
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="textSecondary"
|
||||||
|
sx={{ mt: 0, mb: 1 }}
|
||||||
|
>
|
||||||
(Прямой / Обратный)
|
(Прямой / Обратный)
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('path', {
|
{...register("path", {
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
setValueAs: (value: string) => {
|
setValueAs: (value: string) => {
|
||||||
try {
|
try {
|
||||||
// Парсим строку в массив массивов
|
// Разбиваем строку на строки и парсим каждую строку как пару координат
|
||||||
return JSON.parse(value)
|
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 {
|
} catch {
|
||||||
return []
|
return [];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
validate: (value: unknown) => {
|
validate: (value: unknown) => {
|
||||||
if (!Array.isArray(value)) return 'Неверный формат'
|
if (!Array.isArray(value)) return "Неверный формат";
|
||||||
if (!value.every((point: unknown) => Array.isArray(point) && point.length === 2)) {
|
if (value.length === 0)
|
||||||
return 'Каждая точка должна быть массивом из двух координат'
|
return "Введите хотя бы одну пару координат";
|
||||||
|
if (
|
||||||
|
!value.every(
|
||||||
|
(point: unknown) => Array.isArray(point) && point.length === 2
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return "Каждая строка должна содержать две координаты";
|
||||||
}
|
}
|
||||||
if (!value.every((point: unknown[]) => point.every((coord: unknown) => !isNaN(Number(coord)) && typeof coord === 'number'))) {
|
if (
|
||||||
return 'Координаты должны быть числами'
|
!value.every((point: unknown[]) =>
|
||||||
|
point.every(
|
||||||
|
(coord: unknown) =>
|
||||||
|
!isNaN(Number(coord)) && typeof coord === "number"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return "Координаты должны быть числами";
|
||||||
}
|
}
|
||||||
return true
|
return true;
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.path}
|
error={!!(errors as any)?.path}
|
||||||
helperText={(errors as any)?.path?.message} // 'Формат: [[lat1,lon1], [lat2,lon2], ...]'
|
helperText={(errors as any)?.path?.message}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="text"
|
type="text"
|
||||||
label={'Координаты маршрута *'}
|
label={"Координаты маршрута *"}
|
||||||
name="path"
|
name="path"
|
||||||
placeholder="[[1.1, 2.2], [2.1, 4.5]]"
|
placeholder="55.7558 37.6173
|
||||||
|
55.7539 37.6208"
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('route_sys_number', {
|
{...register("route_sys_number", {
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.route_sys_number}
|
error={!!(errors as any)?.route_sys_number}
|
||||||
helperText={(errors as any)?.route_sys_number?.message}
|
helperText={(errors as any)?.route_sys_number?.message}
|
||||||
@ -124,13 +194,14 @@ export const RouteCreate = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="number"
|
type="number"
|
||||||
label={'Системный номер маршрута *'}
|
label={"Системный номер маршрута *"}
|
||||||
name="route_sys_number"
|
name="route_sys_number"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('governor_appeal', {
|
{...register("governor_appeal", {
|
||||||
// required: 'Это поле является обязательным',
|
// required: 'Это поле является обязательным',
|
||||||
|
setValueAs: (value) => Number(value),
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.governor_appeal}
|
error={!!(errors as any)?.governor_appeal}
|
||||||
helperText={(errors as any)?.governor_appeal?.message}
|
helperText={(errors as any)?.governor_appeal?.message}
|
||||||
@ -138,13 +209,14 @@ export const RouteCreate = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="number"
|
type="number"
|
||||||
label={'Обращение губернатора'}
|
label={"Обращение губернатора"}
|
||||||
name="governor_appeal"
|
name="governor_appeal"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('scale_min', {
|
{...register("scale_min", {
|
||||||
// required: 'Это поле является обязательным',
|
// required: 'Это поле является обязательным',
|
||||||
|
setValueAs: (value) => Number(value),
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.scale_min}
|
error={!!(errors as any)?.scale_min}
|
||||||
helperText={(errors as any)?.scale_min?.message}
|
helperText={(errors as any)?.scale_min?.message}
|
||||||
@ -152,13 +224,14 @@ export const RouteCreate = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="number"
|
type="number"
|
||||||
label={'Масштаб (мин)'}
|
label={"Масштаб (мин)"}
|
||||||
name="scale_min"
|
name="scale_min"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('scale_max', {
|
{...register("scale_max", {
|
||||||
// required: 'Это поле является обязательным',
|
// required: 'Это поле является обязательным',
|
||||||
|
setValueAs: (value) => Number(value),
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.scale_max}
|
error={!!(errors as any)?.scale_max}
|
||||||
helperText={(errors as any)?.scale_max?.message}
|
helperText={(errors as any)?.scale_max?.message}
|
||||||
@ -166,13 +239,14 @@ export const RouteCreate = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="number"
|
type="number"
|
||||||
label={'Масштаб (макс)'}
|
label={"Масштаб (макс)"}
|
||||||
name="scale_max"
|
name="scale_max"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('rotate', {
|
{...register("rotate", {
|
||||||
// required: 'Это поле является обязательным',
|
// required: 'Это поле является обязательным',
|
||||||
|
setValueAs: (value) => Number(value),
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.rotate}
|
error={!!(errors as any)?.rotate}
|
||||||
helperText={(errors as any)?.rotate?.message}
|
helperText={(errors as any)?.rotate?.message}
|
||||||
@ -180,13 +254,14 @@ export const RouteCreate = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="number"
|
type="number"
|
||||||
label={'Поворот'}
|
label={"Поворот"}
|
||||||
name="rotate"
|
name="rotate"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('center_latitude', {
|
{...register("center_latitude", {
|
||||||
// required: 'Это поле является обязательным',
|
// required: 'Это поле является обязательным',
|
||||||
|
setValueAs: (value) => Number(value),
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.center_latitude}
|
error={!!(errors as any)?.center_latitude}
|
||||||
helperText={(errors as any)?.center_latitude?.message}
|
helperText={(errors as any)?.center_latitude?.message}
|
||||||
@ -194,13 +269,14 @@ export const RouteCreate = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="number"
|
type="number"
|
||||||
label={'Центр. широта'}
|
label={"Центр. широта"}
|
||||||
name="center_latitude"
|
name="center_latitude"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('center_longitude', {
|
{...register("center_longitude", {
|
||||||
// required: 'Это поле является обязательным',
|
// required: 'Это поле является обязательным',
|
||||||
|
setValueAs: (value) => Number(value),
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.center_longitude}
|
error={!!(errors as any)?.center_longitude}
|
||||||
helperText={(errors as any)?.center_longitude?.message}
|
helperText={(errors as any)?.center_longitude?.message}
|
||||||
@ -208,10 +284,10 @@ export const RouteCreate = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="number"
|
type="number"
|
||||||
label={'Центр. долгота'}
|
label={"Центр. долгота"}
|
||||||
name="center_longitude"
|
name="center_longitude"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Create>
|
</Create>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
@ -1,10 +1,23 @@
|
|||||||
import {Autocomplete, Box, TextField, FormControlLabel, Checkbox, Typography} from '@mui/material'
|
import {
|
||||||
import {Edit, useAutocomplete} from '@refinedev/mui'
|
Autocomplete,
|
||||||
import {useForm} from '@refinedev/react-hook-form'
|
Box,
|
||||||
import {Controller} from 'react-hook-form'
|
TextField,
|
||||||
import {useParams} from 'react-router'
|
FormControlLabel,
|
||||||
import {LinkedItems} from '../../components/LinkedItems'
|
Checkbox,
|
||||||
import {StationItem, VehicleItem, stationFields, vehicleFields} from './types'
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { Edit, useAutocomplete } from "@refinedev/mui";
|
||||||
|
import { useForm } from "@refinedev/react-hook-form";
|
||||||
|
import { Controller } from "react-hook-form";
|
||||||
|
import { useParams } from "react-router";
|
||||||
|
import { LinkedItems } from "../../components/LinkedItems";
|
||||||
|
import {
|
||||||
|
StationItem,
|
||||||
|
VehicleItem,
|
||||||
|
stationFields,
|
||||||
|
vehicleFields,
|
||||||
|
} from "./types";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export const RouteEdit = () => {
|
export const RouteEdit = () => {
|
||||||
const {
|
const {
|
||||||
@ -12,53 +25,88 @@ export const RouteEdit = () => {
|
|||||||
register,
|
register,
|
||||||
control,
|
control,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm({})
|
refineCore: { queryResult },
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
} = useForm({});
|
||||||
|
|
||||||
const {id: routeId} = useParams<{id: string}>()
|
const { id: routeId } = useParams<{ id: string }>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (queryResult?.data?.data && Array.isArray(queryResult.data.data.path)) {
|
||||||
|
const formattedPath = queryResult.data.data.path
|
||||||
|
.map((coords) => coords.join(" "))
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
setValue("path", formattedPath);
|
||||||
|
}
|
||||||
|
}, [queryResult?.data?.data, setValue]);
|
||||||
|
|
||||||
const { autocompleteProps: carrierAutocompleteProps } = useAutocomplete({
|
const { autocompleteProps: carrierAutocompleteProps } = useAutocomplete({
|
||||||
resource: 'carrier',
|
resource: "carrier",
|
||||||
onSearch: (value) => [
|
onSearch: (value) => [
|
||||||
{
|
{
|
||||||
field: 'short_name',
|
field: "short_name",
|
||||||
operator: 'contains',
|
operator: "contains",
|
||||||
value,
|
value,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Edit saveButtonProps={saveButtonProps}>
|
<Edit saveButtonProps={saveButtonProps}>
|
||||||
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
<Box
|
||||||
|
component="form"
|
||||||
|
sx={{ display: "flex", flexDirection: "column" }}
|
||||||
|
autoComplete="off"
|
||||||
|
>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="carrier_id"
|
name="carrier_id"
|
||||||
rules={{required: 'Это поле является обязательным'}}
|
rules={{ required: "Это поле является обязательным" }}
|
||||||
defaultValue={null}
|
defaultValue={null}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
{...carrierAutocompleteProps}
|
{...carrierAutocompleteProps}
|
||||||
value={carrierAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
value={
|
||||||
|
carrierAutocompleteProps.options.find(
|
||||||
|
(option) => option.id === field.value
|
||||||
|
) || null
|
||||||
|
}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
field.onChange(value?.id || '')
|
field.onChange(value?.id || "");
|
||||||
}}
|
}}
|
||||||
getOptionLabel={(item) => {
|
getOptionLabel={(item) => {
|
||||||
return item ? item.short_name : ''
|
return item ? item.short_name : "";
|
||||||
}}
|
}}
|
||||||
isOptionEqualToValue={(option, value) => {
|
isOptionEqualToValue={(option, value) => {
|
||||||
return option.id === value?.id
|
return option.id === value?.id;
|
||||||
}}
|
}}
|
||||||
filterOptions={(options, { inputValue }) => {
|
filterOptions={(options, { inputValue }) => {
|
||||||
return options.filter((option) => option.short_name.toLowerCase().includes(inputValue.toLowerCase()))
|
return options.filter((option) =>
|
||||||
|
option.short_name
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(inputValue.toLowerCase())
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
renderInput={(params) => <TextField {...params} label="Выберите перевозчика" margin="normal" variant="outlined" error={!!errors.carrier_id} helperText={(errors as any)?.carrier_id?.message} required />}
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Выберите перевозчика"
|
||||||
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
error={!!errors.carrier_id}
|
||||||
|
helperText={(errors as any)?.carrier_id?.message}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('route_number', {
|
{...register("route_number", {
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
setValueAs: (value) => String(value),
|
setValueAs: (value) => String(value),
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.route_number}
|
error={!!(errors as any)?.route_number}
|
||||||
@ -67,67 +115,96 @@ export const RouteEdit = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="text"
|
type="text"
|
||||||
label={'Номер маршрута'}
|
label={"Номер маршрута"}
|
||||||
name="route_number"
|
name="route_number"
|
||||||
/>
|
/>
|
||||||
<Controller
|
<Controller
|
||||||
name="route_direction" // boolean
|
name="route_direction" // boolean
|
||||||
control={control}
|
control={control}
|
||||||
defaultValue={false}
|
defaultValue={false}
|
||||||
render={({field}: {field: any}) => <FormControlLabel label="Прямой маршрут?" control={<Checkbox {...field} checked={field.value} onChange={(e) => field.onChange(e.target.checked)} />} />}
|
render={({ field }: { field: any }) => (
|
||||||
/>
|
<FormControlLabel
|
||||||
|
label="Прямой маршрут?"
|
||||||
<Typography variant="caption" color="textSecondary" sx={{mt: 0, mb: 1}}>
|
control={
|
||||||
(Прямой / Обратный)
|
<Checkbox
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
name="path"
|
|
||||||
control={control}
|
|
||||||
defaultValue={[]}
|
|
||||||
rules={{
|
|
||||||
required: 'Это поле является обязательным',
|
|
||||||
validate: (value: unknown) => {
|
|
||||||
if (!Array.isArray(value)) return 'Неверный формат'
|
|
||||||
if (!value.every((point: unknown) => Array.isArray(point) && point.length === 2)) {
|
|
||||||
return 'Каждая точка должна быть массивом из двух координат'
|
|
||||||
}
|
|
||||||
if (!value.every((point: unknown[]) => point.every((coord: unknown) => !isNaN(Number(coord)) && typeof coord === 'number'))) {
|
|
||||||
return 'Координаты должны быть числами'
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
render={({field, fieldState: {error}}) => (
|
|
||||||
<TextField
|
|
||||||
{...field}
|
{...field}
|
||||||
value={Array.isArray(field.value) ? JSON.stringify(field.value) : ''}
|
checked={field.value}
|
||||||
onChange={(e) => {
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
try {
|
/>
|
||||||
const parsed = JSON.parse(e.target.value)
|
|
||||||
field.onChange(parsed)
|
|
||||||
} catch {
|
|
||||||
field.onChange([])
|
|
||||||
}
|
}
|
||||||
}}
|
|
||||||
error={!!error}
|
|
||||||
helperText={error?.message} // 'Формат: [[lat1,lon1], [lat2,lon2], ...]'
|
|
||||||
margin="normal"
|
|
||||||
fullWidth
|
|
||||||
InputLabelProps={{shrink: true}}
|
|
||||||
type="text"
|
|
||||||
label={'Координаты маршрута'}
|
|
||||||
placeholder="[[1.1, 2.2], [2.1, 4.5]]"
|
|
||||||
sx={{
|
|
||||||
marginBottom: 2,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="textSecondary"
|
||||||
|
sx={{ mt: 0, mb: 1 }}
|
||||||
|
>
|
||||||
|
(Прямой / Обратный)
|
||||||
|
</Typography>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('route_sys_number', {
|
{...register("path", {
|
||||||
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);
|
||||||
|
return [lat, lon];
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
validate: (value: unknown) => {
|
||||||
|
if (!Array.isArray(value)) return "Неверный формат";
|
||||||
|
if (value.length === 0)
|
||||||
|
return "Введите хотя бы одну пару координат";
|
||||||
|
if (
|
||||||
|
!value.every(
|
||||||
|
(point: unknown) => Array.isArray(point) && point.length === 2
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return "Каждая строка должна содержать две координаты";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!value.every((point: unknown[]) =>
|
||||||
|
point.every(
|
||||||
|
(coord: unknown) =>
|
||||||
|
!isNaN(Number(coord)) && typeof coord === "number"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return "Координаты должны быть числами";
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
error={!!(errors as any)?.path}
|
||||||
|
helperText={(errors as any)?.path?.message}
|
||||||
|
margin="normal"
|
||||||
|
fullWidth
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
type="text"
|
||||||
|
label={"Координаты маршрута *"}
|
||||||
|
name="path"
|
||||||
|
placeholder="55.7558 37.6173
|
||||||
|
55.7539 37.6208"
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
sx={{
|
||||||
|
marginBottom: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
{...register("route_sys_number", {
|
||||||
|
required: "Это поле является обязательным",
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.route_sys_number}
|
error={!!(errors as any)?.route_sys_number}
|
||||||
helperText={(errors as any)?.route_sys_number?.message}
|
helperText={(errors as any)?.route_sys_number?.message}
|
||||||
@ -135,12 +212,12 @@ export const RouteEdit = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="number"
|
type="number"
|
||||||
label={'Системный номер маршрута *'}
|
label={"Системный номер маршрута *"}
|
||||||
name="route_sys_number"
|
name="route_sys_number"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('governor_appeal', {
|
{...register("governor_appeal", {
|
||||||
// required: 'Это поле является обязательным',
|
// required: 'Это поле является обязательным',
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.governor_appeal}
|
error={!!(errors as any)?.governor_appeal}
|
||||||
@ -149,13 +226,14 @@ export const RouteEdit = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="number"
|
type="number"
|
||||||
label={'Обращение губернатора'}
|
label={"Обращение губернатора"}
|
||||||
name="governor_appeal"
|
name="governor_appeal"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('scale_min', {
|
{...register("scale_min", {
|
||||||
// required: 'Это поле является обязательным',
|
// required: 'Это поле является обязательным',
|
||||||
|
setValueAs: (value) => Number(value),
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.scale_min}
|
error={!!(errors as any)?.scale_min}
|
||||||
helperText={(errors as any)?.scale_min?.message}
|
helperText={(errors as any)?.scale_min?.message}
|
||||||
@ -163,13 +241,14 @@ export const RouteEdit = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="number"
|
type="number"
|
||||||
label={'Масштаб (мин)'}
|
label={"Масштаб (мин)"}
|
||||||
name="scale_min"
|
name="scale_min"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('scale_max', {
|
{...register("scale_max", {
|
||||||
// required: 'Это поле является обязательным',
|
// required: 'Это поле является обязательным',
|
||||||
|
setValueAs: (value) => Number(value),
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.scale_max}
|
error={!!(errors as any)?.scale_max}
|
||||||
helperText={(errors as any)?.scale_max?.message}
|
helperText={(errors as any)?.scale_max?.message}
|
||||||
@ -177,13 +256,14 @@ export const RouteEdit = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="number"
|
type="number"
|
||||||
label={'Масштаб (макс)'}
|
label={"Масштаб (макс)"}
|
||||||
name="scale_max"
|
name="scale_max"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('rotate', {
|
{...register("rotate", {
|
||||||
// required: 'Это поле является обязательным',
|
// required: 'Это поле является обязательным',
|
||||||
|
setValueAs: (value) => Number(value),
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.rotate}
|
error={!!(errors as any)?.rotate}
|
||||||
helperText={(errors as any)?.rotate?.message}
|
helperText={(errors as any)?.rotate?.message}
|
||||||
@ -191,13 +271,14 @@ export const RouteEdit = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="number"
|
type="number"
|
||||||
label={'Поворот'}
|
label={"Поворот"}
|
||||||
name="rotate"
|
name="rotate"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('center_latitude', {
|
{...register("center_latitude", {
|
||||||
// required: 'Это поле является обязательным',
|
// required: 'Это поле является обязательным',
|
||||||
|
setValueAs: (value) => Number(value),
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.center_latitude}
|
error={!!(errors as any)?.center_latitude}
|
||||||
helperText={(errors as any)?.center_latitude?.message}
|
helperText={(errors as any)?.center_latitude?.message}
|
||||||
@ -205,13 +286,14 @@ export const RouteEdit = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="number"
|
type="number"
|
||||||
label={'Центр. широта'}
|
label={"Центр. широта"}
|
||||||
name="center_latitude"
|
name="center_latitude"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('center_longitude', {
|
{...register("center_longitude", {
|
||||||
// required: 'Это поле является обязательным',
|
// required: 'Это поле является обязательным',
|
||||||
|
setValueAs: (value) => Number(value),
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.center_longitude}
|
error={!!(errors as any)?.center_longitude}
|
||||||
helperText={(errors as any)?.center_longitude?.message}
|
helperText={(errors as any)?.center_longitude?.message}
|
||||||
@ -219,18 +301,33 @@ export const RouteEdit = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="number"
|
type="number"
|
||||||
label={'Центр. долгота'}
|
label={"Центр. долгота"}
|
||||||
name="center_longitude"
|
name="center_longitude"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{routeId && (
|
{routeId && (
|
||||||
<>
|
<>
|
||||||
<LinkedItems<StationItem> type="edit" parentId={routeId} parentResource="route" childResource="station" fields={stationFields} title="станции" />
|
<LinkedItems<StationItem>
|
||||||
|
type="edit"
|
||||||
|
parentId={routeId}
|
||||||
|
parentResource="route"
|
||||||
|
childResource="station"
|
||||||
|
fields={stationFields}
|
||||||
|
title="станции"
|
||||||
|
dragAllowed={true}
|
||||||
|
/>
|
||||||
|
|
||||||
<LinkedItems<VehicleItem> type="edit" parentId={routeId} parentResource="route" childResource="vehicle" fields={vehicleFields} title="транспортные средства" />
|
<LinkedItems<VehicleItem>
|
||||||
|
type="edit"
|
||||||
|
parentId={routeId}
|
||||||
|
parentResource="route"
|
||||||
|
childResource="vehicle"
|
||||||
|
fields={vehicleFields}
|
||||||
|
title="транспортные средства"
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Edit>
|
</Edit>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
@ -1,136 +1,146 @@
|
|||||||
import {type GridColDef} from '@mui/x-data-grid'
|
import { type GridColDef } from "@mui/x-data-grid";
|
||||||
import {CustomDataGrid} from '../../components/CustomDataGrid'
|
import { CustomDataGrid } from "../../components/CustomDataGrid";
|
||||||
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
|
import {
|
||||||
import {Typography} from '@mui/material'
|
DeleteButton,
|
||||||
import React from 'react'
|
EditButton,
|
||||||
|
List,
|
||||||
|
ShowButton,
|
||||||
|
useDataGrid,
|
||||||
|
} from "@refinedev/mui";
|
||||||
|
import { Typography } from "@mui/material";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
import {localeText} from '../../locales/ru/localeText'
|
import { localeText } from "../../locales/ru/localeText";
|
||||||
|
|
||||||
export const RouteList = () => {
|
export const RouteList = () => {
|
||||||
const { dataGridProps } = useDataGrid({
|
const { dataGridProps } = useDataGrid({
|
||||||
resource: 'route/',
|
resource: "route/",
|
||||||
})
|
});
|
||||||
|
|
||||||
const columns = React.useMemo<GridColDef[]>(
|
const columns = React.useMemo<GridColDef[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
field: 'id',
|
field: "id",
|
||||||
headerName: 'ID',
|
headerName: "ID",
|
||||||
type: 'number',
|
type: "number",
|
||||||
minWidth: 70,
|
minWidth: 70,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'carrier_id',
|
field: "carrier_id",
|
||||||
headerName: 'ID перевозчика',
|
headerName: "ID перевозчика",
|
||||||
type: 'number',
|
type: "number",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'carrier',
|
field: "carrier",
|
||||||
headerName: 'Перевозчик',
|
headerName: "Перевозчик",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'route_number',
|
field: "route_number",
|
||||||
headerName: 'Номер маршрута',
|
headerName: "Номер маршрута",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'route_sys_number',
|
field: "route_sys_number",
|
||||||
headerName: 'Системный номер маршрута',
|
headerName: "Системный номер маршрута",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'governor_appeal',
|
field: "governor_appeal",
|
||||||
headerName: 'Обращение губернатора',
|
headerName: "Обращение губернатора",
|
||||||
type: 'number',
|
type: "number",
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'scale_min',
|
field: "scale_min",
|
||||||
headerName: 'Масштаб (мин)',
|
headerName: "Масштаб (мин)",
|
||||||
type: 'number',
|
type: "number",
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'scale_max',
|
field: "scale_max",
|
||||||
headerName: 'Масштаб (макс)',
|
headerName: "Масштаб (макс)",
|
||||||
type: 'number',
|
type: "number",
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'rotate',
|
field: "rotate",
|
||||||
headerName: 'Поворот',
|
headerName: "Поворот",
|
||||||
type: 'number',
|
type: "number",
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'center_latitude',
|
field: "center_latitude",
|
||||||
headerName: 'Центр. широта',
|
headerName: "Центр. широта",
|
||||||
type: 'number',
|
type: "number",
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'center_longitude',
|
field: "center_longitude",
|
||||||
headerName: 'Центр. долгота',
|
headerName: "Центр. долгота",
|
||||||
type: 'number',
|
type: "number",
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'route_direction',
|
field: "route_direction",
|
||||||
headerName: 'Направление маршрута',
|
headerName: "Направление маршрута",
|
||||||
type: 'boolean',
|
type: "boolean",
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
renderCell: ({value}) => <Typography style={{color: value ? '#48989f' : '#7f6b58'}}>{value ? 'прямое' : 'обратное'}</Typography>,
|
renderCell: ({ value }) => (
|
||||||
|
<Typography style={{ color: value ? "#48989f" : "#7f6b58" }}>
|
||||||
|
{value ? "прямое" : "обратное"}
|
||||||
|
</Typography>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'actions',
|
field: "actions",
|
||||||
headerName: 'Действия',
|
headerName: "Действия",
|
||||||
cellClassName: 'route-actions',
|
cellClassName: "route-actions",
|
||||||
align: 'right',
|
align: "right",
|
||||||
headerAlign: 'center',
|
headerAlign: "center",
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
sortable: false,
|
sortable: false,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
disableColumnMenu: true,
|
disableColumnMenu: true,
|
||||||
@ -139,18 +149,27 @@ export const RouteList = () => {
|
|||||||
<>
|
<>
|
||||||
<EditButton hideText recordItemId={row.id} />
|
<EditButton hideText recordItemId={row.id} />
|
||||||
<ShowButton hideText recordItemId={row.id} />
|
<ShowButton hideText recordItemId={row.id} />
|
||||||
<DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} />
|
<DeleteButton
|
||||||
|
hideText
|
||||||
|
confirmTitle="Вы уверены?"
|
||||||
|
recordItemId={row.id}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[]
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List>
|
<List>
|
||||||
<CustomDataGrid {...dataGridProps} columns={columns} localeText={localeText} getRowId={(row: any) => row.id} />
|
<CustomDataGrid
|
||||||
|
{...dataGridProps}
|
||||||
|
columns={columns}
|
||||||
|
localeText={localeText}
|
||||||
|
getRowId={(row: any) => row.id}
|
||||||
|
/>
|
||||||
</List>
|
</List>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
@ -1,46 +1,54 @@
|
|||||||
import {Stack, Typography, Box} from '@mui/material'
|
import { Stack, Typography, Box } from "@mui/material";
|
||||||
import {useShow} from '@refinedev/core'
|
import { useShow } from "@refinedev/core";
|
||||||
import {Show, TextFieldComponent as TextField} from '@refinedev/mui'
|
import { Show, TextFieldComponent as TextField } from "@refinedev/mui";
|
||||||
import {LinkedItems} from '../../components/LinkedItems'
|
import { LinkedItems } from "../../components/LinkedItems";
|
||||||
import {StationItem, VehicleItem, stationFields, vehicleFields} from './types'
|
import {
|
||||||
|
StationItem,
|
||||||
|
VehicleItem,
|
||||||
|
stationFields,
|
||||||
|
vehicleFields,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
export const RouteShow = () => {
|
export const RouteShow = () => {
|
||||||
const {query} = useShow({})
|
const { query } = useShow({});
|
||||||
const {data, isLoading} = query
|
const { data, isLoading } = query;
|
||||||
const record = data?.data
|
const record = data?.data;
|
||||||
|
|
||||||
const fields = [
|
const fields = [
|
||||||
{label: 'Перевозчик', data: 'carrier'},
|
{ label: "Перевозчик", data: "carrier" },
|
||||||
{label: 'Номер маршрута', data: 'route_number'},
|
{ label: "Номер маршрута", data: "route_number" },
|
||||||
{
|
{
|
||||||
label: 'Направление маршрута',
|
label: "Направление маршрута",
|
||||||
data: 'route_direction',
|
data: "route_direction",
|
||||||
render: (value: number[][]) => <Typography style={{color: value ? '#48989f' : '#7f6b58'}}>{value ? 'прямое' : 'обратное'}</Typography>,
|
render: (value: number[][]) => (
|
||||||
|
<Typography style={{ color: value ? "#48989f" : "#7f6b58" }}>
|
||||||
|
{value ? "прямое" : "обратное"}
|
||||||
|
</Typography>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Координаты маршрута',
|
label: "Координаты маршрута",
|
||||||
data: 'path',
|
data: "path",
|
||||||
render: (value: number[][]) => (
|
render: (value: number[][]) => (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: 'monospace',
|
fontFamily: "monospace",
|
||||||
bgcolor: (theme) => theme.palette.background.paper,
|
bgcolor: (theme) => theme.palette.background.paper,
|
||||||
p: 2,
|
p: 2,
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
maxHeight: '200px',
|
maxHeight: "200px",
|
||||||
overflow: 'auto',
|
overflow: "auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{JSON.stringify(value)}
|
{value?.map((point, index) => (
|
||||||
{/* {value?.map((point, index) => (
|
|
||||||
<Typography key={index} sx={{ mb: 0.5 }}>
|
<Typography key={index} sx={{ mb: 0.5 }}>
|
||||||
Точка {index + 1}: [{point[0]}, {point[1]}]
|
{point[0]}, {point[1]}
|
||||||
</Typography>
|
</Typography>
|
||||||
))} */}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show isLoading={isLoading}>
|
<Show isLoading={isLoading}>
|
||||||
@ -50,18 +58,36 @@ export const RouteShow = () => {
|
|||||||
<Typography variant="body1" fontWeight="bold">
|
<Typography variant="body1" fontWeight="bold">
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
{render ? render(record?.[data]) : <TextField value={record?.[data]} />}
|
{render ? (
|
||||||
|
render(record?.[data])
|
||||||
|
) : (
|
||||||
|
<TextField value={record?.[data]} />
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{record?.id && (
|
{record?.id && (
|
||||||
<>
|
<>
|
||||||
<LinkedItems<StationItem> type="show" parentId={record.id} parentResource="route" childResource="station" fields={stationFields} title="станции" />
|
<LinkedItems<StationItem>
|
||||||
|
type="show"
|
||||||
|
parentId={record.id}
|
||||||
|
parentResource="route"
|
||||||
|
childResource="station"
|
||||||
|
fields={stationFields}
|
||||||
|
title="станции"
|
||||||
|
/>
|
||||||
|
|
||||||
<LinkedItems<VehicleItem> type="show" parentId={record.id} parentResource="route" childResource="vehicle" fields={vehicleFields} title="транспортные средства" />
|
<LinkedItems<VehicleItem>
|
||||||
|
type="show"
|
||||||
|
parentId={record.id}
|
||||||
|
parentResource="route"
|
||||||
|
childResource="vehicle"
|
||||||
|
fields={vehicleFields}
|
||||||
|
title="транспортные средства"
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
@ -1,35 +1,36 @@
|
|||||||
import {VEHICLE_TYPES} from '../../lib/constants'
|
import { VEHICLE_TYPES } from "../../lib/constants";
|
||||||
|
|
||||||
export type StationItem = {
|
export type StationItem = {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
description: string
|
description: string;
|
||||||
[key: string]: string | number
|
[key: string]: string | number;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type VehicleItem = {
|
export type VehicleItem = {
|
||||||
id: number
|
id: number;
|
||||||
tail_number: number
|
tail_number: number;
|
||||||
type: number
|
type: number;
|
||||||
[key: string]: string | number
|
[key: string]: string | number;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type FieldType<T> = {
|
export type FieldType<T> = {
|
||||||
label: string
|
label: string;
|
||||||
data: keyof T
|
data: keyof T;
|
||||||
render?: (value: any) => React.ReactNode
|
render?: (value: any) => React.ReactNode;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const stationFields: Array<FieldType<StationItem>> = [
|
export const stationFields: Array<FieldType<StationItem>> = [
|
||||||
{label: 'Название', data: 'system_name'},
|
{ label: "Название", data: "name" },
|
||||||
{label: 'Описание', data: 'description'},
|
{ label: "Описание", data: "description" },
|
||||||
]
|
];
|
||||||
|
|
||||||
export const vehicleFields: Array<FieldType<VehicleItem>> = [
|
export const vehicleFields: Array<FieldType<VehicleItem>> = [
|
||||||
{label: 'Бортовой номер', data: 'tail_number'},
|
{ label: "Бортовой номер", data: "tail_number" },
|
||||||
{
|
{
|
||||||
label: 'Тип',
|
label: "Тип",
|
||||||
data: 'type',
|
data: "type",
|
||||||
render: (value: number) => VEHICLE_TYPES.find((type) => type.value === value)?.label || value,
|
render: (value: number) =>
|
||||||
|
VEHICLE_TYPES.find((type) => type.value === value)?.label || value,
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
@ -1,130 +1,291 @@
|
|||||||
import {Autocomplete, Box, TextField, Typography, Paper} from '@mui/material'
|
import { Autocomplete, Box, TextField, Typography, Paper } from "@mui/material";
|
||||||
import {Create, useAutocomplete} from '@refinedev/mui'
|
import { Create, useAutocomplete } from "@refinedev/mui";
|
||||||
import {useForm} from '@refinedev/react-hook-form'
|
import { useForm } from "@refinedev/react-hook-form";
|
||||||
import {Controller} from 'react-hook-form'
|
import { Controller } from "react-hook-form";
|
||||||
import {Link} from 'react-router'
|
import { cityStore } from "../../store/CityStore";
|
||||||
import React, {useState, useEffect} from 'react'
|
import React, { useState, useEffect } from "react";
|
||||||
import {TOKEN_KEY} from '../../authProvider'
|
import { TOKEN_KEY } from "../../authProvider";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { useLocation } from "react-router";
|
||||||
|
import { languageStore } from "../../store/LanguageStore";
|
||||||
|
export const SightCreate = observer(() => {
|
||||||
|
const { language, setLanguageAction } = languageStore;
|
||||||
|
const [sightData, setSightData] = useState({
|
||||||
|
ru: {
|
||||||
|
name: "",
|
||||||
|
address: "",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
name: "",
|
||||||
|
address: "",
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
name: "",
|
||||||
|
address: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Состояния для предпросмотра
|
||||||
|
const handleLanguageChange = (lang: string) => {
|
||||||
|
setSightData((prevData) => ({
|
||||||
|
...prevData,
|
||||||
|
[language]: {
|
||||||
|
name: watch("name") ?? "",
|
||||||
|
address: watch("address") ?? "",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
setLanguageAction(lang);
|
||||||
|
};
|
||||||
|
|
||||||
export const SightCreate = () => {
|
|
||||||
const {
|
const {
|
||||||
saveButtonProps,
|
saveButtonProps,
|
||||||
refineCore: { formLoading },
|
refineCore: { formLoading },
|
||||||
register,
|
register,
|
||||||
control,
|
control,
|
||||||
watch,
|
watch,
|
||||||
|
setValue,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm({
|
} = useForm({
|
||||||
refineCoreProps: {
|
refineCoreProps: {
|
||||||
resource: 'sight/',
|
resource: "sight/",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
const { city_id } = cityStore;
|
||||||
|
|
||||||
// Состояния для предпросмотра
|
useEffect(() => {
|
||||||
const [namePreview, setNamePreview] = useState('')
|
if (sightData[language as keyof typeof sightData]?.name) {
|
||||||
const [coordinatesPreview, setCoordinatesPreview] = useState({latitude: '', longitude: ''})
|
setValue("name", sightData[language as keyof typeof sightData]?.name);
|
||||||
const [cityPreview, setCityPreview] = useState('')
|
}
|
||||||
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null)
|
if (sightData[language as keyof typeof sightData]?.address) {
|
||||||
const [watermarkLUPreview, setWatermarkLUPreview] = useState<string | null>(null)
|
setValue(
|
||||||
const [watermarkRDPreview, setWatermarkRDPreview] = useState<string | null>(null)
|
"address",
|
||||||
const [leftArticlePreview, setLeftArticlePreview] = useState('')
|
sightData[language as keyof typeof sightData]?.address
|
||||||
const [previewArticlePreview, setPreviewArticlePreview] = useState('')
|
);
|
||||||
|
}
|
||||||
|
}, [sightData, language, setValue]);
|
||||||
|
const [namePreview, setNamePreview] = useState("");
|
||||||
|
const [coordinatesPreview, setCoordinatesPreview] = useState({
|
||||||
|
latitude: "",
|
||||||
|
longitude: "",
|
||||||
|
});
|
||||||
|
const [cityPreview, setCityPreview] = useState("");
|
||||||
|
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
|
||||||
|
const [watermarkLUPreview, setWatermarkLUPreview] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [watermarkRDPreview, setWatermarkRDPreview] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [leftArticlePreview, setLeftArticlePreview] = useState("");
|
||||||
|
const [previewArticlePreview, setPreviewArticlePreview] = useState("");
|
||||||
|
|
||||||
|
const handleCoordinatesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const [lat, lon] = e.target.value.split(",").map((s) => s.trim());
|
||||||
|
setCoordinatesPreview({
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon,
|
||||||
|
});
|
||||||
|
setValue("latitude", lat);
|
||||||
|
setValue("longitude", lon);
|
||||||
|
};
|
||||||
|
|
||||||
// Автокомплиты
|
// Автокомплиты
|
||||||
const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({
|
const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({
|
||||||
resource: 'city',
|
resource: "city",
|
||||||
onSearch: (value) => [
|
onSearch: (value) => [
|
||||||
{
|
{
|
||||||
field: 'name',
|
field: "name",
|
||||||
operator: 'contains',
|
operator: "contains",
|
||||||
value,
|
value,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
meta: {
|
||||||
|
headers: {
|
||||||
|
"Accept-Language": language,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const { autocompleteProps: mediaAutocompleteProps } = useAutocomplete({
|
const { autocompleteProps: mediaAutocompleteProps } = useAutocomplete({
|
||||||
resource: 'media',
|
resource: "media",
|
||||||
onSearch: (value) => [
|
onSearch: (value) => [
|
||||||
{
|
{
|
||||||
field: 'media_name',
|
field: "media_name",
|
||||||
operator: 'contains',
|
operator: "contains",
|
||||||
value,
|
value,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
|
|
||||||
const { autocompleteProps: articleAutocompleteProps } = useAutocomplete({
|
const { autocompleteProps: articleAutocompleteProps } = useAutocomplete({
|
||||||
resource: 'article',
|
resource: "article",
|
||||||
onSearch: (value) => [
|
onSearch: (value) => [
|
||||||
{
|
{
|
||||||
field: 'heading',
|
field: "heading",
|
||||||
operator: 'contains',
|
operator: "contains",
|
||||||
value,
|
value,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
|
|
||||||
// Следим за изменениями во всех полях
|
// Следим за изменениями во всех полях
|
||||||
const nameContent = watch('name')
|
const nameContent = watch("name");
|
||||||
const latitudeContent = watch('latitude')
|
const addressContent = watch("address");
|
||||||
const longitudeContent = watch('longitude')
|
const latitudeContent = watch("latitude");
|
||||||
const cityContent = watch('city_id')
|
const longitudeContent = watch("longitude");
|
||||||
const thumbnailContent = watch('thumbnail')
|
const cityContent = watch("city_id");
|
||||||
const watermarkLUContent = watch('watermark_lu')
|
const thumbnailContent = watch("thumbnail");
|
||||||
const watermarkRDContent = watch('watermark_rd')
|
const watermarkLUContent = watch("watermark_lu");
|
||||||
const leftArticleContent = watch('left_article')
|
const watermarkRDContent = watch("watermark_rd");
|
||||||
const previewArticleContent = watch('preview_article')
|
const leftArticleContent = watch("left_article");
|
||||||
|
const previewArticleContent = watch("preview_article");
|
||||||
|
|
||||||
// Обновляем состояния при изменении полей
|
// Обновляем состояния при изменении полей
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNamePreview(nameContent || '')
|
setNamePreview(nameContent || "");
|
||||||
}, [nameContent])
|
}, [nameContent]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCoordinatesPreview({
|
setCoordinatesPreview({
|
||||||
latitude: latitudeContent || '',
|
latitude: latitudeContent || "",
|
||||||
longitude: longitudeContent || '',
|
longitude: longitudeContent || "",
|
||||||
})
|
});
|
||||||
}, [latitudeContent, longitudeContent])
|
}, [latitudeContent, longitudeContent]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const selectedCity = cityAutocompleteProps.options.find((option) => option.id === cityContent)
|
const selectedCity = cityAutocompleteProps.options.find(
|
||||||
setCityPreview(selectedCity?.name || '')
|
(option) => option.id === cityContent
|
||||||
}, [cityContent, cityAutocompleteProps.options])
|
);
|
||||||
|
setCityPreview(selectedCity?.name || "");
|
||||||
|
}, [cityContent, cityAutocompleteProps.options]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const selectedThumbnail = mediaAutocompleteProps.options.find((option) => option.id === thumbnailContent)
|
const selectedThumbnail = mediaAutocompleteProps.options.find(
|
||||||
setThumbnailPreview(selectedThumbnail ? `https://wn.krbl.ru/media/${selectedThumbnail.id}/download?token=${localStorage.getItem(TOKEN_KEY)}` : null)
|
(option) => option.id === thumbnailContent
|
||||||
}, [thumbnailContent, mediaAutocompleteProps.options])
|
);
|
||||||
|
setThumbnailPreview(
|
||||||
|
selectedThumbnail
|
||||||
|
? `${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
|
selectedThumbnail.id
|
||||||
|
}/download?token=${localStorage.getItem(TOKEN_KEY)}`
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
}, [thumbnailContent, mediaAutocompleteProps.options]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const selectedWatermarkLU = mediaAutocompleteProps.options.find((option) => option.id === watermarkLUContent)
|
if (city_id) {
|
||||||
setWatermarkLUPreview(selectedWatermarkLU ? `https://wn.krbl.ru/media/${selectedWatermarkLU.id}/download?token=${localStorage.getItem(TOKEN_KEY)}` : null)
|
setValue("city_id", +city_id);
|
||||||
}, [watermarkLUContent, mediaAutocompleteProps.options])
|
}
|
||||||
|
}, [city_id, setValue]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const selectedWatermarkRD = mediaAutocompleteProps.options.find((option) => option.id === watermarkRDContent)
|
const selectedWatermarkLU = mediaAutocompleteProps.options.find(
|
||||||
setWatermarkRDPreview(selectedWatermarkRD ? `https://wn.krbl.ru/media/${selectedWatermarkRD.id}/download?token=${localStorage.getItem(TOKEN_KEY)}` : null)
|
(option) => option.id === watermarkLUContent
|
||||||
}, [watermarkRDContent, mediaAutocompleteProps.options])
|
);
|
||||||
|
setWatermarkLUPreview(
|
||||||
|
selectedWatermarkLU
|
||||||
|
? `${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
|
selectedWatermarkLU.id
|
||||||
|
}/download?token=${localStorage.getItem(TOKEN_KEY)}`
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
}, [watermarkLUContent, mediaAutocompleteProps.options]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const selectedLeftArticle = articleAutocompleteProps.options.find((option) => option.id === leftArticleContent)
|
const selectedWatermarkRD = mediaAutocompleteProps.options.find(
|
||||||
setLeftArticlePreview(selectedLeftArticle?.heading || '')
|
(option) => option.id === watermarkRDContent
|
||||||
}, [leftArticleContent, articleAutocompleteProps.options])
|
);
|
||||||
|
setWatermarkRDPreview(
|
||||||
|
selectedWatermarkRD
|
||||||
|
? `${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
|
selectedWatermarkRD.id
|
||||||
|
}/download?token=${localStorage.getItem(TOKEN_KEY)}`
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
}, [watermarkRDContent, mediaAutocompleteProps.options]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const selectedPreviewArticle = articleAutocompleteProps.options.find((option) => option.id === previewArticleContent)
|
const selectedLeftArticle = articleAutocompleteProps.options.find(
|
||||||
setPreviewArticlePreview(selectedPreviewArticle?.heading || '')
|
(option) => option.id === leftArticleContent
|
||||||
}, [previewArticleContent, articleAutocompleteProps.options])
|
);
|
||||||
|
setLeftArticlePreview(selectedLeftArticle?.heading || "");
|
||||||
|
}, [leftArticleContent, articleAutocompleteProps.options]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const selectedPreviewArticle = articleAutocompleteProps.options.find(
|
||||||
|
(option) => option.id === previewArticleContent
|
||||||
|
);
|
||||||
|
setPreviewArticlePreview(selectedPreviewArticle?.heading || "");
|
||||||
|
}, [previewArticleContent, articleAutocompleteProps.options]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
|
<Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
|
||||||
<Box sx={{display: 'flex', gap: 2}}>
|
<Box sx={{ display: "flex", gap: 2 }}>
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", flex: 1, gap: 2 }}>
|
||||||
{/* Форма создания */}
|
{/* Форма создания */}
|
||||||
<Box component="form" sx={{flex: 1, display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
cursor: "pointer",
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: language === "ru" ? "primary.main" : "transparent",
|
||||||
|
color: language === "ru" ? "white" : "inherit",
|
||||||
|
borderRadius: 1,
|
||||||
|
p: 1,
|
||||||
|
}}
|
||||||
|
onClick={() => handleLanguageChange("ru")}
|
||||||
|
>
|
||||||
|
RU
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
cursor: "pointer",
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: language === "en" ? "primary.main" : "transparent",
|
||||||
|
color: language === "en" ? "white" : "inherit",
|
||||||
|
borderRadius: 1,
|
||||||
|
p: 1,
|
||||||
|
}}
|
||||||
|
onClick={() => handleLanguageChange("en")}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
cursor: "pointer",
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: language === "zh" ? "primary.main" : "transparent",
|
||||||
|
color: language === "zh" ? "white" : "inherit",
|
||||||
|
borderRadius: 1,
|
||||||
|
p: 1,
|
||||||
|
}}
|
||||||
|
onClick={() => handleLanguageChange("zh")}
|
||||||
|
>
|
||||||
|
ZH
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
sx={{ flex: 1, display: "flex", flexDirection: "column" }}
|
||||||
|
autoComplete="off"
|
||||||
|
>
|
||||||
<TextField
|
<TextField
|
||||||
{...register('name', {
|
{...register("name", {
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.name}
|
error={!!(errors as any)?.name}
|
||||||
helperText={(errors as any)?.name?.message}
|
helperText={(errors as any)?.name?.message}
|
||||||
@ -132,60 +293,91 @@ export const SightCreate = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="text"
|
type="text"
|
||||||
label={'Название *'}
|
label={"Название *"}
|
||||||
name="name"
|
name="name"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('latitude', {
|
value={`${coordinatesPreview.latitude}, ${coordinatesPreview.longitude}`}
|
||||||
required: 'Это поле является обязательным',
|
onChange={handleCoordinatesChange}
|
||||||
valueAsNumber: true,
|
|
||||||
})}
|
|
||||||
error={!!(errors as any)?.latitude}
|
error={!!(errors as any)?.latitude}
|
||||||
helperText={(errors as any)?.latitude?.message}
|
helperText={(errors as any)?.latitude?.message}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="number"
|
type="text"
|
||||||
label={'Широта *'}
|
label={"Координаты *"}
|
||||||
name="latitude"
|
|
||||||
/>
|
/>
|
||||||
<TextField
|
<input
|
||||||
{...register('longitude', {
|
type="hidden"
|
||||||
required: 'Это поле является обязательным',
|
{...register("longitude", {
|
||||||
|
value: coordinatesPreview.longitude,
|
||||||
|
required: "Это поле является обязательным",
|
||||||
valueAsNumber: true,
|
valueAsNumber: true,
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.longitude}
|
/>
|
||||||
helperText={(errors as any)?.longitude?.message}
|
<input
|
||||||
|
type="hidden"
|
||||||
|
{...register("latitude", {
|
||||||
|
value: coordinatesPreview.latitude,
|
||||||
|
required: "Это поле является обязательным",
|
||||||
|
valueAsNumber: true,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
{...register("address", {
|
||||||
|
required: "Это поле является обязательным",
|
||||||
|
})}
|
||||||
|
error={!!(errors as any)?.address}
|
||||||
|
helperText={(errors as any)?.address?.message}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="number"
|
type="text"
|
||||||
label={'Долгота *'}
|
label={"Адрес *"}
|
||||||
name="longitude"
|
name="address"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="city_id"
|
name="city_id"
|
||||||
rules={{required: 'Это поле является обязательным'}}
|
rules={{ required: "Это поле является обязательным" }}
|
||||||
defaultValue={null}
|
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
{...cityAutocompleteProps}
|
{...cityAutocompleteProps}
|
||||||
value={cityAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
value={
|
||||||
|
cityAutocompleteProps.options.find(
|
||||||
|
(option) => option.id === field.value
|
||||||
|
) || null
|
||||||
|
}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
field.onChange(value?.id || '')
|
field.onChange(value?.id || "");
|
||||||
}}
|
}}
|
||||||
getOptionLabel={(item) => {
|
getOptionLabel={(item) => {
|
||||||
return item ? item.name : ''
|
return item ? item.name : "";
|
||||||
}}
|
}}
|
||||||
isOptionEqualToValue={(option, value) => {
|
isOptionEqualToValue={(option, value) => {
|
||||||
return option.id === value?.id
|
return option.id === value?.id;
|
||||||
}}
|
}}
|
||||||
filterOptions={(options, { inputValue }) => {
|
filterOptions={(options, { inputValue }) => {
|
||||||
return options.filter((option) => option.name.toLowerCase().includes(inputValue.toLowerCase()))
|
return options.filter((option) =>
|
||||||
|
option.name
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(inputValue.toLowerCase())
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
renderInput={(params) => <TextField {...params} label="Выберите город" margin="normal" variant="outlined" error={!!errors.city_id} helperText={(errors as any)?.city_id?.message} required />}
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Выберите город"
|
||||||
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
error={!!errors.city_id}
|
||||||
|
helperText={(errors as any)?.city_id?.message}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -197,20 +389,38 @@ export const SightCreate = () => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
{...mediaAutocompleteProps}
|
{...mediaAutocompleteProps}
|
||||||
value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
value={
|
||||||
|
mediaAutocompleteProps.options.find(
|
||||||
|
(option) => option.id === field.value
|
||||||
|
) || null
|
||||||
|
}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
field.onChange(value?.id || '')
|
field.onChange(value?.id || "");
|
||||||
}}
|
}}
|
||||||
getOptionLabel={(item) => {
|
getOptionLabel={(item) => {
|
||||||
return item ? item.media_name : ''
|
return item ? item.media_name : "";
|
||||||
}}
|
}}
|
||||||
isOptionEqualToValue={(option, value) => {
|
isOptionEqualToValue={(option, value) => {
|
||||||
return option.id === value?.id
|
return option.id === value?.id;
|
||||||
}}
|
}}
|
||||||
filterOptions={(options, { inputValue }) => {
|
filterOptions={(options, { inputValue }) => {
|
||||||
return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase()))
|
return options.filter((option) =>
|
||||||
|
option.media_name
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(inputValue.toLowerCase())
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
renderInput={(params) => <TextField {...params} label="Выберите обложку" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />}
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Выберите логотип достопримечательности"
|
||||||
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
error={!!errors.thumbnail}
|
||||||
|
helperText={(errors as any)?.thumbnail?.message}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -222,20 +432,37 @@ export const SightCreate = () => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
{...mediaAutocompleteProps}
|
{...mediaAutocompleteProps}
|
||||||
value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
value={
|
||||||
|
mediaAutocompleteProps.options.find(
|
||||||
|
(option) => option.id === field.value
|
||||||
|
) || null
|
||||||
|
}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
field.onChange(value?.id || '')
|
field.onChange(value?.id || "");
|
||||||
}}
|
}}
|
||||||
getOptionLabel={(item) => {
|
getOptionLabel={(item) => {
|
||||||
return item ? item.media_name : ''
|
return item ? item.media_name : "";
|
||||||
}}
|
}}
|
||||||
isOptionEqualToValue={(option, value) => {
|
isOptionEqualToValue={(option, value) => {
|
||||||
return option.id === value?.id
|
return option.id === value?.id;
|
||||||
}}
|
}}
|
||||||
filterOptions={(options, { inputValue }) => {
|
filterOptions={(options, { inputValue }) => {
|
||||||
return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase()))
|
return options.filter((option) =>
|
||||||
|
option.media_name
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(inputValue.toLowerCase())
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
renderInput={(params) => <TextField {...params} label="Выберите водный знак (Левый верх)" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />}
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Выберите водный знак (Левый верх)"
|
||||||
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
error={!!errors.watermark_lu}
|
||||||
|
helperText={(errors as any)?.watermark_lu?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -247,20 +474,37 @@ export const SightCreate = () => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
{...mediaAutocompleteProps}
|
{...mediaAutocompleteProps}
|
||||||
value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
value={
|
||||||
|
mediaAutocompleteProps.options.find(
|
||||||
|
(option) => option.id === field.value
|
||||||
|
) || null
|
||||||
|
}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
field.onChange(value?.id || '')
|
field.onChange(value?.id || "");
|
||||||
}}
|
}}
|
||||||
getOptionLabel={(item) => {
|
getOptionLabel={(item) => {
|
||||||
return item ? item.media_name : ''
|
return item ? item.media_name : "";
|
||||||
}}
|
}}
|
||||||
isOptionEqualToValue={(option, value) => {
|
isOptionEqualToValue={(option, value) => {
|
||||||
return option.id === value?.id
|
return option.id === value?.id;
|
||||||
}}
|
}}
|
||||||
filterOptions={(options, { inputValue }) => {
|
filterOptions={(options, { inputValue }) => {
|
||||||
return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase()))
|
return options.filter((option) =>
|
||||||
|
option.media_name
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(inputValue.toLowerCase())
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
renderInput={(params) => <TextField {...params} label="Выберите водный знак (Правый низ)" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />}
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Выберите водный знак (Правый верх)"
|
||||||
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
error={!!errors.watermark_rd}
|
||||||
|
helperText={(errors as any)?.watermark_rd?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -272,20 +516,37 @@ export const SightCreate = () => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
{...articleAutocompleteProps}
|
{...articleAutocompleteProps}
|
||||||
value={articleAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
value={
|
||||||
|
articleAutocompleteProps.options.find(
|
||||||
|
(option) => option.id === field.value
|
||||||
|
) || null
|
||||||
|
}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
field.onChange(value?.id || '')
|
field.onChange(value?.id || "");
|
||||||
}}
|
}}
|
||||||
getOptionLabel={(item) => {
|
getOptionLabel={(item) => {
|
||||||
return item ? item.heading : ''
|
return item ? item.heading : "";
|
||||||
}}
|
}}
|
||||||
isOptionEqualToValue={(option, value) => {
|
isOptionEqualToValue={(option, value) => {
|
||||||
return option.id === value?.id
|
return option.id === value?.id;
|
||||||
}}
|
}}
|
||||||
filterOptions={(options, { inputValue }) => {
|
filterOptions={(options, { inputValue }) => {
|
||||||
return options.filter((option) => option.heading.toLowerCase().includes(inputValue.toLowerCase()))
|
return options.filter((option) =>
|
||||||
|
option.heading
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(inputValue.toLowerCase())
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
renderInput={(params) => <TextField {...params} label="Левая статья" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />}
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Левая статья"
|
||||||
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
error={!!errors.left_article}
|
||||||
|
helperText={(errors as any)?.left_article?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -297,85 +558,144 @@ export const SightCreate = () => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
{...articleAutocompleteProps}
|
{...articleAutocompleteProps}
|
||||||
value={articleAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
value={
|
||||||
|
articleAutocompleteProps.options.find(
|
||||||
|
(option) => option.id === field.value
|
||||||
|
) || null
|
||||||
|
}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
field.onChange(value?.id || '')
|
field.onChange(value?.id || "");
|
||||||
}}
|
}}
|
||||||
getOptionLabel={(item) => {
|
getOptionLabel={(item) => {
|
||||||
return item ? item.heading : ''
|
return item ? item.heading : "";
|
||||||
}}
|
}}
|
||||||
isOptionEqualToValue={(option, value) => {
|
isOptionEqualToValue={(option, value) => {
|
||||||
return option.id === value?.id
|
return option.id === value?.id;
|
||||||
}}
|
}}
|
||||||
filterOptions={(options, { inputValue }) => {
|
filterOptions={(options, { inputValue }) => {
|
||||||
return options.filter((option) => option.heading.toLowerCase().includes(inputValue.toLowerCase()))
|
return options.filter((option) =>
|
||||||
|
option.heading
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(inputValue.toLowerCase())
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
renderInput={(params) => <TextField {...params} label="Cтатья-предпросмотр" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />}
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Медиа-предпросмотр"
|
||||||
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
error={!!errors.preview_article}
|
||||||
|
helperText={(errors as any)?.preview_article?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Блок предпросмотра */}
|
{/* Preview Panel */}
|
||||||
<Paper
|
<Paper
|
||||||
sx={{
|
sx={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
p: 2,
|
p: 2,
|
||||||
maxHeight: 'calc(100vh - 200px)',
|
maxHeight: "calc(100vh - 200px)",
|
||||||
overflowY: 'auto',
|
overflowY: "auto",
|
||||||
position: 'sticky',
|
position: "sticky",
|
||||||
top: 16,
|
top: 16,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
border: '1px solid',
|
border: "1px solid",
|
||||||
borderColor: 'primary.main',
|
borderColor: "primary.main",
|
||||||
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : '#fff'),
|
bgcolor: (theme) =>
|
||||||
|
theme.palette.mode === "dark" ? "background.paper" : "#fff",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="h6" gutterBottom color="primary">
|
<Typography variant="h6" gutterBottom color="primary">
|
||||||
Предпросмотр
|
Предпросмотр
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* Название */}
|
{/* Название достопримечательности */}
|
||||||
<Typography variant="h4" gutterBottom sx={{color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800')}}>
|
<Typography
|
||||||
|
variant="h4"
|
||||||
|
gutterBottom
|
||||||
|
sx={{
|
||||||
|
color: (theme) =>
|
||||||
|
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{namePreview}
|
{namePreview}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* Город */}
|
{/* Город */}
|
||||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||||
<Box component="span" sx={{color: 'text.secondary'}}>
|
<Box component="span" sx={{ color: "text.secondary" }}>
|
||||||
Город:{' '}
|
Город:{" "}
|
||||||
</Box>
|
</Box>
|
||||||
<Box component="span" sx={{color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800')}}>
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
color: (theme) =>
|
||||||
|
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{cityPreview}
|
{cityPreview}
|
||||||
</Box>
|
</Box>
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
{/* Адрес */}
|
||||||
|
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||||
|
<Box component="span" sx={{ color: "text.secondary" }}>
|
||||||
|
Адрес:{" "}
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
color: (theme) =>
|
||||||
|
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{addressContent}
|
||||||
|
</Box>
|
||||||
|
</Typography>
|
||||||
|
|
||||||
{/* Координаты */}
|
{/* Координаты */}
|
||||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||||
<Box component="span" sx={{color: 'text.secondary'}}>
|
<Box component="span" sx={{ color: "text.secondary" }}>
|
||||||
Координаты:{' '}
|
Координаты:{" "}
|
||||||
</Box>
|
</Box>
|
||||||
<Box component="span" sx={{color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800')}}>
|
<Box
|
||||||
{coordinatesPreview.latitude}, {coordinatesPreview.longitude}
|
component="span"
|
||||||
|
sx={{
|
||||||
|
color: (theme) =>
|
||||||
|
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`${coordinatesPreview.latitude}, ${coordinatesPreview.longitude}`}
|
||||||
</Box>
|
</Box>
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* Обложка */}
|
{/* Обложка */}
|
||||||
{thumbnailPreview && (
|
{thumbnailPreview && (
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
<Typography variant="body1" gutterBottom sx={{color: 'text.secondary'}}>
|
<Typography
|
||||||
Обложка:
|
variant="body1"
|
||||||
|
gutterBottom
|
||||||
|
sx={{ color: "text.secondary" }}
|
||||||
|
>
|
||||||
|
Логотип достопримечательности:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box
|
<Box
|
||||||
component="img"
|
component="img"
|
||||||
src={thumbnailPreview}
|
src={thumbnailPreview}
|
||||||
alt="Обложка"
|
alt="Логотип"
|
||||||
sx={{
|
sx={{
|
||||||
maxWidth: '100%',
|
maxWidth: "100%",
|
||||||
height: 'auto',
|
height: "40vh",
|
||||||
borderRadius: 1,
|
borderRadius: 2,
|
||||||
border: '1px solid',
|
border: "1px solid",
|
||||||
borderColor: 'primary.main',
|
borderColor: "primary.main",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@ -383,13 +703,21 @@ export const SightCreate = () => {
|
|||||||
|
|
||||||
{/* Водяные знаки */}
|
{/* Водяные знаки */}
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2 }}>
|
||||||
<Typography variant="body1" gutterBottom sx={{color: 'text.secondary'}}>
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
gutterBottom
|
||||||
|
sx={{ color: "text.secondary" }}
|
||||||
|
>
|
||||||
Водяные знаки:
|
Водяные знаки:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{display: 'flex', gap: 2}}>
|
<Box sx={{ display: "flex", gap: 2 }}>
|
||||||
{watermarkLUPreview && (
|
{watermarkLUPreview && (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="body2" gutterBottom sx={{color: 'text.secondary'}}>
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
gutterBottom
|
||||||
|
sx={{ color: "text.secondary" }}
|
||||||
|
>
|
||||||
Левый верхний:
|
Левый верхний:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box
|
<Box
|
||||||
@ -399,18 +727,22 @@ export const SightCreate = () => {
|
|||||||
sx={{
|
sx={{
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 100,
|
height: 100,
|
||||||
objectFit: 'cover',
|
objectFit: "cover",
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
border: '1px solid',
|
border: "1px solid",
|
||||||
borderColor: 'primary.main',
|
borderColor: "primary.main",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{watermarkRDPreview && (
|
{watermarkRDPreview && (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="body2" gutterBottom sx={{color: 'text.secondary'}}>
|
<Typography
|
||||||
Правый нижний:
|
variant="body2"
|
||||||
|
gutterBottom
|
||||||
|
sx={{ color: "text.secondary" }}
|
||||||
|
>
|
||||||
|
Правый верхний:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box
|
<Box
|
||||||
component="img"
|
component="img"
|
||||||
@ -419,10 +751,10 @@ export const SightCreate = () => {
|
|||||||
sx={{
|
sx={{
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 100,
|
height: 100,
|
||||||
objectFit: 'cover',
|
objectFit: "cover",
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
border: '1px solid',
|
border: "1px solid",
|
||||||
borderColor: 'primary.main',
|
borderColor: "primary.main",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@ -432,23 +764,16 @@ export const SightCreate = () => {
|
|||||||
|
|
||||||
{/* Связанные статьи */}
|
{/* Связанные статьи */}
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="body1" gutterBottom sx={{color: 'text.secondary'}}>
|
|
||||||
Связанные статьи:
|
|
||||||
</Typography>
|
|
||||||
{leftArticlePreview && (
|
{leftArticlePreview && (
|
||||||
<Typography variant="body1" gutterBottom>
|
<Typography variant="body1" gutterBottom>
|
||||||
<Box component="span" sx={{color: 'text.secondary'}}>
|
<Box component="span" sx={{ color: "text.secondary" }}>
|
||||||
Левая статья:{' '}
|
Левая статья:{" "}
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
component={Link}
|
component="span"
|
||||||
to={`/article/show/${watch('left_article')}`}
|
|
||||||
sx={{
|
sx={{
|
||||||
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'),
|
color: (theme) =>
|
||||||
textDecoration: 'none',
|
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
|
||||||
'&:hover': {
|
|
||||||
textDecoration: 'underline',
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{leftArticlePreview}
|
{leftArticlePreview}
|
||||||
@ -457,18 +782,14 @@ export const SightCreate = () => {
|
|||||||
)}
|
)}
|
||||||
{previewArticlePreview && (
|
{previewArticlePreview && (
|
||||||
<Typography variant="body1" gutterBottom>
|
<Typography variant="body1" gutterBottom>
|
||||||
<Box component="span" sx={{color: 'text.secondary'}}>
|
<Box component="span" sx={{ color: "text.secondary" }}>
|
||||||
Статья-предпросмотр:{' '}
|
Статья-предпросмотр:{" "}
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
component={Link}
|
component="span"
|
||||||
to={`/article/show/${watch('preview_article')}`}
|
|
||||||
sx={{
|
sx={{
|
||||||
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'),
|
color: (theme) =>
|
||||||
textDecoration: 'none',
|
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
|
||||||
'&:hover': {
|
|
||||||
textDecoration: 'underline',
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{previewArticlePreview}
|
{previewArticlePreview}
|
||||||
@ -479,5 +800,5 @@ export const SightCreate = () => {
|
|||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
</Create>
|
</Create>
|
||||||
)
|
);
|
||||||
}
|
});
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,106 +1,136 @@
|
|||||||
import React from 'react'
|
import React, { useEffect } from "react";
|
||||||
import {type GridColDef} from '@mui/x-data-grid'
|
import { type GridColDef } from "@mui/x-data-grid";
|
||||||
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
|
import {
|
||||||
import {Stack} from '@mui/material'
|
DeleteButton,
|
||||||
import {CustomDataGrid} from '../../components/CustomDataGrid'
|
EditButton,
|
||||||
import {localeText} from '../../locales/ru/localeText'
|
List,
|
||||||
|
ShowButton,
|
||||||
|
useDataGrid,
|
||||||
|
} from "@refinedev/mui";
|
||||||
|
import { Stack } from "@mui/material";
|
||||||
|
import { CustomDataGrid } from "../../components/CustomDataGrid";
|
||||||
|
import { localeText } from "../../locales/ru/localeText";
|
||||||
|
import { cityStore } from "../../store/CityStore";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { languageStore } from "../../store/LanguageStore";
|
||||||
|
|
||||||
export const SightList = () => {
|
export const SightList = observer(() => {
|
||||||
const {dataGridProps} = useDataGrid({resource: 'sight/'})
|
const { city_id } = cityStore;
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
|
const { dataGridProps } = useDataGrid({
|
||||||
|
resource: "sight",
|
||||||
|
|
||||||
|
meta: {
|
||||||
|
headers: {
|
||||||
|
"Accept-Language": language,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
filters: {
|
||||||
|
permanent: [
|
||||||
|
{
|
||||||
|
field: "cityID",
|
||||||
|
operator: "eq",
|
||||||
|
value: city_id === "0" ? null : city_id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const columns = React.useMemo<GridColDef[]>(
|
const columns = React.useMemo<GridColDef[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
field: 'id',
|
field: "id",
|
||||||
headerName: 'ID',
|
headerName: "ID",
|
||||||
type: 'number',
|
type: "number",
|
||||||
minWidth: 70,
|
minWidth: 70,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'name',
|
field: "name",
|
||||||
headerName: 'Название',
|
headerName: "Название",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'latitude',
|
field: "latitude",
|
||||||
headerName: 'Широта',
|
headerName: "Широта",
|
||||||
type: 'number',
|
type: "number",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'longitude',
|
field: "longitude",
|
||||||
headerName: 'Долгота',
|
headerName: "Долгота",
|
||||||
type: 'number',
|
type: "number",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'city_id',
|
field: "city_id",
|
||||||
headerName: 'ID города',
|
headerName: "ID города",
|
||||||
type: 'number',
|
type: "number",
|
||||||
minWidth: 70,
|
minWidth: 70,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'city',
|
field: "city",
|
||||||
headerName: 'Город',
|
headerName: "Город",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 100,
|
minWidth: 100,
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'thumbnail',
|
field: "thumbnail",
|
||||||
headerName: 'Карточка',
|
headerName: "Карточка",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'watermark_lu',
|
field: "watermark_lu",
|
||||||
headerName: 'Вод. знак (lu)',
|
headerName: "Вод. знак (lu)",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'watermark_rd',
|
field: "watermark_rd",
|
||||||
headerName: 'Вод. знак (rd)',
|
headerName: "Вод. знак (rd)",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'left_article',
|
field: "left_article",
|
||||||
headerName: 'Левая статья',
|
headerName: "Левая статья",
|
||||||
type: 'number',
|
type: "number",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'preview_article',
|
field: "preview_article",
|
||||||
headerName: 'Пред. просмотр статьи',
|
headerName: "Пред. просмотр статьи",
|
||||||
type: 'number',
|
type: "number",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'actions',
|
field: "actions",
|
||||||
headerName: 'Действия',
|
headerName: "Действия",
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'right',
|
align: "right",
|
||||||
headerAlign: 'center',
|
headerAlign: "center",
|
||||||
sortable: false,
|
sortable: false,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
disableColumnMenu: true,
|
disableColumnMenu: true,
|
||||||
@ -109,20 +139,31 @@ export const SightList = () => {
|
|||||||
<>
|
<>
|
||||||
<EditButton hideText recordItemId={row.id} />
|
<EditButton hideText recordItemId={row.id} />
|
||||||
<ShowButton hideText recordItemId={row.id} />
|
<ShowButton hideText recordItemId={row.id} />
|
||||||
<DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} />
|
<DeleteButton
|
||||||
|
hideText
|
||||||
|
confirmTitle="Вы уверены?"
|
||||||
|
recordItemId={row.id}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[]
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List>
|
<List>
|
||||||
<Stack gap={2.5}>
|
<Stack gap={2.5}>
|
||||||
<CustomDataGrid {...dataGridProps} columns={columns} localeText={localeText} getRowId={(row: any) => row.id} hasCoordinates />
|
<CustomDataGrid
|
||||||
|
{...dataGridProps}
|
||||||
|
languageEnabled
|
||||||
|
columns={columns}
|
||||||
|
localeText={localeText}
|
||||||
|
getRowId={(row: any) => row.id}
|
||||||
|
hasCoordinates
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</List>
|
</List>
|
||||||
)
|
);
|
||||||
}
|
});
|
||||||
|
@ -1,22 +1,23 @@
|
|||||||
import {Stack, Typography} from '@mui/material'
|
import { Stack, Typography } from "@mui/material";
|
||||||
import {useShow} from '@refinedev/core'
|
import { useShow } from "@refinedev/core";
|
||||||
import {Show, TextFieldComponent} from '@refinedev/mui'
|
import { Show, TextFieldComponent } from "@refinedev/mui";
|
||||||
import {LinkedItems} from '../../components/LinkedItems'
|
import { LinkedItems } from "../../components/LinkedItems";
|
||||||
import {ArticleItem, articleFields} from './types'
|
import { ArticleItem, articleFields } from "./types";
|
||||||
|
|
||||||
export const SightShow = () => {
|
export const SightShow = () => {
|
||||||
const {query} = useShow({})
|
const { query } = useShow({});
|
||||||
const {data, isLoading} = query
|
const { data, isLoading } = query;
|
||||||
const record = data?.data
|
const record = data?.data;
|
||||||
|
|
||||||
const fields = [
|
const fields = [
|
||||||
// {label: 'ID', data: 'id'},
|
// {label: 'ID', data: 'id'},
|
||||||
{label: 'Название', data: 'name'},
|
{ label: "Название", data: "name" },
|
||||||
// {label: 'Широта', data: 'latitude'}, #*
|
// {label: 'Широта', data: 'latitude'}, #*
|
||||||
// {label: 'Долгота', data: 'longitude'}, #*
|
// {label: 'Долгота', data: 'longitude'}, #*
|
||||||
// {label: 'ID города', data: 'city_id'},
|
// {label: 'ID города', data: 'city_id'},
|
||||||
{label: 'Город', data: 'city'},
|
{ label: "Адрес", data: "address" },
|
||||||
]
|
{ label: "Город", data: "city" },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show isLoading={isLoading}>
|
<Show isLoading={isLoading}>
|
||||||
@ -30,8 +31,17 @@ export const SightShow = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{record?.id && <LinkedItems<ArticleItem> type="show" parentId={record.id} parentResource="sight" childResource="article" fields={articleFields} title="статьи" />}
|
{record?.id && (
|
||||||
|
<LinkedItems<ArticleItem>
|
||||||
|
type="show"
|
||||||
|
parentId={record.id}
|
||||||
|
parentResource="sight"
|
||||||
|
childResource="article"
|
||||||
|
fields={articleFields}
|
||||||
|
title="статьи"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
@ -1,19 +1,28 @@
|
|||||||
import {Autocomplete, Box, TextField, Typography, Paper, Grid} from '@mui/material'
|
import {
|
||||||
import {Create, useAutocomplete} from '@refinedev/mui'
|
Autocomplete,
|
||||||
import {useForm} from '@refinedev/react-hook-form'
|
Box,
|
||||||
import {Controller} from 'react-hook-form'
|
TextField,
|
||||||
|
Typography,
|
||||||
|
FormControlLabel,
|
||||||
|
Checkbox,
|
||||||
|
Grid,
|
||||||
|
Paper,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { Create, useAutocomplete } from "@refinedev/mui";
|
||||||
|
import { useForm } from "@refinedev/react-hook-form";
|
||||||
|
import { Controller } from "react-hook-form";
|
||||||
|
|
||||||
const TRANSFER_FIELDS = [
|
const TRANSFER_FIELDS = [
|
||||||
{name: 'bus', label: 'Автобус'},
|
{ name: "bus", label: "Автобус" },
|
||||||
{name: 'metro_blue', label: 'Метро (синяя)'},
|
{ name: "metro_blue", label: "Метро (синяя)" },
|
||||||
{name: 'metro_green', label: 'Метро (зеленая)'},
|
{ name: "metro_green", label: "Метро (зеленая)" },
|
||||||
{name: 'metro_orange', label: 'Метро (оранжевая)'},
|
{ name: "metro_orange", label: "Метро (оранжевая)" },
|
||||||
{name: 'metro_purple', label: 'Метро (фиолетовая)'},
|
{ name: "metro_purple", label: "Метро (фиолетовая)" },
|
||||||
{name: 'metro_red', label: 'Метро (красная)'},
|
{ name: "metro_red", label: "Метро (красная)" },
|
||||||
{name: 'train', label: 'Электричка'},
|
{ name: "train", label: "Электричка" },
|
||||||
{name: 'tram', label: 'Трамвай'},
|
{ name: "tram", label: "Трамвай" },
|
||||||
{name: 'trolleybus', label: 'Троллейбус'},
|
{ name: "trolleybus", label: "Троллейбус" },
|
||||||
]
|
];
|
||||||
|
|
||||||
export const StationCreate = () => {
|
export const StationCreate = () => {
|
||||||
const {
|
const {
|
||||||
@ -24,27 +33,31 @@ export const StationCreate = () => {
|
|||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm({
|
} = useForm({
|
||||||
refineCoreProps: {
|
refineCoreProps: {
|
||||||
resource: 'station/',
|
resource: "station/",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({
|
const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({
|
||||||
resource: 'city',
|
resource: "city",
|
||||||
onSearch: (value) => [
|
onSearch: (value) => [
|
||||||
{
|
{
|
||||||
field: 'name',
|
field: "name",
|
||||||
operator: 'contains',
|
operator: "contains",
|
||||||
value,
|
value,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
|
<Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
|
||||||
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
<Box
|
||||||
|
component="form"
|
||||||
|
sx={{ display: "flex", flexDirection: "column" }}
|
||||||
|
autoComplete="off"
|
||||||
|
>
|
||||||
<TextField
|
<TextField
|
||||||
{...register('name', {
|
{...register("name", {
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.name}
|
error={!!(errors as any)?.name}
|
||||||
helperText={(errors as any)?.name?.message}
|
helperText={(errors as any)?.name?.message}
|
||||||
@ -52,12 +65,12 @@ export const StationCreate = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="text"
|
type="text"
|
||||||
label={'Название *'}
|
label={"Название *"}
|
||||||
name="name"
|
name="name"
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
{...register('system_name', {
|
{...register("system_name", {
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.system_name}
|
error={!!(errors as any)?.system_name}
|
||||||
helperText={(errors as any)?.system_name?.message}
|
helperText={(errors as any)?.system_name?.message}
|
||||||
@ -65,11 +78,25 @@ export const StationCreate = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="text"
|
type="text"
|
||||||
label={'Системное название *'}
|
label={"Системное название *"}
|
||||||
name="system_name"
|
name="system_name"
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
{...register('description', {
|
{...register("address", {
|
||||||
|
// required: 'Это поле является обязательным',
|
||||||
|
})}
|
||||||
|
error={!!(errors as any)?.address}
|
||||||
|
helperText={(errors as any)?.address?.message}
|
||||||
|
margin="normal"
|
||||||
|
fullWidth
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
type="text"
|
||||||
|
label={"Адрес"}
|
||||||
|
name="address"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
{...register("description", {
|
||||||
// required: 'Это поле является обязательным',
|
// required: 'Это поле является обязательным',
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.description}
|
error={!!(errors as any)?.description}
|
||||||
@ -78,12 +105,29 @@ export const StationCreate = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="text"
|
type="text"
|
||||||
label={'Описание'}
|
label={"Описание"}
|
||||||
name="description"
|
name="description"
|
||||||
/>
|
/>
|
||||||
|
<Controller
|
||||||
|
name="direction" // boolean
|
||||||
|
control={control}
|
||||||
|
defaultValue={false}
|
||||||
|
render={({ field }: { field: any }) => (
|
||||||
|
<FormControlLabel
|
||||||
|
label="Прямой маршрут?"
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
{...field}
|
||||||
|
checked={field.value}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
{...register('latitude', {
|
{...register("latitude", {
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
valueAsNumber: true,
|
valueAsNumber: true,
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.latitude}
|
error={!!(errors as any)?.latitude}
|
||||||
@ -92,12 +136,12 @@ export const StationCreate = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="number"
|
type="number"
|
||||||
label={'Широта *'}
|
label={"Широта *"}
|
||||||
name="latitude"
|
name="latitude"
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
{...register('longitude', {
|
{...register("longitude", {
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
valueAsNumber: true,
|
valueAsNumber: true,
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.longitude}
|
error={!!(errors as any)?.longitude}
|
||||||
@ -106,38 +150,54 @@ export const StationCreate = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="number"
|
type="number"
|
||||||
label={'Долгота *'}
|
label={"Долгота *"}
|
||||||
name="longitude"
|
name="longitude"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="city_id"
|
name="city_id"
|
||||||
rules={{required: 'Это поле является обязательным'}}
|
rules={{ required: "Это поле является обязательным" }}
|
||||||
defaultValue={null}
|
defaultValue={null}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
{...cityAutocompleteProps}
|
{...cityAutocompleteProps}
|
||||||
value={cityAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
value={
|
||||||
|
cityAutocompleteProps.options.find(
|
||||||
|
(option) => option.id === field.value
|
||||||
|
) || null
|
||||||
|
}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
field.onChange(value?.id || '')
|
field.onChange(value?.id || "");
|
||||||
}}
|
}}
|
||||||
getOptionLabel={(item) => {
|
getOptionLabel={(item) => {
|
||||||
return item ? item.name : ''
|
return item ? item.name : "";
|
||||||
}}
|
}}
|
||||||
isOptionEqualToValue={(option, value) => {
|
isOptionEqualToValue={(option, value) => {
|
||||||
return option.id === value?.id
|
return option.id === value?.id;
|
||||||
}}
|
}}
|
||||||
filterOptions={(options, { inputValue }) => {
|
filterOptions={(options, { inputValue }) => {
|
||||||
return options.filter((option) => option.name.toLowerCase().includes(inputValue.toLowerCase()))
|
return options.filter((option) =>
|
||||||
|
option.name.toLowerCase().includes(inputValue.toLowerCase())
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
renderInput={(params) => <TextField {...params} label="Выберите город" margin="normal" variant="outlined" error={!!errors.city_id} helperText={(errors as any)?.city_id?.message} required />}
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Выберите город"
|
||||||
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
error={!!errors.city_id}
|
||||||
|
helperText={(errors as any)?.city_id?.message}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('offset_x', {
|
{...register("offset_x", {
|
||||||
// required: 'Это поле является обязательным',
|
// required: 'Это поле является обязательным',
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.offset_x}
|
error={!!(errors as any)?.offset_x}
|
||||||
@ -146,12 +206,12 @@ export const StationCreate = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="number"
|
type="number"
|
||||||
label={'Смещение (X)'}
|
label={"Смещение (X)"}
|
||||||
name="offset_x"
|
name="offset_x"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
{...register('offset_y', {
|
{...register("offset_y", {
|
||||||
// required: 'Это поле является обязательным',
|
// required: 'Это поле является обязательным',
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.offset_y}
|
error={!!(errors as any)?.offset_y}
|
||||||
@ -160,7 +220,7 @@ export const StationCreate = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="number"
|
type="number"
|
||||||
label={'Смещение (Y)'}
|
label={"Смещение (Y)"}
|
||||||
name="offset_y"
|
name="offset_y"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -172,12 +232,22 @@ export const StationCreate = () => {
|
|||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
{TRANSFER_FIELDS.map((field) => (
|
{TRANSFER_FIELDS.map((field) => (
|
||||||
<Grid item xs={12} sm={6} md={4} key={field.name}>
|
<Grid item xs={12} sm={6} md={4} key={field.name}>
|
||||||
<TextField {...register(`transfers.${field.name}`)} error={!!(errors as any)?.transfers?.[field.name]} helperText={(errors as any)?.transfers?.[field.name]?.message} margin="normal" fullWidth InputLabelProps={{shrink: true}} type="text" label={field.label} name={`transfers.${field.name}`} />
|
<TextField
|
||||||
|
{...register(`transfers.${field.name}`)}
|
||||||
|
error={!!(errors as any)?.transfers?.[field.name]}
|
||||||
|
helperText={(errors as any)?.transfers?.[field.name]?.message}
|
||||||
|
margin="normal"
|
||||||
|
fullWidth
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
type="text"
|
||||||
|
label={field.label}
|
||||||
|
name={`transfers.${field.name}`}
|
||||||
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
</Create>
|
</Create>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
@ -1,51 +1,199 @@
|
|||||||
import {Autocomplete, Box, TextField, Typography, Paper, Grid} from '@mui/material'
|
import {
|
||||||
import {Edit, useAutocomplete} from '@refinedev/mui'
|
Autocomplete,
|
||||||
import {useForm} from '@refinedev/react-hook-form'
|
Box,
|
||||||
import {Controller} from 'react-hook-form'
|
TextField,
|
||||||
|
Typography,
|
||||||
|
FormControlLabel,
|
||||||
|
Paper,
|
||||||
|
Grid,
|
||||||
|
Checkbox,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { Edit, useAutocomplete } from "@refinedev/mui";
|
||||||
|
import { useForm } from "@refinedev/react-hook-form";
|
||||||
|
import { Controller } from "react-hook-form";
|
||||||
|
|
||||||
import {useParams} from 'react-router'
|
import { useParams } from "react-router";
|
||||||
import {LinkedItems} from '../../components/LinkedItems'
|
import { LinkedItems } from "../../components/LinkedItems";
|
||||||
import {type SightItem, sightFields} from './types'
|
import { type SightItem, sightFields } from "./types";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { languageStore } from "../../store/LanguageStore";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { LanguageSwitch } from "../../components/LanguageSwitch/index";
|
||||||
|
|
||||||
const TRANSFER_FIELDS = [
|
const TRANSFER_FIELDS = [
|
||||||
{name: 'bus', label: 'Автобус'},
|
{ name: "bus", label: "Автобус" },
|
||||||
{name: 'metro_blue', label: 'Метро (синяя)'},
|
{ name: "metro_blue", label: "Метро (синяя)" },
|
||||||
{name: 'metro_green', label: 'Метро (зеленая)'},
|
{ name: "metro_green", label: "Метро (зеленая)" },
|
||||||
{name: 'metro_orange', label: 'Метро (оранжевая)'},
|
{ name: "metro_orange", label: "Метро (оранжевая)" },
|
||||||
{name: 'metro_purple', label: 'Метро (фиолетовая)'},
|
{ name: "metro_purple", label: "Метро (фиолетовая)" },
|
||||||
{name: 'metro_red', label: 'Метро (красная)'},
|
{ name: "metro_red", label: "Метро (красная)" },
|
||||||
{name: 'train', label: 'Электричка'},
|
{ name: "train", label: "Электричка" },
|
||||||
{name: 'tram', label: 'Трамвай'},
|
{ name: "tram", label: "Трамвай" },
|
||||||
{name: 'trolleybus', label: 'Троллейбус'},
|
{ name: "trolleybus", label: "Троллейбус" },
|
||||||
]
|
];
|
||||||
|
|
||||||
|
export const StationEdit = observer(() => {
|
||||||
|
const { language, setLanguageAction } = languageStore;
|
||||||
|
const [stationData, setStationData] = useState({
|
||||||
|
ru: {
|
||||||
|
name: "",
|
||||||
|
system_name: "",
|
||||||
|
description: "",
|
||||||
|
address: "",
|
||||||
|
latitude: "",
|
||||||
|
longitude: "",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
name: "",
|
||||||
|
system_name: "",
|
||||||
|
description: "",
|
||||||
|
address: "",
|
||||||
|
latitude: "",
|
||||||
|
longitude: "",
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
name: "",
|
||||||
|
system_name: "",
|
||||||
|
description: "",
|
||||||
|
address: "",
|
||||||
|
latitude: "",
|
||||||
|
longitude: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleLanguageChange = () => {
|
||||||
|
setStationData((prevData) => ({
|
||||||
|
...prevData,
|
||||||
|
[language]: {
|
||||||
|
name: watch("name") ?? "",
|
||||||
|
system_name: watch("system_name") ?? "",
|
||||||
|
description: watch("description") ?? "",
|
||||||
|
address: watch("address") ?? "",
|
||||||
|
latitude: watch("latitude") ?? "",
|
||||||
|
longitude: watch("longitude") ?? "",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const [coordinatesPreview, setCoordinatesPreview] = useState({
|
||||||
|
latitude: "",
|
||||||
|
longitude: "",
|
||||||
|
});
|
||||||
|
|
||||||
export const StationEdit = () => {
|
|
||||||
const {
|
const {
|
||||||
saveButtonProps,
|
saveButtonProps,
|
||||||
register,
|
register,
|
||||||
control,
|
control,
|
||||||
|
getValues,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm({})
|
} = useForm({
|
||||||
|
refineCoreProps: {
|
||||||
|
meta: {
|
||||||
|
headers: { "Accept-Language": language },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const {id: stationId} = useParams<{id: string}>()
|
useEffect(() => {
|
||||||
|
if (stationData[language as keyof typeof stationData]?.name) {
|
||||||
|
setValue("name", stationData[language as keyof typeof stationData]?.name);
|
||||||
|
}
|
||||||
|
if (stationData[language as keyof typeof stationData]?.address) {
|
||||||
|
setValue(
|
||||||
|
"system_name",
|
||||||
|
stationData[language as keyof typeof stationData]?.system_name || ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (stationData[language as keyof typeof stationData]?.description) {
|
||||||
|
setValue(
|
||||||
|
"description",
|
||||||
|
stationData[language as keyof typeof stationData]?.description || ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (stationData[language as keyof typeof stationData]?.latitude) {
|
||||||
|
setValue(
|
||||||
|
"latitude",
|
||||||
|
stationData[language as keyof typeof stationData]?.latitude || ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (stationData[language as keyof typeof stationData]?.longitude) {
|
||||||
|
setValue(
|
||||||
|
"longitude",
|
||||||
|
stationData[language as keyof typeof stationData]?.longitude || ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [language, stationData, setValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLanguageAction("ru");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { id: stationId } = useParams<{ id: string }>();
|
||||||
|
|
||||||
|
const handleCoordinatesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const [lat, lon] = e.target.value.split(",").map((s) => s.trim());
|
||||||
|
setCoordinatesPreview({
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon,
|
||||||
|
});
|
||||||
|
setValue("latitude", lat);
|
||||||
|
setValue("longitude", lon);
|
||||||
|
};
|
||||||
|
|
||||||
|
const latitudeContent = watch("latitude");
|
||||||
|
const longitudeContent = watch("longitude");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCoordinatesPreview({
|
||||||
|
latitude: latitudeContent || "",
|
||||||
|
longitude: longitudeContent || "",
|
||||||
|
});
|
||||||
|
}, [latitudeContent, longitudeContent]);
|
||||||
|
|
||||||
const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({
|
const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({
|
||||||
resource: 'city',
|
resource: "city",
|
||||||
onSearch: (value) => [
|
onSearch: (value) => [
|
||||||
{
|
{
|
||||||
field: 'name',
|
field: "name",
|
||||||
operator: 'contains',
|
operator: "contains",
|
||||||
value,
|
value,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
|
||||||
|
meta: {
|
||||||
|
headers: {
|
||||||
|
"Accept-Language": "ru",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
queryOptions: {
|
||||||
|
queryKey: ["city"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const latitude = getValues("latitude");
|
||||||
|
const longitude = getValues("longitude");
|
||||||
|
if (latitude && longitude) {
|
||||||
|
setCoordinatesPreview({
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [getValues]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Edit saveButtonProps={saveButtonProps}>
|
<Edit saveButtonProps={saveButtonProps}>
|
||||||
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
<Box
|
||||||
|
component="form"
|
||||||
|
sx={{ display: "flex", flexDirection: "column" }}
|
||||||
|
autoComplete="off"
|
||||||
|
>
|
||||||
|
<LanguageSwitch action={handleLanguageChange} />
|
||||||
<TextField
|
<TextField
|
||||||
{...register('name', {
|
{...register("name", {
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.name}
|
error={!!(errors as any)?.name}
|
||||||
helperText={(errors as any)?.name?.message}
|
helperText={(errors as any)?.name?.message}
|
||||||
@ -53,12 +201,12 @@ export const StationEdit = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="text"
|
type="text"
|
||||||
label={'Название *'}
|
label={"Название *"}
|
||||||
name="name"
|
name="name"
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
{...register('system_name', {
|
{...register("system_name", {
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.system_name}
|
error={!!(errors as any)?.system_name}
|
||||||
helperText={(errors as any)?.system_name?.message}
|
helperText={(errors as any)?.system_name?.message}
|
||||||
@ -66,11 +214,28 @@ export const StationEdit = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="text"
|
type="text"
|
||||||
label={'Системное название *'}
|
label={"Системное название *"}
|
||||||
name="system_name"
|
name="system_name"
|
||||||
/>
|
/>
|
||||||
|
<Controller
|
||||||
|
name="direction" // boolean
|
||||||
|
control={control}
|
||||||
|
defaultValue={false}
|
||||||
|
render={({ field }: { field: any }) => (
|
||||||
|
<FormControlLabel
|
||||||
|
label="Прямой маршрут?"
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
{...field}
|
||||||
|
checked={field.value}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
{...register('description', {
|
{...register("description", {
|
||||||
// required: 'Это поле является обязательным',
|
// required: 'Это поле является обязательным',
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.description}
|
error={!!(errors as any)?.description}
|
||||||
@ -79,105 +244,86 @@ export const StationEdit = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="text"
|
type="text"
|
||||||
label={'Описание'}
|
label={"Описание"}
|
||||||
name="description"
|
name="description"
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
{...register('latitude', {
|
{...register("address", {
|
||||||
required: 'Это поле является обязательным',
|
// required: 'Это поле является обязательным',
|
||||||
valueAsNumber: true,
|
|
||||||
})}
|
})}
|
||||||
|
error={!!(errors as any)?.address}
|
||||||
|
helperText={(errors as any)?.address?.message}
|
||||||
|
margin="normal"
|
||||||
|
fullWidth
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
type="text"
|
||||||
|
label={"Адрес"}
|
||||||
|
name="address"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
value={`${coordinatesPreview.latitude}, ${coordinatesPreview.longitude}`}
|
||||||
|
onChange={handleCoordinatesChange}
|
||||||
error={!!(errors as any)?.latitude}
|
error={!!(errors as any)?.latitude}
|
||||||
helperText={(errors as any)?.latitude?.message}
|
helperText={(errors as any)?.latitude?.message}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
fullWidth
|
fullWidth
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
type="number"
|
type="text"
|
||||||
label={'Широта *'}
|
label={"Координаты *"}
|
||||||
name="latitude"
|
|
||||||
/>
|
/>
|
||||||
<TextField
|
<input
|
||||||
{...register('longitude', {
|
type="hidden"
|
||||||
required: 'Это поле является обязательным',
|
{...register("latitude", {
|
||||||
valueAsNumber: true,
|
value: coordinatesPreview.latitude,
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.longitude}
|
/>
|
||||||
helperText={(errors as any)?.longitude?.message}
|
<input
|
||||||
margin="normal"
|
type="hidden"
|
||||||
fullWidth
|
{...register("longitude", { value: coordinatesPreview.longitude })}
|
||||||
InputLabelProps={{shrink: true}}
|
|
||||||
type="number"
|
|
||||||
label={'Долгота *'}
|
|
||||||
name="longitude"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="city_id"
|
name="city_id"
|
||||||
rules={{required: 'Это поле является обязательным'}}
|
rules={{ required: "Это поле является обязательным" }}
|
||||||
defaultValue={null}
|
defaultValue={null}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
{...cityAutocompleteProps}
|
{...cityAutocompleteProps}
|
||||||
value={cityAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
value={
|
||||||
|
cityAutocompleteProps.options.find(
|
||||||
|
(option) => option.id === field.value
|
||||||
|
) || null
|
||||||
|
}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
field.onChange(value?.id || '')
|
field.onChange(value?.id || "");
|
||||||
}}
|
}}
|
||||||
getOptionLabel={(item) => {
|
getOptionLabel={(item) => {
|
||||||
return item ? item.name : ''
|
return item ? item.name : "";
|
||||||
}}
|
}}
|
||||||
isOptionEqualToValue={(option, value) => {
|
isOptionEqualToValue={(option, value) => {
|
||||||
return option.id === value?.id
|
return option.id === value?.id;
|
||||||
}}
|
}}
|
||||||
filterOptions={(options, { inputValue }) => {
|
filterOptions={(options, { inputValue }) => {
|
||||||
return options.filter((option) => option.name.toLowerCase().includes(inputValue.toLowerCase()))
|
return options.filter((option) =>
|
||||||
|
option.name.toLowerCase().includes(inputValue.toLowerCase())
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
renderInput={(params) => <TextField {...params} label="Выберите город" margin="normal" variant="outlined" error={!!errors.city_id} helperText={(errors as any)?.city_id?.message} required />}
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Выберите город"
|
||||||
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
error={!!errors.city_id}
|
||||||
|
helperText={(errors as any)?.city_id?.message}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<TextField
|
|
||||||
{...register('offset_x', {
|
|
||||||
// required: 'Это поле является обязательным',
|
|
||||||
})}
|
|
||||||
error={!!(errors as any)?.offset_x}
|
|
||||||
helperText={(errors as any)?.offset_x?.message}
|
|
||||||
margin="normal"
|
|
||||||
fullWidth
|
|
||||||
InputLabelProps={{shrink: true}}
|
|
||||||
type="number"
|
|
||||||
label={'Смещение (X)'}
|
|
||||||
name="offset_x"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
|
||||||
{...register('offset_y', {
|
|
||||||
// required: 'Это поле является обязательным',
|
|
||||||
})}
|
|
||||||
error={!!(errors as any)?.offset_y}
|
|
||||||
helperText={(errors as any)?.offset_y?.message}
|
|
||||||
margin="normal"
|
|
||||||
fullWidth
|
|
||||||
InputLabelProps={{shrink: true}}
|
|
||||||
type="number"
|
|
||||||
label={'Смещение (Y)'}
|
|
||||||
name="offset_y"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Группа полей пересадок */}
|
|
||||||
<Paper sx={{p: 2, mt: 2}}>
|
|
||||||
<Typography variant="h6" gutterBottom>
|
|
||||||
Пересадки
|
|
||||||
</Typography>
|
|
||||||
<Grid container spacing={2}>
|
|
||||||
{TRANSFER_FIELDS.map((field) => (
|
|
||||||
<Grid item xs={12} sm={6} md={4} key={field.name}>
|
|
||||||
<TextField {...register(`transfers.${field.name}`)} error={!!(errors as any)?.transfers?.[field.name]} helperText={(errors as any)?.transfers?.[field.name]?.message} margin="normal" fullWidth InputLabelProps={{shrink: true}} type="text" label={field.label} name={`transfers.${field.name}`} />
|
|
||||||
</Grid>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{stationId && (
|
{stationId && (
|
||||||
@ -188,8 +334,9 @@ export const StationEdit = () => {
|
|||||||
childResource="sight"
|
childResource="sight"
|
||||||
fields={sightFields}
|
fields={sightFields}
|
||||||
title="достопримечательности"
|
title="достопримечательности"
|
||||||
|
dragAllowed={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Edit>
|
</Edit>
|
||||||
)
|
);
|
||||||
}
|
});
|
||||||
|
@ -1,104 +1,145 @@
|
|||||||
import React from 'react'
|
import React, { useEffect, useMemo } from "react";
|
||||||
import {type GridColDef} from '@mui/x-data-grid'
|
import { type GridColDef } from "@mui/x-data-grid";
|
||||||
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
|
import {
|
||||||
import {Stack} from '@mui/material'
|
DeleteButton,
|
||||||
import {CustomDataGrid} from '../../components/CustomDataGrid'
|
EditButton,
|
||||||
import {localeText} from '../../locales/ru/localeText'
|
List,
|
||||||
|
ShowButton,
|
||||||
|
useDataGrid,
|
||||||
|
} from "@refinedev/mui";
|
||||||
|
import { Stack, Typography } from "@mui/material";
|
||||||
|
import { CustomDataGrid } from "../../components/CustomDataGrid";
|
||||||
|
import { localeText } from "../../locales/ru/localeText";
|
||||||
|
import { cityStore } from "../../store/CityStore";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { languageStore } from "../../store/LanguageStore";
|
||||||
|
|
||||||
export const StationList = () => {
|
export const StationList = observer(() => {
|
||||||
const {dataGridProps} = useDataGrid({resource: 'station/'})
|
const { city_id } = cityStore;
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
|
const { dataGridProps } = useDataGrid({
|
||||||
|
resource: "station",
|
||||||
|
meta: {
|
||||||
|
headers: {
|
||||||
|
"Accept-Language": language,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
permanent: [
|
||||||
|
{
|
||||||
|
field: "cityID",
|
||||||
|
operator: "eq",
|
||||||
|
value: city_id === "0" ? null : city_id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const columns = React.useMemo<GridColDef[]>(
|
const columns = React.useMemo<GridColDef[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
field: 'id',
|
field: "id",
|
||||||
headerName: 'ID',
|
headerName: "ID",
|
||||||
type: 'number',
|
type: "number",
|
||||||
minWidth: 70,
|
minWidth: 70,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'name',
|
field: "name",
|
||||||
headerName: 'Название',
|
headerName: "Название",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 300,
|
minWidth: 300,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'system_name',
|
field: "system_name",
|
||||||
headerName: 'Системное название',
|
headerName: "Системное название",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 200,
|
minWidth: 200,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'latitude',
|
field: "direction",
|
||||||
headerName: 'Широта',
|
headerName: "Направление",
|
||||||
type: 'number',
|
type: "boolean",
|
||||||
|
minWidth: 200,
|
||||||
|
display: "flex",
|
||||||
|
|
||||||
|
renderCell: ({ value }) => (
|
||||||
|
<Typography style={{ color: value ? "#48989f" : "#7f6b58" }}>
|
||||||
|
{value ? "прямой" : "обратный"}
|
||||||
|
</Typography>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "latitude",
|
||||||
|
headerName: "Широта",
|
||||||
|
type: "number",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'longitude',
|
field: "longitude",
|
||||||
headerName: 'Долгота',
|
headerName: "Долгота",
|
||||||
type: 'number',
|
type: "number",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'city_id',
|
field: "city_id",
|
||||||
headerName: 'ID города',
|
headerName: "ID города",
|
||||||
type: 'number',
|
type: "number",
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'offset_x',
|
field: "offset_x",
|
||||||
headerName: 'Смещение (X)',
|
headerName: "Смещение (X)",
|
||||||
type: 'number',
|
type: "number",
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'offset_y',
|
field: "offset_y",
|
||||||
headerName: 'Смещение (Y)',
|
headerName: "Смещение (Y)",
|
||||||
type: 'number',
|
type: "number",
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'description',
|
field: "description",
|
||||||
headerName: 'Описание',
|
headerName: "Описание",
|
||||||
type: 'string',
|
type: "string",
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'actions',
|
field: "actions",
|
||||||
headerName: 'Действия',
|
headerName: "Действия",
|
||||||
cellClassName: 'station-actions',
|
cellClassName: "station-actions",
|
||||||
align: 'right',
|
align: "right",
|
||||||
headerAlign: 'center',
|
headerAlign: "center",
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
sortable: false,
|
sortable: false,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
disableColumnMenu: true,
|
disableColumnMenu: true,
|
||||||
@ -107,20 +148,31 @@ export const StationList = () => {
|
|||||||
<>
|
<>
|
||||||
<EditButton hideText recordItemId={row.id} />
|
<EditButton hideText recordItemId={row.id} />
|
||||||
<ShowButton hideText recordItemId={row.id} />
|
<ShowButton hideText recordItemId={row.id} />
|
||||||
<DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} />
|
<DeleteButton
|
||||||
|
hideText
|
||||||
|
confirmTitle="Вы уверены?"
|
||||||
|
recordItemId={row.id}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[]
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List>
|
<List key={city_id}>
|
||||||
<Stack gap={2.5}>
|
<Stack gap={2.5}>
|
||||||
<CustomDataGrid {...dataGridProps} columns={columns} localeText={localeText} getRowId={(row: any) => row.id} hasCoordinates />
|
<CustomDataGrid
|
||||||
|
{...dataGridProps}
|
||||||
|
columns={columns}
|
||||||
|
languageEnabled
|
||||||
|
localeText={localeText}
|
||||||
|
getRowId={(row: any) => row.id}
|
||||||
|
hasCoordinates
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</List>
|
</List>
|
||||||
)
|
);
|
||||||
}
|
});
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import {useShow} from '@refinedev/core'
|
import { useShow } from "@refinedev/core";
|
||||||
import {Show, TextFieldComponent as TextField} from '@refinedev/mui'
|
import { Show, TextFieldComponent as TextField } from "@refinedev/mui";
|
||||||
import {Stack, Typography} from '@mui/material'
|
import { Box, Stack, Typography } from "@mui/material";
|
||||||
import {LinkedItems} from '../../components/LinkedItems'
|
import { LinkedItems } from "../../components/LinkedItems";
|
||||||
import {type SightItem, sightFields, stationFields} from './types'
|
import { type SightItem, sightFields, stationFields } from "./types";
|
||||||
|
|
||||||
export const StationShow = () => {
|
export const StationShow = () => {
|
||||||
const {query} = useShow({})
|
const { query } = useShow({});
|
||||||
const {data, isLoading} = query
|
const { data, isLoading } = query;
|
||||||
const record = data?.data
|
const record = data?.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show isLoading={isLoading}>
|
<Show isLoading={isLoading}>
|
||||||
@ -16,8 +16,16 @@ export const StationShow = () => {
|
|||||||
<Stack key={data} gap={1}>
|
<Stack key={data} gap={1}>
|
||||||
<Typography variant="body1" fontWeight="bold">
|
<Typography variant="body1" fontWeight="bold">
|
||||||
{label}
|
{label}
|
||||||
|
{label === "Системное название" && (
|
||||||
|
<Box>
|
||||||
|
<TextField
|
||||||
|
value={record?.direction ? "(Прямой)" : "(Обратный)"}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<TextField value={record?.[data] || ''} />
|
|
||||||
|
<TextField value={record?.[data] || ""} />
|
||||||
</Stack>
|
</Stack>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@ -33,5 +41,5 @@ export const StationShow = () => {
|
|||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
@ -1,44 +1,46 @@
|
|||||||
import React from 'react'
|
import React from "react";
|
||||||
|
|
||||||
export type StationItem = {
|
export type StationItem = {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
description: string
|
description: string;
|
||||||
latitude: number
|
latitude: number;
|
||||||
longitude: number
|
longitude: number;
|
||||||
[key: string]: string | number
|
[key: string]: string | number;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type SightItem = {
|
export type SightItem = {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
latitude: number
|
latitude: number;
|
||||||
longitude: number
|
longitude: number;
|
||||||
city_id: number
|
city_id: number;
|
||||||
city: string
|
city: string;
|
||||||
[key: string]: string | number
|
[key: string]: string | number;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type FieldType<T> = {
|
export type FieldType<T> = {
|
||||||
label: string
|
label: string;
|
||||||
data: keyof T
|
data: keyof T;
|
||||||
render?: (value: any) => React.ReactNode
|
render?: (value: any) => React.ReactNode;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const stationFields: Array<FieldType<StationItem>> = [
|
export const stationFields: Array<FieldType<StationItem>> = [
|
||||||
// {label: 'ID', data: 'id'},
|
// {label: 'ID', data: 'id'},
|
||||||
{label: 'Название', data: 'name'},
|
{ label: "Название", data: "name" },
|
||||||
{label: 'Системное название', data: 'system_name'},
|
{ label: "Системное название", data: "system_name" },
|
||||||
|
// { label: "Направление", data: "direction" },
|
||||||
|
{ label: "Адрес", data: "address" },
|
||||||
// {label: 'Широта', data: 'latitude'},
|
// {label: 'Широта', data: 'latitude'},
|
||||||
// {label: 'Долгота', data: 'longitude'},
|
// {label: 'Долгота', data: 'longitude'},
|
||||||
{label: 'Описание', data: 'description'},
|
{ label: "Описание", data: "description" },
|
||||||
]
|
];
|
||||||
|
|
||||||
export const sightFields: Array<FieldType<SightItem>> = [
|
export const sightFields: Array<FieldType<SightItem>> = [
|
||||||
// {label: 'ID', data: 'id'},
|
// {label: 'ID', data: 'id'},
|
||||||
{label: 'Название', data: 'name'},
|
{ label: "Название", data: "name" },
|
||||||
// {label: 'Широта', data: 'latitude'},
|
// {label: 'Широта', data: 'latitude'},
|
||||||
// {label: 'Долгота', data: 'longitude'},
|
// {label: 'Долгота', data: 'longitude'},
|
||||||
// {label: 'ID города', data: 'city_id'},
|
// {label: 'ID города', data: 'city_id'},
|
||||||
{label: 'Город', data: 'city'},
|
{ label: "Город", data: "city" },
|
||||||
]
|
];
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
import {Autocomplete, Box, TextField} from '@mui/material'
|
import { Autocomplete, Box, TextField } from "@mui/material";
|
||||||
import {Edit, useAutocomplete} from '@refinedev/mui'
|
import { Edit, useAutocomplete } from "@refinedev/mui";
|
||||||
import {useForm} from '@refinedev/react-hook-form'
|
import { useForm } from "@refinedev/react-hook-form";
|
||||||
import {Controller} from 'react-hook-form'
|
import { Controller } from "react-hook-form";
|
||||||
|
|
||||||
import {VEHICLE_TYPES} from '../../lib/constants'
|
import { VEHICLE_TYPES } from "../../lib/constants";
|
||||||
|
|
||||||
type VehicleFormValues = {
|
type VehicleFormValues = {
|
||||||
tail_number: number
|
tail_number: number;
|
||||||
type: number
|
type: number;
|
||||||
city_id: number
|
city_id: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const VehicleEdit = () => {
|
export const VehicleEdit = () => {
|
||||||
const {
|
const {
|
||||||
@ -17,25 +17,29 @@ export const VehicleEdit = () => {
|
|||||||
register,
|
register,
|
||||||
control,
|
control,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<VehicleFormValues>({})
|
} = useForm<VehicleFormValues>({});
|
||||||
|
|
||||||
const { autocompleteProps: carrierAutocompleteProps } = useAutocomplete({
|
const { autocompleteProps: carrierAutocompleteProps } = useAutocomplete({
|
||||||
resource: 'carrier',
|
resource: "carrier",
|
||||||
onSearch: (value) => [
|
onSearch: (value) => [
|
||||||
{
|
{
|
||||||
field: 'short_name',
|
field: "short_name",
|
||||||
operator: 'contains',
|
operator: "contains",
|
||||||
value,
|
value,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Edit saveButtonProps={saveButtonProps}>
|
<Edit saveButtonProps={saveButtonProps}>
|
||||||
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
<Box
|
||||||
|
component="form"
|
||||||
|
sx={{ display: "flex", flexDirection: "column" }}
|
||||||
|
autoComplete="off"
|
||||||
|
>
|
||||||
<TextField
|
<TextField
|
||||||
{...register('tail_number', {
|
{...register("tail_number", {
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
valueAsNumber: true,
|
valueAsNumber: true,
|
||||||
})}
|
})}
|
||||||
error={!!(errors as any)?.tail_number}
|
error={!!(errors as any)?.tail_number}
|
||||||
@ -52,23 +56,36 @@ export const VehicleEdit = () => {
|
|||||||
control={control}
|
control={control}
|
||||||
name="type"
|
name="type"
|
||||||
rules={{
|
rules={{
|
||||||
required: 'Это поле является обязательным',
|
required: "Это поле является обязательным",
|
||||||
}}
|
}}
|
||||||
defaultValue={null}
|
defaultValue={null}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
options={VEHICLE_TYPES}
|
options={VEHICLE_TYPES}
|
||||||
value={VEHICLE_TYPES.find((option) => option.value === field.value) || null}
|
value={
|
||||||
|
VEHICLE_TYPES.find((option) => option.value === field.value) ||
|
||||||
|
null
|
||||||
|
}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
field.onChange(value?.value || null)
|
field.onChange(value?.value || null);
|
||||||
}}
|
}}
|
||||||
getOptionLabel={(item) => {
|
getOptionLabel={(item) => {
|
||||||
return item ? item.label : ''
|
return item ? item.label : "";
|
||||||
}}
|
}}
|
||||||
isOptionEqualToValue={(option, value) => {
|
isOptionEqualToValue={(option, value) => {
|
||||||
return option.value === value?.value
|
return option.value === value?.value;
|
||||||
}}
|
}}
|
||||||
renderInput={(params) => <TextField {...params} label="Выберите тип" margin="normal" variant="outlined" error={!!errors.type} helperText={(errors as any)?.type?.message} required />}
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Выберите тип"
|
||||||
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
error={!!errors.type}
|
||||||
|
helperText={(errors as any)?.type?.message}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -76,29 +93,47 @@ export const VehicleEdit = () => {
|
|||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="carrier_id"
|
name="carrier_id"
|
||||||
rules={{required: 'Это поле является обязательным'}}
|
rules={{ required: "Это поле является обязательным" }}
|
||||||
defaultValue={null}
|
defaultValue={null}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
{...carrierAutocompleteProps}
|
{...carrierAutocompleteProps}
|
||||||
value={carrierAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
value={
|
||||||
|
carrierAutocompleteProps.options.find(
|
||||||
|
(option) => option.id === field.value
|
||||||
|
) || null
|
||||||
|
}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
field.onChange(value?.id || '')
|
field.onChange(value?.id || "");
|
||||||
}}
|
}}
|
||||||
getOptionLabel={(item) => {
|
getOptionLabel={(item) => {
|
||||||
return item ? item.short_name : ''
|
return item ? item.short_name : "";
|
||||||
}}
|
}}
|
||||||
isOptionEqualToValue={(option, value) => {
|
isOptionEqualToValue={(option, value) => {
|
||||||
return option.id === value?.id
|
return option.id === value?.id;
|
||||||
}}
|
}}
|
||||||
filterOptions={(options, { inputValue }) => {
|
filterOptions={(options, { inputValue }) => {
|
||||||
return options.filter((option) => option.short_name.toLowerCase().includes(inputValue.toLowerCase()))
|
return options.filter((option) =>
|
||||||
|
option.short_name
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(inputValue.toLowerCase())
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
renderInput={(params) => <TextField {...params} label="Выберите перевозчика" margin="normal" variant="outlined" error={!!errors.city_id} helperText={(errors as any)?.city_id?.message} required />}
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Выберите перевозчика"
|
||||||
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
error={!!errors.city_id}
|
||||||
|
helperText={(errors as any)?.city_id?.message}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Edit>
|
</Edit>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
@ -1,71 +1,90 @@
|
|||||||
import {type GridColDef} from '@mui/x-data-grid'
|
import { type GridColDef } from "@mui/x-data-grid";
|
||||||
import {CustomDataGrid} from '../../components/CustomDataGrid'
|
import { CustomDataGrid } from "../../components/CustomDataGrid";
|
||||||
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
|
import {
|
||||||
import React from 'react'
|
DeleteButton,
|
||||||
import {VEHICLE_TYPES} from '../../lib/constants'
|
EditButton,
|
||||||
|
List,
|
||||||
|
ShowButton,
|
||||||
|
useDataGrid,
|
||||||
|
} from "@refinedev/mui";
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { VEHICLE_TYPES } from "../../lib/constants";
|
||||||
|
|
||||||
import {localeText} from '../../locales/ru/localeText'
|
import { localeText } from "../../locales/ru/localeText";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { languageStore } from "../../store/LanguageStore";
|
||||||
|
|
||||||
export const VehicleList = () => {
|
export const VehicleList = observer(() => {
|
||||||
const {dataGridProps} = useDataGrid({})
|
const { language } = languageStore;
|
||||||
|
|
||||||
|
const { dataGridProps } = useDataGrid({
|
||||||
|
resource: "vehicle",
|
||||||
|
meta: {
|
||||||
|
headers: {
|
||||||
|
"Accept-Language": language,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const columns = React.useMemo<GridColDef[]>(
|
const columns = React.useMemo<GridColDef[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
field: 'id',
|
field: "id",
|
||||||
headerName: 'ID',
|
headerName: "ID",
|
||||||
type: 'number',
|
type: "number",
|
||||||
minWidth: 70,
|
minWidth: 70,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'carrier_id',
|
field: "carrier_id",
|
||||||
headerName: 'ID перевозчика',
|
headerName: "ID перевозчика",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'tail_number',
|
field: "tail_number",
|
||||||
headerName: 'Бортовой номер',
|
headerName: "Бортовой номер",
|
||||||
type: 'number',
|
type: "number",
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'type',
|
field: "type",
|
||||||
headerName: 'Тип',
|
headerName: "Тип",
|
||||||
type: 'string',
|
type: "string",
|
||||||
minWidth: 200,
|
minWidth: 200,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
renderCell: (params) => {
|
renderCell: (params) => {
|
||||||
const value = params.row.type
|
const value = params.row.type;
|
||||||
return VEHICLE_TYPES.find((type) => type.value === value)?.label || value
|
return (
|
||||||
|
VEHICLE_TYPES.find((type) => type.value === value)?.label || value
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'city',
|
field: "city",
|
||||||
headerName: 'Город',
|
headerName: "Город",
|
||||||
type: 'string',
|
type: "string",
|
||||||
align: 'left',
|
align: "left",
|
||||||
headerAlign: 'left',
|
headerAlign: "left",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'actions',
|
field: "actions",
|
||||||
headerName: 'Действия',
|
headerName: "Действия",
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
align: 'right',
|
align: "right",
|
||||||
headerAlign: 'center',
|
headerAlign: "center",
|
||||||
sortable: false,
|
sortable: false,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
disableColumnMenu: true,
|
disableColumnMenu: true,
|
||||||
@ -74,18 +93,28 @@ export const VehicleList = () => {
|
|||||||
<>
|
<>
|
||||||
<EditButton hideText recordItemId={row.id} />
|
<EditButton hideText recordItemId={row.id} />
|
||||||
<ShowButton hideText recordItemId={row.id} />
|
<ShowButton hideText recordItemId={row.id} />
|
||||||
<DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} />
|
<DeleteButton
|
||||||
|
hideText
|
||||||
|
confirmTitle="Вы уверены?"
|
||||||
|
recordItemId={row.id}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[]
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List>
|
<List>
|
||||||
<CustomDataGrid {...dataGridProps} columns={columns} localeText={localeText} getRowId={(row: any) => row.id} />
|
<CustomDataGrid
|
||||||
|
{...dataGridProps}
|
||||||
|
languageEnabled
|
||||||
|
columns={columns}
|
||||||
|
localeText={localeText}
|
||||||
|
getRowId={(row: any) => row.id}
|
||||||
|
/>
|
||||||
</List>
|
</List>
|
||||||
)
|
);
|
||||||
}
|
});
|
||||||
|
0
src/preview/assets/.gitkeep
Normal file
0
src/preview/assets/.gitkeep
Normal file
7
src/preview/assets/icons/company-logo.svg
Normal file
7
src/preview/assets/icons/company-logo.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 18 KiB |
BIN
src/preview/assets/images/loader.gif
Normal file
BIN
src/preview/assets/images/loader.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.3 MiB |
@ -0,0 +1,39 @@
|
|||||||
|
.attraction-card {
|
||||||
|
height: 415px;
|
||||||
|
width: 315px;
|
||||||
|
background: linear-gradient(
|
||||||
|
113.51deg,
|
||||||
|
rgba(255, 255, 255, 0) 8.71%,
|
||||||
|
rgba(255, 255, 255, 0.16) 69.69%
|
||||||
|
),
|
||||||
|
#806c59;
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attraction-card__content {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attraction-card__title {
|
||||||
|
margin: 8px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attraction-card__text {
|
||||||
|
margin: 30px 0 0 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attraction-card__subtitle {
|
||||||
|
margin: 8px 0 0 0;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attraction-card__image {
|
||||||
|
min-width: 100%;
|
||||||
|
max-height: 50%;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 10px 10px 0 0;
|
||||||
|
}
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
126
src/preview/components/AttractionWidget/AttractionWidget.css
Normal file
126
src/preview/components/AttractionWidget/AttractionWidget.css
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
.widget-container {
|
||||||
|
width: 545px;
|
||||||
|
height: var(--attraction-widget-container-height, 100%);
|
||||||
|
max-height: calc(100% - 90px);
|
||||||
|
color: #ffffff;
|
||||||
|
background: #806c59;
|
||||||
|
border: 2px solid #806c59;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-content {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-slide {
|
||||||
|
position: relative;
|
||||||
|
display: none;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-slide.active,
|
||||||
|
.widget-slide.preview {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-media {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: 644px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-container {
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-header {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%),
|
||||||
|
rgba(179, 165, 152, 0.4);
|
||||||
|
box-shadow: inset 4px 4px 12px rgba(255, 255, 255, 0.12);
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 120%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-text {
|
||||||
|
width: 100%;
|
||||||
|
align-self: self-start;
|
||||||
|
padding: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 150%; /* or 27px */
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.5s ease-in-out;
|
||||||
|
user-select: none;
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-text p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-text.preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 48px;
|
||||||
|
line-height: 120%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-text.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-titles {
|
||||||
|
display: flex;
|
||||||
|
height: 50px;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
margin: 5px 0 0 0;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%),
|
||||||
|
rgba(179, 165, 152, 0.4);
|
||||||
|
box-shadow: inset 4px 4px 12px rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 0 0 10px 10px;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-title {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 21px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
width: 100px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-title.active {
|
||||||
|
font-weight: bold;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-title.preview {
|
||||||
|
display: none;
|
||||||
|
}
|
114
src/preview/components/AttractionWidget/AttractionWidget.tsx
Normal file
114
src/preview/components/AttractionWidget/AttractionWidget.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import React, { HTMLAttributes, useEffect, useState } from "react";
|
||||||
|
import { useServerLocalization } from "@mt/i18n";
|
||||||
|
import cn from "classnames";
|
||||||
|
import { useSwipeable } from "react-swipeable";
|
||||||
|
import { ArticleBase } from "@mt/common-types";
|
||||||
|
import "./AttractionWidget.css";
|
||||||
|
import { usePrevious } from "@mt/utils";
|
||||||
|
import { AttractionMedia } from "./media/AttractionMedia";
|
||||||
|
import { TouchScrollWrapper } from "../TouchScrollWrapper/TouchScrollWrapper";
|
||||||
|
|
||||||
|
export interface AttractionsWidgetProps extends HTMLAttributes<HTMLElement> {
|
||||||
|
articles: ArticleBase[];
|
||||||
|
isIdleMode: boolean;
|
||||||
|
isPreviewOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttractionWidget({
|
||||||
|
articles,
|
||||||
|
isIdleMode,
|
||||||
|
isPreviewOnly = false,
|
||||||
|
...props
|
||||||
|
}: AttractionsWidgetProps) {
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const prevArticles = usePrevious<ArticleBase[]>(articles) || [];
|
||||||
|
const localizeText = useServerLocalization();
|
||||||
|
|
||||||
|
const swipeHandlers = useSwipeable({
|
||||||
|
onSwipedLeft: ({ event }) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveIndex((activeIndex) => (activeIndex + 1) % articles.length);
|
||||||
|
},
|
||||||
|
onSwipedRight: ({ event }) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveIndex(
|
||||||
|
(activeIndex) => (activeIndex - 1 + articles.length) % articles.length
|
||||||
|
);
|
||||||
|
},
|
||||||
|
swipeDuration: 500,
|
||||||
|
preventScrollOnSwipe: true,
|
||||||
|
trackMouse: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClick = (index: number) => {
|
||||||
|
setActiveIndex(index);
|
||||||
|
document.querySelector(".widget-text.active")!.scrollTop = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => setActiveIndex(activeIndex), [activeIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!isPreviewOnly &&
|
||||||
|
(isIdleMode || JSON.stringify(prevArticles) !== JSON.stringify(articles))
|
||||||
|
) {
|
||||||
|
setActiveIndex(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// admin specific case: during edit we removed active article
|
||||||
|
if (prevArticles?.length > articles?.length) {
|
||||||
|
setActiveIndex(0);
|
||||||
|
}
|
||||||
|
}, [isPreviewOnly, isIdleMode, articles]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="widget-container g-flex-column__item-fixed" {...props}>
|
||||||
|
<div className="widget-content">
|
||||||
|
{articles?.map((article, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`widget-slide ${index === activeIndex ? "active" : ""}`}
|
||||||
|
onPointerUp={() => handleClick(index)}
|
||||||
|
>
|
||||||
|
<div className="widget-media">
|
||||||
|
<AttractionMedia media={article.media} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{index !== 0 && (
|
||||||
|
<div className="widget-header">
|
||||||
|
{localizeText(articles[0].text)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TouchScrollWrapper
|
||||||
|
className={cn("widget-text", {
|
||||||
|
active: index === activeIndex,
|
||||||
|
preview: article.isPreview,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
dangerouslySetInnerHTML={{ __html: localizeText(article.text) }}
|
||||||
|
{...swipeHandlers}
|
||||||
|
/>
|
||||||
|
</TouchScrollWrapper>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="widget-titles">
|
||||||
|
{articles?.map((article, index) => (
|
||||||
|
<div
|
||||||
|
key={`title-${index}`}
|
||||||
|
className={cn("widget-title", {
|
||||||
|
active: index === activeIndex,
|
||||||
|
preview: article.isPreview,
|
||||||
|
})}
|
||||||
|
onPointerUp={() => handleClick(index)}
|
||||||
|
>
|
||||||
|
{localizeText(article.name)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,52 @@
|
|||||||
|
.widget-image,
|
||||||
|
.widget-video,
|
||||||
|
.widget-3d-model {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-3d-model {
|
||||||
|
height: 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-media__wrapper {
|
||||||
|
position: relative;
|
||||||
|
/*TODO: it worth to investigate it further... quite weird behavior of */
|
||||||
|
box-sizing: content-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullscreen-photo-sphere-btn,
|
||||||
|
.fullscreen-3d-btn {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 100;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-with-watermark {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watermark {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
width: 50px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psv-autorotate-button {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psv-menu-button {
|
||||||
|
display: none !important;
|
||||||
|
}
|
@ -0,0 +1,36 @@
|
|||||||
|
import { Media } from "@mt/common-types";
|
||||||
|
import { ImageMedia } from "./ImageMedia";
|
||||||
|
import { VideoMedia } from "./VideoMedia";
|
||||||
|
import { PhotoSphereMedia } from "./PhotoSphereMedia";
|
||||||
|
import { Object3DMedia } from "./Object3DMedia";
|
||||||
|
import { memo } from "react";
|
||||||
|
|
||||||
|
export const AttractionMedia = memo(
|
||||||
|
({ media }: { media: Media }) => {
|
||||||
|
const { type, url, watermarkUrl } = media;
|
||||||
|
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "IMAGE":
|
||||||
|
return (
|
||||||
|
<ImageMedia url={url} alt={media.url} watermarkUrl={watermarkUrl} />
|
||||||
|
);
|
||||||
|
case "VIDEO":
|
||||||
|
return <VideoMedia url={url} watermarkUrl={watermarkUrl} />;
|
||||||
|
case "PHOTO_SPHERE":
|
||||||
|
return <PhotoSphereMedia url={url} watermarkUrl={watermarkUrl} />;
|
||||||
|
case "OBJECT_3D":
|
||||||
|
return <Object3DMedia url={url} watermarkUrl={watermarkUrl} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
({ media }, { media: newMedia }) => {
|
||||||
|
return (
|
||||||
|
media.url === newMedia.url &&
|
||||||
|
media.watermarkUrl === newMedia.watermarkUrl &&
|
||||||
|
media.type === newMedia.type
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
25
src/preview/components/AttractionWidget/media/ImageMedia.tsx
Normal file
25
src/preview/components/AttractionWidget/media/ImageMedia.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import cn from 'classnames';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import './AttractionMedia.css';
|
||||||
|
|
||||||
|
interface ImageMediaProps {
|
||||||
|
url: string;
|
||||||
|
alt: string;
|
||||||
|
watermarkUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImageMedia = ({ url, alt, watermarkUrl }: ImageMediaProps) => (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={alt}
|
||||||
|
className={cn('widget-image', {
|
||||||
|
'media-with-watermark': watermarkUrl !== null,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{watermarkUrl && (
|
||||||
|
<img src={watermarkUrl} alt="Watermark" className="watermark" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
@ -0,0 +1,52 @@
|
|||||||
|
import cn from "classnames";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import "./AttractionMedia.css";
|
||||||
|
import ModelViewer from "../../model-viewer/ModelViewer";
|
||||||
|
import { Icons, useLightboxContext } from "@mt/components";
|
||||||
|
import { Object3DLightboxData } from "@mt/common-types";
|
||||||
|
|
||||||
|
interface Object3DMediaProps {
|
||||||
|
url: string;
|
||||||
|
watermarkUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Object3DMedia = ({ url, watermarkUrl }: Object3DMediaProps) => {
|
||||||
|
// prettier-ignore
|
||||||
|
const { setData, openLightbox } = useLightboxContext<Object3DLightboxData>();
|
||||||
|
const [autoRotate, setAutoRotate] = useState(true);
|
||||||
|
|
||||||
|
const handle3DFullscreenOpen = () => {
|
||||||
|
setAutoRotate(false);
|
||||||
|
setData({
|
||||||
|
type: "OBJECT_3D",
|
||||||
|
modelUrl: url,
|
||||||
|
watermarkUrl,
|
||||||
|
});
|
||||||
|
openLightbox();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAutoRotate(true);
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="widget-media__wrapper">
|
||||||
|
<div
|
||||||
|
className={cn("widget-3d-model", {
|
||||||
|
"media-with-watermark": watermarkUrl !== null,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ModelViewer key={url} pathToModel={url} autoRotate={autoRotate} />
|
||||||
|
{watermarkUrl && (
|
||||||
|
<img src={watermarkUrl} alt="Watermark" className="watermark" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Icons.FullscreenIcon
|
||||||
|
className="fullscreen-3d-btn"
|
||||||
|
onPointerUp={() => handle3DFullscreenOpen()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,62 @@
|
|||||||
|
import cn from "classnames";
|
||||||
|
import React, { useRef } from "react";
|
||||||
|
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
|
||||||
|
|
||||||
|
import { PhotoSphereLightboxData } from "@mt/common-types";
|
||||||
|
|
||||||
|
import "./AttractionMedia.css";
|
||||||
|
import { useLightboxContext } from "../../lightbox";
|
||||||
|
import { Icons } from "@mt/components";
|
||||||
|
|
||||||
|
interface PhotoSphereMediaProps {
|
||||||
|
url: string;
|
||||||
|
watermarkUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PhotoSphereMedia = ({
|
||||||
|
url,
|
||||||
|
watermarkUrl,
|
||||||
|
}: PhotoSphereMediaProps) => {
|
||||||
|
// prettier-ignore
|
||||||
|
const { setData, openLightbox } = useLightboxContext<PhotoSphereLightboxData>();
|
||||||
|
// react-photo-sphere-viewer doesn't have exported types, so here's a bit of a hardcoded piece
|
||||||
|
const photoSphereRef = useRef<any>(null);
|
||||||
|
|
||||||
|
const handlePhotoSphereFullscreenOpen = () => {
|
||||||
|
photoSphereRef.current?.stopAutoRotate();
|
||||||
|
setData({
|
||||||
|
type: "PHOTO_SPHERE",
|
||||||
|
imageUrl: url,
|
||||||
|
watermarkUrl,
|
||||||
|
});
|
||||||
|
openLightbox();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="widget-media__wrapper">
|
||||||
|
<ReactPhotoSphereViewer
|
||||||
|
ref={photoSphereRef}
|
||||||
|
key={url}
|
||||||
|
src={url}
|
||||||
|
height={"350px"}
|
||||||
|
width={"100%"}
|
||||||
|
container={cn("widget-media", {
|
||||||
|
"media-with-watermark": watermarkUrl !== null,
|
||||||
|
})}
|
||||||
|
moveInertia={false}
|
||||||
|
mousemove={true}
|
||||||
|
navbar={["autorotate", "zoom"]}
|
||||||
|
keyboard={false}
|
||||||
|
loadingTxt="Загрузка..."
|
||||||
|
/>
|
||||||
|
{watermarkUrl && (
|
||||||
|
<img src={watermarkUrl} alt="Watermark" className="watermark" />
|
||||||
|
)}
|
||||||
|
{/* the following is a workaround to open lightbox-like preview in the middle of the screen instead of the real fullscreen */}
|
||||||
|
<Icons.FullscreenIcon
|
||||||
|
className="fullscreen-photo-sphere-btn"
|
||||||
|
onPointerUp={() => handlePhotoSphereFullscreenOpen()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
26
src/preview/components/AttractionWidget/media/VideoMedia.tsx
Normal file
26
src/preview/components/AttractionWidget/media/VideoMedia.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import cn from "classnames";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import "./AttractionMedia.css";
|
||||||
|
|
||||||
|
interface VideoMediaProps {
|
||||||
|
url: string;
|
||||||
|
watermarkUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VideoMedia = ({ url, watermarkUrl }: VideoMediaProps) => (
|
||||||
|
<>
|
||||||
|
<video
|
||||||
|
src={url}
|
||||||
|
className={cn("widget-video", {
|
||||||
|
"media-with-watermark": watermarkUrl !== null,
|
||||||
|
})}
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
/>
|
||||||
|
{watermarkUrl && (
|
||||||
|
<img src={watermarkUrl} alt="Watermark" className="watermark" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
47
src/preview/components/Drawer/Drawer.styles.tsx
Normal file
47
src/preview/components/Drawer/Drawer.styles.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
// TODO: rewrite as css module
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
|
export const StyledDrawer = styled.div`
|
||||||
|
z-index: 1000;
|
||||||
|
position: absolute;
|
||||||
|
width: 290px;
|
||||||
|
height: 100%;
|
||||||
|
transition: all ease-in-out 0.3s;
|
||||||
|
|
||||||
|
transform: translateX(-100%);
|
||||||
|
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%),
|
||||||
|
#806c59;
|
||||||
|
|
||||||
|
&.nav-widget--opened {
|
||||||
|
transform: translateX(0);
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
transform: rotate(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12px;
|
||||||
|
left: 310px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-btn-inverse {
|
||||||
|
transform: scale(1, -1);
|
||||||
|
}
|
||||||
|
`;
|
53
src/preview/components/Drawer/Drawer.tsx
Normal file
53
src/preview/components/Drawer/Drawer.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import cn from "classnames";
|
||||||
|
import { HTMLAttributes, ReactNode } from "react";
|
||||||
|
|
||||||
|
import { StyledDrawer } from "./Drawer.styles";
|
||||||
|
import { Icons } from "@mt/components";
|
||||||
|
import { Locale, LocaleSwitcher } from "@mt/i18n";
|
||||||
|
|
||||||
|
export interface DrawerProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
onToggle: (isOpened: boolean) => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
onHomeBtnClick?: () => void;
|
||||||
|
onLocaleChange: (locale: Locale) => void;
|
||||||
|
actions?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: consider refactoring - drawer and controls should be separated
|
||||||
|
export function Drawer({
|
||||||
|
children,
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
onHomeBtnClick,
|
||||||
|
onLocaleChange,
|
||||||
|
actions,
|
||||||
|
...props
|
||||||
|
}: DrawerProps) {
|
||||||
|
return (
|
||||||
|
<StyledDrawer
|
||||||
|
className={cn("g-flex-column", { "nav-widget--opened": isOpen })}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<div className="g-flex actions">
|
||||||
|
<div
|
||||||
|
className="action-btn toggle-btn"
|
||||||
|
onPointerUp={() => onToggle(!isOpen)}
|
||||||
|
>
|
||||||
|
<Icons.ArrowBtn />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="action-btn" onPointerUp={() => onHomeBtnClick?.()}>
|
||||||
|
<Icons.HomeBtn />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actions}
|
||||||
|
|
||||||
|
<LocaleSwitcher onLocaleChange={onLocaleChange} />
|
||||||
|
</div>
|
||||||
|
</StyledDrawer>
|
||||||
|
);
|
||||||
|
}
|
1
src/preview/components/Drawer/index.ts
Normal file
1
src/preview/components/Drawer/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { Drawer } from './Drawer';
|
274
src/preview/components/MapWidget/MapWidgetContext.tsx
Normal file
274
src/preview/components/MapWidget/MapWidgetContext.tsx
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useState,
|
||||||
|
useContext,
|
||||||
|
ReactNode,
|
||||||
|
useMemo,
|
||||||
|
useEffect,
|
||||||
|
} from "react";
|
||||||
|
import { geoMercator } from "d3-geo";
|
||||||
|
import { Coordinates, Track, uuid } from "@mt/common-types";
|
||||||
|
import { useNearStation, usePassedTrackIndex } from "./hooks";
|
||||||
|
import { AttractionGroup, MapData, StationOnMap } from "./map-widget.interface";
|
||||||
|
import { getMapPoint } from "./utils";
|
||||||
|
import { EMPTY_SETTING_VALUE, zeroCoordinates } from "./map-widget.constant";
|
||||||
|
import {
|
||||||
|
MapSettings,
|
||||||
|
MapWidgetContextType,
|
||||||
|
} from "./map-widget-context.interface";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
export const mapCanvasProps = {
|
||||||
|
style: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
width: 500,
|
||||||
|
height: 400,
|
||||||
|
};
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
export const MapWidgetContext = createContext<MapWidgetContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const MapWidgetProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [track, setTrack] = useState<Track | null>(null);
|
||||||
|
const [stations, setStations] = useState<StationOnMap[]>([]);
|
||||||
|
const [updatedStationIds, setUpdatedStationIds] = useState<uuid[]>([]);
|
||||||
|
const [attractionGroups, setAttractionGroups] = useState<AttractionGroup[]>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const [rotateAngle, setRotateAngle] = useState<number>(0);
|
||||||
|
|
||||||
|
const [scale, setScale] = useState<number>(0);
|
||||||
|
const [fullScale, setFullScale] = useState<number>(0);
|
||||||
|
const [zoomedScale, setZoomedScale] = useState<number>(0);
|
||||||
|
|
||||||
|
const [center, setCenter] = useState(zeroCoordinates);
|
||||||
|
const [baseCenter, setBaseCenter] = useState(zeroCoordinates);
|
||||||
|
|
||||||
|
const [currentPosition, setCurrentPosition] = useState<Coordinates | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const [isEditMode, setIsEditMode] = useState<boolean>(false);
|
||||||
|
const [isDragMode, setIsDragMode] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const [initialSettingsData, setInitialSettingsData] =
|
||||||
|
useState<MapSettings>(EMPTY_SETTING_VALUE);
|
||||||
|
const [isSettingsDataChanged, setIsSettingsDataChanged] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
|
||||||
|
const isMapDataChanged = useMemo(
|
||||||
|
() => isSettingsDataChanged || updatedStationIds.length > 0,
|
||||||
|
[isSettingsDataChanged, updatedStationIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
const stationsMap = useMemo(
|
||||||
|
() => new Map(stations.map((station) => [station.id, station])),
|
||||||
|
[stations]
|
||||||
|
);
|
||||||
|
|
||||||
|
const middleTrackCoordinates: Coordinates | null = useMemo(() => {
|
||||||
|
if (!track?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const middleTrackIndex = Math.floor(track.length / 2);
|
||||||
|
|
||||||
|
return track[middleTrackIndex];
|
||||||
|
}, [track]);
|
||||||
|
|
||||||
|
const settingsForm = useForm({
|
||||||
|
mode: "onChange",
|
||||||
|
reValidateMode: "onChange",
|
||||||
|
defaultValues: initialSettingsData,
|
||||||
|
});
|
||||||
|
useEffect(
|
||||||
|
() => settingsForm.reset(initialSettingsData),
|
||||||
|
[initialSettingsData]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onMapDataFetched = (data: MapData) => {
|
||||||
|
setTrack(data.trackPoints);
|
||||||
|
setStations(data.stationsOnMap);
|
||||||
|
setAttractionGroups(data.touristAttractionGroupsOnMap);
|
||||||
|
setRotateAngle(data.mapRotateAngle);
|
||||||
|
|
||||||
|
setCenter(data.centerOfMapPoint);
|
||||||
|
setBaseCenter(data.centerOfMapPoint);
|
||||||
|
|
||||||
|
setScale(data.fullMapScale);
|
||||||
|
setFullScale(data.fullMapScale);
|
||||||
|
setZoomedScale(data.zoomedMapScale);
|
||||||
|
|
||||||
|
setInitialSettingsData({
|
||||||
|
rotateAngle: data.mapRotateAngle,
|
||||||
|
center: data.centerOfMapPoint,
|
||||||
|
fullScale: data.fullMapScale,
|
||||||
|
zoomedScale: data.zoomedMapScale,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsSettingsDataChanged(false);
|
||||||
|
setUpdatedStationIds([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSettingsFormChange = () => {
|
||||||
|
const formData = settingsForm.getValues();
|
||||||
|
const { center, rotateAngle, fullScale, zoomedScale, currentStationId } =
|
||||||
|
formData;
|
||||||
|
|
||||||
|
setBaseCenter(center);
|
||||||
|
setRotateAngle(rotateAngle);
|
||||||
|
setFullScale(fullScale);
|
||||||
|
setZoomedScale(zoomedScale);
|
||||||
|
|
||||||
|
if (currentStationId) {
|
||||||
|
const { pointOnMap } = stationsMap.get(currentStationId) as StationOnMap;
|
||||||
|
setCenter(pointOnMap);
|
||||||
|
setScale(zoomedScale);
|
||||||
|
setCurrentPosition(pointOnMap);
|
||||||
|
setIsDragMode(false);
|
||||||
|
} else {
|
||||||
|
setCenter(center);
|
||||||
|
setScale(fullScale);
|
||||||
|
setCurrentPosition(middleTrackCoordinates);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMapDataChanged(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMapCenterMoved = (center: Coordinates) => {
|
||||||
|
setBaseCenter(center);
|
||||||
|
setCenter(center);
|
||||||
|
settingsForm.setValue("center", center, { shouldDirty: true });
|
||||||
|
|
||||||
|
updateMapDataChanged(settingsForm.getValues());
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMapDataChanged = (data: MapSettings) => {
|
||||||
|
const { rotateAngle, center, fullScale, zoomedScale } = data;
|
||||||
|
|
||||||
|
setIsSettingsDataChanged(
|
||||||
|
JSON.stringify({
|
||||||
|
rotateAngle,
|
||||||
|
center,
|
||||||
|
fullScale,
|
||||||
|
zoomedScale,
|
||||||
|
}) !== JSON.stringify(initialSettingsData)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onStationUpdate: MapWidgetContextType["onStationUpdate"] = (
|
||||||
|
stationId,
|
||||||
|
{ labelOffset, labelAlignment }
|
||||||
|
) => {
|
||||||
|
const updatedStation = {
|
||||||
|
...(stationsMap.get(stationId) as StationOnMap),
|
||||||
|
...(labelOffset && { labelOffset }),
|
||||||
|
...(labelAlignment && { labelAlignment }),
|
||||||
|
};
|
||||||
|
|
||||||
|
setStations((stations) =>
|
||||||
|
stations.map((station) =>
|
||||||
|
station.id === stationId ? updatedStation : station
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setUpdatedStationIds((ids) => [...ids, stationId]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUpdatedStations = () => {
|
||||||
|
return updatedStationIds.reduce((acc: Record<uuid, any>, id: uuid) => {
|
||||||
|
const { labelAlignment, labelOffset } = stationsMap.get(
|
||||||
|
id
|
||||||
|
) as StationOnMap;
|
||||||
|
|
||||||
|
acc[id] = {
|
||||||
|
textAlignment: labelAlignment,
|
||||||
|
mapOffsets: labelOffset,
|
||||||
|
};
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const projection = useMemo(() => {
|
||||||
|
const { width, height } = mapCanvasProps;
|
||||||
|
|
||||||
|
return geoMercator()
|
||||||
|
.translate([width / 2, height / 2])
|
||||||
|
.center(getMapPoint(center))
|
||||||
|
.scale(scale);
|
||||||
|
}, [center, scale]);
|
||||||
|
|
||||||
|
const { passedTrackIndex } = usePassedTrackIndex(track, currentPosition);
|
||||||
|
const { currentStation, nextStation, isOnStation } = useNearStation(
|
||||||
|
currentPosition,
|
||||||
|
stations,
|
||||||
|
passedTrackIndex
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bind map center and zoom to currentStation in not EditMode
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStation) {
|
||||||
|
const { pointOnMap } = currentStation;
|
||||||
|
setCenter(pointOnMap);
|
||||||
|
setScale(zoomedScale);
|
||||||
|
} else {
|
||||||
|
setCenter(baseCenter);
|
||||||
|
setScale(fullScale);
|
||||||
|
}
|
||||||
|
}, [currentStation]);
|
||||||
|
|
||||||
|
const contextValue = {
|
||||||
|
track,
|
||||||
|
center,
|
||||||
|
rotateAngle,
|
||||||
|
projection,
|
||||||
|
attractionGroups,
|
||||||
|
stations,
|
||||||
|
currentPosition,
|
||||||
|
middleTrackCoordinates,
|
||||||
|
passedTrackIndex,
|
||||||
|
currentStation,
|
||||||
|
isOnStation,
|
||||||
|
nextStation,
|
||||||
|
|
||||||
|
setCurrentPosition,
|
||||||
|
|
||||||
|
isDragMode,
|
||||||
|
setIsDragMode,
|
||||||
|
isEditMode,
|
||||||
|
setIsEditMode,
|
||||||
|
|
||||||
|
onMapDataFetched,
|
||||||
|
settingsForm,
|
||||||
|
onSettingsFormChange,
|
||||||
|
onMapCenterMoved,
|
||||||
|
onStationUpdate,
|
||||||
|
getUpdatedStations,
|
||||||
|
|
||||||
|
isMapDataChanged,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapWidgetContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</MapWidgetContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useMapWidgetContext = function (): MapWidgetContextType {
|
||||||
|
const context = useContext(MapWidgetContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"useMapWidgetContext must be used within a MapWidgetProvider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
@ -0,0 +1,25 @@
|
|||||||
|
import { TrackAttractions, TrackLine, TrackStations, TramMarker } from '../index';
|
||||||
|
import { getMapPoint } from '../../utils';
|
||||||
|
import { useMapWidgetContext } from '../../MapWidgetContext';
|
||||||
|
|
||||||
|
export const MapContent = () => {
|
||||||
|
const { rotateAngle, isEditMode, currentPosition, currentStation, nextStation } =
|
||||||
|
useMapWidgetContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g className="g-transform-origin__center" style={{ transform: `rotate(${rotateAngle}deg)` }}>
|
||||||
|
<TrackLine />
|
||||||
|
|
||||||
|
<TrackAttractions />
|
||||||
|
|
||||||
|
<TrackStations />
|
||||||
|
|
||||||
|
{!isEditMode && currentPosition && nextStation && (
|
||||||
|
<TramMarker
|
||||||
|
coordinates={getMapPoint(currentPosition)}
|
||||||
|
nextStopPoint={(currentStation ?? nextStation).pointOnMap}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,4 @@
|
|||||||
|
.mapWidget {
|
||||||
|
position: relative;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
ComposableMap,
|
||||||
|
ZoomableGroup,
|
||||||
|
ZoomableGroupProps,
|
||||||
|
} from "react-simple-maps";
|
||||||
|
import styles from "./MapWidget.module.css";
|
||||||
|
import { mapCanvasProps, useMapWidgetContext } from "../../MapWidgetContext";
|
||||||
|
import { useState, FC, ReactNode } from "react";
|
||||||
|
import { MapContent } from "./MapContent";
|
||||||
|
|
||||||
|
// Create wrapper components to handle type issues
|
||||||
|
const ComposableMapWrapper: FC<any> = (props) => {
|
||||||
|
// @ts-ignore - Ignore type issues with the ComposableMap component
|
||||||
|
return <ComposableMap {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZoomableGroupWrapper: FC<ZoomableGroupProps> = (props) => {
|
||||||
|
// @ts-ignore - Ignore type issues with the ZoomableGroup component
|
||||||
|
return <ZoomableGroup {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// default coordinates for 3a route: 59.943, 30.331
|
||||||
|
export const MapWidget = () => {
|
||||||
|
const { onMapCenterMoved, projection, isDragMode, rotateAngle } =
|
||||||
|
useMapWidgetContext();
|
||||||
|
const [key, setKey] = useState(42);
|
||||||
|
|
||||||
|
const handleMoveEnd: ZoomableGroupProps["onMoveEnd"] = (e, d3Zoom) => {
|
||||||
|
const { PI, cos, sin } = Math;
|
||||||
|
const { x, y } = d3Zoom.transform;
|
||||||
|
const { width, height } = mapCanvasProps;
|
||||||
|
|
||||||
|
const alpha = (-rotateAngle * PI) / 180;
|
||||||
|
|
||||||
|
const x1 = x * cos(alpha) - y * sin(alpha);
|
||||||
|
const y1 = x * sin(alpha) + y * cos(alpha);
|
||||||
|
|
||||||
|
const cX = width / 2 - x1;
|
||||||
|
const cY = height / 2 - y1;
|
||||||
|
|
||||||
|
const [lon, lat] = projection.invert?.([cX, cY]) ?? [0, 0];
|
||||||
|
|
||||||
|
onMapCenterMoved({ lon, lat });
|
||||||
|
setKey(-key);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComposableMapWrapper
|
||||||
|
projection={projection as any}
|
||||||
|
className={styles.mapWidget}
|
||||||
|
{...mapCanvasProps}
|
||||||
|
>
|
||||||
|
<ZoomableGroupWrapper
|
||||||
|
key={key}
|
||||||
|
center={projection.center()}
|
||||||
|
onMoveEnd={handleMoveEnd}
|
||||||
|
filterZoomEvent={() => isDragMode}
|
||||||
|
minZoom={1}
|
||||||
|
maxZoom={1}
|
||||||
|
>
|
||||||
|
<MapContent />
|
||||||
|
</ZoomableGroupWrapper>
|
||||||
|
</ComposableMapWrapper>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1 @@
|
|||||||
|
export * from './MapWidget';
|
@ -0,0 +1,41 @@
|
|||||||
|
.markerLarge,
|
||||||
|
.markerSmall {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markerLarge {
|
||||||
|
width: 23px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markerLarge .counter {
|
||||||
|
transform: translate(30%, 15%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markerSmall {
|
||||||
|
width: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markerSmall .counter {
|
||||||
|
transform: translate(50%, -25%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
line-height: 8px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #896f58;
|
||||||
|
font-size: 0.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
import { Point, Marker } 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>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,25 @@
|
|||||||
|
import { AttractionMarker } from "./AttractionMarker";
|
||||||
|
import { getMapPoint } from "../../utils";
|
||||||
|
import { useMapWidgetContext } from "../../MapWidgetContext";
|
||||||
|
|
||||||
|
export const TrackAttractions = () => {
|
||||||
|
const { attractionGroups, rotateAngle } = useMapWidgetContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{attractionGroups.map((group) => (
|
||||||
|
<AttractionMarker
|
||||||
|
key={
|
||||||
|
group.touristAttractionsOnMap[0]?.id ||
|
||||||
|
`${group.pointOnMap.lat}:${group.pointOnMap.lon}`
|
||||||
|
}
|
||||||
|
coordinates={getMapPoint(group.pointOnMap)}
|
||||||
|
// Inverse angle to compensate map rotation
|
||||||
|
rotate={-rotateAngle}
|
||||||
|
counter={group.touristAttractionsOnMap.length}
|
||||||
|
size={group.iconSize}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1 @@
|
|||||||
|
export * from './TrackAttractions';
|
37
src/preview/components/MapWidget/components/TrackLine.tsx
Normal file
37
src/preview/components/MapWidget/components/TrackLine.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { Line, Point } from "react-simple-maps";
|
||||||
|
import { getMapPoint } from "../utils";
|
||||||
|
import { useMapWidgetContext } from "../MapWidgetContext";
|
||||||
|
import { zeroCoordinates } from "../map-widget.constant";
|
||||||
|
|
||||||
|
const passedTrackColor = "#ed1c24";
|
||||||
|
const trackColor = "#cccccc";
|
||||||
|
|
||||||
|
export const TrackLine = () => {
|
||||||
|
const { track, passedTrackIndex, currentPosition } = useMapWidgetContext();
|
||||||
|
const mappedTrack: Point[] = useMemo(
|
||||||
|
() => (track ? track.map(({ lat, lon }) => [lon, lat]) : []),
|
||||||
|
[track]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Line
|
||||||
|
coordinates={mappedTrack}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
stroke={trackColor}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Line
|
||||||
|
coordinates={[
|
||||||
|
...mappedTrack.slice(0, passedTrackIndex),
|
||||||
|
getMapPoint(currentPosition ?? zeroCoordinates),
|
||||||
|
]}
|
||||||
|
strokeWidth={3.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
stroke={passedTrackColor}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,8 @@
|
|||||||
|
// TODO: resolve circular deps
|
||||||
|
import { StationLabelContent, StationLabelContentProps } from './StationLabelContent';
|
||||||
|
|
||||||
|
export const StationLabel = ({ station }: StationLabelContentProps) => (
|
||||||
|
<foreignObject className="track-station" {...station.labelOffset}>
|
||||||
|
<StationLabelContent station={station} />
|
||||||
|
</foreignObject>
|
||||||
|
);
|
@ -0,0 +1,55 @@
|
|||||||
|
import { HTMLAttributes, ReactNode, useContext } from 'react';
|
||||||
|
|
||||||
|
import { StationOnMap, TransportIcon, useMapWidgetContext } from '@mt/components';
|
||||||
|
import { OnMapTextAlignment } from '@mt/common-types';
|
||||||
|
import { LocalizationContext } from '@mt/i18n';
|
||||||
|
|
||||||
|
export interface StationLabelContentProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
station: StationOnMap;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextAlign = Lowercase<OnMapTextAlignment>;
|
||||||
|
|
||||||
|
export const StationLabelContent = ({
|
||||||
|
station,
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
}: StationLabelContentProps) => {
|
||||||
|
const { locale } = useContext(LocalizationContext);
|
||||||
|
const { rotateAngle } = useMapWidgetContext();
|
||||||
|
|
||||||
|
const { pointOnMap, labelAlignment, iconUrl, shortName, name, transferStationInfos } = station;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={`${pointOnMap.lat}:${pointOnMap.lon}`}
|
||||||
|
className={`track-station__wrapper ${className}`}
|
||||||
|
style={{
|
||||||
|
textAlign: labelAlignment as TextAlign,
|
||||||
|
// Inverse angle to compensate map rotation
|
||||||
|
transform: `rotate(${-rotateAngle}deg)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="track-station__label">
|
||||||
|
{iconUrl && <img className="track-station__icon" src={iconUrl} />}
|
||||||
|
{(shortName ?? name).ru}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="track-station__transfers-wrapper">
|
||||||
|
{transferStationInfos.map((transfer) => (
|
||||||
|
<div className="track-station__label" key={transfer.name.ru}>
|
||||||
|
<TransportIcon type={transfer.type} className="transport-icon" />
|
||||||
|
{transfer.name.ru}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="track-station__label-locale">
|
||||||
|
{locale === 'zh' ? (shortName ?? name).zh : (shortName ?? name).en}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,63 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import Draggable, { DraggableData, DraggableEvent } from 'react-draggable';
|
||||||
|
import { ButtonGroup, IconButton } from '@mui/material';
|
||||||
|
import AlignHorizontalLeftRoundedIcon from '@mui/icons-material/AlignHorizontalLeftRounded';
|
||||||
|
import AlignHorizontalCenterRoundedIcon from '@mui/icons-material/AlignHorizontalCenterRounded';
|
||||||
|
import AlignHorizontalRightRoundedIcon from '@mui/icons-material/AlignHorizontalRightRounded';
|
||||||
|
|
||||||
|
// TODO: resolve circular deps
|
||||||
|
import { OnMapOffset, OnMapTextAlignment } from '@mt/common-types';
|
||||||
|
import { useMapWidgetContext } from '@mt/components';
|
||||||
|
import { StationLabelContent, StationLabelContentProps } from './StationLabelContent';
|
||||||
|
|
||||||
|
const CONTAINER_WIDTH = 1343;
|
||||||
|
const SVG_WIDTH = 500;
|
||||||
|
|
||||||
|
export const StationLabelEdit = ({ station }: StationLabelContentProps) => {
|
||||||
|
const { onStationUpdate } = useMapWidgetContext();
|
||||||
|
const { id, labelOffset } = station;
|
||||||
|
|
||||||
|
const [calculatedOffset] = useState<OnMapOffset>(labelOffset);
|
||||||
|
const [dragStartPoint, setDragStartPoint] = useState<OnMapOffset>({ x: -1, y: -1 });
|
||||||
|
|
||||||
|
const onDragStart = () => setDragStartPoint(calculatedOffset);
|
||||||
|
|
||||||
|
const onDragStop = (e: DraggableEvent, { lastX, lastY }: DraggableData) => {
|
||||||
|
const labelOffset = {
|
||||||
|
x: dragStartPoint.x + lastX,
|
||||||
|
y: dragStartPoint.y + lastY,
|
||||||
|
};
|
||||||
|
|
||||||
|
onStationUpdate(id, { labelOffset });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAlignment = (labelAlignment: OnMapTextAlignment): void =>
|
||||||
|
onStationUpdate(id, { labelAlignment });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Draggable
|
||||||
|
onStart={onDragStart}
|
||||||
|
onStop={onDragStop}
|
||||||
|
scale={CONTAINER_WIDTH / SVG_WIDTH}
|
||||||
|
positionOffset={calculatedOffset}
|
||||||
|
>
|
||||||
|
<foreignObject className="track-station">
|
||||||
|
<StationLabelContent station={station} className="editable">
|
||||||
|
<ButtonGroup size="small" className="align-btns-group">
|
||||||
|
<IconButton size="small" onClick={() => setAlignment('LEFT')}>
|
||||||
|
<AlignHorizontalLeftRoundedIcon />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<IconButton size="small" onClick={() => setAlignment('CENTER')}>
|
||||||
|
<AlignHorizontalCenterRoundedIcon />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<IconButton size="small" onClick={() => setAlignment('RIGHT')}>
|
||||||
|
<AlignHorizontalRightRoundedIcon />
|
||||||
|
</IconButton>
|
||||||
|
</ButtonGroup>
|
||||||
|
</StationLabelContent>
|
||||||
|
</foreignObject>
|
||||||
|
</Draggable>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,68 @@
|
|||||||
|
/* foreignObject */
|
||||||
|
.track-station {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-station__wrapper {
|
||||||
|
transform-origin: left top;
|
||||||
|
transform-box: fill-box;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-station__wrapper.editable {
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-station__label,
|
||||||
|
.track-station__label-locale {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-station__label {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 0.32rem;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-station__label-locale {
|
||||||
|
color: #cbcbcb;
|
||||||
|
font-size: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-station__transfers-wrapper:not(:empty) {
|
||||||
|
margin: 1.5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-btns-group {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
background-color: #ffffff;
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-station__wrapper:hover .align-btns-group {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-btns-group > button {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transport-icon,
|
||||||
|
.track-station__icon {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 1px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transport-icon,
|
||||||
|
.track-station__icon,
|
||||||
|
.track-station svg {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
@ -0,0 +1,71 @@
|
|||||||
|
import { Marker } from "react-simple-maps";
|
||||||
|
// TODO: resolve circular deps
|
||||||
|
import type { uuid } from "@mt/common-types";
|
||||||
|
|
||||||
|
import { getMapPoint } from "../../utils";
|
||||||
|
|
||||||
|
import "./TrackStations.css";
|
||||||
|
import { StationLabelEdit } from "./StationLabelEdit";
|
||||||
|
import { useMapWidgetContext } from "../../MapWidgetContext";
|
||||||
|
import { StationLabel } from "./StationLabel";
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
black: "#000000",
|
||||||
|
red: "#ed1c24",
|
||||||
|
grey: "#cccccc",
|
||||||
|
yellow: "#fcd500",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TrackStations = () => {
|
||||||
|
const {
|
||||||
|
stations,
|
||||||
|
currentStation,
|
||||||
|
passedTrackIndex,
|
||||||
|
isOnStation,
|
||||||
|
isEditMode,
|
||||||
|
} = useMapWidgetContext();
|
||||||
|
const isTerminalStation = (index: number) =>
|
||||||
|
index === 0 || index === stations.length - 1;
|
||||||
|
|
||||||
|
const getStationFill = (
|
||||||
|
id: uuid,
|
||||||
|
trackIndex: number,
|
||||||
|
index: number
|
||||||
|
): string => {
|
||||||
|
if (isOnStation && currentStation?.id === id) {
|
||||||
|
return colors.yellow;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTerminalStation(index)) {
|
||||||
|
return colors.black;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trackIndex <= passedTrackIndex ? colors.red : colors.grey;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStationStroke = (index: number) => {
|
||||||
|
if (index === 0) return colors.red;
|
||||||
|
if (index === stations.length - 1) return colors.grey;
|
||||||
|
return colors.black;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{stations.map((it, index) => (
|
||||||
|
<Marker key={it.id} coordinates={getMapPoint(it.pointOnMap)}>
|
||||||
|
<circle
|
||||||
|
fill={getStationFill(it.id, it.pointOnMap.trackIndex, index)}
|
||||||
|
stroke={getStationStroke(index)}
|
||||||
|
r={3.5}
|
||||||
|
strokeWidth={isTerminalStation(index) ? 2 : 1.5}
|
||||||
|
/>
|
||||||
|
{isEditMode ? (
|
||||||
|
<StationLabelEdit station={it} />
|
||||||
|
) : (
|
||||||
|
<StationLabel station={it} />
|
||||||
|
)}
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1 @@
|
|||||||
|
export * from './TrackStations';
|
@ -0,0 +1,36 @@
|
|||||||
|
.icon {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconContainer {
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e20613;
|
||||||
|
padding: 3px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
transform: translate(16px, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flipped {
|
||||||
|
transform: translate(-48px, -48px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconContainer:after {
|
||||||
|
content: '';
|
||||||
|
background: linear-gradient(135deg, #e20713, #00000000);
|
||||||
|
clip-path: polygon(0 0, 50% 100%, 100% 50%);
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
transform-origin: bottom right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flipped:after {
|
||||||
|
transform: translate(-50%, -50%) rotate(180deg);
|
||||||
|
}
|
@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getIntersection, getIntersectionArea } from "../../utils";
|
||||||
|
import { Marker, Point } from "react-simple-maps";
|
||||||
|
import { Coordinates } from "@mt/common-types";
|
||||||
|
import cn from "classnames";
|
||||||
|
import styles from "./TramMarker.module.css";
|
||||||
|
import { Icons, useMapWidgetContext } from "@mt/components";
|
||||||
|
|
||||||
|
interface TramMarkerProps {
|
||||||
|
coordinates: Point;
|
||||||
|
nextStopPoint: Coordinates;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TramMarker = ({ coordinates, nextStopPoint }: TramMarkerProps) => {
|
||||||
|
const [flipped, setFlipped] = useState(false);
|
||||||
|
|
||||||
|
const { rotateAngle } = useMapWidgetContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tramRect = document
|
||||||
|
.getElementById("tram-marker")
|
||||||
|
?.getBoundingClientRect();
|
||||||
|
const nextStopRect = document
|
||||||
|
.getElementById(`${nextStopPoint.lat}:${nextStopPoint.lon}`)
|
||||||
|
?.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (tramRect && nextStopRect) {
|
||||||
|
// prettier-ignore
|
||||||
|
const hasIntersection = getIntersection(tramRect, nextStopRect) !== null;
|
||||||
|
const intersectionArea = getIntersectionArea(tramRect, nextStopRect);
|
||||||
|
|
||||||
|
if (hasIntersection && intersectionArea > 150) {
|
||||||
|
setFlipped((isFlipped) => !isFlipped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [coordinates]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Marker coordinates={coordinates} id="tram-marker">
|
||||||
|
<foreignObject
|
||||||
|
className={cn(styles.iconContainer, { [styles.flipped]: flipped })}
|
||||||
|
>
|
||||||
|
<Icons.TramMarkerIcon
|
||||||
|
className={`${styles.icon} g-transform-origin__center`}
|
||||||
|
// Inverse angle to compensate map rotation
|
||||||
|
style={{ transform: `rotate(${-rotateAngle}deg)` }}
|
||||||
|
/>
|
||||||
|
</foreignObject>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1 @@
|
|||||||
|
export * from './TramMarker';
|
5
src/preview/components/MapWidget/components/index.ts
Normal file
5
src/preview/components/MapWidget/components/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export * from './TramMarker';
|
||||||
|
export * from './TrackLine';
|
||||||
|
export * from './TrackStations';
|
||||||
|
export * from './TrackAttractions';
|
||||||
|
export * from './MapWidget';
|
2
src/preview/components/MapWidget/hooks/index.ts
Normal file
2
src/preview/components/MapWidget/hooks/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './usePassedTrackIndex';
|
||||||
|
export * from './useNearStation';
|
61
src/preview/components/MapWidget/hooks/useNearStation.ts
Normal file
61
src/preview/components/MapWidget/hooks/useNearStation.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { StationOnMap } from "../map-widget.interface";
|
||||||
|
import { getDistance } from "../utils";
|
||||||
|
import { Coordinates } from "@mt/common-types";
|
||||||
|
|
||||||
|
const ZOOM_DISTANCE = 100;
|
||||||
|
const ON_STATION_DISTANCE = 15;
|
||||||
|
|
||||||
|
export function useNearStation(
|
||||||
|
currentPosition: Coordinates | null,
|
||||||
|
stations: StationOnMap[],
|
||||||
|
passedTrackIndex: number
|
||||||
|
) {
|
||||||
|
const [nextStation, setNextStation] = useState<StationOnMap | null>(null);
|
||||||
|
const [currentStation, setCurrentStation] = useState<StationOnMap | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [isOnStation, setIsOnStation] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentPosition) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStationIndex = stations.findIndex(
|
||||||
|
({ pointOnMap }) => pointOnMap.trackIndex > passedTrackIndex
|
||||||
|
);
|
||||||
|
|
||||||
|
const nextStation = stations[nextStationIndex] ?? null;
|
||||||
|
const prevStation = stations[nextStationIndex - 1] ?? null;
|
||||||
|
|
||||||
|
const distanceToNext = nextStation
|
||||||
|
? getDistance(currentPosition, nextStation.pointOnMap)
|
||||||
|
: ZOOM_DISTANCE + 1;
|
||||||
|
|
||||||
|
setNextStation(nextStation);
|
||||||
|
|
||||||
|
if (distanceToNext <= ZOOM_DISTANCE) {
|
||||||
|
setCurrentStation(nextStation);
|
||||||
|
setIsOnStation(distanceToNext <= ON_STATION_DISTANCE);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distanceToPrev = prevStation
|
||||||
|
? getDistance(currentPosition, prevStation.pointOnMap)
|
||||||
|
: ZOOM_DISTANCE + 1;
|
||||||
|
|
||||||
|
if (distanceToPrev <= ZOOM_DISTANCE) {
|
||||||
|
setCurrentStation(prevStation);
|
||||||
|
setIsOnStation(distanceToPrev <= ON_STATION_DISTANCE);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentStation(null);
|
||||||
|
setIsOnStation(false);
|
||||||
|
}, [currentPosition, stations, passedTrackIndex]);
|
||||||
|
|
||||||
|
return { currentStation, nextStation, isOnStation };
|
||||||
|
}
|
@ -0,0 +1,61 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Coordinates, Track } from "@mt/common-types";
|
||||||
|
|
||||||
|
import { getDistance, getPointDeviation } from "../utils";
|
||||||
|
|
||||||
|
const APPROXIMATE_DISTANCE = 15; // [meters] half of tramway length (~30 meters)
|
||||||
|
|
||||||
|
export function usePassedTrackIndex(
|
||||||
|
track: Track | null,
|
||||||
|
currentPosition: Coordinates | null
|
||||||
|
) {
|
||||||
|
const [passedTrackIndex, setPassedTrackIndex] = useState<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!track || !currentPosition) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let minDistance = getDistance(track[0], currentPosition);
|
||||||
|
let newPassedIndex = 0;
|
||||||
|
|
||||||
|
for (let i = 1; i < track.length; i++) {
|
||||||
|
const distance = getDistance(track[i], currentPosition);
|
||||||
|
|
||||||
|
if (distance < minDistance) {
|
||||||
|
newPassedIndex = i;
|
||||||
|
minDistance = distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is current position more than APPROXIMATE_DISTANCE far from found track point
|
||||||
|
* we need to check that we really reach newPassedIndex. If not — should decrement index
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
getDistance(track[newPassedIndex], currentPosition) > APPROXIMATE_DISTANCE
|
||||||
|
) {
|
||||||
|
const prevIndex = Math.max(newPassedIndex - 1, 0);
|
||||||
|
const nextIndex = Math.min(newPassedIndex + 1, track.length - 1);
|
||||||
|
|
||||||
|
const leftDeviation = getPointDeviation(
|
||||||
|
track[prevIndex],
|
||||||
|
track[newPassedIndex], // Ближайшая точка трека
|
||||||
|
currentPosition
|
||||||
|
);
|
||||||
|
const rightDeviation = getPointDeviation(
|
||||||
|
track[newPassedIndex], // Ближайшая точка трека
|
||||||
|
track[nextIndex],
|
||||||
|
currentPosition
|
||||||
|
);
|
||||||
|
|
||||||
|
if (leftDeviation >= rightDeviation) {
|
||||||
|
newPassedIndex--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPassedTrackIndex(newPassedIndex);
|
||||||
|
}, [track, currentPosition]);
|
||||||
|
|
||||||
|
return { passedTrackIndex };
|
||||||
|
}
|
5
src/preview/components/MapWidget/index.ts
Normal file
5
src/preview/components/MapWidget/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export * from './map-widget.constant';
|
||||||
|
export * from './map-widget.interface';
|
||||||
|
export * from './components';
|
||||||
|
export * from './MapWidgetContext';
|
||||||
|
export * from './map-widget-context.interface';
|
@ -0,0 +1,50 @@
|
|||||||
|
import { Coordinates, SetState, uuid } from "@mt/common-types";
|
||||||
|
import { MapData, StationOnMap } from "@mt/components";
|
||||||
|
|
||||||
|
import { UseFormReturn } from "react-hook-form";
|
||||||
|
import { RouteStation } from "@mt/common-types";
|
||||||
|
import { GeoProjection } from "d3-geo";
|
||||||
|
|
||||||
|
export interface MapWidgetContextType {
|
||||||
|
// External data
|
||||||
|
track: MapData["trackPoints"] | null;
|
||||||
|
stations: MapData["stationsOnMap"];
|
||||||
|
attractionGroups: MapData["touristAttractionGroupsOnMap"];
|
||||||
|
rotateAngle: MapData["mapRotateAngle"];
|
||||||
|
center: Coordinates;
|
||||||
|
projection: GeoProjection;
|
||||||
|
currentPosition: Coordinates | null;
|
||||||
|
middleTrackCoordinates: Coordinates | null;
|
||||||
|
passedTrackIndex: number;
|
||||||
|
currentStation: StationOnMap | null;
|
||||||
|
nextStation: StationOnMap | null;
|
||||||
|
isOnStation: boolean;
|
||||||
|
|
||||||
|
// Calculated data
|
||||||
|
setCurrentPosition: SetState<Coordinates | null>;
|
||||||
|
|
||||||
|
isDragMode: boolean;
|
||||||
|
|
||||||
|
setIsDragMode: SetState<boolean>;
|
||||||
|
isEditMode: boolean;
|
||||||
|
setIsEditMode: SetState<boolean>;
|
||||||
|
|
||||||
|
onMapDataFetched: (payload: MapData) => void;
|
||||||
|
settingsForm: UseFormReturn<MapSettings>;
|
||||||
|
onSettingsFormChange: () => void;
|
||||||
|
onMapCenterMoved: (center: Coordinates) => void;
|
||||||
|
onStationUpdate: (
|
||||||
|
stationId: uuid,
|
||||||
|
data: Partial<Pick<StationOnMap, "labelAlignment" | "labelOffset">>
|
||||||
|
) => void;
|
||||||
|
getUpdatedStations: () => Partial<RouteStation>;
|
||||||
|
isMapDataChanged: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapSettings {
|
||||||
|
rotateAngle: number;
|
||||||
|
fullScale: number;
|
||||||
|
zoomedScale: number;
|
||||||
|
center: Coordinates;
|
||||||
|
currentStationId?: string;
|
||||||
|
}
|
10
src/preview/components/MapWidget/map-widget.constant.ts
Normal file
10
src/preview/components/MapWidget/map-widget.constant.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { MapSettings } from "./map-widget-context.interface";
|
||||||
|
|
||||||
|
export const zeroCoordinates = { lat: 0, lon: 0 };
|
||||||
|
|
||||||
|
export const EMPTY_SETTING_VALUE: MapSettings = {
|
||||||
|
rotateAngle: 0,
|
||||||
|
center: zeroCoordinates,
|
||||||
|
fullScale: 0,
|
||||||
|
zoomedScale: 0,
|
||||||
|
};
|
38
src/preview/components/MapWidget/map-widget.interface.ts
Normal file
38
src/preview/components/MapWidget/map-widget.interface.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
AttractionGroupIconSizeType,
|
||||||
|
Coordinates,
|
||||||
|
StationOnMap as StationOnMapBase,
|
||||||
|
Track,
|
||||||
|
uuid,
|
||||||
|
Transfer,
|
||||||
|
} from "@mt/common-types";
|
||||||
|
|
||||||
|
export type PointOnTrack = Coordinates & {
|
||||||
|
trackIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StationOnMap = StationOnMapBase & {
|
||||||
|
pointOnMap: PointOnTrack;
|
||||||
|
transferStationInfos: Transfer[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AttractionOnMap {
|
||||||
|
id: uuid;
|
||||||
|
pointOnMap: Coordinates;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttractionGroup {
|
||||||
|
iconSize: AttractionGroupIconSizeType;
|
||||||
|
pointOnMap: Coordinates;
|
||||||
|
touristAttractionsOnMap: AttractionOnMap[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapData {
|
||||||
|
mapRotateAngle: number;
|
||||||
|
fullMapScale: number;
|
||||||
|
zoomedMapScale: number;
|
||||||
|
centerOfMapPoint: Coordinates;
|
||||||
|
trackPoints: Track;
|
||||||
|
stationsOnMap: StationOnMap[];
|
||||||
|
touristAttractionGroupsOnMap: AttractionGroup[];
|
||||||
|
}
|
23
src/preview/components/MapWidget/utils/get-deviation.ts
Normal file
23
src/preview/components/MapWidget/utils/get-deviation.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Coordinates } from '@mt/common-types';
|
||||||
|
import { getDistance } from './get-distance';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function return deviation of point form the passed straight line
|
||||||
|
* If deviation equals 0 this means the point lay on the line
|
||||||
|
* otherwise don't and we can draw a triangle by this 3 point
|
||||||
|
* @param begin: Point
|
||||||
|
* @param end: Point
|
||||||
|
* @param point: Point
|
||||||
|
* @returns deviation: number
|
||||||
|
*/
|
||||||
|
export function getPointDeviation(
|
||||||
|
begin: Coordinates,
|
||||||
|
end: Coordinates,
|
||||||
|
point: Coordinates
|
||||||
|
): number {
|
||||||
|
const distanceBtw = getDistance(begin, end);
|
||||||
|
const distanceTo = getDistance(begin, point);
|
||||||
|
const distanceFrom = getDistance(point, end);
|
||||||
|
|
||||||
|
return distanceBtw - (distanceFrom + distanceTo);
|
||||||
|
}
|
28
src/preview/components/MapWidget/utils/get-distance.ts
Normal file
28
src/preview/components/MapWidget/utils/get-distance.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { Coordinates } from '@mt/common-types';
|
||||||
|
|
||||||
|
const EARTH_RADIUS = 6372795; // meters
|
||||||
|
|
||||||
|
export function getDistance(a: Coordinates, b: Coordinates): number {
|
||||||
|
const { PI, sin, cos, pow, sqrt, atan2 } = Math;
|
||||||
|
|
||||||
|
const aRad = {
|
||||||
|
lat: (a.lat * PI) / 180,
|
||||||
|
lon: (a.lon * PI) / 180,
|
||||||
|
};
|
||||||
|
|
||||||
|
const bRad = {
|
||||||
|
lat: (b.lat * PI) / 180,
|
||||||
|
lon: (b.lon * PI) / 180,
|
||||||
|
};
|
||||||
|
|
||||||
|
const delta = bRad.lon - aRad.lon;
|
||||||
|
|
||||||
|
// вычисления длины большого круга
|
||||||
|
const y = sqrt(
|
||||||
|
pow(cos(bRad.lat) * sin(delta), 2) +
|
||||||
|
pow(cos(aRad.lat) * sin(bRad.lat) - sin(aRad.lat) * cos(bRad.lat) * cos(delta), 2)
|
||||||
|
);
|
||||||
|
const x = sin(aRad.lat) * sin(bRad.lat) + cos(aRad.lat) * cos(bRad.lat) * cos(delta);
|
||||||
|
|
||||||
|
return +(atan2(y, x) * EARTH_RADIUS).toFixed(2);
|
||||||
|
}
|
6
src/preview/components/MapWidget/utils/get-map-point.ts
Normal file
6
src/preview/components/MapWidget/utils/get-map-point.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { Coordinates } from '@mt/common-types';
|
||||||
|
import { Point } from 'react-simple-maps';
|
||||||
|
|
||||||
|
export function getMapPoint({ lat, lon }: Coordinates): Point {
|
||||||
|
return [lon, lat];
|
||||||
|
}
|
4
src/preview/components/MapWidget/utils/index.ts
Normal file
4
src/preview/components/MapWidget/utils/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from './get-deviation';
|
||||||
|
export * from './get-distance';
|
||||||
|
export * from './get-map-point';
|
||||||
|
export * from './intersections';
|
30
src/preview/components/MapWidget/utils/intersections.ts
Normal file
30
src/preview/components/MapWidget/utils/intersections.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
interface Rectangle {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIntersection(rect1: Rectangle, rect2: Rectangle): Rectangle | null {
|
||||||
|
const left = Math.max(rect1.left, rect2.left);
|
||||||
|
const top = Math.max(rect1.top, rect2.top);
|
||||||
|
const right = Math.min(rect1.right, rect2.right);
|
||||||
|
const bottom = Math.min(rect1.bottom, rect2.bottom);
|
||||||
|
|
||||||
|
if (left < right && top < bottom) {
|
||||||
|
return { left, top, right, bottom };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIntersectionArea(rect1: Rectangle, rect2: Rectangle): number {
|
||||||
|
const intersection = getIntersection(rect1, rect2);
|
||||||
|
if (intersection === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = intersection.right - intersection.left;
|
||||||
|
const height = intersection.bottom - intersection.top;
|
||||||
|
return width * height;
|
||||||
|
}
|
7
src/preview/components/MyComponent/index.tsx
Normal file
7
src/preview/components/MyComponent/index.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export const MyComponent = () => {
|
||||||
|
return (
|
||||||
|
<div style={{ width: "100px", height: "100px", backgroundColor: "red" }}>
|
||||||
|
MyComponent
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,67 @@
|
|||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%),
|
||||||
|
rgba(179, 165, 152, 0.4);
|
||||||
|
box-shadow: inset 4px 4px 12px rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
background: #fcd500;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 92px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
width: 265px;
|
||||||
|
height: 96px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crawlLine {
|
||||||
|
display: inline-block;
|
||||||
|
animation: crawl linear infinite;
|
||||||
|
animation-duration: 10s;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleStart,
|
||||||
|
.titleEnd {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 28px;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleTranslation {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 15px;
|
||||||
|
|
||||||
|
color: #cbcbcb;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes crawl {
|
||||||
|
0% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user