Compare commits

..

13 Commits

257 changed files with 37406 additions and 10437 deletions

2
.env Normal file
View File

@ -0,0 +1,2 @@
VITE_KRBL_MEDIA = "https://wn.krbl.ru/media/"
VITE_KRBL_API = "https://wn.krbl.ru"

View File

@ -1,7 +1,7 @@
# 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:
# 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

5
compose.yaml Normal file
View File

@ -0,0 +1,5 @@
services:
refine:
image: white-nights:latest
ports:
- "3000:3000"

12782
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,174 +1,182 @@
import type {AuthProvider} from '@refinedev/core'
import axios, {AxiosError} from 'axios'
import {BACKEND_URL} from './lib/constants'
import {jwtDecode} from 'jwt-decode'
import type { AuthProvider } from "@refinedev/core";
import axios, { AxiosError } from "axios";
export const TOKEN_KEY = 'refine-auth'
import { jwtDecode } from "jwt-decode";
export const TOKEN_KEY = "refine-auth";
interface AuthResponse {
token: string
token: string;
user: {
id: number
name: string
email: string
is_admin: boolean
}
id: number;
name: string;
email: string;
is_admin: boolean;
};
}
interface ErrorResponse {
message: string
message: string;
}
class AuthError extends Error {
constructor(message: string) {
super(message)
this.name = 'AuthError'
super(message);
this.name = "AuthError";
}
}
interface JWTPayload {
user_id: number
email: string
is_admin: boolean
exp: number
user_id: number;
email: string;
is_admin: boolean;
exp: number;
}
export const authProvider: AuthProvider = {
login: async ({email, password}) => {
login: async ({ email, password }) => {
try {
const response = await axios.post<AuthResponse>(`${BACKEND_URL}/auth/login`, {
email,
password,
})
const response = await axios.post<AuthResponse>(
`${import.meta.env.VITE_KRBL_API}/auth/login`,
{
email,
password,
}
);
const {token, user} = response.data
const { token, user } = response.data;
if (token) {
localStorage.setItem(TOKEN_KEY, token)
localStorage.setItem('user', JSON.stringify(user))
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem("user", JSON.stringify(user));
return {
success: true,
redirectTo: '/',
}
redirectTo: "/",
};
}
throw new AuthError('Неверный email или пароль')
throw new AuthError("Неверный email или пароль");
} catch (error) {
const errorMessage = (error as AxiosError<ErrorResponse>)?.response?.data?.message || 'Неверный email или пароль'
const errorMessage =
(error as AxiosError<ErrorResponse>)?.response?.data?.message ||
"Неверный email или пароль";
return {
success: false,
error: new AuthError(errorMessage),
}
};
}
},
logout: async () => {
try {
await axios.post(
`${BACKEND_URL}/auth/logout`,
`${import.meta.env.VITE_KRBL_API}/auth/logout`,
{},
{
headers: {
Authorization: `Bearer ${localStorage.getItem(TOKEN_KEY)}`,
},
},
)
}
);
} catch (error) {
console.error('Ошибка при выходе:', error)
console.error("Ошибка при выходе:", error);
}
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem('user')
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem("user");
return {
success: true,
redirectTo: '/login',
}
redirectTo: "/login",
};
},
check: async () => {
const token = localStorage.getItem(TOKEN_KEY)
const token = localStorage.getItem(TOKEN_KEY);
if (!token) {
return {
authenticated: false,
redirectTo: '/login',
}
redirectTo: "/login",
};
}
try {
const response = await axios.get(`${BACKEND_URL}/auth/me`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
const response = await axios.get(
`${import.meta.env.VITE_KRBL_API}/auth/me`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (response.status === 200) {
// Обновляем информацию о пользователе
localStorage.setItem('user', JSON.stringify(response.data))
localStorage.setItem("user", JSON.stringify(response.data));
return {
authenticated: true,
}
};
}
} catch (error) {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem('user')
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem("user");
return {
authenticated: false,
redirectTo: '/login',
error: new AuthError('Сессия истекла, пожалуйста, войдите снова'),
}
redirectTo: "/login",
error: new AuthError("Сессия истекла, пожалуйста, войдите снова"),
};
}
return {
authenticated: false,
redirectTo: '/login',
}
redirectTo: "/login",
};
},
getPermissions: async () => {
const token = localStorage.getItem(TOKEN_KEY)
if (!token) return null
const token = localStorage.getItem(TOKEN_KEY);
if (!token) return null;
try {
const decoded = jwtDecode<JWTPayload>(token)
const decoded = jwtDecode<JWTPayload>(token);
if (decoded.is_admin) {
document.body.classList.add('is-admin')
document.body.classList.add("is-admin");
} 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 {
document.body.classList.remove('is-admin')
return ['user']
document.body.classList.remove("is-admin");
return ["user"];
}
},
getIdentity: async () => {
const token = localStorage.getItem(TOKEN_KEY)
const user = localStorage.getItem('user')
const token = localStorage.getItem(TOKEN_KEY);
const user = localStorage.getItem("user");
if (!token || !user) return null
if (!token || !user) return null;
try {
const decoded = jwtDecode<JWTPayload>(token)
const userData = JSON.parse(user)
const decoded = jwtDecode<JWTPayload>(token);
const userData = JSON.parse(user);
return {
...userData,
is_admin: decoded.is_admin, // всегда используем значение из токена
}
};
} catch {
return null
return null;
}
},
onError: async (error) => {
const status = (error as AxiosError)?.response?.status
const status = (error as AxiosError)?.response?.status;
if (status === 401 || status === 403) {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem('user')
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem("user");
return {
logout: true,
redirectTo: '/login',
error: new AuthError('Сессия истекла, пожалуйста, войдите снова'),
}
redirectTo: "/login",
error: new AuthError("Сессия истекла, пожалуйста, войдите снова"),
};
}
return {error}
return { error };
},
}
};

View File

@ -1,133 +1,174 @@
import {Typography, Button, Box, Accordion, AccordionSummary, AccordionDetails, useTheme, TextField} from '@mui/material'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import {axiosInstance} from '../providers/data'
import {BACKEND_URL} from '../lib/constants'
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'
import {
Typography,
Button,
Box,
Accordion,
AccordionSummary,
AccordionDetails,
useTheme,
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 = {
file: File
preview: string
uploading: boolean
mediaId?: number
}
file: File;
preview: string;
uploading: boolean;
mediaId?: number;
};
type Props = {
parentId: string | number
parentResource: string
childResource: string
title: string
}
parentId: string | number;
parentResource: string;
childResource: string;
title: string;
left?: boolean;
};
export const CreateSightArticle = ({parentId, parentResource, childResource, title}: Props) => {
const theme = useTheme()
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([])
export const CreateSightArticle = ({
parentId,
parentResource,
childResource,
title,
left,
}: Props) => {
const theme = useTheme();
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]);
const {
register: registerItem,
control: controlItem,
handleSubmit: handleSubmitItem,
reset: resetItem,
formState: {errors: itemErrors},
formState: { errors: itemErrors },
} = useForm({
defaultValues: {
heading: '',
body: '',
heading: "",
body: "",
},
})
});
const simpleMDEOptions = React.useMemo(
() => ({
placeholder: 'Введите контент в формате Markdown...',
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])
}, [])
}));
setMediaFiles((prev) => [...prev, ...newFiles]);
}, []);
const {getRootProps, getInputProps, isDragActive} = useDropzone({
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'image/*': ALLOWED_IMAGE_TYPES,
'video/*': ALLOWED_VIDEO_TYPES,
"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 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(`${BACKEND_URL}/media`, formData)
return response.data.id
}
const response = await axiosInstance.post(
`${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 {
// Создаем статью
const response = await axiosInstance.post(`${BACKEND_URL}/${childResource}`, data)
const itemId = response.data.id
const response = await axiosInstance.post(
`${import.meta.env.VITE_KRBL_API}/${childResource}`,
data
);
const itemId = response.data.id;
// Получаем существующие статьи для определения порядкового номера
const existingItemsResponse = await axiosInstance.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`)
const existingItems = existingItemsResponse.data || []
const nextPageNum = existingItems.length + 1
const existingItemsResponse = await axiosInstance.get(
`${
import.meta.env.VITE_KRBL_API
}/${parentResource}/${parentId}/${childResource}`
);
const existingItems = existingItemsResponse.data || [];
const nextPageNum = existingItems.length + 1;
// Привязываем статью к достопримечательности
await axiosInstance.post(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}/`, {
[`${childResource}_id`]: itemId,
page_num: nextPageNum,
})
if (!left) {
// Привязываем статью к достопримечательности если она не левая
await axiosInstance.post(
`${
import.meta.env.VITE_KRBL_API
}/${parentResource}/${parentId}/${childResource}/`,
{
[`${childResource}_id`]: itemId,
page_num: nextPageNum,
}
);
}
// Загружаем все медиа файлы и получаем их ID
const mediaIds = await Promise.all(
mediaFiles.map(async (mediaFile) => {
return await uploadMedia(mediaFile)
}),
)
return await uploadMedia(mediaFile);
})
);
// Привязываем все медиа к статье
await Promise.all(
mediaIds.map((mediaId, index) =>
axiosInstance.post(`${BACKEND_URL}/article/${itemId}/media/`, {
media_id: mediaId,
media_order: index + 1,
}),
),
)
axiosInstance.post(
`${import.meta.env.VITE_KRBL_API}/article/${itemId}/media/`,
{
media_id: mediaId,
media_order: index + 1,
}
)
)
);
resetItem()
setMediaFiles([])
window.location.reload()
resetItem();
setMediaFiles([]);
window.location.reload();
} catch (err: any) {
console.error('Error creating item:', err)
console.error("Error creating item:", err);
}
}
};
const removeMedia = (index: number) => {
setMediaFiles((prev) => {
const newFiles = [...prev]
URL.revokeObjectURL(newFiles[index].preview)
newFiles.splice(index, 1)
return newFiles
})
}
const newFiles = [...prev];
URL.revokeObjectURL(newFiles[index].preview);
newFiles.splice(index, 1);
return newFiles;
});
};
return (
<Accordion>
@ -137,82 +178,106 @@ export const CreateSightArticle = ({parentId, parentResource, childResource, tit
marginTop: 2,
background: theme.palette.background.paper,
borderBottom: `1px solid ${theme.palette.divider}`,
zIndex: 2000,
}}
>
<Typography variant="subtitle1" fontWeight="bold">
Создать {title}
</Typography>
</AccordionSummary>
<AccordionDetails sx={{background: theme.palette.background.paper}}>
<AccordionDetails sx={{ background: theme.palette.background.paper }}>
<Box component="form" onSubmit={handleSubmitItem(handleCreate)}>
<TextField
{...registerItem('heading', {
required: 'Это поле является обязательным',
{...registerItem("heading", {
required: "Это поле является обязательным",
})}
error={!!(itemErrors as any)?.heading}
helperText={(itemErrors as any)?.heading?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="text"
sx={{
zIndex: 2000,
backgroundColor: theme.palette.background.paper,
}}
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 для медиа файлов */}
<Box sx={{mt: 2, mb: 2}}>
<Box sx={{ mt: 2, mb: 2 }}>
<Box
{...getRootProps()}
sx={{
border: '2px dashed',
borderColor: isDragActive ? 'primary.main' : 'grey.300',
border: "2px dashed",
borderColor: isDragActive ? "primary.main" : "grey.300",
borderRadius: 1,
p: 2,
textAlign: 'center',
cursor: 'pointer',
'&:hover': {
borderColor: 'primary.main',
textAlign: "center",
cursor: "pointer",
"&:hover": {
borderColor: "primary.main",
},
}}
>
<input {...getInputProps()} />
<Typography>{isDragActive ? 'Перетащите файлы сюда...' : 'Перетащите файлы сюда или кликните для выбора'}</Typography>
<Typography>
{isDragActive
? "Перетащите файлы сюда..."
: "Перетащите файлы сюда или кликните для выбора"}
</Typography>
</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) => (
<Box
key={mediaFile.preview}
sx={{
position: 'relative',
position: "relative",
width: 100,
height: 100,
}}
>
{mediaFile.file.type.startsWith('image/') ? (
{mediaFile.file.type.startsWith("image/") ? (
<img
src={mediaFile.preview}
alt={mediaFile.file.name}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
) : (
<Box
sx={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'grey.200',
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "grey.200",
}}
>
<Typography variant="caption">{mediaFile.file.name}</Typography>
<Typography variant="caption">
{mediaFile.file.name}
</Typography>
</Box>
)}
<Button
@ -220,10 +285,10 @@ export const CreateSightArticle = ({parentId, parentResource, childResource, tit
color="error"
onClick={() => removeMedia(index)}
sx={{
position: 'absolute',
position: "absolute",
top: 0,
right: 0,
minWidth: 'auto',
minWidth: "auto",
width: 20,
height: 20,
p: 0,
@ -236,16 +301,16 @@ export const CreateSightArticle = ({parentId, parentResource, childResource, tit
</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>
<Button
variant="outlined"
onClick={() => {
resetItem()
mediaFiles.forEach((file) => URL.revokeObjectURL(file.preview))
setMediaFiles([])
resetItem();
mediaFiles.forEach((file) => URL.revokeObjectURL(file.preview));
setMediaFiles([]);
}}
>
Очистить
@ -254,5 +319,5 @@ export const CreateSightArticle = ({parentId, parentResource, childResource, tit
</Box>
</AccordionDetails>
</Accordion>
)
}
);
};

View File

@ -1,83 +1,142 @@
import {DataGrid, type DataGridProps, type GridColumnVisibilityModel} from '@mui/x-data-grid'
import {Stack, Button, Typography} 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 {
DataGrid,
type DataGridProps,
type GridColumnVisibilityModel,
} from "@mui/x-data-grid";
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 {
hasCoordinates?: boolean
resource?: string // Add this prop
hasCoordinates?: boolean;
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 {triggerExport, isLoading: exportLoading} = useExport({
resource: resource ?? '',
const { triggerExport, isLoading: exportLoading } = useExport({
resource: resource ?? "",
// pageSize: 100, #*
// maxItemCount: 100, #*
})
});
const initialShowCoordinates = Cookies.get('showCoordinates') === 'true'
const initialShowDevData = false // Default to false in both prod and dev
const [showCoordinates, setShowCoordinates] = useState(initialShowCoordinates)
const [showDevData, setShowDevData] = useState(Cookies.get('showDevData') === 'true')
const initialShowCoordinates = Cookies.get("showCoordinates") === "true";
const initialShowDevData = false; // Default to false in both prod and dev
const [showCoordinates, setShowCoordinates] = useState(
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 model: GridColumnVisibilityModel = {}
const model: GridColumnVisibilityModel = {};
availableDevFields.forEach((field) => {
model[field] = initialShowDevData
})
model[field] = initialShowDevData;
});
if (hasCoordinates) {
model.latitude = initialShowCoordinates
model.longitude = initialShowCoordinates
model.latitude = initialShowCoordinates;
model.longitude = initialShowCoordinates;
}
return model
}, [availableDevFields, hasCoordinates, initialShowCoordinates, initialShowDevData])
return model;
}, [
availableDevFields,
hasCoordinates,
initialShowCoordinates,
initialShowDevData,
]);
const [columnVisibilityModel, setColumnVisibilityModel] = useState<GridColumnVisibilityModel>(initialVisibilityModel)
const [columnVisibilityModel, setColumnVisibilityModel] =
useState<GridColumnVisibilityModel>(initialVisibilityModel);
useEffect(() => {
setColumnVisibilityModel((prevModel) => {
const newModel = {...prevModel}
const newModel = { ...prevModel };
availableDevFields.forEach((field) => {
newModel[field] = showDevData
})
newModel[field] = showDevData;
});
if (hasCoordinates) {
newModel.latitude = showCoordinates
newModel.longitude = showCoordinates
newModel.latitude = showCoordinates;
newModel.longitude = showCoordinates;
}
return newModel
})
return newModel;
});
if (hasCoordinates) {
Cookies.set('showCoordinates', String(showCoordinates))
Cookies.set("showCoordinates", String(showCoordinates));
}
Cookies.set('showDevData', String(showDevData))
}, [showCoordinates, showDevData, hasCoordinates, availableDevFields])
Cookies.set("showDevData", String(showDevData));
}, [showCoordinates, showDevData, hasCoordinates, availableDevFields]);
const toggleCoordinates = () => {
setShowCoordinates((prev) => !prev)
}
setShowCoordinates((prev) => !prev);
};
const toggleDevData = () => {
setShowDevData((prev) => !prev)
}
setShowDevData((prev) => !prev);
};
return (
<Stack spacing={2}>
<Box sx={{ visibility: languageEnabled ? "visible" : "hidden" }}>
<LanguageSwitch />
</Box>
<DataGrid
{...props}
columns={columns}
@ -92,31 +151,37 @@ export const CustomDataGrid = ({hasCoordinates = false, columns = [], resource,
// paginationModel: {pageSize: 25, page: 0},
// },
sorting: {
sortModel: [{field: 'id', sort: 'asc'}],
sortModel: [{ field: "id", sort: "asc" }],
},
}}
pageSizeOptions={[10, 25, 50, 100]}
/>
<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 && (
<Button variant="contained" onClick={toggleCoordinates}>
{showCoordinates ? 'Скрыть координаты' : 'Показать координаты'}
{showCoordinates ? "Скрыть координаты" : "Показать координаты"}
</Button>
)}
{(import.meta.env.DEV || showDevData) && availableDevFields.length > 0 && (
<Button variant="contained" onClick={toggleDevData}>
{showDevData ? 'Скрыть служебные данные' : 'Показать служебные данные'}
</Button>
)}
{(import.meta.env.DEV || showDevData) &&
availableDevFields.length > 0 && (
<Button variant="contained" onClick={toggleDevData}>
{showDevData
? "Скрыть служебные данные"
: "Показать служебные данные"}
</Button>
)}
</Stack>
<ExportButton onClick={triggerExport} loading={exportLoading} hideText={false}>
<Typography sx={{marginLeft: '-2px'}}>Экспорт</Typography>
<ExportButton
onClick={triggerExport}
loading={exportLoading}
hideText={false}
>
<Typography sx={{ marginLeft: "-2px" }}>Экспорт</Typography>
</ExportButton>
</Stack>
</Stack>
)
}
);
};

View 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>
);
});

View File

@ -1,275 +1,501 @@
import {useState, useEffect} from 'react'
import {Stack, Typography, Button, FormControl, Grid, Box, Accordion, AccordionSummary, AccordionDetails, useTheme, TextField, Autocomplete} from '@mui/material'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import {axiosInstance} from '../providers/data'
import {BACKEND_URL} from '../lib/constants'
import {Link} from 'react-router'
import {TOKEN_KEY} from '../authProvider'
import { useState, useEffect } from "react";
import { languageStore } from "../store/LanguageStore";
import {
Stack,
Typography,
Button,
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> = {
label: string
data: keyof T
render?: (value: any) => React.ReactNode
}
label: string;
data: keyof T;
render?: (value: any) => React.ReactNode;
};
type ExtraFieldConfig = {
type: 'number'
label: string
minValue: number
maxValue: (linkedItems: any[]) => number
}
type: "number";
label: string;
minValue: number;
maxValue: (linkedItems: any[]) => number;
};
type LinkedItemsProps<T> = {
parentId: string | number
parentResource: string
childResource: string
fields: Field<T>[]
title: string
type: 'show' | 'edit'
extraField?: ExtraFieldConfig
}
parentId: string | number;
parentResource: string;
childResource: string;
fields: Field<T>[];
setItemsParent?: (items: T[]) => void;
title: string;
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 [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()
const reorder = (list: any[], startIndex: number, endIndex: number) => {
const result = Array.from(list);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
};
export const LinkedItems = <T extends { id: number; [key: string]: any }>({
parentId,
parentResource,
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(() => {
if (parentId) {
axiosInstance
.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`)
.get(
`${
import.meta.env.VITE_KRBL_API
}/${parentResource}/${parentId}/${childResource}`
)
.then((response) => {
setLinkedItems(response?.data || [])
setLinkedItems(response?.data || []);
})
.catch(() => {
setLinkedItems([])
})
setLinkedItems([]);
});
}
}, [parentId, parentResource, childResource])
}, [parentId, parentResource, childResource, language]);
useEffect(() => {
if (type === 'edit') {
if (type === "edit") {
axiosInstance
.get(`${BACKEND_URL}/${childResource}/`)
.get(`${import.meta.env.VITE_KRBL_API}/${childResource}/`)
.then((response) => {
setItems(response?.data || [])
setIsLoading(false)
setItems(response?.data || []);
setIsLoading(false);
})
.catch(() => {
setItems([])
setIsLoading(false)
})
setItems([]);
setIsLoading(false);
});
} else {
setIsLoading(false)
setIsLoading(false);
}
}, [childResource, type])
}, [childResource, type]);
useEffect(() => {
if (childResource === 'article' && parentResource === 'sight') {
setPageNum(linkedItems.length + 1)
if (childResource === "article" && parentResource === "sight") {
setPageNum(linkedItems.length + 1);
}
}, [linkedItems, childResource, parentResource])
const availableItems = items.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
}, [linkedItems, childResource, parentResource]);
const linkItem = () => {
if (selectedItemId !== null) {
const requestData =
childResource === 'article'
childResource === "article"
? {
[`${childResource}_id`]: selectedItemId,
page_num: pageNum,
}
: childResource === 'media'
: childResource === "media"
? {
[`${childResource}_id`]: selectedItemId,
media_order: mediaOrder,
}
: {
[`${childResource}_id`]: selectedItemId,
: childResource === "station"
? {
stations: insertAtPosition(
linkedItems.map((item) => ({
id: item.id,
})),
position,
{
id: selectedItemId,
}
),
}
: { [`${childResource}_id`]: selectedItemId };
axiosInstance
.post(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`, requestData)
.post(
`${
import.meta.env.VITE_KRBL_API
}/${parentResource}/${parentId}/${childResource}`,
requestData
)
.then(() => {
axiosInstance.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`).then((response) => {
setLinkedItems(response?.data || [])
setSelectedItemId(null)
if (childResource === 'article') {
setPageNum(pageNum + 1)
}
})
axiosInstance
.get(
`${
import.meta.env.VITE_KRBL_API
}/${parentResource}/${parentId}/${childResource}`
)
.then((response) => {
setLinkedItems(response?.data || []);
setSelectedItemId(null);
if (childResource === "article") {
setPageNum(pageNum + 1);
}
});
})
.catch((error) => {
console.error('Error linking item:', error)
})
console.error("Error linking item:", error);
});
}
}
};
const deleteItem = (itemId: number) => {
axiosInstance
.delete(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`, {
data: {[`${childResource}_id`]: itemId},
})
.delete(
`${
import.meta.env.VITE_KRBL_API
}/${parentResource}/${parentId}/${childResource}`,
{
data: { [`${childResource}_id`]: itemId },
}
)
.then(() => {
setLinkedItems((prev) => prev.filter((item) => item.id !== itemId))
setLinkedItems((prev) => prev.filter((item) => item.id !== itemId));
})
.catch((error) => {
console.error('Error unlinking item:', error)
})
}
console.error("Error unlinking item:", error);
});
};
return (
<Accordion>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
background: theme.palette.background.paper,
borderBottom: `1px solid ${theme.palette.divider}`,
}}
>
<Typography variant="subtitle1" fontWeight="bold">
Привязанные {title}
</Typography>
</AccordionSummary>
<>
<Accordion>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
background: theme.palette.background.paper,
borderBottom: `1px solid ${theme.palette.divider}`,
}}
>
<Typography variant="subtitle1" fontWeight="bold">
Привязанные {title}
</Typography>
</AccordionSummary>
<AccordionDetails sx={{background: theme.palette.background.paper}}>
<Stack gap={2}>
<Grid container gap={1.25}>
{isLoading ? (
<Typography>Загрузка...</Typography>
) : linkedItems.length > 0 ? (
linkedItems.map((item, index) => (
<Box
component={Link}
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}) => (
<Typography variant="body2" color="textSecondary" key={String(data)}>
<strong>{label}:</strong> {render ? render(item[data]) : item[data]}
</Typography>
))}
{type === 'edit' && (
<Button
variant="outlined"
color="error"
size="small"
onClick={(e) => {
e.preventDefault()
deleteItem(item.id)
}}
sx={{mt: 1.5}}
<AccordionDetails sx={{ background: theme.palette.background.paper }}>
<Stack gap={2}>
<DragDropContext onDragEnd={onDragEnd}>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
{type === "edit" && dragAllowed && (
<TableCell width="40px"></TableCell>
)}
<TableCell key="id"></TableCell>
{fields.map((field) => (
<TableCell key={String(field.data)}>
{field.label}
</TableCell>
))}
{type === "edit" && (
<TableCell width="120px">Действие</TableCell>
)}
</TableRow>
</TableHead>
<Droppable
droppableId="droppable"
isDropDisabled={type !== "edit" || !dragAllowed}
>
{(provided) => (
<TableBody
ref={provided.innerRef}
{...provided.droppableProps}
>
Отвязать
</Button>
{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
variant="outlined"
color="error"
size="small"
onClick={(e) => {
e.stopPropagation();
deleteItem(item.id);
}}
>
Отвязать
</Button>
</TableCell>
)}
</TableRow>
</>
)}
</Draggable>
))}
{provided.placeholder}
</TableBody>
)}
</Stack>
</Box>
))
) : (
<Typography color="textSecondary">{title} не найдены</Typography>
</Droppable>
</Table>
</TableContainer>
</DragDropContext>
{linkedItems.length === 0 && !isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
{title} не найдены
</Typography>
)}
</Grid>
{type === 'edit' && (
<Stack gap={2}>
<Typography variant="subtitle1">Добавить {title}</Typography>
<Autocomplete
fullWidth
value={availableItems.find((item) => item.id === selectedItemId) || null}
onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)}
options={availableItems}
getOptionLabel={(item) => String(item[fields[0].data])}
renderInput={(params) => <TextField {...params} label={`Выберите ${title}`} fullWidth />}
isOptionEqualToValue={(option, value) => option.id === value?.id}
filterOptions={(options, {inputValue}) => {
// return options.filter((option) => String(option[fields[0].data]).toLowerCase().includes(inputValue.toLowerCase()))
const searchWords = inputValue
.toLowerCase()
.split(' ')
.filter((word) => word.length > 0)
return options.filter((option) => {
const optionWords = String(option[fields[0].data]).toLowerCase().split(' ')
return searchWords.every((searchWord) => optionWords.some((word) => word.startsWith(searchWord)))
})
}}
/>
{type === "edit" && (
<Stack gap={2} mt={2}>
<Typography variant="subtitle1">Добавить {title}</Typography>
<Autocomplete
fullWidth
value={
availableItems?.find(
(item) => item.id === selectedItemId
) || null
}
onChange={(_, newValue) =>
setSelectedItemId(newValue?.id || null)
}
options={availableItems}
getOptionLabel={(item) => String(item[fields[0].data])}
renderInput={(params) => (
<TextField
{...params}
label={`Выберите ${title}`}
fullWidth
/>
)}
isOptionEqualToValue={(option, value) =>
option.id === value?.id
}
filterOptions={(options, { inputValue }) => {
const searchWords = inputValue
.toLowerCase()
.split(" ")
.filter((word) => word.length > 0);
return options.filter((option) => {
const optionWords = String(option[fields[0].data])
.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' && (
<FormControl fullWidth>
{childResource === "article" && (
<FormControl fullWidth>
<TextField
type="number"
label="Позиция добавляемой статьи"
name="page_num"
value={pageNum}
onChange={(e) => {
const newValue = Number(e.target.value);
const minValue = linkedItems.length + 1;
setPageNum(newValue < minValue ? minValue : newValue);
}}
fullWidth
InputLabelProps={{ shrink: true }}
/>
</FormControl>
)}
{childResource === "media" && (
<FormControl fullWidth>
<TextField
type="number"
label="Порядок отображения медиа"
value={mediaOrder}
onChange={(e) => {
const newValue = Number(e.target.value);
const maxValue = linkedItems.length + 1;
const value = Math.max(1, Math.min(newValue, maxValue));
setMediaOrder(value);
}}
fullWidth
InputLabelProps={{ shrink: true }}
/>
</FormControl>
)}
<Button
variant="contained"
onClick={linkItem}
disabled={!selectedItemId}
sx={{ alignSelf: "flex-start" }}
>
Добавить
</Button>
{childResource == "station" && (
<TextField
type="number"
label="Номер страницы"
name="page_num"
value={pageNum}
type="text"
label="Позиция добавляемой остановки к маршруту"
value={position}
onChange={(e) => {
const newValue = Number(e.target.value)
const minValue = linkedItems.length + 1 // page number on articles lenght
setPageNum(newValue < minValue ? minValue : newValue)
const newValue = Number(e.target.value);
setPosition(
newValue > linkedItems.length + 1
? linkedItems.length + 1
: newValue
);
}}
fullWidth
InputLabelProps={{shrink: true}}
/>
</FormControl>
)}
{childResource === 'media' && type === 'edit' && (
<FormControl fullWidth>
<TextField
type="number"
label="Порядок отображения медиа"
value={mediaOrder}
onChange={(e) => {
const newValue = Number(e.target.value)
const maxValue = linkedItems.length + 1
const value = Math.max(1, Math.min(newValue, maxValue))
setMediaOrder(value)
}}
fullWidth
InputLabelProps={{shrink: true}}
/>
</FormControl>
)}
<Button variant="contained" onClick={linkItem} disabled={!selectedItemId}>
Добавить
</Button>
</Stack>
)}
</Stack>
</AccordionDetails>
</Accordion>
)
}
></TextField>
)}
</Stack>
)}
</Stack>
</AccordionDetails>
</Accordion>
<ArticleEditModal />
<StationEditModal />
</>
);
};

View File

@ -1,181 +1,226 @@
import DarkModeOutlined from '@mui/icons-material/DarkModeOutlined'
import LightModeOutlined from '@mui/icons-material/LightModeOutlined'
import AppBar from '@mui/material/AppBar'
import Avatar from '@mui/material/Avatar'
import IconButton from '@mui/material/IconButton'
import Stack from '@mui/material/Stack'
import Toolbar from '@mui/material/Toolbar'
import Typography from '@mui/material/Typography'
import {useGetIdentity, usePermissions, useWarnAboutChange} from '@refinedev/core'
import {HamburgerMenu, RefineThemedLayoutV2HeaderProps} from '@refinedev/mui'
import React, {useContext, useEffect} from 'react'
import {ColorModeContext} from '../../contexts/color-mode'
import Cookies from 'js-cookie'
import {useTranslation} from 'react-i18next'
import {Button} from '@mui/material'
import {useNavigate} from 'react-router'
import DarkModeOutlined from "@mui/icons-material/DarkModeOutlined";
import LightModeOutlined from "@mui/icons-material/LightModeOutlined";
import AppBar from "@mui/material/AppBar";
import Avatar from "@mui/material/Avatar";
import IconButton from "@mui/material/IconButton";
import Stack from "@mui/material/Stack";
import Toolbar from "@mui/material/Toolbar";
import Typography from "@mui/material/Typography";
import { languageStore } from "../../store/LanguageStore";
import {
useGetIdentity,
useList,
usePermissions,
useWarnAboutChange,
} from "@refinedev/core";
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 = {
id: number
name: string
avatar: string
is_admin: boolean
}
id: number;
name: string;
avatar: string;
is_admin: boolean;
};
export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = ({sticky = true}) => {
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()
export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = observer(
({ sticky }) => {
const { city_id, setCityIdAction } = cityStore;
const { language } = languageStore;
const { data: cities } = useList({
resource: "city",
});
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 handleLanguageChange = async (lang: string) => {
// console.log('Language change requested:', lang)
// console.log('Current warnWhen state:', warnWhen)
const handleChange = (event: SelectChangeEvent<string>) => {
setCityIdAction(event.target.value);
};
const form = document.querySelector('form')
const inputs = form?.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>('input, textarea, select')
const saveButton = document.querySelector('.refine-save-button') as HTMLButtonElement
const handleLanguageChange = async (lang: string) => {
// console.log('Language change requested:', lang)
// console.log('Current warnWhen state:', warnWhen)
// Сохраняем текущий URL перед любыми действиями
const currentLocation = window.location.pathname + window.location.search
const form = document.querySelector("form");
const inputs = form?.querySelectorAll<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
>("input, textarea, select");
const saveButton = document.querySelector(
".refine-save-button"
) as HTMLButtonElement;
if (form && saveButton) {
const hasChanges = Array.from(inputs || []).some((input) => {
if (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) {
return input.value !== input.defaultValue
}
if (input instanceof HTMLSelectElement) {
return input.value !== input.options[input.selectedIndex].defaultSelected.toString()
}
return false
})
// Сохраняем текущий URL перед любыми действиями
const currentLocation = window.location.pathname + window.location.search;
if (hasChanges || warnWhen) {
try {
// console.log('Attempting to save changes...')
setWarnWhen(false)
saveButton.click()
// console.log('Save button clicked')
if (form && saveButton) {
const hasChanges = Array.from(inputs || []).some((input) => {
if (
input instanceof HTMLInputElement ||
input instanceof HTMLTextAreaElement
) {
return input.value !== input.defaultValue;
}
if (input instanceof HTMLSelectElement) {
return (
input.value !==
input.options[input.selectedIndex].defaultSelected.toString()
);
}
return false;
});
await new Promise((resolve) => setTimeout(resolve, 1000))
if (hasChanges || warnWhen) {
try {
// console.log('Attempting to save changes...')
setWarnWhen(false);
saveButton.click();
// console.log('Save button clicked')
// После сохранения меняем язык и возвращаемся на ту же страницу
Cookies.set('lang', lang)
i18n.changeLanguage(lang)
navigate(currentLocation)
return
} catch (error) {
console.error('Failed to save form:', error)
setWarnWhen(true)
return
await new Promise((resolve) => setTimeout(resolve, 1000));
// После сохранения меняем язык и возвращаемся на ту же страницу
Cookies.set("lang", lang);
i18n.changeLanguage(lang);
navigate(currentLocation);
return;
} catch (error) {
console.error("Failed to save form:", error);
setWarnWhen(true);
return;
}
}
}
}
// Если нет формы или изменений, просто меняем язык
// console.log('Setting language cookie:', lang)
Cookies.set('lang', lang)
// Если нет формы или изменений, просто меняем язык
// console.log('Setting language cookie:', lang)
Cookies.set("lang", lang);
// console.log('Changing i18n language')
i18n.changeLanguage(lang)
// console.log('Changing i18n language')
i18n.changeLanguage(lang);
// Используем текущий URL для навигации
navigate(0)
}
// Используем текущий URL для навигации
navigate(0);
};
useEffect(() => {
const savedLang = Cookies.get('lang') || 'ru'
i18n.changeLanguage(savedLang)
}, [i18n])
useEffect(() => {
const savedLang = Cookies.get("lang") || "ru";
i18n.changeLanguage(savedLang);
}, [i18n]);
return (
<AppBar position={sticky ? "sticky" : "relative"}>
<Toolbar>
<Stack
direction="row"
width="100%"
justifyContent="flex-end"
alignItems="center"
>
<HamburgerMenu />
return (
<AppBar position={sticky ? 'sticky' : 'relative'}>
<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
direction="row"
spacing={1}
sx={{
backgroundColor: 'background.paper',
padding: '4px',
borderRadius: '4px',
}}
width="100%"
justifyContent="flex-end"
alignItems="center"
spacing={2}
>
{['ru', 'en', 'zh'].map((lang) => (
<Button
key={lang}
onClick={() => handleLanguageChange(lang)}
variant={i18n.language === lang ? 'contained' : 'outlined'}
size="small"
sx={{
minWidth: '30px',
padding: '2px 0px',
textTransform: 'uppercase',
}}
>
{lang}
</Button>
))}
</Stack>
<IconButton
color="inherit"
onClick={() => {
setMode()
}}
sx={{
marginRight: '2px',
}}
>
{mode === 'dark' ? <LightModeOutlined /> : <DarkModeOutlined />}
</IconButton>
{(user?.avatar || user?.name) && (
<Stack direction="row" gap="16px" alignItems="center" justifyContent="center">
{user?.name && (
<Stack direction="column" alignItems="start" gap="0px">
<Typography
sx={{
display: {
xs: 'none',
sm: 'inline-block',
},
}}
variant="subtitle2"
>
{user?.name}
</Typography>
<Typography
sx={{
display: {
xs: 'none',
sm: 'inline-block',
},
backgroundColor: 'primary.main',
color: 'rgba(255, 255, 255, 0.7)',
padding: '1px 4px',
borderRadius: 1,
fontSize: '0.6rem',
}}
variant="subtitle2"
>
{isAdmin ? 'Администратор' : 'Пользователь'}
</Typography>
</Stack>
<FormControl variant="standard" sx={{ width: "min-content" }}>
{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>
))}
</Select>
)}
<Avatar src={user?.avatar} alt={user?.name} />
</Stack>
)}
</FormControl>
<IconButton
color="inherit"
onClick={() => {
setMode();
}}
sx={{
marginRight: "2px",
}}
>
{mode === "dark" ? <LightModeOutlined /> : <DarkModeOutlined />}
</IconButton>
{(user?.avatar || user?.name) && (
<Stack
direction="row"
gap="16px"
alignItems="center"
justifyContent="center"
>
{user?.name && (
<Stack direction="column" alignItems="start" gap="0px">
<Typography
sx={{
display: {
xs: "none",
sm: "inline-block",
},
}}
variant="subtitle2"
>
{user?.name}
</Typography>
<Typography
sx={{
display: {
xs: "none",
sm: "inline-block",
},
backgroundColor: "primary.main",
color: "rgba(255, 255, 255, 0.7)",
padding: "1px 4px",
borderRadius: 1,
fontSize: "0.6rem",
}}
variant="subtitle2"
>
{isAdmin ? "Администратор" : "Пользователь"}
</Typography>
</Stack>
)}
<Avatar src={user?.avatar} alt={user?.name} />
</Stack>
)}
</Stack>
</Stack>
</Stack>
</Toolbar>
</AppBar>
)
}
</Toolbar>
</AppBar>
);
}
);

View File

@ -1 +0,0 @@
export {Header} from './header'

View File

@ -1,72 +1,142 @@
import {useState} from 'react'
import {UseFormSetError, UseFormClearErrors, UseFormSetValue} from 'react-hook-form'
import { useState } from "react";
import {
UseFormSetError,
UseFormClearErrors,
UseFormSetValue,
} from "react-hook-form";
export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
export const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/ogg']
export const ALLOWED_IMAGE_TYPES = [
"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) => {
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)) {
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 = {
selectedMediaType: number
setError: UseFormSetError<any>
clearErrors: UseFormClearErrors<any>
setValue: UseFormSetValue<any>
}
selectedMediaType: number;
setError: UseFormSetError<any>;
clearErrors: UseFormClearErrors<any>;
setValue: UseFormSetValue<any>;
};
export const useMediaFileUpload = ({selectedMediaType, setError, clearErrors, setValue}: UseMediaFileUploadProps) => {
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
export const useMediaFileUpload = ({
selectedMediaType,
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 file = event.target.files?.[0]
if (!file) return
const file = event.target.files?.[0];
if (!file) return;
if (selectedMediaType) {
const error = validateFileType(file, selectedMediaType)
const error = validateFileType(file, selectedMediaType);
if (error) {
setError('file', {type: 'manual', message: error})
event.target.value = ''
return
setError("file", { type: "manual", message: error });
event.target.value = "";
return;
}
}
clearErrors('file')
setValue('file', file)
setSelectedFile(file)
clearErrors("file");
setValue("file", file);
setSelectedFile(file);
if (file.type.startsWith('image/')) {
const url = URL.createObjectURL(file)
setPreviewUrl(url)
if (file.type.startsWith("image/")) {
const url = URL.createObjectURL(file);
setPreviewUrl(url);
} else {
setPreviewUrl(null)
setPreviewUrl(null);
}
}
};
const handleMediaTypeChange = (newMediaType: number | null) => {
setValue('media_type', newMediaType || null)
setValue("media_type", newMediaType || null);
if (selectedFile && newMediaType) {
const error = validateFileType(selectedFile, newMediaType)
const error = validateFileType(selectedFile, newMediaType);
if (error) {
setError('file', {type: 'manual', message: error})
setValue('file', null)
setSelectedFile(null)
setPreviewUrl(null)
setError("file", { type: "manual", message: error });
setValue("file", null);
setSelectedFile(null);
setPreviewUrl(null);
} else {
clearErrors('file')
clearErrors("file");
}
}
}
};
return {
selectedFile,
@ -75,5 +145,5 @@ export const useMediaFileUpload = ({selectedMediaType, setError, clearErrors, se
setPreviewUrl,
handleFileChange,
handleMediaTypeChange,
}
}
};
};

View 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>
);
});

View 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>
);
});

View File

@ -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 './globals.css'
import App from "./App";
import "./globals.css";
const container = document.getElementById('root') as HTMLElement
const root = createRoot(container)
const container = document.getElementById("root") as HTMLElement;
const root = createRoot(container);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
root.render(<App />);

View File

@ -1,11 +1,13 @@
export const BACKEND_URL = 'https://wn.krbl.ru'
export const MEDIA_TYPES = [
{label: 'Фото', value: 1},
{label: 'Видео', value: 2},
]
{ label: "Фото", value: 1 },
{ label: "Видео", value: 2 },
{ label: "Иконка", value: 3 },
{ label: "Водяной знак", value: 4 },
{ label: "Панорама", value: 5 },
{ label: "3Д-модель", value: 6 },
];
export const VEHICLE_TYPES = [
{label: 'Трамвай', value: 1},
{label: 'Троллейбус', value: 2},
]
{ label: "Трамвай", value: 1 },
{ label: "Троллейбус", value: 2 },
];

View File

@ -1,72 +1,196 @@
import {Box, TextField, Typography, Paper} from '@mui/material'
import {Create} from '@refinedev/mui'
import {useForm} from '@refinedev/react-hook-form'
import {Controller} from 'react-hook-form'
import React, {useState, useEffect} from 'react'
import ReactMarkdown from 'react-markdown'
import { Box, TextField, Typography, Paper } from "@mui/material";
import { Create } from "@refinedev/mui";
import { useForm } from "@refinedev/react-hook-form";
import { Controller } from "react-hook-form";
import React, { useState, useEffect } from "react";
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'
import 'easymde/dist/easymde.min.css'
const MemoizedSimpleMDE = React.memo(MarkdownEditor)
const MemoizedSimpleMDE = React.memo(MarkdownEditor);
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 {
saveButtonProps,
refineCore: {formLoading},
refineCore: { formLoading },
register,
control,
watch,
formState: {errors},
formState: { errors },
setValue,
} = useForm({
refineCoreProps: {
resource: 'article/',
resource: "article/",
meta: {
headers: {
"Accept-Language": language,
},
},
},
})
});
const [preview, setPreview] = useState('')
const [headingPreview, setHeadingPreview] = useState('')
useEffect(() => {
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
const bodyContent = watch('body')
const headingContent = watch('heading')
const bodyContent = watch("body");
const headingContent = watch("heading");
useEffect(() => {
setPreview(bodyContent || '')
}, [bodyContent])
setPreview(bodyContent || "");
}, [bodyContent]);
useEffect(() => {
setHeadingPreview(headingContent || '')
}, [headingContent])
setHeadingPreview(headingContent || "");
}, [headingContent]);
const simpleMDEOptions = React.useMemo(
() => ({
placeholder: 'Введите контент в формате Markdown...',
placeholder: "Введите контент в формате Markdown...",
spellChecker: false,
}),
[],
)
[]
);
return (
<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">
<TextField
{...register('heading', {
required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.heading}
helperText={(errors as any)?.heading?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
type="text"
label="Заголовок *"
name="heading"
/>
<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
{...register("heading", {
required: "Это поле является обязательным",
})}
error={!!(errors as any)?.heading}
helperText={(errors as any)?.heading?.message}
margin="normal"
fullWidth
InputLabelProps={{ shrink: true }}
type="text"
label="Заголовок *"
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>
{/* Блок предпросмотра */}
@ -74,14 +198,15 @@ export const ArticleCreate = () => {
sx={{
flex: 1,
p: 2,
maxHeight: 'calc(100vh - 200px)',
overflowY: 'auto',
position: 'sticky',
maxHeight: "calc(100vh - 200px)",
overflowY: "auto",
position: "sticky",
top: 16,
borderRadius: 2,
border: '1px solid',
borderColor: 'primary.main',
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : '#fff'),
border: "1px solid",
borderColor: "primary.main",
bgcolor: (theme) =>
theme.palette.mode === "dark" ? "background.paper" : "#fff",
}}
>
<Typography variant="h6" gutterBottom color="primary">
@ -93,7 +218,8 @@ export const ArticleCreate = () => {
variant="h4"
gutterBottom
sx={{
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'),
color: (theme) =>
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
mb: 3,
}}
>
@ -103,39 +229,41 @@ export const ArticleCreate = () => {
{/* Markdown контент */}
<Box
sx={{
'& img': {
maxWidth: '100%',
height: 'auto',
"& img": {
maxWidth: "100%",
height: "auto",
borderRadius: 1,
},
'& h1, & h2, & h3, & h4, & h5, & h6': {
color: 'primary.main',
"& h1, & h2, & h3, & h4, & h5, & h6": {
color: "primary.main",
mt: 2,
mb: 1,
},
'& p': {
"& p": {
mb: 2,
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'),
color: (theme) =>
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
},
'& a': {
color: 'primary.main',
textDecoration: 'none',
'&:hover': {
textDecoration: 'underline',
"& a": {
color: "primary.main",
textDecoration: "none",
"&:hover": {
textDecoration: "underline",
},
},
'& blockquote': {
borderLeft: '4px solid',
borderColor: 'primary.main',
"& blockquote": {
borderLeft: "4px solid",
borderColor: "primary.main",
pl: 2,
my: 2,
color: 'text.secondary',
color: "text.secondary",
},
'& code': {
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100'),
"& code": {
bgcolor: (theme) =>
theme.palette.mode === "dark" ? "grey.900" : "grey.100",
p: 0.5,
borderRadius: 0.5,
color: 'primary.main',
color: "primary.main",
},
}}
>
@ -144,5 +272,5 @@ export const ArticleCreate = () => {
</Paper>
</Box>
</Create>
)
}
);
};

View File

@ -1,96 +1,217 @@
import {Box, TextField, Typography, Paper} from '@mui/material'
import {Edit} from '@refinedev/mui'
import {useForm} from '@refinedev/react-hook-form'
import {Controller} from 'react-hook-form'
import {useParams} from 'react-router'
import React, {useState, useEffect} from 'react'
import ReactMarkdown from 'react-markdown'
import {useList} from '@refinedev/core'
import { Box, TextField, Typography, Paper } from "@mui/material";
import { Edit } from "@refinedev/mui";
import { useForm } from "@refinedev/react-hook-form";
import { Controller } from "react-hook-form";
import { useParams } from "react-router";
import React, { useState, useEffect, useMemo } from "react";
import ReactMarkdown from "react-markdown";
import { useList } from "@refinedev/core";
import {MarkdownEditor} from '../../components/MarkdownEditor'
import {LinkedItems} from '../../components/LinkedItems'
import {MediaItem, mediaFields} from './types'
import {TOKEN_KEY} from '../../authProvider'
import 'easymde/dist/easymde.min.css'
import { MarkdownEditor } from "../../components/MarkdownEditor";
import { LinkedItems } from "../../components/LinkedItems";
import { MediaItem, mediaFields } from "./types";
import { TOKEN_KEY } from "../../authProvider";
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 {
saveButtonProps,
register,
control,
handleSubmit,
watch,
formState: {errors},
} = useForm()
const {id: articleId} = useParams<{id: string}>()
const [preview, setPreview] = useState('')
const [headingPreview, setHeadingPreview] = useState('')
// Получаем привязанные медиа
const {data: mediaData} = useList<MediaItem>({
resource: `article/${articleId}/media`,
queryOptions: {
enabled: !!articleId,
formState: { errors },
setValue,
} = useForm<{ heading: string; body: string }>({
refineCoreProps: {
meta: {
headers: {
"Accept-Language": language,
},
},
},
})
// Следим за изменениями в полях body и heading
const bodyContent = watch('body')
const headingContent = watch('heading')
});
useEffect(() => {
setPreview(bodyContent || '')
}, [bodyContent])
if (articleData[language as keyof typeof articleData]?.heading) {
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(() => {
setHeadingPreview(headingContent || '')
}, [headingContent])
setPreview(bodyContent || "");
}, [bodyContent]);
const simpleMDEOptions = React.useMemo(
() => ({
placeholder: 'Введите контент в формате Markdown...',
spellChecker: false,
}),
[],
)
useEffect(() => {
setHeadingPreview(headingContent || "");
}, [headingContent]);
const { data: mediaData } = useList<MediaItem>({
resource: `article/${articleId}/media`,
});
useEffect(() => {
return () => {
setLanguageAction("ru");
};
}, [setLanguageAction]);
return (
<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">
<TextField
{...register('heading', {
required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.heading}
helperText={(errors as any)?.heading?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
type="text"
label="Заголовок *"
name="heading"
/>
{/* Форма создания */}
<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
{...register("heading", {
required: "Это поле является обязательным",
})}
error={!!errors?.heading}
helperText={errors?.heading?.message as string}
margin="normal"
fullWidth
InputLabelProps={{ shrink: true }}
type="text"
label="Заголовок *"
name="heading"
/>
<Controller
control={control}
name="body"
rules={{required: 'Это поле является обязательным'}}
defaultValue=""
render={({field: {onChange, value}}) => (
<MemoizedSimpleMDE
value={value} // markdown
onChange={onChange}
options={simpleMDEOptions}
className="my-markdown-editor"
<Controller
control={control}
name="body"
rules={{ required: "Это поле является обязательным" }}
defaultValue=""
render={({ field: { onChange, value } }) => (
<MemoizedSimpleMDE
value={value} // markdown
onChange={onChange}
options={simpleMDEOptions}
className="my-markdown-editor"
/>
)}
/>
{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>
{/* Блок предпросмотра */}
@ -98,14 +219,15 @@ export const ArticleEdit = () => {
sx={{
flex: 1,
p: 2,
maxHeight: 'calc(100vh - 200px)',
overflowY: 'auto',
position: 'sticky',
maxHeight: "calc(100vh - 200px)",
overflowY: "auto",
position: "sticky",
top: 16,
borderRadius: 2,
border: '1px solid',
borderColor: 'primary.main',
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : '#fff'),
border: "1px solid",
borderColor: "primary.main",
bgcolor: (theme) =>
theme.palette.mode === "dark" ? "background.paper" : "#fff",
}}
>
<Typography variant="h6" gutterBottom color="primary">
@ -117,7 +239,8 @@ export const ArticleEdit = () => {
variant="h4"
gutterBottom
sx={{
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'),
color: (theme) =>
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
mb: 3,
}}
>
@ -127,39 +250,41 @@ export const ArticleEdit = () => {
{/* Markdown контент */}
<Box
sx={{
'& img': {
maxWidth: '100%',
height: 'auto',
"& img": {
maxWidth: "100%",
height: "auto",
borderRadius: 1,
},
'& h1, & h2, & h3, & h4, & h5, & h6': {
color: 'primary.main',
"& h1, & h2, & h3, & h4, & h5, & h6": {
color: "primary.main",
mt: 2,
mb: 1,
},
'& p': {
"& p": {
mb: 2,
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'),
color: (theme) =>
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
},
'& a': {
color: 'primary.main',
textDecoration: 'none',
'&:hover': {
textDecoration: 'underline',
"& a": {
color: "primary.main",
textDecoration: "none",
"&:hover": {
textDecoration: "underline",
},
},
'& blockquote': {
borderLeft: '4px solid',
borderColor: 'primary.main',
"& blockquote": {
borderLeft: "4px solid",
borderColor: "primary.main",
pl: 2,
my: 2,
color: 'text.secondary',
color: "text.secondary",
},
'& code': {
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100'),
"& code": {
bgcolor: (theme) =>
theme.palette.mode === "dark" ? "grey.900" : "grey.100",
p: 0.5,
borderRadius: 0.5,
color: 'primary.main',
color: "primary.main",
},
}}
>
@ -168,15 +293,15 @@ export const ArticleEdit = () => {
{/* Привязанные медиа */}
{mediaData?.data && mediaData.data.length > 0 && (
<Box sx={{mb: 3}}>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" gutterBottom color="primary">
Привязанные медиа:
</Typography>
<Box
sx={{
display: 'flex',
display: "flex",
gap: 1,
flexWrap: 'wrap',
flexWrap: "wrap",
mb: 2,
}}
>
@ -187,18 +312,20 @@ export const ArticleEdit = () => {
width: 120,
height: 120,
borderRadius: 1,
overflow: 'hidden',
border: '1px solid',
borderColor: 'primary.main',
overflow: "hidden",
border: "1px solid",
borderColor: "primary.main",
}}
>
<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}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
</Box>
@ -209,5 +336,5 @@ export const ArticleEdit = () => {
</Paper>
</Box>
</Edit>
)
}
);
});

View File

@ -1,34 +1,49 @@
import {type GridColDef} from '@mui/x-data-grid'
import {CustomDataGrid} from '../../components/CustomDataGrid'
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
import React from 'react'
import { type GridColDef } from "@mui/x-data-grid";
import { CustomDataGrid } from "../../components/CustomDataGrid";
import {
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 = () => {
const {dataGridProps} = useDataGrid({
resource: 'article/',
})
export const ArticleList = observer(() => {
const { language } = languageStore;
const { dataGridProps } = useDataGrid({
resource: "article/",
meta: {
headers: {
"Accept-Language": language,
},
},
});
const columns = React.useMemo<GridColDef[]>(
() => [
{
field: 'id',
headerName: 'ID',
type: 'number',
field: "id",
headerName: "ID",
type: "number",
minWidth: 70,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'heading',
headerName: 'Заголовок',
type: 'string',
field: "heading",
headerName: "Заголовок",
type: "string",
minWidth: 300,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
flex: 1,
},
// {
@ -41,32 +56,38 @@ export const ArticleList = () => {
// flex: 1,
// },
{
field: 'actions',
headerName: 'Действия',
align: 'right',
headerAlign: 'center',
field: "actions",
headerName: "Действия",
align: "right",
headerAlign: "center",
minWidth: 120,
display: 'flex',
display: "flex",
sortable: false,
filterable: false,
disableColumnMenu: true,
renderCell: function render({row}) {
renderCell: function render({ row }) {
return (
<>
<EditButton hideText recordItemId={row.id} />
<ShowButton hideText recordItemId={row.id} />
<DeleteButton hideText recordItemId={row.id} />
</>
)
);
},
},
],
[],
)
[]
);
return (
<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>
)
}
);
});

View File

@ -1,172 +1,202 @@
import {Autocomplete, Box, TextField} from '@mui/material'
import {Create, useAutocomplete} from '@refinedev/mui'
import {useForm} from '@refinedev/react-hook-form'
import {Controller} from 'react-hook-form'
export const CarrierCreate = () => {
import { Autocomplete, Box, TextField } from "@mui/material";
import { Create, useAutocomplete } from "@refinedev/mui";
import { useForm } from "@refinedev/react-hook-form";
import { Controller } from "react-hook-form";
import { observer } from "mobx-react-lite";
import { languageStore } from "../../store/LanguageStore";
export const CarrierCreate = observer(() => {
const { language } = languageStore;
const {
saveButtonProps,
refineCore: {formLoading},
refineCore: { formLoading },
register,
control,
formState: {errors},
} = useForm({})
formState: { errors },
} = useForm({
refineCoreProps: {
meta: {
headers: {
"Accept-Language": language,
},
},
},
});
const {autocompleteProps: cityAutocompleteProps} = useAutocomplete({
resource: 'city',
const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({
resource: "city",
onSearch: (value) => [
{
field: 'name',
operator: 'contains',
field: "name",
operator: "contains",
value,
},
],
})
});
const {autocompleteProps: mediaAutocompleteProps} = useAutocomplete({
resource: 'media',
const { autocompleteProps: mediaAutocompleteProps } = useAutocomplete({
resource: "media",
onSearch: (value) => [
{
field: 'media_name',
operator: 'contains',
field: "media_name",
operator: "contains",
value,
},
],
})
});
return (
<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
control={control}
name="city_id"
rules={{required: 'Это поле является обязательным'}}
rules={{ required: "Это поле является обязательным" }}
defaultValue={null}
render={({field}) => (
render={({ field }) => (
<Autocomplete
{...cityAutocompleteProps}
value={cityAutocompleteProps.options.find((option) => option.id === field.value) || null}
value={
cityAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => {
field.onChange(value?.id || '')
field.onChange(value?.id || "");
}}
getOptionLabel={(item) => {
return item ? item.name : ''
return item ? item.name : "";
}}
isOptionEqualToValue={(option, value) => {
return option.id === value?.id
return option.id === value?.id;
}}
filterOptions={(options, {inputValue}) => {
return options.filter((option) => option.name.toLowerCase().includes(inputValue.toLowerCase()))
filterOptions={(options, { inputValue }) => {
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('full_name', {
required: 'Это поле является обязательным',
{...register("full_name", {
required: "Это поле является обязательным",
})}
error={!!(errors as any)?.full_name}
helperText={(errors as any)?.full_name?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="text"
label={'Полное имя *'}
label={"Полное имя *"}
name="full_name"
/>
<TextField
{...register('short_name', {
required: 'Это поле является обязательным',
{...register("short_name", {
required: "Это поле является обязательным",
})}
error={!!(errors as any)?.short_name}
helperText={(errors as any)?.short_name?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="text"
label={'Короткое имя *'}
label={"Короткое имя *"}
name="short_name"
/>
<TextField
{...register('main_color', {
{...register("main_color", {
// required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.main_color}
helperText={(errors as any)?.main_color?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="color"
label={'Основной цвет'}
label={"Основной цвет"}
name="main_color"
sx={{
'& input': {
height: '50px',
paddingBlock: '14px',
paddingInline: '14px',
cursor: 'pointer',
"& input": {
height: "50px",
paddingBlock: "14px",
paddingInline: "14px",
cursor: "pointer",
},
}}
/>
<TextField
{...register('left_color', {
{...register("left_color", {
// required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.left_color}
helperText={(errors as any)?.left_color?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="color"
label={'Цвет левого виджета'}
label={"Цвет левого виджета"}
name="left_color"
sx={{
'& input': {
height: '50px',
paddingBlock: '14px',
paddingInline: '14px',
cursor: 'pointer',
"& input": {
height: "50px",
paddingBlock: "14px",
paddingInline: "14px",
cursor: "pointer",
},
}}
/>
<TextField
{...register('right_color', {
{...register("right_color", {
// required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.right_color}
helperText={(errors as any)?.right_color?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="color"
label={'Цвет правого виджета'}
label={"Цвет правого виджета"}
name="right_color"
sx={{
'& input': {
height: '50px',
paddingBlock: '14px',
paddingInline: '14px',
cursor: 'pointer',
"& input": {
height: "50px",
paddingBlock: "14px",
paddingInline: "14px",
cursor: "pointer",
},
}}
/>
<TextField
{...register('slogan', {
{...register("slogan", {
// required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.slogan}
helperText={(errors as any)?.slogan?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="text"
label={'Слоган'}
label={"Слоган"}
name="slogan"
/>
@ -175,27 +205,44 @@ export const CarrierCreate = () => {
name="logo"
// rules={{required: 'Это поле является обязательным'}}
defaultValue={null}
render={({field}) => (
render={({ field }) => (
<Autocomplete
{...mediaAutocompleteProps}
value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null}
value={
mediaAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => {
field.onChange(value?.id || '')
field.onChange(value?.id || "");
}}
getOptionLabel={(item) => {
return item ? item.media_name : ''
return item ? item.media_name : "";
}}
isOptionEqualToValue={(option, value) => {
return option.id === value?.id
return option.id === value?.id;
}}
filterOptions={(options, {inputValue}) => {
return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase()))
filterOptions={(options, { inputValue }) => {
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>
</Create>
)
}
);
});

View File

@ -1,171 +1,191 @@
import {Autocomplete, Box, TextField} from '@mui/material'
import {Edit, useAutocomplete} from '@refinedev/mui'
import {useForm} from '@refinedev/react-hook-form'
import {Controller} from 'react-hook-form'
import { Autocomplete, Box, TextField } from "@mui/material";
import { Edit, useAutocomplete } from "@refinedev/mui";
import { useForm } from "@refinedev/react-hook-form";
import { Controller } from "react-hook-form";
export const CarrierEdit = () => {
const {
saveButtonProps,
register,
control,
formState: {errors},
} = useForm()
formState: { errors },
} = useForm();
const {autocompleteProps: cityAutocompleteProps} = useAutocomplete({
resource: 'city',
const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({
resource: "city",
onSearch: (value) => [
{
field: 'name',
operator: 'contains',
field: "name",
operator: "contains",
value,
},
],
})
});
const {autocompleteProps: mediaAutocompleteProps} = useAutocomplete({
resource: 'media',
const { autocompleteProps: mediaAutocompleteProps } = useAutocomplete({
resource: "media",
onSearch: (value) => [
{
field: 'media_name',
operator: 'contains',
field: "media_name",
operator: "contains",
value,
},
],
})
});
return (
<Edit saveButtonProps={saveButtonProps}>
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
<Box
component="form"
sx={{ display: "flex", flexDirection: "column" }}
autoComplete="off"
>
<Controller
control={control}
name="city_id"
rules={{required: 'Это поле является обязательным'}}
rules={{ required: "Это поле является обязательным" }}
defaultValue={null}
render={({field}) => (
render={({ field }) => (
<Autocomplete
{...cityAutocompleteProps}
value={cityAutocompleteProps.options.find((option) => option.id === field.value) || null}
value={
cityAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => {
field.onChange(value?.id || '')
field.onChange(value?.id || "");
}}
getOptionLabel={(item) => {
return item ? item.name : ''
return item ? item.name : "";
}}
isOptionEqualToValue={(option, value) => {
return option.id === value?.id
return option.id === value?.id;
}}
filterOptions={(options, {inputValue}) => {
return options.filter((option) => option.name.toLowerCase().includes(inputValue.toLowerCase()))
filterOptions={(options, { inputValue }) => {
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('full_name', {
required: 'Это поле является обязательным',
{...register("full_name", {
required: "Это поле является обязательным",
})}
error={!!(errors as any)?.full_name}
helperText={(errors as any)?.full_name?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="text"
label={'Полное имя *'}
label={"Полное имя *"}
name="full_name"
/>
<TextField
{...register('short_name', {
required: 'Это поле является обязательным',
{...register("short_name", {
required: "Это поле является обязательным",
})}
error={!!(errors as any)?.short_name}
helperText={(errors as any)?.short_name?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="text"
label={'Короткое имя *'}
label={"Короткое имя *"}
name="short_name"
/>
<TextField
{...register('main_color', {
{...register("main_color", {
// required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.main_color}
helperText={(errors as any)?.main_color?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="color"
label={'Основной цвет'}
label={"Основной цвет"}
name="main_color"
sx={{
'& input': {
height: '50px',
paddingBlock: '14px',
paddingInline: '14px',
cursor: 'pointer',
"& input": {
height: "50px",
paddingBlock: "14px",
paddingInline: "14px",
cursor: "pointer",
},
}}
/>
<TextField
{...register('left_color', {
{...register("left_color", {
// required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.left_color}
helperText={(errors as any)?.left_color?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="color"
label={'Цвет левого виджета'}
label={"Цвет левого виджета"}
name="left_color"
sx={{
'& input': {
height: '50px',
paddingBlock: '14px',
paddingInline: '14px',
cursor: 'pointer',
"& input": {
height: "50px",
paddingBlock: "14px",
paddingInline: "14px",
cursor: "pointer",
},
}}
/>
<TextField
{...register('right_color', {
{...register("right_color", {
// required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.right_color}
helperText={(errors as any)?.right_color?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="color"
label={'Цвет правого виджета'}
label={"Цвет правого виджета"}
name="right_color"
sx={{
'& input': {
height: '50px',
paddingBlock: '14px',
paddingInline: '14px',
cursor: 'pointer',
"& input": {
height: "50px",
paddingBlock: "14px",
paddingInline: "14px",
cursor: "pointer",
},
}}
/>
<TextField
{...register('slogan', {
{...register("slogan", {
// required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.slogan}
helperText={(errors as any)?.slogan?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="text"
label={'Слоган'}
label={"Слоган"}
name="slogan"
/>
@ -174,27 +194,44 @@ export const CarrierEdit = () => {
name="logo"
// rules={{required: 'Это поле является обязательным'}}
defaultValue={null}
render={({field}) => (
render={({ field }) => (
<Autocomplete
{...mediaAutocompleteProps}
value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null}
value={
mediaAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => {
field.onChange(value?.id || '')
field.onChange(value?.id || "");
}}
getOptionLabel={(item) => {
return item ? item.media_name : ''
return item ? item.media_name : "";
}}
isOptionEqualToValue={(option, value) => {
return option.id === value?.id
return option.id === value?.id;
}}
filterOptions={(options, {inputValue}) => {
return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase()))
filterOptions={(options, { inputValue }) => {
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>
</Edit>
)
}
);
};

View File

@ -1,110 +1,181 @@
import {type GridColDef} from '@mui/x-data-grid'
import {CustomDataGrid} from '../../components/CustomDataGrid'
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
import React from 'react'
import { type GridColDef } from "@mui/x-data-grid";
import { CustomDataGrid } from "../../components/CustomDataGrid";
import {
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 = () => {
const {dataGridProps} = useDataGrid({})
export const CarrierList = observer(() => {
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[]>(
() => [
{
field: 'id',
headerName: 'ID',
type: 'number',
field: "id",
headerName: "ID",
type: "number",
minWidth: 50,
align: 'left',
headerAlign: 'left',
align: "left",
headerAlign: "left",
},
{
field: 'city_id',
headerName: 'ID Города',
type: 'number',
field: "city_id",
headerName: "ID Города",
type: "number",
minWidth: 100,
align: 'left',
headerAlign: 'left',
align: "left",
headerAlign: "left",
},
{
field: 'full_name',
headerName: 'Полное имя',
type: 'string',
field: "full_name",
headerName: "Полное имя",
type: "string",
minWidth: 200,
},
{
field: 'short_name',
headerName: 'Короткое имя',
type: 'string',
field: "short_name",
headerName: "Короткое имя",
type: "string",
minWidth: 125,
},
{
field: 'city',
headerName: 'Город',
type: 'string',
field: "city",
headerName: "Город",
type: "string",
minWidth: 125,
align: 'left',
headerAlign: 'left',
align: "left",
headerAlign: "left",
flex: 1,
},
{
field: 'main_color',
headerName: 'Основной цвет',
type: 'string',
field: "main_color",
headerName: "Основной цвет",
type: "string",
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',
headerName: 'Цвет левого виджета',
type: 'string',
field: "left_color",
headerName: "Цвет левого виджета",
type: "string",
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',
headerName: 'Цвет правого виджета',
type: 'string',
field: "right_color",
headerName: "Цвет правого виджета",
type: "string",
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',
headerName: 'Лого',
type: 'string',
field: "logo",
headerName: "Лого",
type: "string",
minWidth: 150,
},
{
field: 'slogan',
headerName: 'Слоган',
type: 'string',
field: "slogan",
headerName: "Слоган",
type: "string",
minWidth: 150,
},
{
field: 'actions',
headerName: 'Действия',
field: "actions",
headerName: "Действия",
minWidth: 120,
display: 'flex',
align: 'right',
headerAlign: 'center',
display: "flex",
align: "right",
headerAlign: "center",
sortable: false,
filterable: false,
disableColumnMenu: true,
renderCell: function render({row}) {
renderCell: function render({ row }) {
return (
<>
<EditButton hideText recordItemId={row.id} />
<ShowButton hideText recordItemId={row.id} />
<DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} />
<DeleteButton
hideText
confirmTitle="Вы уверены?"
recordItemId={row.id}
/>
</>
)
);
},
},
],
[],
)
[]
);
return (
<List>
<CustomDataGrid {...dataGridProps} columns={columns} />
<CustomDataGrid {...dataGridProps} languageEnabled columns={columns} />
</List>
)
}
);
});

View File

@ -1,60 +1,114 @@
import {Box, Stack, Typography} from '@mui/material'
import {useShow} from '@refinedev/core'
import {Show, TextFieldComponent as TextField} from '@refinedev/mui'
import {TOKEN_KEY} from '../../authProvider'
import { Box, Stack, Typography } from "@mui/material";
import { useShow } from "@refinedev/core";
import { Show, TextFieldComponent as TextField } from "@refinedev/mui";
import { TOKEN_KEY } from "../../authProvider";
export type FieldType = {
label: string
data: any
render?: (value: any) => React.ReactNode
}
label: string;
data: any;
render?: (value: any) => React.ReactNode;
};
export const CarrierShow = () => {
const {query} = useShow({})
const {data, isLoading} = query
const { query } = useShow({});
const { data, isLoading } = query;
const record = data?.data
const record = data?.data;
const fields: FieldType[] = [
{label: 'Полное имя', data: 'full_name'},
{label: 'Короткое имя', data: 'short_name'},
{label: 'Город', data: 'city'},
{ label: "Полное имя", data: "full_name" },
{ label: "Короткое имя", data: "short_name" },
{ label: "Город", data: "city" },
{
label: 'Основной цвет',
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>,
label: "Основной цвет",
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>
),
},
{
label: 'Цвет левого виджета',
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>,
label: "Цвет левого виджета",
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>
),
},
{
label: 'Цвет правого виджета',
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>,
label: "Цвет правого виджета",
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>
),
},
{label: 'Слоган', data: 'slogan'},
{ label: "Слоган", data: "slogan" },
{
label: 'Логотип',
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}} />,
label: "Логотип",
data: "logo",
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 (
<Show isLoading={isLoading}>
<Stack gap={4}>
{fields.map(({label, data, render}) => (
{fields.map(({ label, data, render }) => (
<Stack key={data} gap={1}>
<Typography variant="body1" fontWeight="bold">
{label}
</Typography>
{render ? render(record?.[data]) : <TextField value={record?.[data]} />}
{render ? (
render(record?.[data])
) : (
<TextField value={record?.[data]} />
)}
</Stack>
))}
</Stack>
</Show>
)
}
);
};

View File

@ -1,78 +1,100 @@
import {type GridColDef} from '@mui/x-data-grid'
import {CustomDataGrid} from '../../components/CustomDataGrid'
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
import React from 'react'
import { type GridColDef } from "@mui/x-data-grid";
import { CustomDataGrid } from "../../components/CustomDataGrid";
import {
DeleteButton,
EditButton,
List,
ShowButton,
useDataGrid,
} from "@refinedev/mui";
import React, { useEffect } from "react";
export const CityList = () => {
const {dataGridProps} = useDataGrid({})
import { observer } from "mobx-react-lite";
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[]>(
() => [
{
field: 'id',
headerName: 'ID',
type: 'number',
field: "id",
headerName: "ID",
type: "number",
minWidth: 50,
align: 'left',
headerAlign: 'left',
align: "left",
headerAlign: "left",
},
{
field: 'country_code',
headerName: 'Код страны',
type: 'string',
field: "country_code",
headerName: "Код страны",
type: "string",
minWidth: 150,
align: 'left',
headerAlign: 'left',
align: "left",
headerAlign: "left",
},
{
field: 'country',
headerName: 'Cтрана',
type: 'string',
field: "country",
headerName: "Cтрана",
type: "string",
minWidth: 150,
align: 'left',
headerAlign: 'left',
align: "left",
headerAlign: "left",
},
{
field: 'name',
headerName: 'Название',
type: 'string',
field: "name",
headerName: "Название",
type: "string",
minWidth: 150,
flex: 1,
},
{
field: 'arms',
headerName: 'Герб',
type: 'string',
field: "arms",
headerName: "Герб",
type: "string",
flex: 1,
},
{
field: 'actions',
headerName: 'Действия',
cellClassName: 'city-actions',
field: "actions",
headerName: "Действия",
cellClassName: "city-actions",
minWidth: 120,
display: 'flex',
align: 'right',
headerAlign: 'center',
display: "flex",
align: "right",
headerAlign: "center",
sortable: false,
filterable: false,
disableColumnMenu: true,
renderCell: function render({row}) {
renderCell: function render({ row }) {
return (
<>
<EditButton hideText recordItemId={row.id} />
<ShowButton hideText recordItemId={row.id} />
<DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} />
<DeleteButton
hideText
confirmTitle="Вы уверены?"
recordItemId={row.id}
/>
</>
)
);
},
},
],
[],
)
[]
);
return (
<List>
<CustomDataGrid {...dataGridProps} columns={columns} />
<CustomDataGrid {...dataGridProps} columns={columns} languageEnabled />
</List>
)
}
);
});

View File

@ -1,35 +1,51 @@
import {Stack, Typography} from '@mui/material'
import {useShow} from '@refinedev/core'
import {Show, TextFieldComponent as TextField} from '@refinedev/mui'
import {TOKEN_KEY} from '../../authProvider'
import { Stack, Typography } from "@mui/material";
import { useShow } from "@refinedev/core";
import { Show, TextFieldComponent as TextField } from "@refinedev/mui";
import { TOKEN_KEY } from "../../authProvider";
export const CityShow = () => {
const {query} = useShow({})
const {data, isLoading} = query
const { query } = useShow({});
const { data, isLoading } = query;
const record = data?.data
const record = data?.data;
const fields = [
// {label: 'ID', data: 'id'},
{label: 'Название', data: 'name'},
{ label: "Название", data: "name" },
// {label: 'Код страны', data: 'country_code'},
{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: "country" },
{
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 (
<Show isLoading={isLoading}>
<Stack gap={4}>
{fields.map(({label, data, render}) => (
{fields.map(({ label, data, render }) => (
<Stack key={data} gap={1}>
<Typography variant="body1" fontWeight="bold">
{label}
</Typography>
{render ? render(record?.[data]) : <TextField value={record?.[data]} />}
{render ? (
render(record?.[data])
) : (
<TextField value={record?.[data]} />
)}
</Stack>
))}
</Stack>
</Show>
)
}
);
};

View File

@ -1,44 +1,48 @@
import {Box, TextField} from '@mui/material'
import {Edit} from '@refinedev/mui'
import {useForm} from '@refinedev/react-hook-form'
import { Box, TextField } from "@mui/material";
import { Edit } from "@refinedev/mui";
import { useForm } from "@refinedev/react-hook-form";
export const CountryEdit = () => {
const {
saveButtonProps,
register,
formState: {errors},
} = useForm({})
formState: { errors },
} = useForm({});
return (
<Edit saveButtonProps={saveButtonProps}>
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
<Box
component="form"
sx={{ display: "flex", flexDirection: "column" }}
autoComplete="off"
>
<TextField
{...register('code', {
required: 'Это поле является обязательным',
{...register("code", {
required: "Это поле является обязательным",
})}
error={!!(errors as any)?.code}
helperText={(errors as any)?.code?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="text"
label={'Код *'}
label={"Код *"}
name="code"
/>
<TextField
{...register('name', {
required: 'Это поле является обязательным',
{...register("name", {
required: "Это поле является обязательным",
})}
error={!!(errors as any)?.name}
helperText={(errors as any)?.name?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="text"
label={'Название *'}
label={"Название *"}
name="name"
/>
</Box>
</Edit>
)
}
);
};

View File

@ -1,56 +1,82 @@
import {type GridColDef} from '@mui/x-data-grid'
import {CustomDataGrid} from '../../components/CustomDataGrid'
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
import React from 'react'
import { type GridColDef } from "@mui/x-data-grid";
import { CustomDataGrid } from "../../components/CustomDataGrid";
import {
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 = () => {
const {dataGridProps} = useDataGrid({})
export const CountryList = observer(() => {
const { language } = languageStore;
const { dataGridProps } = useDataGrid({
resource: "country",
meta: {
headers: {
"Accept-Language": language,
},
},
});
const columns = React.useMemo<GridColDef[]>(
() => [
{
field: 'code',
headerName: 'Код',
type: 'string',
field: "code",
headerName: "Код",
type: "string",
minWidth: 150,
align: 'left',
headerAlign: 'left',
align: "left",
headerAlign: "left",
},
{
field: 'name',
headerName: 'Название',
type: 'string',
field: "name",
headerName: "Название",
type: "string",
minWidth: 150,
flex: 1,
},
{
field: 'actions',
headerName: 'Действия',
cellClassName: 'country-actions',
field: "actions",
headerName: "Действия",
cellClassName: "country-actions",
minWidth: 120,
display: 'flex',
align: 'right',
headerAlign: 'center',
display: "flex",
align: "right",
headerAlign: "center",
sortable: false,
filterable: false,
disableColumnMenu: true,
renderCell: function render({row}) {
renderCell: function render({ row }) {
return (
<>
<EditButton hideText recordItemId={row.code} />
<ShowButton hideText recordItemId={row.code} />
<DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.code} />
<DeleteButton
hideText
confirmTitle="Вы уверены?"
recordItemId={row.code}
/>
</>
)
);
},
},
],
[],
)
[]
);
return (
<List>
<CustomDataGrid {...dataGridProps} columns={columns} getRowId={(row: any) => row.code} />
<CustomDataGrid
{...dataGridProps}
languageEnabled
columns={columns}
getRowId={(row: any) => row.code}
/>
</List>
)
}
);
});

View 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>
);
};

View File

@ -1,39 +1,58 @@
import {Box, TextField, 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 {
Box,
TextField,
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 {ALLOWED_IMAGE_TYPES, ALLOWED_VIDEO_TYPES, useMediaFileUpload} from '../../components/media/MediaFormUtils'
import { MEDIA_TYPES } from "../../lib/constants";
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 = {
media_name: string
media_type: number
file?: File
}
media_name: string;
media_type: number;
file?: File;
};
export const MediaCreate = () => {
const {
saveButtonProps,
refineCore: {formLoading, onFinish},
refineCore: { formLoading, onFinish },
register,
control,
formState: {errors},
formState: { errors },
setValue,
handleSubmit,
watch,
setError,
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({
selectedMediaType,
setError,
clearErrors,
setValue,
})
const { selectedFile, previewUrl, handleFileChange, handleMediaTypeChange } =
useMediaFileUpload({
selectedMediaType,
setError,
clearErrors,
setValue,
});
return (
<Create
@ -42,19 +61,20 @@ export const MediaCreate = () => {
...saveButtonProps,
disabled: !!errors.file || !selectedFile,
onClick: handleSubmit((data) => {
console.log(data);
if (data.file) {
const formData = new FormData()
formData.append('media_name', data.media_name)
formData.append('filename', data.file.name)
formData.append('type', String(data.media_type))
formData.append('file', data.file)
const formData = new FormData();
formData.append("media_name", data.media_name);
formData.append("filename", data.file.name);
formData.append("type", String(data.media_type));
formData.append("file", data.file);
console.log('Отправляемые данные:')
console.log("Отправляемые данные:");
for (const pair of formData.entries()) {
console.log(pair[0] + ': ' + pair[1])
console.log(pair[0] + ": " + pair[1]);
}
onFinish(formData)
onFinish(formData);
}
}),
}}
@ -63,47 +83,97 @@ export const MediaCreate = () => {
control={control}
name="media_type"
rules={{
required: 'Это поле является обязательным',
required: "Это поле является обязательным",
}}
render={({field}) => (
render={({ field }) => (
<Autocomplete
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) => {
field.onChange(value?.value || null)
handleMediaTypeChange(value?.value || null)
field.onChange(value?.value || null);
handleMediaTypeChange(value?.value || null);
}}
getOptionLabel={(item) => {
return item ? item.label : ''
return item ? item.label : "";
}}
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
{...register('media_name', {
required: 'Это поле является обязательным',
{...register("media_name", {
required: "Это поле является обязательным",
})}
error={!!(errors as any)?.media_name}
helperText={(errors as any)?.media_name?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="text"
label="Название *"
name="media_name"
/>
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off" style={{marginTop: 10}}>
<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 === 1 ? ALLOWED_IMAGE_TYPES.join(',') : ALLOWED_VIDEO_TYPES.join(',')} />
<Box
component="form"
sx={{ display: "flex", flexDirection: "column" }}
autoComplete="off"
style={{ marginTop: 10 }}
>
<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>
{selectedFile && (
@ -121,11 +191,53 @@ export const MediaCreate = () => {
{previewUrl && selectedMediaType === 1 && (
<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>
)}
{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>
</Create>
)
}
);
};

View File

@ -1,26 +1,40 @@
import {Box, TextField, Button, Typography, 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 {
Box,
TextField,
Button,
Typography,
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 {ALLOWED_IMAGE_TYPES, ALLOWED_VIDEO_TYPES, useMediaFileUpload} from '../../components/media/MediaFormUtils'
import {TOKEN_KEY} from '../../authProvider'
import { TOKEN_KEY } from "../../authProvider";
import { MEDIA_TYPES } from "../../lib/constants";
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 = {
media_name: string
media_type: number
file?: File
}
media_name: string;
media_type: number;
file?: File;
};
export const MediaEdit = () => {
const {
saveButtonProps,
refineCore: {onFinish},
refineCore: { onFinish },
register,
formState: {errors},
formState: { errors },
setValue,
handleSubmit,
watch,
@ -29,32 +43,42 @@ export const MediaEdit = () => {
control,
} = useForm<MediaFormValues>({
defaultValues: {
media_name: '',
media_type: '',
media_name: "",
media_type: "",
file: undefined,
},
})
});
const {query} = useShow()
const {data} = query
const record = data?.data
const { query } = useShow();
const { data } = query;
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,
setError,
clearErrors,
setValue,
})
});
useEffect(() => {
if (record?.id) {
setPreviewUrl(`https://wn.krbl.ru/media/${record.id}/download?token=${localStorage.getItem(TOKEN_KEY)}`)
setValue('media_name', record?.media_name || '')
setValue('media_type', record?.media_type)
setPreviewUrl(
`${import.meta.env.VITE_KRBL_MEDIA}${
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 (
<Edit
@ -66,57 +90,108 @@ export const MediaEdit = () => {
media_name: data.media_name,
filename: selectedFile?.name || record?.filename,
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
control={control}
name="media_type"
rules={{
required: 'Это поле является обязательным',
required: "Это поле является обязательным",
}}
defaultValue={null}
render={({field}) => (
render={({ field }) => (
<Autocomplete
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) => {
field.onChange(value?.value || null)
handleMediaTypeChange(value?.value || null)
field.onChange(value?.value || null);
handleMediaTypeChange(value?.value || null);
}}
getOptionLabel={(item) => {
return item ? item.label : ''
return item ? item.label : "";
}}
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
{...register('media_name', {
required: 'Это поле является обязательным',
{...register("media_name", {
required: "Это поле является обязательным",
})}
error={!!(errors as any)?.media_name}
helperText={(errors as any)?.media_name?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="text"
label="Название *"
name="media_name"
/>
<Box display="flex" flexDirection="column-reverse" alignItems="center" 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(',') : ALLOWED_VIDEO_TYPES.join(',')} />
<Box
display="flex"
flexDirection="column-reverse"
alignItems="center"
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>
{selectedFile && (
@ -134,11 +209,15 @@ export const MediaEdit = () => {
{previewUrl && selectedMediaType === 1 && (
<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>
</Edit>
)
}
);
};

View File

@ -1,78 +1,151 @@
import {Stack, Typography, Box, Button} from '@mui/material'
import {useShow} from '@refinedev/core'
import {Show, TextFieldComponent as TextField} from '@refinedev/mui'
import {MEDIA_TYPES} from '../../lib/constants'
import {TOKEN_KEY} from '../../authProvider'
import { Stack, Typography, Box, Button } from "@mui/material";
import { useShow } from "@refinedev/core";
import { Show, TextFieldComponent as TextField } from "@refinedev/mui";
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
import sky from "./12414.jpg";
import { MEDIA_TYPES } from "../../lib/constants";
import { TOKEN_KEY } from "../../authProvider";
import { ModelViewer } from "./ModelViewer/index";
export const MediaShow = () => {
const {query} = useShow({})
const {data, isLoading} = query
const { query } = useShow({});
const { data, isLoading } = query;
const record = data?.data
const token = localStorage.getItem(TOKEN_KEY)
const record = data?.data;
const token = localStorage.getItem(TOKEN_KEY);
const fields = [
// {label: 'Название файла', data: 'filename'},
{label: 'Название', data: 'media_name'},
{ label: "Название", data: "media_name" },
{
label: 'Тип',
data: 'media_type',
render: (value: number) => MEDIA_TYPES.find((type) => type.value === value)?.label || value,
label: "Тип",
data: "media_type",
render: (value: number) =>
MEDIA_TYPES.find((type) => type.value === value)?.label || value,
},
// {label: 'ID', data: 'id'},
]
];
return (
<Show isLoading={isLoading}>
<Stack gap={4}>
{record && record.media_type === 1 && (
<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}
style={{
maxWidth: '100%',
height: '40vh',
objectFit: 'contain',
maxWidth: "100%",
height: "40vh",
objectFit: "contain",
borderRadius: 8,
}}
/>
)}
{record && record.media_type === 2 && (
<Box
sx={{
p: 2,
border: '1px solid text.pimary',
borderRadius: 2,
bgcolor: 'primary.light',
width: 'fit-content',
<video
src={`${import.meta.env.VITE_KRBL_MEDIA}${
record?.id
}/download?token=${token}`}
style={{
maxWidth: "50%",
objectFit: "contain",
borderRadius: 30,
}}
>
<Typography
variant="body1"
gutterBottom
sx={{
color: '#FFFFFF',
}}
>
Видео доступно для скачивания по ссылке:
</Typography>
<Button variant="contained" href={`https://wn.krbl.ru/media/${record?.id}/download?token=${token}`} target="_blank" sx={{mt: 1, width: '100%'}}>
Скачать видео
</Button>
</Box>
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,
}}
/>
)}
{fields.map(({label, data, render}) => (
{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]} />
<TextField
value={render ? render(record?.[data]) : record?.[data]}
/>
</Stack>
))}
<Box
sx={{
p: 2,
border: "1px solid text.pimary",
borderRadius: 2,
bgcolor: "primary.light",
width: "fit-content",
}}
>
<Typography
variant="body1"
gutterBottom
sx={{
color: "#FFFFFF",
}}
>
Доступно для скачивания по ссылке:
</Typography>
<Button
variant="contained"
href={`${import.meta.env.VITE_KRBL_MEDIA}${
record?.id
}/download?token=${token}`}
target="_blank"
sx={{ mt: 1, width: "100%" }}
>
Скачать медиа
</Button>
</Box>
</Stack>
</Show>
)
}
);
};

View File

@ -1,73 +1,102 @@
import {Autocomplete, Box, 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'
import {
Autocomplete,
Box,
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 = () => {
const {
saveButtonProps,
refineCore: {formLoading},
refineCore: { formLoading },
register,
control,
formState: {errors},
formState: { errors },
} = useForm({
refineCoreProps: {
resource: 'route/',
resource: "route/",
},
})
});
const {autocompleteProps: carrierAutocompleteProps} = useAutocomplete({
resource: 'carrier',
const { autocompleteProps: carrierAutocompleteProps } = useAutocomplete({
resource: "carrier",
onSearch: (value) => [
{
field: 'short_name',
operator: 'contains',
field: "short_name",
operator: "contains",
value,
},
],
})
});
return (
<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
control={control}
name="carrier_id"
rules={{required: 'Это поле является обязательным'}}
rules={{ required: "Это поле является обязательным" }}
defaultValue={null}
render={({field}) => (
render={({ field }) => (
<Autocomplete
{...carrierAutocompleteProps}
value={carrierAutocompleteProps.options.find((option) => option.id === field.value) || null}
value={
carrierAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => {
field.onChange(value?.id || '')
field.onChange(value?.id || "");
}}
getOptionLabel={(item) => {
return item ? item.short_name : ''
return item ? item.short_name : "";
}}
isOptionEqualToValue={(option, value) => {
return option.id === value?.id
return option.id === value?.id;
}}
filterOptions={(options, {inputValue}) => {
return options.filter((option) => option.short_name.toLowerCase().includes(inputValue.toLowerCase()))
filterOptions={(options, { inputValue }) => {
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
{...register('route_number', {
required: 'Это поле является обязательным',
{...register("route_number", {
required: "Это поле является обязательным",
setValueAs: (value) => String(value),
})}
error={!!(errors as any)?.route_number}
helperText={(errors as any)?.route_number?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="text"
label={'Номер маршрута *'}
label={"Номер маршрута *"}
name="route_number"
/>
@ -75,143 +104,190 @@ export const RouteCreate = () => {
name="route_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)} />} />}
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>
<TextField
{...register('path', {
required: 'Это поле является обязательным',
{...register("path", {
required: "Это поле является обязательным",
setValueAs: (value: string) => {
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 {
return []
return [];
}
},
validate: (value: unknown) => {
if (!Array.isArray(value)) return 'Неверный формат'
if (!value.every((point: unknown) => Array.isArray(point) && point.length === 2)) {
return 'Каждая точка должна быть массивом из двух координат'
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 'Координаты должны быть числами'
if (
!value.every((point: unknown[]) =>
point.every(
(coord: unknown) =>
!isNaN(Number(coord)) && typeof coord === "number"
)
)
) {
return "Координаты должны быть числами";
}
return true
return true;
},
})}
error={!!(errors as any)?.path}
helperText={(errors as any)?.path?.message} // 'Формат: [[lat1,lon1], [lat2,lon2], ...]'
helperText={(errors as any)?.path?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="text"
label={'Координаты маршрута *'}
label={"Координаты маршрута *"}
name="path"
placeholder="[[1.1, 2.2], [2.1, 4.5]]"
placeholder="55.7558 37.6173
55.7539 37.6208"
multiline
rows={4}
/>
<TextField
{...register('route_sys_number', {
required: 'Это поле является обязательным',
{...register("route_sys_number", {
required: "Это поле является обязательным",
})}
error={!!(errors as any)?.route_sys_number}
helperText={(errors as any)?.route_sys_number?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label={'Системный номер маршрута *'}
label={"Системный номер маршрута *"}
name="route_sys_number"
/>
<TextField
{...register('governor_appeal', {
{...register("governor_appeal", {
// required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})}
error={!!(errors as any)?.governor_appeal}
helperText={(errors as any)?.governor_appeal?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label={'Обращение губернатора'}
label={"Обращение губернатора"}
name="governor_appeal"
/>
<TextField
{...register('scale_min', {
{...register("scale_min", {
// required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})}
error={!!(errors as any)?.scale_min}
helperText={(errors as any)?.scale_min?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label={'Масштаб (мин)'}
label={"Масштаб (мин)"}
name="scale_min"
/>
<TextField
{...register('scale_max', {
{...register("scale_max", {
// required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})}
error={!!(errors as any)?.scale_max}
helperText={(errors as any)?.scale_max?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label={'Масштаб (макс)'}
label={"Масштаб (макс)"}
name="scale_max"
/>
<TextField
{...register('rotate', {
{...register("rotate", {
// required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})}
error={!!(errors as any)?.rotate}
helperText={(errors as any)?.rotate?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label={'Поворот'}
label={"Поворот"}
name="rotate"
/>
<TextField
{...register('center_latitude', {
{...register("center_latitude", {
// required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})}
error={!!(errors as any)?.center_latitude}
helperText={(errors as any)?.center_latitude?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label={'Центр. широта'}
label={"Центр. широта"}
name="center_latitude"
/>
<TextField
{...register('center_longitude', {
{...register("center_longitude", {
// required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})}
error={!!(errors as any)?.center_longitude}
helperText={(errors as any)?.center_longitude?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label={'Центр. долгота'}
label={"Центр. долгота"}
name="center_longitude"
/>
</Box>
</Create>
)
}
);
};

View File

@ -1,236 +1,333 @@
import {Autocomplete, Box, TextField, FormControlLabel, Checkbox, 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 {
Autocomplete,
Box,
TextField,
FormControlLabel,
Checkbox,
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 = () => {
const {
saveButtonProps,
register,
control,
formState: {errors},
} = useForm({})
formState: { errors },
refineCore: { queryResult },
setValue,
watch,
} = useForm({});
const {id: routeId} = useParams<{id: string}>()
const { id: routeId } = useParams<{ id: string }>();
const {autocompleteProps: carrierAutocompleteProps} = useAutocomplete({
resource: 'carrier',
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({
resource: "carrier",
onSearch: (value) => [
{
field: 'short_name',
operator: 'contains',
field: "short_name",
operator: "contains",
value,
},
],
})
});
return (
<Edit saveButtonProps={saveButtonProps}>
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
<Box
component="form"
sx={{ display: "flex", flexDirection: "column" }}
autoComplete="off"
>
<Controller
control={control}
name="carrier_id"
rules={{required: 'Это поле является обязательным'}}
rules={{ required: "Это поле является обязательным" }}
defaultValue={null}
render={({field}) => (
render={({ field }) => (
<Autocomplete
{...carrierAutocompleteProps}
value={carrierAutocompleteProps.options.find((option) => option.id === field.value) || null}
value={
carrierAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => {
field.onChange(value?.id || '')
field.onChange(value?.id || "");
}}
getOptionLabel={(item) => {
return item ? item.short_name : ''
return item ? item.short_name : "";
}}
isOptionEqualToValue={(option, value) => {
return option.id === value?.id
return option.id === value?.id;
}}
filterOptions={(options, {inputValue}) => {
return options.filter((option) => option.short_name.toLowerCase().includes(inputValue.toLowerCase()))
filterOptions={(options, { inputValue }) => {
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
{...register('route_number', {
required: 'Это поле является обязательным',
{...register("route_number", {
required: "Это поле является обязательным",
setValueAs: (value) => String(value),
})}
error={!!(errors as any)?.route_number}
helperText={(errors as any)?.route_number?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="text"
label={'Номер маршрута'}
label={"Номер маршрута"}
name="route_number"
/>
<Controller
name="route_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)} />} />}
/>
<Typography variant="caption" color="textSecondary" sx={{mt: 0, mb: 1}}>
(Прямой / Обратный)
</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 'Каждая точка должна быть массивом из двух координат'
render={({ field }: { field: any }) => (
<FormControlLabel
label="Прямой маршрут?"
control={
<Checkbox
{...field}
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
/>
}
if (!value.every((point: unknown[]) => point.every((coord: unknown) => !isNaN(Number(coord)) && typeof coord === 'number'))) {
return 'Координаты должны быть числами'
}
return true
},
}}
render={({field, fieldState: {error}}) => (
<TextField
{...field}
value={Array.isArray(field.value) ? JSON.stringify(field.value) : ''}
onChange={(e) => {
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
{...register('route_sys_number', {
required: 'Это поле является обязательным',
{...register("path", {
required: "Это поле является обязательным",
setValueAs: (value: string) => {
try {
const lines = value.trim().split("\n");
return lines.map((line) => {
const [lat, lon] = line
.trim()
.split(/[\s,]+/)
.map(Number);
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}
helperText={(errors as any)?.route_sys_number?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label={'Системный номер маршрута *'}
label={"Системный номер маршрута *"}
name="route_sys_number"
/>
<TextField
{...register('governor_appeal', {
{...register("governor_appeal", {
// required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.governor_appeal}
helperText={(errors as any)?.governor_appeal?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label={'Обращение губернатора'}
label={"Обращение губернатора"}
name="governor_appeal"
/>
<TextField
{...register('scale_min', {
{...register("scale_min", {
// required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})}
error={!!(errors as any)?.scale_min}
helperText={(errors as any)?.scale_min?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label={'Масштаб (мин)'}
label={"Масштаб (мин)"}
name="scale_min"
/>
<TextField
{...register('scale_max', {
{...register("scale_max", {
// required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})}
error={!!(errors as any)?.scale_max}
helperText={(errors as any)?.scale_max?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label={'Масштаб (макс)'}
label={"Масштаб (макс)"}
name="scale_max"
/>
<TextField
{...register('rotate', {
{...register("rotate", {
// required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})}
error={!!(errors as any)?.rotate}
helperText={(errors as any)?.rotate?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label={'Поворот'}
label={"Поворот"}
name="rotate"
/>
<TextField
{...register('center_latitude', {
{...register("center_latitude", {
// required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})}
error={!!(errors as any)?.center_latitude}
helperText={(errors as any)?.center_latitude?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label={'Центр. широта'}
label={"Центр. широта"}
name="center_latitude"
/>
<TextField
{...register('center_longitude', {
{...register("center_longitude", {
// required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})}
error={!!(errors as any)?.center_longitude}
helperText={(errors as any)?.center_longitude?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label={'Центр. долгота'}
label={"Центр. долгота"}
name="center_longitude"
/>
</Box>
{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>
)
}
);
};

View File

@ -1,156 +1,175 @@
import {type GridColDef} from '@mui/x-data-grid'
import {CustomDataGrid} from '../../components/CustomDataGrid'
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
import {Typography} from '@mui/material'
import React from 'react'
import { type GridColDef } from "@mui/x-data-grid";
import { CustomDataGrid } from "../../components/CustomDataGrid";
import {
DeleteButton,
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 = () => {
const {dataGridProps} = useDataGrid({
resource: 'route/',
})
const { dataGridProps } = useDataGrid({
resource: "route/",
});
const columns = React.useMemo<GridColDef[]>(
() => [
{
field: 'id',
headerName: 'ID',
type: 'number',
field: "id",
headerName: "ID",
type: "number",
minWidth: 70,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'carrier_id',
headerName: 'ID перевозчика',
type: 'number',
field: "carrier_id",
headerName: "ID перевозчика",
type: "number",
minWidth: 150,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'carrier',
headerName: 'Перевозчик',
type: 'string',
field: "carrier",
headerName: "Перевозчик",
type: "string",
minWidth: 150,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'route_number',
headerName: 'Номер маршрута',
type: 'string',
field: "route_number",
headerName: "Номер маршрута",
type: "string",
minWidth: 150,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'route_sys_number',
headerName: 'Системный номер маршрута',
type: 'string',
field: "route_sys_number",
headerName: "Системный номер маршрута",
type: "string",
minWidth: 120,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'governor_appeal',
headerName: 'Обращение губернатора',
type: 'number',
field: "governor_appeal",
headerName: "Обращение губернатора",
type: "number",
minWidth: 120,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'scale_min',
headerName: 'Масштаб (мин)',
type: 'number',
field: "scale_min",
headerName: "Масштаб (мин)",
type: "number",
minWidth: 120,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'scale_max',
headerName: 'Масштаб (макс)',
type: 'number',
field: "scale_max",
headerName: "Масштаб (макс)",
type: "number",
minWidth: 120,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'rotate',
headerName: 'Поворот',
type: 'number',
field: "rotate",
headerName: "Поворот",
type: "number",
minWidth: 120,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'center_latitude',
headerName: 'Центр. широта',
type: 'number',
field: "center_latitude",
headerName: "Центр. широта",
type: "number",
minWidth: 120,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'center_longitude',
headerName: 'Центр. долгота',
type: 'number',
field: "center_longitude",
headerName: "Центр. долгота",
type: "number",
minWidth: 120,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'route_direction',
headerName: 'Направление маршрута',
type: 'boolean',
display: 'flex',
align: 'left',
headerAlign: 'left',
field: "route_direction",
headerName: "Направление маршрута",
type: "boolean",
display: "flex",
align: "left",
headerAlign: "left",
minWidth: 120,
flex: 1,
renderCell: ({value}) => <Typography style={{color: value ? '#48989f' : '#7f6b58'}}>{value ? 'прямое' : 'обратное'}</Typography>,
renderCell: ({ value }) => (
<Typography style={{ color: value ? "#48989f" : "#7f6b58" }}>
{value ? "прямое" : "обратное"}
</Typography>
),
},
{
field: 'actions',
headerName: 'Действия',
cellClassName: 'route-actions',
align: 'right',
headerAlign: 'center',
field: "actions",
headerName: "Действия",
cellClassName: "route-actions",
align: "right",
headerAlign: "center",
minWidth: 120,
display: 'flex',
display: "flex",
sortable: false,
filterable: false,
disableColumnMenu: true,
renderCell: function render({row}) {
renderCell: function render({ row }) {
return (
<>
<EditButton hideText recordItemId={row.id} />
<ShowButton hideText recordItemId={row.id} />
<DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} />
<DeleteButton
hideText
confirmTitle="Вы уверены?"
recordItemId={row.id}
/>
</>
)
);
},
},
],
[],
)
[]
);
return (
<List>
<CustomDataGrid {...dataGridProps} columns={columns} localeText={localeText} getRowId={(row: any) => row.id} />
<CustomDataGrid
{...dataGridProps}
columns={columns}
localeText={localeText}
getRowId={(row: any) => row.id}
/>
</List>
)
}
);
};

View File

@ -1,67 +1,93 @@
import {Stack, Typography, Box} from '@mui/material'
import {useShow} from '@refinedev/core'
import {Show, TextFieldComponent as TextField} from '@refinedev/mui'
import {LinkedItems} from '../../components/LinkedItems'
import {StationItem, VehicleItem, stationFields, vehicleFields} from './types'
import { Stack, Typography, Box } from "@mui/material";
import { useShow } from "@refinedev/core";
import { Show, TextFieldComponent as TextField } from "@refinedev/mui";
import { LinkedItems } from "../../components/LinkedItems";
import {
StationItem,
VehicleItem,
stationFields,
vehicleFields,
} from "./types";
export const RouteShow = () => {
const {query} = useShow({})
const {data, isLoading} = query
const record = data?.data
const { query } = useShow({});
const { data, isLoading } = query;
const record = data?.data;
const fields = [
{label: 'Перевозчик', data: 'carrier'},
{label: 'Номер маршрута', data: 'route_number'},
{ label: "Перевозчик", data: "carrier" },
{ label: "Номер маршрута", data: "route_number" },
{
label: 'Направление маршрута',
data: 'route_direction',
render: (value: number[][]) => <Typography style={{color: value ? '#48989f' : '#7f6b58'}}>{value ? 'прямое' : 'обратное'}</Typography>,
label: "Направление маршрута",
data: "route_direction",
render: (value: number[][]) => (
<Typography style={{ color: value ? "#48989f" : "#7f6b58" }}>
{value ? "прямое" : "обратное"}
</Typography>
),
},
{
label: 'Координаты маршрута',
data: 'path',
label: "Координаты маршрута",
data: "path",
render: (value: number[][]) => (
<Box
sx={{
fontFamily: 'monospace',
fontFamily: "monospace",
bgcolor: (theme) => theme.palette.background.paper,
p: 2,
borderRadius: 1,
maxHeight: '200px',
overflow: 'auto',
maxHeight: "200px",
overflow: "auto",
}}
>
{JSON.stringify(value)}
{/* {value?.map((point, index) => (
<Typography key={index} sx={{mb: 0.5}}>
Точка {index + 1}: [{point[0]}, {point[1]}]
{value?.map((point, index) => (
<Typography key={index} sx={{ mb: 0.5 }}>
{point[0]}, {point[1]}
</Typography>
))} */}
))}
</Box>
),
},
]
];
return (
<Show isLoading={isLoading}>
<Stack gap={4}>
{fields.map(({label, data, render}) => (
{fields.map(({ label, data, render }) => (
<Stack key={data} gap={1}>
<Typography variant="body1" fontWeight="bold">
{label}
</Typography>
{render ? render(record?.[data]) : <TextField value={record?.[data]} />}
{render ? (
render(record?.[data])
) : (
<TextField value={record?.[data]} />
)}
</Stack>
))}
{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>
</Show>
)
}
);
};

View File

@ -1,35 +1,36 @@
import {VEHICLE_TYPES} from '../../lib/constants'
import { VEHICLE_TYPES } from "../../lib/constants";
export type StationItem = {
id: number
name: string
description: string
[key: string]: string | number
}
id: number;
name: string;
description: string;
[key: string]: string | number;
};
export type VehicleItem = {
id: number
tail_number: number
type: number
[key: string]: string | number
}
id: number;
tail_number: number;
type: number;
[key: string]: string | number;
};
export type FieldType<T> = {
label: string
data: keyof T
render?: (value: any) => React.ReactNode
}
label: string;
data: keyof T;
render?: (value: any) => React.ReactNode;
};
export const stationFields: Array<FieldType<StationItem>> = [
{label: 'Название', data: 'system_name'},
{label: 'Описание', data: 'description'},
]
{ label: "Название", data: "name" },
{ label: "Описание", data: "description" },
];
export const vehicleFields: Array<FieldType<VehicleItem>> = [
{label: 'Бортовой номер', data: 'tail_number'},
{ label: "Бортовой номер", data: "tail_number" },
{
label: 'Тип',
data: 'type',
render: (value: number) => VEHICLE_TYPES.find((type) => type.value === value)?.label || value,
label: "Тип",
data: "type",
render: (value: number) =>
VEHICLE_TYPES.find((type) => type.value === value)?.label || value,
},
]
];

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,128 +1,169 @@
import React from 'react'
import {type GridColDef} from '@mui/x-data-grid'
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
import {Stack} from '@mui/material'
import {CustomDataGrid} from '../../components/CustomDataGrid'
import {localeText} from '../../locales/ru/localeText'
import React, { useEffect } from "react";
import { type GridColDef } from "@mui/x-data-grid";
import {
DeleteButton,
EditButton,
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 = () => {
const {dataGridProps} = useDataGrid({resource: 'sight/'})
export const SightList = observer(() => {
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[]>(
() => [
{
field: 'id',
headerName: 'ID',
type: 'number',
field: "id",
headerName: "ID",
type: "number",
minWidth: 70,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'name',
headerName: 'Название',
type: 'string',
field: "name",
headerName: "Название",
type: "string",
minWidth: 150,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'latitude',
headerName: 'Широта',
type: 'number',
field: "latitude",
headerName: "Широта",
type: "number",
minWidth: 150,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'longitude',
headerName: 'Долгота',
type: 'number',
field: "longitude",
headerName: "Долгота",
type: "number",
minWidth: 150,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'city_id',
headerName: 'ID города',
type: 'number',
field: "city_id",
headerName: "ID города",
type: "number",
minWidth: 70,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'city',
headerName: 'Город',
type: 'string',
field: "city",
headerName: "Город",
type: "string",
minWidth: 100,
align: 'left',
headerAlign: 'left',
align: "left",
headerAlign: "left",
flex: 1,
},
{
field: 'thumbnail',
headerName: 'Карточка',
type: 'string',
field: "thumbnail",
headerName: "Карточка",
type: "string",
minWidth: 150,
},
{
field: 'watermark_lu',
headerName: 'Вод. знак (lu)',
type: 'string',
field: "watermark_lu",
headerName: "Вод. знак (lu)",
type: "string",
minWidth: 150,
},
{
field: 'watermark_rd',
headerName: 'Вод. знак (rd)',
type: 'string',
field: "watermark_rd",
headerName: "Вод. знак (rd)",
type: "string",
minWidth: 150,
},
{
field: 'left_article',
headerName: 'Левая статья',
type: 'number',
field: "left_article",
headerName: "Левая статья",
type: "number",
minWidth: 150,
},
{
field: 'preview_article',
headerName: 'Пред. просмотр статьи',
type: 'number',
field: "preview_article",
headerName: "Пред. просмотр статьи",
type: "number",
minWidth: 150,
},
{
field: 'actions',
headerName: 'Действия',
field: "actions",
headerName: "Действия",
minWidth: 120,
display: 'flex',
align: 'right',
headerAlign: 'center',
display: "flex",
align: "right",
headerAlign: "center",
sortable: false,
filterable: false,
disableColumnMenu: true,
renderCell: function render({row}) {
renderCell: function render({ row }) {
return (
<>
<EditButton hideText recordItemId={row.id} />
<ShowButton hideText recordItemId={row.id} />
<DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} />
<DeleteButton
hideText
confirmTitle="Вы уверены?"
recordItemId={row.id}
/>
</>
)
);
},
},
],
[],
)
[]
);
return (
<List>
<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>
</List>
)
}
);
});

View File

@ -1,27 +1,28 @@
import {Stack, Typography} from '@mui/material'
import {useShow} from '@refinedev/core'
import {Show, TextFieldComponent} from '@refinedev/mui'
import {LinkedItems} from '../../components/LinkedItems'
import {ArticleItem, articleFields} from './types'
import { Stack, Typography } from "@mui/material";
import { useShow } from "@refinedev/core";
import { Show, TextFieldComponent } from "@refinedev/mui";
import { LinkedItems } from "../../components/LinkedItems";
import { ArticleItem, articleFields } from "./types";
export const SightShow = () => {
const {query} = useShow({})
const {data, isLoading} = query
const record = data?.data
const { query } = useShow({});
const { data, isLoading } = query;
const record = data?.data;
const fields = [
// {label: 'ID', data: 'id'},
{label: 'Название', data: 'name'},
{ label: "Название", data: "name" },
// {label: 'Широта', data: 'latitude'}, #*
// {label: 'Долгота', data: 'longitude'}, #*
// {label: 'ID города', data: 'city_id'},
{label: 'Город', data: 'city'},
]
{ label: "Адрес", data: "address" },
{ label: "Город", data: "city" },
];
return (
<Show isLoading={isLoading}>
<Stack gap={4}>
{fields.map(({label, data}) => (
{fields.map(({ label, data }) => (
<Stack key={data} gap={1}>
<Typography variant="body1" fontWeight="bold">
{label}
@ -30,8 +31,17 @@ export const SightShow = () => {
</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>
</Show>
)
}
);
};

View File

@ -1,183 +1,253 @@
import {Autocomplete, Box, TextField, Typography, Paper, Grid} from '@mui/material'
import {Create, useAutocomplete} from '@refinedev/mui'
import {useForm} from '@refinedev/react-hook-form'
import {Controller} from 'react-hook-form'
import {
Autocomplete,
Box,
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 = [
{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: 'Троллейбус'},
]
{ 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: "Троллейбус" },
];
export const StationCreate = () => {
const {
saveButtonProps,
refineCore: {formLoading},
refineCore: { formLoading },
register,
control,
formState: {errors},
formState: { errors },
} = useForm({
refineCoreProps: {
resource: 'station/',
resource: "station/",
},
})
});
const {autocompleteProps: cityAutocompleteProps} = useAutocomplete({
resource: 'city',
const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({
resource: "city",
onSearch: (value) => [
{
field: 'name',
operator: 'contains',
field: "name",
operator: "contains",
value,
},
],
})
});
return (
<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
{...register('name', {
required: 'Это поле является обязательным',
{...register("name", {
required: "Это поле является обязательным",
})}
error={!!(errors as any)?.name}
helperText={(errors as any)?.name?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="text"
label={'Название *'}
label={"Название *"}
name="name"
/>
<TextField
{...register('system_name', {
required: 'Это поле является обязательным',
{...register("system_name", {
required: "Это поле является обязательным",
})}
error={!!(errors as any)?.system_name}
helperText={(errors as any)?.system_name?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="text"
label={'Системное название *'}
label={"Системное название *"}
name="system_name"
/>
<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: 'Это поле является обязательным',
})}
error={!!(errors as any)?.description}
helperText={(errors as any)?.description?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="text"
label={'Описание'}
label={"Описание"}
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
{...register('latitude', {
required: 'Это поле является обязательным',
{...register("latitude", {
required: "Это поле является обязательным",
valueAsNumber: true,
})}
error={!!(errors as any)?.latitude}
helperText={(errors as any)?.latitude?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label={'Широта *'}
label={"Широта *"}
name="latitude"
/>
<TextField
{...register('longitude', {
required: 'Это поле является обязательным',
{...register("longitude", {
required: "Это поле является обязательным",
valueAsNumber: true,
})}
error={!!(errors as any)?.longitude}
helperText={(errors as any)?.longitude?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label={'Долгота *'}
label={"Долгота *"}
name="longitude"
/>
<Controller
control={control}
name="city_id"
rules={{required: 'Это поле является обязательным'}}
rules={{ required: "Это поле является обязательным" }}
defaultValue={null}
render={({field}) => (
render={({ field }) => (
<Autocomplete
{...cityAutocompleteProps}
value={cityAutocompleteProps.options.find((option) => option.id === field.value) || null}
value={
cityAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => {
field.onChange(value?.id || '')
field.onChange(value?.id || "");
}}
getOptionLabel={(item) => {
return item ? item.name : ''
return item ? item.name : "";
}}
isOptionEqualToValue={(option, value) => {
return option.id === value?.id
return option.id === value?.id;
}}
filterOptions={(options, {inputValue}) => {
return options.filter((option) => option.name.toLowerCase().includes(inputValue.toLowerCase()))
filterOptions={(options, { inputValue }) => {
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', {
{...register("offset_x", {
// required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.offset_x}
helperText={(errors as any)?.offset_x?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label={'Смещение (X)'}
label={"Смещение (X)"}
name="offset_x"
/>
<TextField
{...register('offset_y', {
{...register("offset_y", {
// required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.offset_y}
helperText={(errors as any)?.offset_y?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label={'Смещение (Y)'}
label={"Смещение (Y)"}
name="offset_y"
/>
{/* Группа полей пересадок */}
<Paper sx={{p: 2, mt: 2}}>
<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}`} />
<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>
</Create>
)
}
);
};

View File

@ -1,183 +1,329 @@
import {Autocomplete, Box, TextField, Typography, Paper, Grid} from '@mui/material'
import {Edit, useAutocomplete} from '@refinedev/mui'
import {useForm} from '@refinedev/react-hook-form'
import {Controller} from 'react-hook-form'
import {
Autocomplete,
Box,
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 {LinkedItems} from '../../components/LinkedItems'
import {type SightItem, sightFields} from './types'
import { useParams } from "react-router";
import { LinkedItems } from "../../components/LinkedItems";
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 = [
{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: 'Троллейбус'},
]
{ 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: "Троллейбус" },
];
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 {
saveButtonProps,
register,
control,
formState: {errors},
} = useForm({})
getValues,
setValue,
watch,
formState: { errors },
} = 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]);
const {autocompleteProps: cityAutocompleteProps} = useAutocomplete({
resource: 'city',
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({
resource: "city",
onSearch: (value) => [
{
field: 'name',
operator: 'contains',
field: "name",
operator: "contains",
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 (
<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
{...register('name', {
required: 'Это поле является обязательным',
{...register("name", {
required: "Это поле является обязательным",
})}
error={!!(errors as any)?.name}
helperText={(errors as any)?.name?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="text"
label={'Название *'}
label={"Название *"}
name="name"
/>
<TextField
{...register('system_name', {
required: 'Это поле является обязательным',
{...register("system_name", {
required: "Это поле является обязательным",
})}
error={!!(errors as any)?.system_name}
helperText={(errors as any)?.system_name?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="text"
label={'Системное название *'}
label={"Системное название *"}
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
{...register('description', {
{...register("description", {
// required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.description}
helperText={(errors as any)?.description?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="text"
label={'Описание'}
label={"Описание"}
name="description"
/>
<TextField
{...register('latitude', {
required: 'Это поле является обязательным',
valueAsNumber: true,
{...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
value={`${coordinatesPreview.latitude}, ${coordinatesPreview.longitude}`}
onChange={handleCoordinatesChange}
error={!!(errors as any)?.latitude}
helperText={(errors as any)?.latitude?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
type="number"
label={'Широта *'}
name="latitude"
InputLabelProps={{ shrink: true }}
type="text"
label={"Координаты *"}
/>
<TextField
{...register('longitude', {
required: 'Это поле является обязательным',
valueAsNumber: true,
<input
type="hidden"
{...register("latitude", {
value: coordinatesPreview.latitude,
})}
error={!!(errors as any)?.longitude}
helperText={(errors as any)?.longitude?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
type="number"
label={'Долгота *'}
name="longitude"
/>
<input
type="hidden"
{...register("longitude", { value: coordinatesPreview.longitude })}
/>
<Controller
control={control}
name="city_id"
rules={{required: 'Это поле является обязательным'}}
rules={{ required: "Это поле является обязательным" }}
defaultValue={null}
render={({field}) => (
render={({ field }) => (
<Autocomplete
{...cityAutocompleteProps}
value={cityAutocompleteProps.options.find((option) => option.id === field.value) || null}
value={
cityAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => {
field.onChange(value?.id || '')
field.onChange(value?.id || "");
}}
getOptionLabel={(item) => {
return item ? item.name : ''
return item ? item.name : "";
}}
isOptionEqualToValue={(option, value) => {
return option.id === value?.id
return option.id === value?.id;
}}
filterOptions={(options, {inputValue}) => {
return options.filter((option) => option.name.toLowerCase().includes(inputValue.toLowerCase()))
filterOptions={(options, { inputValue }) => {
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>
{stationId && (
@ -188,8 +334,9 @@ export const StationEdit = () => {
childResource="sight"
fields={sightFields}
title="достопримечательности"
dragAllowed={false}
/>
)}
</Edit>
)
}
);
});

View File

@ -1,126 +1,178 @@
import React from 'react'
import {type GridColDef} from '@mui/x-data-grid'
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
import {Stack} from '@mui/material'
import {CustomDataGrid} from '../../components/CustomDataGrid'
import {localeText} from '../../locales/ru/localeText'
import React, { useEffect, useMemo } from "react";
import { type GridColDef } from "@mui/x-data-grid";
import {
DeleteButton,
EditButton,
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 = () => {
const {dataGridProps} = useDataGrid({resource: 'station/'})
export const StationList = observer(() => {
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[]>(
() => [
{
field: 'id',
headerName: 'ID',
type: 'number',
field: "id",
headerName: "ID",
type: "number",
minWidth: 70,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'name',
headerName: 'Название',
type: 'string',
field: "name",
headerName: "Название",
type: "string",
minWidth: 300,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'system_name',
headerName: 'Системное название',
type: 'string',
field: "system_name",
headerName: "Системное название",
type: "string",
minWidth: 200,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'latitude',
headerName: 'Широта',
type: 'number',
field: "direction",
headerName: "Направление",
type: "boolean",
minWidth: 200,
display: "flex",
renderCell: ({ value }) => (
<Typography style={{ color: value ? "#48989f" : "#7f6b58" }}>
{value ? "прямой" : "обратный"}
</Typography>
),
},
{
field: "latitude",
headerName: "Широта",
type: "number",
minWidth: 150,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'longitude',
headerName: 'Долгота',
type: 'number',
field: "longitude",
headerName: "Долгота",
type: "number",
minWidth: 150,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'city_id',
headerName: 'ID города',
type: 'number',
field: "city_id",
headerName: "ID города",
type: "number",
minWidth: 120,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'offset_x',
headerName: 'Смещение (X)',
type: 'number',
field: "offset_x",
headerName: "Смещение (X)",
type: "number",
minWidth: 120,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'offset_y',
headerName: 'Смещение (Y)',
type: 'number',
field: "offset_y",
headerName: "Смещение (Y)",
type: "number",
minWidth: 120,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'description',
headerName: 'Описание',
type: 'string',
display: 'flex',
align: 'left',
headerAlign: 'left',
field: "description",
headerName: "Описание",
type: "string",
display: "flex",
align: "left",
headerAlign: "left",
flex: 1,
},
{
field: 'actions',
headerName: 'Действия',
cellClassName: 'station-actions',
align: 'right',
headerAlign: 'center',
field: "actions",
headerName: "Действия",
cellClassName: "station-actions",
align: "right",
headerAlign: "center",
minWidth: 120,
display: 'flex',
display: "flex",
sortable: false,
filterable: false,
disableColumnMenu: true,
renderCell: function render({row}) {
renderCell: function render({ row }) {
return (
<>
<EditButton hideText recordItemId={row.id} />
<ShowButton hideText recordItemId={row.id} />
<DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} />
<DeleteButton
hideText
confirmTitle="Вы уверены?"
recordItemId={row.id}
/>
</>
)
);
},
},
],
[],
)
[]
);
return (
<List>
<List key={city_id}>
<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>
</List>
)
}
);
});

View File

@ -1,23 +1,31 @@
import {useShow} from '@refinedev/core'
import {Show, TextFieldComponent as TextField} from '@refinedev/mui'
import {Stack, Typography} from '@mui/material'
import {LinkedItems} from '../../components/LinkedItems'
import {type SightItem, sightFields, stationFields} from './types'
import { useShow } from "@refinedev/core";
import { Show, TextFieldComponent as TextField } from "@refinedev/mui";
import { Box, Stack, Typography } from "@mui/material";
import { LinkedItems } from "../../components/LinkedItems";
import { type SightItem, sightFields, stationFields } from "./types";
export const StationShow = () => {
const {query} = useShow({})
const {data, isLoading} = query
const record = data?.data
const { query } = useShow({});
const { data, isLoading } = query;
const record = data?.data;
return (
<Show isLoading={isLoading}>
<Stack gap={4}>
{stationFields.map(({label, data}) => (
{stationFields.map(({ label, data }) => (
<Stack key={data} gap={1}>
<Typography variant="body1" fontWeight="bold">
{label}
{label === "Системное название" && (
<Box>
<TextField
value={record?.direction ? "(Прямой)" : "(Обратный)"}
/>
</Box>
)}
</Typography>
<TextField value={record?.[data] || ''} />
<TextField value={record?.[data] || ""} />
</Stack>
))}
@ -33,5 +41,5 @@ export const StationShow = () => {
)}
</Stack>
</Show>
)
}
);
};

View File

@ -1,44 +1,46 @@
import React from 'react'
import React from "react";
export type StationItem = {
id: number
name: string
description: string
latitude: number
longitude: number
[key: string]: string | number
}
id: number;
name: string;
description: string;
latitude: number;
longitude: number;
[key: string]: string | number;
};
export type SightItem = {
id: number
name: string
latitude: number
longitude: number
city_id: number
city: string
[key: string]: string | number
}
id: number;
name: string;
latitude: number;
longitude: number;
city_id: number;
city: string;
[key: string]: string | number;
};
export type FieldType<T> = {
label: string
data: keyof T
render?: (value: any) => React.ReactNode
}
label: string;
data: keyof T;
render?: (value: any) => React.ReactNode;
};
export const stationFields: Array<FieldType<StationItem>> = [
// {label: 'ID', data: 'id'},
{label: 'Название', data: 'name'},
{label: 'Системное название', data: 'system_name'},
{ label: "Название", data: "name" },
{ label: "Системное название", data: "system_name" },
// { label: "Направление", data: "direction" },
{ label: "Адрес", data: "address" },
// {label: 'Широта', data: 'latitude'},
// {label: 'Долгота', data: 'longitude'},
{label: 'Описание', data: 'description'},
]
{ label: "Описание", data: "description" },
];
export const sightFields: Array<FieldType<SightItem>> = [
// {label: 'ID', data: 'id'},
{label: 'Название', data: 'name'},
{ label: "Название", data: "name" },
// {label: 'Широта', data: 'latitude'},
// {label: 'Долгота', data: 'longitude'},
// {label: 'ID города', data: 'city_id'},
{label: 'Город', data: 'city'},
]
{ label: "Город", data: "city" },
];

View File

@ -1,48 +1,52 @@
import {Autocomplete, Box, TextField} from '@mui/material'
import {Edit, useAutocomplete} from '@refinedev/mui'
import {useForm} from '@refinedev/react-hook-form'
import {Controller} from 'react-hook-form'
import { Autocomplete, Box, TextField } from "@mui/material";
import { Edit, useAutocomplete } from "@refinedev/mui";
import { useForm } from "@refinedev/react-hook-form";
import { Controller } from "react-hook-form";
import {VEHICLE_TYPES} from '../../lib/constants'
import { VEHICLE_TYPES } from "../../lib/constants";
type VehicleFormValues = {
tail_number: number
type: number
city_id: number
}
tail_number: number;
type: number;
city_id: number;
};
export const VehicleEdit = () => {
const {
saveButtonProps,
register,
control,
formState: {errors},
} = useForm<VehicleFormValues>({})
formState: { errors },
} = useForm<VehicleFormValues>({});
const {autocompleteProps: carrierAutocompleteProps} = useAutocomplete({
resource: 'carrier',
const { autocompleteProps: carrierAutocompleteProps } = useAutocomplete({
resource: "carrier",
onSearch: (value) => [
{
field: 'short_name',
operator: 'contains',
field: "short_name",
operator: "contains",
value,
},
],
})
});
return (
<Edit saveButtonProps={saveButtonProps}>
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
<Box
component="form"
sx={{ display: "flex", flexDirection: "column" }}
autoComplete="off"
>
<TextField
{...register('tail_number', {
required: 'Это поле является обязательным',
{...register("tail_number", {
required: "Это поле является обязательным",
valueAsNumber: true,
})}
error={!!(errors as any)?.tail_number}
helperText={(errors as any)?.tail_number?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
InputLabelProps={{ shrink: true }}
type="number"
label="Бортовой номер *"
name="tail_number"
@ -52,23 +56,36 @@ export const VehicleEdit = () => {
control={control}
name="type"
rules={{
required: 'Это поле является обязательным',
required: "Это поле является обязательным",
}}
defaultValue={null}
render={({field}) => (
render={({ field }) => (
<Autocomplete
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) => {
field.onChange(value?.value || null)
field.onChange(value?.value || null);
}}
getOptionLabel={(item) => {
return item ? item.label : ''
return item ? item.label : "";
}}
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
control={control}
name="carrier_id"
rules={{required: 'Это поле является обязательным'}}
rules={{ required: "Это поле является обязательным" }}
defaultValue={null}
render={({field}) => (
render={({ field }) => (
<Autocomplete
{...carrierAutocompleteProps}
value={carrierAutocompleteProps.options.find((option) => option.id === field.value) || null}
value={
carrierAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => {
field.onChange(value?.id || '')
field.onChange(value?.id || "");
}}
getOptionLabel={(item) => {
return item ? item.short_name : ''
return item ? item.short_name : "";
}}
isOptionEqualToValue={(option, value) => {
return option.id === value?.id
return option.id === value?.id;
}}
filterOptions={(options, {inputValue}) => {
return options.filter((option) => option.short_name.toLowerCase().includes(inputValue.toLowerCase()))
filterOptions={(options, { inputValue }) => {
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>
</Edit>
)
}
);
};

View File

@ -1,91 +1,120 @@
import {type GridColDef} from '@mui/x-data-grid'
import {CustomDataGrid} from '../../components/CustomDataGrid'
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
import React from 'react'
import {VEHICLE_TYPES} from '../../lib/constants'
import { type GridColDef } from "@mui/x-data-grid";
import { CustomDataGrid } from "../../components/CustomDataGrid";
import {
DeleteButton,
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 = () => {
const {dataGridProps} = useDataGrid({})
export const VehicleList = observer(() => {
const { language } = languageStore;
const { dataGridProps } = useDataGrid({
resource: "vehicle",
meta: {
headers: {
"Accept-Language": language,
},
},
});
const columns = React.useMemo<GridColDef[]>(
() => [
{
field: 'id',
headerName: 'ID',
type: 'number',
field: "id",
headerName: "ID",
type: "number",
minWidth: 70,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'carrier_id',
headerName: 'ID перевозчика',
type: 'string',
field: "carrier_id",
headerName: "ID перевозчика",
type: "string",
minWidth: 150,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'tail_number',
headerName: 'Бортовой номер',
type: 'number',
field: "tail_number",
headerName: "Бортовой номер",
type: "number",
minWidth: 150,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: 'type',
headerName: 'Тип',
type: 'string',
field: "type",
headerName: "Тип",
type: "string",
minWidth: 200,
display: 'flex',
align: 'left',
headerAlign: 'left',
display: "flex",
align: "left",
headerAlign: "left",
renderCell: (params) => {
const value = params.row.type
return VEHICLE_TYPES.find((type) => type.value === value)?.label || value
const value = params.row.type;
return (
VEHICLE_TYPES.find((type) => type.value === value)?.label || value
);
},
},
{
field: 'city',
headerName: 'Город',
type: 'string',
align: 'left',
headerAlign: 'left',
field: "city",
headerName: "Город",
type: "string",
align: "left",
headerAlign: "left",
flex: 1,
},
{
field: 'actions',
headerName: 'Действия',
field: "actions",
headerName: "Действия",
minWidth: 120,
display: 'flex',
align: 'right',
headerAlign: 'center',
display: "flex",
align: "right",
headerAlign: "center",
sortable: false,
filterable: false,
disableColumnMenu: true,
renderCell: function render({row}) {
renderCell: function render({ row }) {
return (
<>
<EditButton hideText recordItemId={row.id} />
<ShowButton hideText recordItemId={row.id} />
<DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} />
<DeleteButton
hideText
confirmTitle="Вы уверены?"
recordItemId={row.id}
/>
</>
)
);
},
},
],
[],
)
[]
);
return (
<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>
)
}
);
});

View File

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 MiB

View File

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

View File

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

View File

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

View 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>
);
}

View File

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

View File

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

View 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" />
)}
</>
);

View File

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

View File

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

View 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" />
)}
</>
);

View 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);
}
`;

View 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>
);
}

View File

@ -0,0 +1 @@
export { Drawer } from './Drawer';

View 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;
};

View File

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

View File

@ -0,0 +1,4 @@
.mapWidget {
position: relative;
z-index: 100;
}

View File

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

View File

@ -0,0 +1 @@
export * from './MapWidget';

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
export * from './TrackAttractions';

View 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}
/>
</>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
export * from './TrackStations';

View File

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

View File

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

View File

@ -0,0 +1 @@
export * from './TramMarker';

View File

@ -0,0 +1,5 @@
export * from './TramMarker';
export * from './TrackLine';
export * from './TrackStations';
export * from './TrackAttractions';
export * from './MapWidget';

View File

@ -0,0 +1,2 @@
export * from './usePassedTrackIndex';
export * from './useNearStation';

View 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 };
}

View File

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

View 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';

View File

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

View 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,
};

View 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[];
}

View 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);
}

View 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);
}

View 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];
}

View File

@ -0,0 +1,4 @@
export * from './get-deviation';
export * from './get-distance';
export * from './get-map-point';
export * from './intersections';

View 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;
}

View File

@ -0,0 +1,7 @@
export const MyComponent = () => {
return (
<div style={{ width: "100px", height: "100px", backgroundColor: "red" }}>
MyComponent
</div>
);
};

View File

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