Compare commits

..

No commits in common. "preview" and "master" have entirely different histories.

96 changed files with 10875 additions and 30891 deletions

2
.env
View File

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

View File

@ -1,51 +0,0 @@
name: release-tag
on:
push
jobs:
release-image:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
env:
DOCKER_ORG: krbl
DOCKER_LATEST: nightly
RUNNER_TOOL_CACHE: /toolcache
IMAGE_NAME: white-nights-admin-panel
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v2
with:
config-inline: |
[registry."gitea.unprism.ru"]
- name: Login to DockerHub
uses: docker/login-action@v2
with:
registry: gitea.unprism.ru
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Get Meta
id: meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile
platforms: |
linux/amd64
push: true
tags: |
gitea.unprism.ru/${{ env.DOCKER_ORG }}/${{ env.IMAGE_NAME }}:${{ env.DOCKER_LATEST }}

View File

@ -1,44 +1,37 @@
# This Dockerfile uses `serve` npm package to serve the static files with node process. # This Dockerfile uses `serve` npm package to serve the static files with node process.
# You can find the Dockerfile for nginx in the following link: # You can find the Dockerfile for nginx in the following link:
# https://github.com/refinedev/dockerfiles/blob/main/vite/Dockerfile.nginx # https://github.com/refinedev/dockerfiles/blob/main/vite/Dockerfile.nginx
FROM refinedev/node:18 AS base
FROM refinedev/node:20 AS base FROM base as deps
FROM base AS deps
# Копируем только файлы зависимостей
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN \ RUN \
if [ -f yarn.lock ]; then yarn install --frozen-lockfile; \ if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \ elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then npm install -g pnpm && pnpm install --frozen-lockfile; \ elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \ else echo "Lockfile not found." && exit 1; \
fi fi
FROM base AS builder FROM base as builder
ENV NODE_ENV=production ENV NODE_ENV production
# Обязательно создать рабочую директорию
WORKDIR /app/refine
COPY --from=deps /app/refine/node_modules ./node_modules COPY --from=deps /app/refine/node_modules ./node_modules
COPY . . COPY . .
# Добавлена проверка и вывод логов RUN npm run build
RUN echo "🚧 Starting build..." && npm run build || (echo "❌ Build failed" && exit 1)
FROM base AS runner FROM base as runner
ENV NODE_ENV=production ENV NODE_ENV production
RUN npm install -g serve RUN npm install -g serve
WORKDIR /app/refine
COPY --from=builder /app/refine/dist ./ COPY --from=builder /app/refine/dist ./
USER refine USER refine
CMD ["serve", "-s", ".", "-l", "3000"] CMD ["serve"]

View File

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

13255
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

7245
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 750 B

View File

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

174
src/authProvider.ts Normal file
View File

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

View File

@ -1,397 +1,258 @@
import { import {Typography, Button, Box, Accordion, AccordionSummary, AccordionDetails, useTheme, TextField} from '@mui/material'
Typography, import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
Button, import {axiosInstance} from '../providers/data'
Box, import {BACKEND_URL} from '../lib/constants'
Accordion, import {useForm, Controller} from 'react-hook-form'
AccordionSummary, import {MarkdownEditor} from './MarkdownEditor'
AccordionDetails, import React, {useState, useCallback} from 'react'
useTheme, import {useDropzone} from 'react-dropzone'
TextField, import {ALLOWED_IMAGE_TYPES, ALLOWED_VIDEO_TYPES} from '../components/media/MediaFormUtils'
} 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, useEffect } from "react";
import { useDropzone } from "react-dropzone";
import {
ALLOWED_IMAGE_TYPES,
ALLOWED_VIDEO_TYPES,
} from "../components/media/MediaFormUtils";
import { EVERY_LANGUAGE, Languages } from "@stores";
import { useNotification } from "@refinedev/core";
const MemoizedSimpleMDE = React.memo(MarkdownEditor); const MemoizedSimpleMDE = React.memo(MarkdownEditor)
type MediaFile = { type MediaFile = {
file: File; file: File
preview: string; preview: string
uploading: boolean; uploading: boolean
mediaId?: number; mediaId?: number
}; }
type Props = { type Props = {
parentId?: string | number; parentId: string | number
parentResource: string; parentResource: string
childResource: string; childResource: string
title: string; title: string
left?: boolean; }
language: Languages;
setHeadingParent?: (heading: string) => void;
setBodyParent?: (body: string) => void;
onSave?: (something: any) => void;
noReset?: boolean;
};
export const CreateSightArticle = ({ export const CreateSightArticle = ({parentId, parentResource, childResource, title}: Props) => {
parentId, const theme = useTheme()
parentResource, const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([])
childResource,
title,
left,
language,
setHeadingParent,
setBodyParent,
onSave,
noReset,
}: Props) => {
const notification = useNotification();
const theme = useTheme();
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]);
const [workingLanguage, setWorkingLanguage] = useState<Languages>(language);
const { const {
register: registerItem, register: registerItem,
watch,
control: controlItem, control: controlItem,
handleSubmit: handleSubmitItem, handleSubmit: handleSubmitItem,
reset: resetItem, reset: resetItem,
setValue, formState: {errors: itemErrors},
formState: { errors: itemErrors },
} = useForm({ } = useForm({
defaultValues: { defaultValues: {
heading: "", heading: '',
body: "", body: '',
}, },
}); })
const [articleData, setArticleData] = useState({
heading: EVERY_LANGUAGE(""),
body: EVERY_LANGUAGE(""),
});
function updateTranslations() {
const newArticleData = {
...articleData,
heading: {
...articleData.heading,
[workingLanguage]: watch("heading") ?? "",
},
body: {
...articleData.body,
[workingLanguage]: watch("body") ?? "",
},
};
setArticleData(newArticleData);
return newArticleData;
}
useEffect(() => {
setValue("heading", articleData.heading[workingLanguage] ?? "");
setValue("body", articleData.body[workingLanguage] ?? "");
}, [workingLanguage, articleData, setValue]);
useEffect(() => {
updateTranslations();
setWorkingLanguage(language);
}, [language]);
useEffect(() => {
setHeadingParent?.(watch("heading"));
setBodyParent?.(watch("body"));
}, [watch("heading"), watch("body"), setHeadingParent, setBodyParent]);
const simpleMDEOptions = React.useMemo( const simpleMDEOptions = React.useMemo(
() => ({ () => ({
placeholder: "Введите контент в формате Markdown...", placeholder: 'Введите контент в формате Markdown...',
spellChecker: false, spellChecker: false,
}), }),
[] [],
); )
const onDrop = useCallback((acceptedFiles: File[]) => { const onDrop = useCallback((acceptedFiles: File[]) => {
const newFiles = acceptedFiles.map((file) => ({ const newFiles = acceptedFiles.map((file) => ({
file, file,
preview: URL.createObjectURL(file), preview: URL.createObjectURL(file),
uploading: false, uploading: false,
})); }))
setMediaFiles((prev) => [...prev, ...newFiles]); setMediaFiles((prev) => [...prev, ...newFiles])
}, []); }, [])
const { getRootProps, getInputProps, isDragActive } = useDropzone({ const {getRootProps, getInputProps, isDragActive} = useDropzone({
onDrop, onDrop,
accept: { accept: {
"image/jpeg": [".jpeg", ".jpg"], 'image/*': ALLOWED_IMAGE_TYPES,
"image/png": [".png"], 'video/*': ALLOWED_VIDEO_TYPES,
"image/webp": [".webp"],
"video/mp4": [".mp4"],
"video/webm": [".webm"],
"video/ogg": [".ogg"],
}, },
multiple: true, multiple: true,
}); })
const uploadMedia = async (mediaFile: MediaFile) => { const uploadMedia = async (mediaFile: MediaFile) => {
const formData = new FormData(); const formData = new FormData()
formData.append("media_name", mediaFile.file.name); formData.append('media_name', mediaFile.file.name)
formData.append("filename", mediaFile.file.name); formData.append('filename', mediaFile.file.name)
formData.append( formData.append('type', mediaFile.file.type.startsWith('image/') ? '1' : '2')
"type", formData.append('file', mediaFile.file)
mediaFile.file.type.startsWith("image/") ? "1" : "2"
);
formData.append("file", mediaFile.file);
const response = await axiosInstance.post( const response = await axiosInstance.post(`${BACKEND_URL}/media`, formData)
`${import.meta.env.VITE_KRBL_API}/media`, return response.data.id
formData }
);
return response.data.id;
};
const handleCreate = async (data: { heading: string; body: string }) => { const handleCreate = async (data: {heading: string; body: string}) => {
try { try {
// Создаем статью // Создаем статью
const response = await axiosInstance.post( const response = await axiosInstance.post(`${BACKEND_URL}/${childResource}`, data)
`${import.meta.env.VITE_KRBL_API}/${childResource}`, const itemId = response.data.id
{
...data,
translations: updateTranslations(),
}
);
const itemId = response.data.id;
if (parentId) { // Получаем существующие статьи для определения порядкового номера
// Получаем существующие статьи для определения порядкового номера const existingItemsResponse = await axiosInstance.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`)
const existingItemsResponse = await axiosInstance.get( const existingItems = existingItemsResponse.data || []
`${ const nextPageNum = existingItems.length + 1
import.meta.env.VITE_KRBL_API
}/${parentResource}/${parentId}/${childResource}`
);
const existingItems = existingItemsResponse.data ?? [];
const nextPageNum = existingItems.length + 1;
if (!left) { // Привязываем статью к достопримечательности
await axiosInstance.post( await axiosInstance.post(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}/`, {
`${ [`${childResource}_id`]: itemId,
import.meta.env.VITE_KRBL_API page_num: nextPageNum,
}/${parentResource}/${parentId}/${childResource}/`, })
{
[`${childResource}_id`]: itemId,
page_num: nextPageNum,
}
);
} else {
const response = await axiosInstance.get(
`${import.meta.env.VITE_KRBL_API}/${parentResource}/${parentId}/`
);
const data = response.data;
if (data) {
await axiosInstance.patch(
`${import.meta.env.VITE_KRBL_API}/${parentResource}/${parentId}/`,
{
...data,
left_article: itemId,
}
);
}
}
}
// Загружаем все медиа файлы и получаем их ID // Загружаем все медиа файлы и получаем их ID
const mediaIds = await Promise.all( const mediaIds = await Promise.all(
mediaFiles.map(async (mediaFile) => { mediaFiles.map(async (mediaFile) => {
return await uploadMedia(mediaFile); return await uploadMedia(mediaFile)
}) }),
); )
// Привязываем все медиа к статье // Привязываем все медиа к статье
await Promise.all( await Promise.all(
mediaIds.map((mediaId, index) => mediaIds.map((mediaId, index) =>
axiosInstance.post( axiosInstance.post(`${BACKEND_URL}/article/${itemId}/media/`, {
`${import.meta.env.VITE_KRBL_API}/article/${itemId}/media/`, media_id: mediaId,
{ media_order: index + 1,
media_id: mediaId, }),
media_order: index + 1, ),
} )
)
) resetItem()
); setMediaFiles([])
if (noReset) { window.location.reload()
setValue("heading", "");
setValue("body", "");
} else {
resetItem();
}
if (onSave) {
onSave(response.data);
if (notification && typeof notification.open === "function") {
notification.open({
message: "Статья успешно создана",
type: "success",
});
}
} else {
window.location.reload();
}
} catch (err: any) { } catch (err: any) {
console.error("Error creating item:", err); console.error('Error creating item:', err)
} }
}; }
const removeMedia = (index: number) => { const removeMedia = (index: number) => {
setMediaFiles((prev) => { setMediaFiles((prev) => {
const newFiles = [...prev]; const newFiles = [...prev]
URL.revokeObjectURL(newFiles[index].preview); URL.revokeObjectURL(newFiles[index].preview)
newFiles.splice(index, 1); newFiles.splice(index, 1)
return newFiles; return newFiles
}); })
}; }
return ( return (
<Box> <Accordion>
<TextField <AccordionSummary
{...registerItem("heading", { expandIcon={<ExpandMoreIcon />}
required: "Это поле является обязательным",
})}
error={!!(itemErrors as any)?.heading}
helperText={(itemErrors as any)?.heading?.message}
margin="normal"
fullWidth
slotProps={{ inputLabel: { shrink: true } }}
type="text"
sx={{ sx={{
backgroundColor: theme.palette.background.paper, marginTop: 2,
background: theme.palette.background.paper,
borderBottom: `1px solid ${theme.palette.divider}`,
}} }}
label="Заголовок *" >
/> <Typography variant="subtitle1" fontWeight="bold">
Создать {title}
<Controller </Typography>
control={controlItem} </AccordionSummary>
name="body" <AccordionDetails sx={{background: theme.palette.background.paper}}>
rules={{ required: "Это поле является обязательным" }} <Box component="form" onSubmit={handleSubmitItem(handleCreate)}>
defaultValue="" <TextField
render={({ field: { onChange, value } }) => ( {...registerItem('heading', {
<MemoizedSimpleMDE required: 'Это поле является обязательным',
value={value} })}
onChange={onChange} error={!!(itemErrors as any)?.heading}
options={simpleMDEOptions} helperText={(itemErrors as any)?.heading?.message}
className="my-markdown-editor" margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
type="text"
label="Заголовок *"
/> />
)}
/>
{/* Dropzone для медиа файлов */} <Controller control={controlItem} name="body" rules={{required: 'Это поле является обязательным'}} defaultValue="" render={({field: {onChange, value}}) => <MemoizedSimpleMDE value={value} onChange={onChange} options={simpleMDEOptions} className="my-markdown-editor" />} />
<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>
{/* Превью загруженных файлов */} {/* Dropzone для медиа файлов */}
<Box sx={{ mt: 2, display: "flex", flexWrap: "wrap", gap: 1 }}> <Box sx={{mt: 2, mb: 2}}>
{mediaFiles.map((mediaFile, index) => (
<Box <Box
key={mediaFile.preview} {...getRootProps()}
sx={{ sx={{
position: "relative", border: '2px dashed',
width: 100, borderColor: isDragActive ? 'primary.main' : 'grey.300',
height: 100, borderRadius: 1,
p: 2,
textAlign: 'center',
cursor: 'pointer',
'&:hover': {
borderColor: 'primary.main',
},
}} }}
> >
{mediaFile.file.type.startsWith("image/") ? ( <input {...getInputProps()} />
<img <Typography>{isDragActive ? 'Перетащите файлы сюда...' : 'Перетащите файлы сюда или кликните для выбора'}</Typography>
src={mediaFile.preview} </Box>
alt={mediaFile.file.name}
style={{ {/* Превью загруженных файлов */}
width: "100%", <Box sx={{mt: 2, display: 'flex', flexWrap: 'wrap', gap: 1}}>
height: "100%", {mediaFiles.map((mediaFile, index) => (
objectFit: "cover",
}}
/>
) : (
<Box <Box
key={mediaFile.preview}
sx={{ sx={{
width: "100%", position: 'relative',
height: "100%", width: 100,
display: "flex", height: 100,
alignItems: "center",
justifyContent: "center",
bgcolor: "grey.200",
}} }}
> >
<Typography variant="caption"> {mediaFile.file.type.startsWith('image/') ? (
{mediaFile.file.name} <img
</Typography> 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>
)} ))}
<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>
</Box>
</Box>
<Box sx={{ mt: 2, display: "flex", gap: 2 }}> <Box sx={{mt: 2, display: 'flex', gap: 2}}>
<Button <Button variant="contained" color="primary" type="submit">
variant="contained" Создать
color="primary" </Button>
type="submit" <Button
onClick={handleSubmitItem(handleCreate)} variant="outlined"
> onClick={() => {
Создать resetItem()
</Button> mediaFiles.forEach((file) => URL.revokeObjectURL(file.preview))
<Button setMediaFiles([])
variant="outlined" }}
onClick={() => { >
resetItem(); Очистить
mediaFiles.forEach((file) => URL.revokeObjectURL(file.preview)); </Button>
setMediaFiles([]); </Box>
}} </Box>
> </AccordionDetails>
Очистить </Accordion>
</Button> )
</Box> }
</Box>
);
};

View File

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

View File

@ -1,68 +0,0 @@
import { Box } from "@mui/material";
import { Languages, languageStore } from "../../store/LanguageStore";
import { observer } from "mobx-react-lite";
export const LanguageSwitch = observer(({ action }: any) => {
const { language, setLanguageAction } = languageStore;
const handleLanguageChange = (lang: Languages) => {
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,555 +1,275 @@
import { useState, useEffect } from "react"; import {useState, useEffect} from 'react'
import { languageStore } from "../store/LanguageStore"; import {Stack, Typography, Button, FormControl, Grid, Box, Accordion, AccordionSummary, AccordionDetails, useTheme, TextField, Autocomplete} from '@mui/material'
import { import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
Stack, import {axiosInstance} from '../providers/data'
Typography, import {BACKEND_URL} from '../lib/constants'
Button, import {Link} from 'react-router'
FormControl, import {TOKEN_KEY} from '../authProvider'
Accordion,
AccordionSummary,
AccordionDetails,
useTheme,
TextField,
Autocomplete,
TableCell,
TableContainer,
Table,
TableHead,
TableRow,
Paper,
TableBody,
IconButton,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import { axiosInstance } from "../providers/data";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
import { articleStore } from "../store/ArticleStore";
import { ArticleEditModal } from "./modals/ArticleEditModal";
import { StationEditModal } from "./modals/StationEditModal";
import { stationStore } from "../store/StationStore";
function insertAtPosition<T>(arr: T[], pos: number, value: T): T[] {
const index = pos - 1;
if (index >= arr.length) {
arr.push(value);
} else {
arr.splice(index, 0, value);
}
return arr;
}
type Field<T> = { type Field<T> = {
label: string; label: string
data: keyof T; data: keyof T
render?: (value: any) => React.ReactNode; render?: (value: any) => React.ReactNode
}; }
type ExtraFieldConfig = { type ExtraFieldConfig = {
type: "number"; type: 'number'
label: string; label: string
minValue: number; minValue: number
maxValue: (linkedItems: any[]) => number; maxValue: (linkedItems: any[]) => number
}; }
type LinkedItemsProps<T> = { type LinkedItemsProps<T> = {
parentId: string | number; parentId: string | number
parentResource: string; parentResource: string
childResource: string; childResource: string
fields: Field<T>[]; fields: Field<T>[]
setItemsParent?: (items: T[]) => void; title: string
title: string; type: 'show' | 'edit'
type: "show" | "edit"; extraField?: ExtraFieldConfig
extraField?: ExtraFieldConfig; }
dragAllowed?: boolean;
onSave?: (items: T[]) => void;
onUpdate?: () => void;
dontRecurse?: boolean;
disableCreation?: boolean;
updatedLinkedItems?: T[];
refresh?: number;
};
const reorder = (list: any[], startIndex: number, endIndex: number) => { export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentId, parentResource, childResource, fields, title, type}: LinkedItemsProps<T>) => {
const result = Array.from(list); const [items, setItems] = useState<T[]>([])
const [removed] = result.splice(startIndex, 1); const [linkedItems, setLinkedItems] = useState<T[]>([])
result.splice(endIndex, 0, removed); const [selectedItemId, setSelectedItemId] = useState<number | null>(null)
return result; const [pageNum, setPageNum] = useState<number>(1)
}; const [isLoading, setIsLoading] = useState<boolean>(true)
const [mediaOrder, setMediaOrder] = useState<number>(1)
export const LinkedItems = <T extends { id: number; [key: string]: any }>( const theme = useTheme()
props: LinkedItemsProps<T>
) => {
const theme = useTheme();
return (
<>
<Accordion>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
background: theme.palette.background.paper,
borderBottom: `1px solid ${theme.palette.divider}`,
}}
>
<Typography variant="subtitle1" fontWeight="bold">
Привязанные {props.title}
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ background: theme.palette.background.paper }}>
<Stack gap={2}>
<LinkedItemsContents {...props} />
</Stack>
</AccordionDetails>
</Accordion>
{!props.dontRecurse && (
<>
<ArticleEditModal />
<StationEditModal />
</>
)}
</>
);
};
export const LinkedItemsContents = <
T extends { id: number; [key: string]: any }
>({
parentId,
parentResource,
childResource,
setItemsParent,
fields,
title,
dragAllowed = false,
type,
onUpdate,
disableCreation = false,
updatedLinkedItems,
refresh,
}: LinkedItemsProps<T>) => {
const { language } = languageStore;
const { setArticleModalOpenAction, setArticleIdAction } = articleStore;
const { setStationModalOpenAction, setStationIdAction, setRouteIdAction } =
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);
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 (!updatedLinkedItems?.length) return;
setLinkedItems(updatedLinkedItems);
}, [updatedLinkedItems]);
useEffect(() => {
setItemsParent?.(linkedItems);
}, [linkedItems, setItemsParent]);
const onDragEnd = (result: any) => {
if (!result.destination) return;
const reorderedItems = reorder(
linkedItems,
result.source.index,
result.destination.index
);
setLinkedItems(reorderedItems);
if (parentResource === "sight" && childResource === "article") {
axiosInstance.post(
`${import.meta.env.VITE_KRBL_API}/sight/${parentId}/article/order`,
{
articles: reorderedItems.map((item) => ({
id: item.id,
})),
}
);
} else {
axiosInstance.post(
`${import.meta.env.VITE_KRBL_API}/route/${parentId}/station`,
{
stations: reorderedItems.map((item) => ({
id: item.id,
})),
}
);
}
};
useEffect(() => { useEffect(() => {
if (parentId) { if (parentId) {
axiosInstance axiosInstance
.get( .get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`)
`${
import.meta.env.VITE_KRBL_API
}/${parentResource}/${parentId}/${childResource}`
)
.then((response) => { .then((response) => {
setLinkedItems(response?.data || []); setLinkedItems(response?.data || [])
}) })
.catch(() => { .catch(() => {
setLinkedItems([]); setLinkedItems([])
}); })
} }
}, [parentId, parentResource, childResource, language, refresh]); }, [parentId, parentResource, childResource])
useEffect(() => { useEffect(() => {
if (type === "edit") { if (type === 'edit') {
axiosInstance axiosInstance
.get(`${import.meta.env.VITE_KRBL_API}/${childResource}/`) .get(`${BACKEND_URL}/${childResource}/`)
.then((response) => { .then((response) => {
setItems(response?.data || []); setItems(response?.data || [])
setIsLoading(false); setIsLoading(false)
}) })
.catch(() => { .catch(() => {
setItems([]); setItems([])
setIsLoading(false); setIsLoading(false)
}); })
} else { } else {
setIsLoading(false); setIsLoading(false)
} }
}, [childResource, type]); }, [childResource, type])
useEffect(() => { useEffect(() => {
if (childResource === "article" && parentResource === "sight") { if (childResource === 'article' && parentResource === 'sight') {
setPageNum(linkedItems.length + 1); setPageNum(linkedItems.length + 1)
} }
}, [linkedItems, childResource, parentResource]); }, [linkedItems, childResource, parentResource])
const availableItems = items.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
const linkItem = () => { const linkItem = () => {
if (selectedItemId !== null) { if (selectedItemId !== null) {
const requestData = const requestData =
childResource === "article" childResource === 'article'
? { ? {
[`${childResource}_id`]: selectedItemId, [`${childResource}_id`]: selectedItemId,
page_num: pageNum, page_num: pageNum,
} }
: childResource === "media" : childResource === 'media'
? { ? {
[`${childResource}_id`]: selectedItemId, [`${childResource}_id`]: selectedItemId,
media_order: mediaOrder, media_order: mediaOrder,
} }
: childResource === "station" : {
? { [`${childResource}_id`]: selectedItemId,
stations: insertAtPosition(
linkedItems.map((item) => ({
id: item.id,
})),
position,
{
id: selectedItemId,
}
),
} }
: { [`${childResource}_id`]: selectedItemId };
axiosInstance axiosInstance
.post( .post(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`, requestData)
`${
import.meta.env.VITE_KRBL_API
}/${parentResource}/${parentId}/${childResource}`,
requestData
)
.then(() => { .then(() => {
axiosInstance axiosInstance.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`).then((response) => {
.get( setLinkedItems(response?.data || [])
`${ setSelectedItemId(null)
import.meta.env.VITE_KRBL_API if (childResource === 'article') {
}/${parentResource}/${parentId}/${childResource}` setPageNum(pageNum + 1)
) }
.then((response) => { })
setLinkedItems(response?.data || []);
setSelectedItemId(null);
if (childResource === "article") {
setPageNum(pageNum + 1);
}
onUpdate?.();
});
}) })
.catch((error) => { .catch((error) => {
console.error("Error linking item:", error); console.error('Error linking item:', error)
}); })
} }
}; }
const deleteItem = (itemId: number) => { const deleteItem = (itemId: number) => {
axiosInstance axiosInstance
.delete( .delete(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`, {
`${ data: {[`${childResource}_id`]: itemId},
import.meta.env.VITE_KRBL_API })
}/${parentResource}/${parentId}/${childResource}`,
{
data: { [`${childResource}_id`]: itemId },
}
)
.then(() => { .then(() => {
setLinkedItems((prev) => prev.filter((item) => item.id !== itemId)); setLinkedItems((prev) => prev.filter((item) => item.id !== itemId))
onUpdate?.();
}) })
.catch((error) => { .catch((error) => {
console.error("Error unlinking item:", error); console.error('Error unlinking item:', error)
}); })
}; }
return ( return (
<> <Accordion>
{linkedItems?.length > 0 && ( <AccordionSummary
<DragDropContext onDragEnd={onDragEnd}> expandIcon={<ExpandMoreIcon />}
<TableContainer component={Paper}> sx={{
<Table> background: theme.palette.background.paper,
<TableHead> borderBottom: `1px solid ${theme.palette.divider}`,
<TableRow> }}
{type === "edit" && dragAllowed && ( >
<TableCell width="40px"></TableCell> <Typography variant="subtitle1" fontWeight="bold">
)} Привязанные {title}
<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}
>
{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" &&
type === "edit"
) {
setArticleModalOpenAction(true);
setArticleIdAction(item.id);
}
if (
childResource === "station" &&
type === "edit"
) {
setStationModalOpenAction(true);
setStationIdAction(item.id);
setRouteIdAction(Number(parentId));
}
}}
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>
)}
</Droppable>
</Table>
</TableContainer>
</DragDropContext>
)}
{linkedItems.length === 0 && !isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
{title} не найдены
</Typography> </Typography>
)} </AccordionSummary>
{type === "edit" && !disableCreation && ( <AccordionDetails sx={{background: theme.palette.background.paper}}>
<Stack gap={2} mt={2}> <Stack gap={2}>
<Typography variant="subtitle1">Добавить {title}</Typography> <Grid container gap={1.25}>
<Autocomplete {isLoading ? (
fullWidth <Typography>Загрузка...</Typography>
value={ ) : linkedItems.length > 0 ? (
availableItems?.find((item) => item.id === selectedItemId) || null linkedItems.map((item, index) => (
} <Box
onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)} component={Link}
options={availableItems} to={`/${childResource}/show/${item.id}`}
getOptionLabel={(item) => String(item[fields[0].data])} key={index}
renderInput={(params) => ( sx={{
<TextField {...params} label={`Выберите ${title}`} fullWidth /> 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}}
>
Отвязать
</Button>
)}
</Stack>
</Box>
))
) : (
<Typography color="textSecondary">{title} не найдены</Typography>
)} )}
isOptionEqualToValue={(option, value) => option.id === value?.id} </Grid>
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" && ( {type === 'edit' && (
<FormControl fullWidth> <Stack gap={2}>
<TextField <Typography variant="subtitle1">Добавить {title}</Typography>
type="number" <Autocomplete
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 fullWidth
InputLabelProps={{ shrink: true }} value={availableItems.find((item) => item.id === selectedItemId) || null}
/> onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)}
</FormControl> options={availableItems}
)} */} getOptionLabel={(item) => String(item[fields[0].data])}
renderInput={(params) => <TextField {...params} label={`Выберите ${title}`} fullWidth />}
{childResource === "media" && ( isOptionEqualToValue={(option, value) => option.id === value?.id}
<FormControl fullWidth> filterOptions={(options, {inputValue}) => {
<TextField // return options.filter((option) => String(option[fields[0].data]).toLowerCase().includes(inputValue.toLowerCase()))
type="text" const searchWords = inputValue
label="Порядок отображения медиа" .toLowerCase()
value={mediaOrder} .split(' ')
onChange={(e) => { .filter((word) => word.length > 0)
const rawValue = e.target.value; return options.filter((option) => {
const numericValue = Number(rawValue); const optionWords = String(option[fields[0].data]).toLowerCase().split(' ')
const maxValue = linkedItems.length + 1; return searchWords.every((searchWord) => optionWords.some((word) => word.startsWith(searchWord)))
})
if (isNaN(numericValue)) {
return;
} else {
let newValue = numericValue;
if (newValue < 10 && newValue > 0) {
setMediaOrder(numericValue);
}
if (newValue > maxValue) {
newValue = maxValue;
}
setMediaOrder(newValue);
}
}} }}
fullWidth
InputLabelProps={{ shrink: true }}
/> />
</FormControl>
)}
<Button {childResource === 'article' && (
variant="contained" <FormControl fullWidth>
onClick={linkItem} <TextField
disabled={ type="number"
!selectedItemId || (childResource == "media" && mediaOrder == 0) label="Номер страницы"
} name="page_num"
sx={{ alignSelf: "flex-start" }} value={pageNum}
> onChange={(e) => {
Добавить const newValue = Number(e.target.value)
</Button> const minValue = linkedItems.length + 1 // page number on articles lenght
{childResource == "station" && ( setPageNum(newValue < minValue ? minValue : newValue)
<TextField }}
type="text" fullWidth
label="Позиция добавляемой остановки к маршруту" InputLabelProps={{shrink: true}}
value={position} />
onChange={(e) => { </FormControl>
const newValue = Number(e.target.value); )}
setPosition(
newValue > linkedItems.length + 1 {childResource === 'media' && type === 'edit' && (
? linkedItems.length + 1 <FormControl fullWidth>
: newValue <TextField
); type="number"
}} label="Порядок отображения медиа"
></TextField> 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> </Stack>
)} </AccordionDetails>
</> </Accordion>
); )
}; }

View File

@ -1,6 +1,6 @@
import {styled} from '@mui/material/styles' import {styled} from '@mui/material/styles'
import zIndex from '@mui/material/styles/zIndex' import zIndex from '@mui/material/styles/zIndex'
import SimpleMDE, {SimpleMDEReactProps, default as SimpleMDEDefault} from 'react-simplemde-editor' import SimpleMDE, {SimpleMDEReactProps} from 'react-simplemde-editor'
const StyledMarkdownEditor = styled('div')(({theme}) => ({ const StyledMarkdownEditor = styled('div')(({theme}) => ({
'& .editor-toolbar': { '& .editor-toolbar': {
@ -63,46 +63,10 @@ const StyledMarkdownEditor = styled('div')(({theme}) => ({
'& .guide': { '& .guide': {
display: 'none', display: 'none',
}, },
})); }))
export const MarkdownEditor = (props: SimpleMDEReactProps) => { export const MarkdownEditor = (props: SimpleMDEReactProps) => (
if(props.options) <StyledMarkdownEditor className="my-markdown-editor" sx={{marginTop: 1.5, marginBottom: 3}}>
props.options.toolbar = [ <SimpleMDE {...props} />
"bold", </StyledMarkdownEditor>
"italic", )
"strikethrough",
{
name: "Underline",
action: (editor: any) => {
const cm = editor.codemirror;
let output = '';
const selectedText = cm.getSelection();
const text = selectedText ?? 'placeholder';
output = '<u>' + text + '</u>';
cm.replaceSelection(output);
},
className: "fa fa-underline", // Look for a suitable icon
title: "Underline (Ctrl/Cmd-Alt-U)",
},
"heading",
"quote",
"unordered-list",
"ordered-list",
"link",
"image",
"code",
"table",
"horizontal-rule",
"preview",
"fullscreen",
"guide"
]
return (
<StyledMarkdownEditor className="my-markdown-editor" sx={{marginTop: 1.5, marginBottom: 3}}>
<SimpleMDE {...props}/>
</StyledMarkdownEditor>
)
}

View File

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

View File

@ -1,5 +1 @@
export * from './AdminOnly' export {Header} from './header'
export * from './CreateSightArticle'
export * from './CustomDataGrid'
export * from './LinkedItems'
export * from './MarkdownEditor'

View File

@ -1,142 +1,72 @@
import { useState } from "react"; import {useState} from 'react'
import { import {UseFormSetError, UseFormClearErrors, UseFormSetValue} from 'react-hook-form'
UseFormSetError,
UseFormClearErrors,
UseFormSetValue,
} from "react-hook-form";
export const ALLOWED_IMAGE_TYPES = [ export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
"image/jpeg", export const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/ogg']
"image/png",
"image/gif",
"image/webp",
];
export const ALLOWED_VIDEO_TYPES = ["video/mp4", "video/webm", "video/ogg"];
export const ALLOWED_PANORAMA_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
];
export const ALLOWED_ICON_TYPES = [
"image/svg+xml",
"image/png",
"image/jpg",
"image/jpeg",
"image/webp",
];
export const ALLOWED_WATERMARK_TYPES = [
"image/svg+xml",
"image/png",
"image/jpg",
"image/jpeg",
"image/webp",
];
export const ALLOWED_3D_MODEL_TYPES = [
".glb",
"glb",
".gltf",
"gltf",
"model/gltf-binary",
".vnd.ms-3d",
];
export const validateFileType = (file: File, mediaType: number) => { export const validateFileType = (file: File, mediaType: number) => {
if (mediaType === 1 && !ALLOWED_IMAGE_TYPES.includes(file.type)) { if (mediaType === 1 && !ALLOWED_IMAGE_TYPES.includes(file.type)) {
return 'Для типа "Фото" разрешены только форматы: JPG, PNG, GIF, WEBP'; return 'Для типа "Фото" разрешены только форматы: JPG, PNG, GIF, WEBP'
} }
if (mediaType === 2 && !ALLOWED_VIDEO_TYPES.includes(file.type)) { if (mediaType === 2 && !ALLOWED_VIDEO_TYPES.includes(file.type)) {
return 'Для типа "Видео" разрешены только форматы: MP4, WEBM, OGG'; return 'Для типа "Видео" разрешены только форматы: MP4, WEBM, OGG'
} }
if (mediaType === 3 && !ALLOWED_ICON_TYPES.includes(file.type)) { return null
return 'Для типа "Иконка" разрешены только форматы: SVG, PNG, JPG, JPEG, WEBP'; }
}
if (mediaType === 4 && !ALLOWED_WATERMARK_TYPES.includes(file.type)) {
return 'Для типа "Водяной знак" разрешены только форматы: SVG, PNG, JPG, JPEG, WEBP';
}
if (mediaType === 5 && !ALLOWED_PANORAMA_TYPES.includes(file.type)) {
return 'Для типа "Панорама" разрешены только форматы: JPG, PNG, GIF, WEBP';
}
if (mediaType === 6 && !ALLOWED_3D_MODEL_TYPES.includes(file.type)) {
const extension = file.name.split(".").pop();
const isMimeTypeValid = ["model/gltf-binary"].includes(file.type);
const isExtensionValid =
extension && ALLOWED_3D_MODEL_TYPES.includes(extension);
if (!isMimeTypeValid && !isExtensionValid) {
return 'Для типа "3D-модель" разрешены только форматы: GLB, GLTF';
}
}
return null;
};
type UseMediaFileUploadProps = { type UseMediaFileUploadProps = {
selectedMediaType: number; selectedMediaType: number
setError: UseFormSetError<any>; setError: UseFormSetError<any>
clearErrors: UseFormClearErrors<any>; clearErrors: UseFormClearErrors<any>
setValue: UseFormSetValue<any>; setValue: UseFormSetValue<any>
}; }
export const useMediaFileUpload = ({ export const useMediaFileUpload = ({selectedMediaType, setError, clearErrors, setValue}: UseMediaFileUploadProps) => {
selectedMediaType, const [selectedFile, setSelectedFile] = useState<File | null>(null)
setError, const [previewUrl, setPreviewUrl] = useState<string | null>(null)
clearErrors,
setValue,
}: UseMediaFileUploadProps) => {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0]
if (!file) return; if (!file) return
if (selectedMediaType) { if (selectedMediaType) {
const error = validateFileType(file, selectedMediaType); const error = validateFileType(file, selectedMediaType)
if (error) { if (error) {
setError("file", { type: "manual", message: error }); setError('file', {type: 'manual', message: error})
event.target.value = ""; event.target.value = ''
return; return
} }
} }
clearErrors("file"); clearErrors('file')
setValue("file", file); setValue('file', file)
setSelectedFile(file); setSelectedFile(file)
if (file.type.startsWith("image/")) { if (file.type.startsWith('image/')) {
const url = URL.createObjectURL(file); const url = URL.createObjectURL(file)
setPreviewUrl(url); setPreviewUrl(url)
} else { } else {
setPreviewUrl(null); setPreviewUrl(null)
} }
}; }
const handleMediaTypeChange = (newMediaType: number | null) => { const handleMediaTypeChange = (newMediaType: number | null) => {
setValue("media_type", newMediaType || null); setValue('media_type', newMediaType || null)
if (selectedFile && newMediaType) { if (selectedFile && newMediaType) {
const error = validateFileType(selectedFile, newMediaType); const error = validateFileType(selectedFile, newMediaType)
if (error) { if (error) {
setError("file", { type: "manual", message: error }); setError('file', {type: 'manual', message: error})
setValue("file", null); setValue('file', null)
setSelectedFile(null); setSelectedFile(null)
setPreviewUrl(null); setPreviewUrl(null)
} else { } else {
clearErrors("file"); clearErrors('file')
} }
} }
}; }
return { return {
selectedFile, selectedFile,
@ -145,5 +75,5 @@ export const useMediaFileUpload = ({
setPreviewUrl, setPreviewUrl,
handleFileChange, handleFileChange,
handleMediaTypeChange, handleMediaTypeChange,
}; }
}; }

View File

@ -1,410 +0,0 @@
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, useState } from "react";
import { MarkdownEditor } from "../../MarkdownEditor";
import { Edit } from "@refinedev/mui";
import { EVERY_LANGUAGE, languageStore } from "../../../store/LanguageStore";
import { LanguageSwitch } from "../../LanguageSwitch/index";
import { useDropzone } from "react-dropzone";
import {
ALLOWED_IMAGE_TYPES,
ALLOWED_VIDEO_TYPES,
} from "../../media/MediaFormUtils";
import { TOKEN_KEY, axiosInstance } from "@providers";
import { LinkedItems } from "../../../components/LinkedItems";
import { mediaFields, MediaItem } from "../../../pages/article/types";
const MemoizedSimpleMDE = memo(MarkdownEditor);
type MediaFile = {
file: File;
preview: string;
uploading: boolean;
media_id?: number;
};
const style = {
marginLeft: "auto",
marginRight: "auto",
//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 { language } = languageStore;
const [articleData, setArticleData] = useState({
heading: EVERY_LANGUAGE(language),
body: EVERY_LANGUAGE(language),
});
const { articleModalOpen, setArticleModalOpenAction, selectedArticleId } =
articleStore;
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]);
const [refresh, setRefresh] = useState(0);
useEffect(() => {
return () => {
setArticleModalOpenAction(false);
};
}, []);
// Load existing media files when editing an article
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);
setRefresh(refresh + 1);
} catch (error) {
console.error("Error loading existing media:", error);
}
}
};
useEffect(() => {
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.media_id);
const existingMediaAmount = mediaFiles.filter(
(file) => file.media_id
).length;
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 + existingMediaAmount + 1,
}
)
)
);
setArticleModalOpenAction(false);
reset();
window.location.reload();
} catch (error) {
console.error("Error handling media:", error);
}
},
meta: {
headers: {
"Accept-Language": language,
},
},
},
});
useEffect(() => {
if (articleData.heading[language]) {
setValue("heading", articleData.heading[language]);
}
if (articleData.body[language]) {
setValue("body", articleData.body[language]);
}
}, [language, articleData, setValue]);
const handleLanguageChange = () => {
setArticleData((prevData) => ({
...prevData,
heading: {
...prevData.heading,
[language]: watch("heading") ?? "",
},
body: {
...prevData.body,
[language]: 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/jpeg": [".jpeg", ".jpg"],
"image/png": [".png"],
"image/webp": [".webp"],
"video/mp4": [".mp4"],
"video/webm": [".webm"],
"video/ogg": [".ogg"],
},
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.media_id) {
try {
await axiosInstance.delete(
`${import.meta.env.VITE_KRBL_API}/media/${mediaFile.media_id}`
);
} 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"
sx={{ overflow: "auto" }}
>
<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"
/>
)}
/>
{selectedArticleId && (
<LinkedItems<MediaItem>
type="edit"
parentId={selectedArticleId}
parentResource="article"
childResource="media"
fields={mediaFields}
title="медиа"
dontRecurse
onUpdate={loadExistingMedia}
/>
)}
</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

@ -1,191 +0,0 @@
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";
import { useCustom } from "@refinedev/core";
import { useApiUrl } from "@refinedev/core";
import { StationItem } from "src/pages/route/types";
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,
selectedRouteId,
} = stationStore;
const { language } = languageStore;
useEffect(() => {
return () => {
setStationModalOpenAction(false);
};
}, []);
const apiUrl = useApiUrl();
const { data: stationQuery, isLoading: isStationLoading } = useCustom({
url: `${apiUrl}/route/${selectedRouteId ?? 1}/station`,
method: "get",
});
const {
register,
control,
formState: { errors },
saveButtonProps,
reset,
setValue,
watch,
handleSubmit,
} = useForm({
refineCoreProps: {
resource: `route/${selectedRouteId ?? 1}/station`,
action: "edit",
id: "",
redirect: false,
onMutationSuccess: (data) => {
setStationModalOpenAction(false);
reset();
window.location.reload();
},
meta: {
headers: {
"Accept-Language": language,
},
},
},
});
useEffect(() => {
if (stationModalOpen) {
const station = stationQuery?.data?.find(
(station: StationItem) => station.id === selectedStationId
);
if (!station) return;
for (const key in station) {
setValue(key, station[key]);
}
setValue("station_id", station.id);
}
}, [stationModalOpen, stationQuery]);
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,71 +0,0 @@
import { Box } from "@mui/material";
import { Languages, languageStore } from "@stores";
import { observer } from "mobx-react-lite";
export const LanguageSelector = observer(({
action
}: {action?: (lang: Languages) => void}) => {
const { language, setLanguageAction } = languageStore;
function handleLanguageChange(language: Languages) {
if(action) action(language);
else setLanguageAction(language);
}
return (
<Box
sx={{
display: "flex",
gap: 2,
height: "min-content"
}}
>
<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,105 +0,0 @@
import { Box } from "@mui/material";
import { TOKEN_KEY } from "@providers";
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
import { ModelViewer } from "./ModelViewer";
export interface MediaData {
id: string | number;
media_type: number;
filename?: string;
}
export function MediaView({ media }: Readonly<{ media?: MediaData }>) {
const token = localStorage.getItem(TOKEN_KEY);
return (
<Box
sx={{
maxHeight: "300px",
width: "100%",
height: "100%",
maxWidth: "300px",
display: "flex",
flexGrow: 1,
justifyContent: "center",
}}
>
{media?.media_type === 1 && (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id
}/download?token=${token}`}
alt={media?.filename}
style={{
maxWidth: "100%",
height: "auto",
objectFit: "contain",
borderRadius: 8,
}}
/>
)}
{media?.media_type === 2 && (
<video
src={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id
}/download?token=${token}`}
style={{
maxWidth: "100%",
height: "100%",
objectFit: "contain",
borderRadius: 30,
}}
controls
autoPlay
muted
/>
)}
{media?.media_type === 3 && (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id
}/download?token=${token}`}
alt={media?.filename}
style={{
maxWidth: "100%",
height: "100%",
objectFit: "contain",
borderRadius: 8,
}}
/>
)}
{media?.media_type === 4 && (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id
}/download?token=${token}`}
alt={media?.filename}
style={{
maxWidth: "100%",
height: "100%",
objectFit: "contain",
borderRadius: 8,
}}
/>
)}
{media?.media_type === 5 && (
<ReactPhotoSphereViewer
src={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id
}/download?token=${token}`}
width={"100%"}
height={"100%"}
/>
)}
{media?.media_type === 6 && (
<ModelViewer
fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${
media?.id
}/download?token=${token}`}
/>
)}
</Box>
);
}

View File

@ -1,22 +0,0 @@
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 = "100%" }: 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,5 +0,0 @@
export * from './Icons';
export * from './LanguageSelector';
export * from './SidebarTitle';
export * from './MediaView';
export * from './ModelViewer';

View File

@ -1,7 +1,7 @@
import {createTheme} from '@mui/material/styles' import {createTheme} from '@mui/material/styles'
import {RefineThemes} from '@refinedev/mui' import {RefineThemes} from '@refinedev/mui'
export const COLORS = { const COLORS = {
primary: '#7f6b58', primary: '#7f6b58',
secondary: '#48989f', secondary: '#48989f',
} }

View File

@ -1,5 +1,5 @@
@import "./stylesheets/hidden-functionality.css"; @import './stylesheets/hidden-functionality.css';
@import "./stylesheets/roles-functionality.css"; @import './stylesheets/roles-functionality.css';
.limited-text { .limited-text {
overflow: hidden; overflow: hidden;
@ -7,19 +7,3 @@
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
} }
.backup-button {
background-color: transparent;
border: none;
cursor: pointer;
padding: 0;
margin: 0;
width: 35px;
height: 35px;
color: #544044;
border-radius: 10%;
transition: all 0.3s ease;
}
.backup-button:hover {
background-color: rgba(84, 64, 68, 0.5);
}

View File

@ -2,7 +2,7 @@ import i18n from 'i18next'
import {initReactI18next} from 'react-i18next' import {initReactI18next} from 'react-i18next'
import {I18nProvider} from '@refinedev/core' import {I18nProvider} from '@refinedev/core'
import translationRU from '../locales/ru/translation.json' import translationRU from './locales/ru/translation.json'
i18n.use(initReactI18next).init({ i18n.use(initReactI18next).init({
resources: { resources: {

View File

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

View File

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

View File

@ -1 +0,0 @@
export { MEDIA_TYPES, VEHICLE_TYPES } from './constants'

View File

@ -99,13 +99,6 @@
"show": "Показать станцию" "show": "Показать станцию"
} }
}, },
"snapshots": {
"titles": {
"create": "Создать снапшот",
"show": "Показать снапшот"
}
},
"vehicle": { "vehicle": {
"titles": { "titles": {
"create": "Создать транспорт", "create": "Создать транспорт",

View File

@ -1,138 +1,72 @@
import { Box, TextField, Typography, Paper } from "@mui/material"; import {Box, TextField, Typography, Paper} from '@mui/material'
import { Create } from "@refinedev/mui"; import {Create} from '@refinedev/mui'
import { useForm } from "@refinedev/react-hook-form"; import {useForm} from '@refinedev/react-hook-form'
import { Controller, FieldValues } from "react-hook-form"; import {Controller} from 'react-hook-form'
import React, { useState, useEffect } from "react"; import React, {useState, useEffect} from 'react'
import ReactMarkdown from "react-markdown"; import ReactMarkdown from 'react-markdown'
import { MarkdownEditor } from "../../components/MarkdownEditor";
import "easymde/dist/easymde.min.css";
import { LanguageSelector } from "@ui";
import { observer } from "mobx-react-lite";
import { EVERY_LANGUAGE, Languages, languageStore, META_LANGUAGE } from "@stores";
import rehypeRaw from "rehype-raw";
const MemoizedSimpleMDE = React.memo(MarkdownEditor); import {MarkdownEditor} from '../../components/MarkdownEditor'
import 'easymde/dist/easymde.min.css'
export const ArticleCreate = observer(() => { const MemoizedSimpleMDE = React.memo(MarkdownEditor)
const { language, setLanguageAction } = languageStore;
const [articleData, setArticleData] = useState({
heading: EVERY_LANGUAGE(""),
body: EVERY_LANGUAGE("")
});
export const ArticleCreate = () => {
const { const {
saveButtonProps, saveButtonProps,
refineCore: { formLoading, onFinish }, refineCore: {formLoading},
register, register,
control, control,
watch, watch,
formState: { errors }, formState: {errors},
setValue,
handleSubmit,
} = useForm({ } = useForm({
refineCoreProps: { refineCoreProps: {
resource: "article", resource: 'article/',
...META_LANGUAGE(language) },
} })
});
const [preview, setPreview] = useState('')
const [headingPreview, setHeadingPreview] = useState('')
// Следим за изменениями в полях body и heading // Следим за изменениями в полях body и heading
const bodyContent = watch("body"); const bodyContent = watch('body')
const headingContent = watch("heading"); const headingContent = watch('heading')
function updateTranslations(update: boolean = true) {
const newArticleData = {
...articleData,
heading: {
...articleData.heading,
[language]: watch("heading") ?? "",
},
body: {
...articleData.body,
[language]: watch("body") ?? "",
}
}
if(update) setArticleData(newArticleData);
return newArticleData;
}
const handleFormSubmit = handleSubmit((values) => {
const newTranslations = updateTranslations(false);
return onFinish({
translations: newTranslations
});
});
useEffect(() => { useEffect(() => {
setValue("heading", articleData.heading[language] ?? ""); setPreview(bodyContent || '')
setValue("body", articleData.body[language] ?? ""); }, [bodyContent])
setPreview(articleData.body[language] ?? "");
setHeadingPreview(articleData.heading[language] ?? "");
}, [language, articleData, setValue]);
const handleLanguageChange = (lang: Languages) => {
updateTranslations();
setLanguageAction(lang);
};
const [preview, setPreview] = useState("");
const [headingPreview, setHeadingPreview] = useState("");
useEffect(() => { useEffect(() => {
setPreview(bodyContent ?? ""); setHeadingPreview(headingContent || '')
}, [bodyContent]); }, [headingContent])
useEffect(() => { const simpleMDEOptions = React.useMemo(
setHeadingPreview(headingContent ?? ""); () => ({
}, [headingContent]); placeholder: 'Введите контент в формате Markdown...',
spellChecker: false,
const simpleMDEOptions = React.useMemo(() => ({ }),
placeholder: "Введите контент в формате Markdown...", [],
spellChecker: false, )
}), []);
return ( return (
<Create isLoading={formLoading} saveButtonProps={{ <Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
onClick: handleFormSubmit <Box sx={{display: 'flex', gap: 2}}>
}}>
<Box sx={{ display: "flex", flex: 1, gap: 2 }}>
{/* Форма создания */} {/* Форма создания */}
<Box sx={{ display: "flex", flex: 1, flexDirection: "column", gap: 2 }}> <Box component="form" sx={{flex: 1, display: 'flex', flexDirection: 'column'}} autoComplete="off">
<LanguageSelector action={handleLanguageChange} /> <TextField
<Box {...register('heading', {
component="form" required: 'Это поле является обязательным',
sx={{ flex: 1, display: "flex", flexDirection: "column" }} })}
autoComplete="off" error={!!(errors as any)?.heading}
> helperText={(errors as any)?.heading?.message}
<TextField margin="normal"
{...register("heading", { fullWidth
required: "Это поле является обязательным", InputLabelProps={{shrink: true}}
})} type="text"
error={!!(errors as any)?.heading} label="Заголовок *"
helperText={(errors as any)?.heading?.message} name="heading"
margin="normal" />
fullWidth
slotProps={{inputLabel: {shrink: true}}}
type="text"
label="Заголовок *"
name="heading"
/>
<Controller <Controller control={control} name="body" rules={{required: 'Это поле является обязательным'}} defaultValue="" render={({field: {onChange, value}}) => <MemoizedSimpleMDE value={value} onChange={onChange} options={simpleMDEOptions} className="my-markdown-editor" />} />
control={control}
name="body"
//rules={{ required: "Это поле является обязательным" }}
defaultValue=""
render={({ field: { onChange, value } }) => (
<MemoizedSimpleMDE
value={value}
onChange={onChange}
options={simpleMDEOptions}
className="my-markdown-editor"
/>
)}
/>
</Box>
</Box> </Box>
{/* Блок предпросмотра */} {/* Блок предпросмотра */}
@ -140,15 +74,14 @@ export const ArticleCreate = observer(() => {
sx={{ sx={{
flex: 1, flex: 1,
p: 2, p: 2,
maxHeight: "calc(100vh - 200px)", maxHeight: 'calc(100vh - 200px)',
overflowY: "auto", overflowY: 'auto',
position: "sticky", position: 'sticky',
top: 16, top: 16,
borderRadius: 2, borderRadius: 2,
border: "1px solid", border: '1px solid',
borderColor: "primary.main", borderColor: 'primary.main',
bgcolor: (theme) => bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : '#fff'),
theme.palette.mode === "dark" ? "background.paper" : "#fff",
}} }}
> >
<Typography variant="h6" gutterBottom color="primary"> <Typography variant="h6" gutterBottom color="primary">
@ -160,8 +93,7 @@ export const ArticleCreate = observer(() => {
variant="h4" variant="h4"
gutterBottom gutterBottom
sx={{ sx={{
color: (theme) => color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'),
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
mb: 3, mb: 3,
}} }}
> >
@ -171,48 +103,46 @@ export const ArticleCreate = observer(() => {
{/* Markdown контент */} {/* Markdown контент */}
<Box <Box
sx={{ sx={{
"& img": { '& img': {
maxWidth: "100%", maxWidth: '100%',
height: "auto", height: 'auto',
borderRadius: 1, borderRadius: 1,
}, },
"& h1, & h2, & h3, & h4, & h5, & h6": { '& h1, & h2, & h3, & h4, & h5, & h6': {
color: "primary.main", color: 'primary.main',
mt: 2, mt: 2,
mb: 1, mb: 1,
}, },
"& p": { '& p': {
mb: 2, mb: 2,
color: (theme) => color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'),
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
}, },
"& a": { '& a': {
color: "primary.main", color: 'primary.main',
textDecoration: "none", textDecoration: 'none',
"&:hover": { '&:hover': {
textDecoration: "underline", textDecoration: 'underline',
}, },
}, },
"& blockquote": { '& blockquote': {
borderLeft: "4px solid", borderLeft: '4px solid',
borderColor: "primary.main", borderColor: 'primary.main',
pl: 2, pl: 2,
my: 2, my: 2,
color: "text.secondary", color: 'text.secondary',
}, },
"& code": { '& code': {
bgcolor: (theme) => bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100'),
theme.palette.mode === "dark" ? "grey.900" : "grey.100",
p: 0.5, p: 0.5,
borderRadius: 0.5, borderRadius: 0.5,
color: "primary.main", color: 'primary.main',
}, },
}} }}
> >
<ReactMarkdown rehypePlugins={[rehypeRaw]}>{preview}</ReactMarkdown> <ReactMarkdown>{preview}</ReactMarkdown>
</Box> </Box>
</Paper> </Paper>
</Box> </Box>
</Create> </Create>
); )
}); }

View File

@ -1,167 +1,96 @@
import { Box, TextField, Typography, Paper } from "@mui/material"; import {Box, TextField, Typography, Paper} from '@mui/material'
import { Edit } from "@refinedev/mui"; import {Edit} from '@refinedev/mui'
import { useForm } from "@refinedev/react-hook-form"; import {useForm} from '@refinedev/react-hook-form'
import { Controller, FieldValues } from "react-hook-form"; import {Controller} from 'react-hook-form'
import { useParams } from "react-router"; import {useParams} from 'react-router'
import React, { useState, useEffect, useMemo } from "react"; import React, {useState, useEffect} from 'react'
import ReactMarkdown from "react-markdown"; import ReactMarkdown from 'react-markdown'
import { useList } from "@refinedev/core"; import {useList} from '@refinedev/core'
import { MarkdownEditor, LinkedItems } from "@components"; import {MarkdownEditor} from '../../components/MarkdownEditor'
import { MediaItem, mediaFields } from "./types"; import {LinkedItems} from '../../components/LinkedItems'
import "easymde/dist/easymde.min.css"; import {MediaItem, mediaFields} from './types'
import { EVERY_LANGUAGE, Languages, languageStore, META_LANGUAGE } from "@stores"; import {TOKEN_KEY} from '../../authProvider'
import { observer } from "mobx-react-lite"; import 'easymde/dist/easymde.min.css'
import { LanguageSelector, MediaView } from "@ui";
import rehypeRaw from "rehype-raw";
const MemoizedSimpleMDE = React.memo(MarkdownEditor); const MemoizedSimpleMDE = React.memo(MarkdownEditor)
export const ArticleEdit = observer(() => {
const { language, setLanguageAction } = languageStore;
const [articleData, setArticleData] = useState({
heading: EVERY_LANGUAGE(""),
body: EVERY_LANGUAGE("")
});
const { id: articleId } = useParams<{ id: string }>();
const simpleMDEOptions = useMemo(
() => ({
placeholder: "Введите контент в формате Markdown...",
spellChecker: false,
}),
[]
);
export const ArticleEdit = () => {
const { const {
saveButtonProps, saveButtonProps,
refineCore: { onFinish },
register, register,
control, control,
handleSubmit,
watch, watch,
formState: { errors }, formState: {errors},
setValue, } = useForm()
getValues,
} = useForm<{ heading: string; body: string }>({
refineCoreProps: META_LANGUAGE(language)
});
const bodyContent = watch("body"); const {id: articleId} = useParams<{id: string}>()
const headingContent = watch("heading"); const [preview, setPreview] = useState('')
const [headingPreview, setHeadingPreview] = useState('')
// Получаем привязанные медиа
const {data: mediaData} = useList<MediaItem>({
resource: `article/${articleId}/media`,
queryOptions: {
enabled: !!articleId,
},
})
// Следим за изменениями в полях body и heading
const bodyContent = watch('body')
const headingContent = watch('heading')
useEffect(() => { useEffect(() => {
console.log(bodyContent) setPreview(bodyContent || '')
}, [bodyContent]) }, [bodyContent])
useEffect(() => { useEffect(() => {
console.log(articleData) setHeadingPreview(headingContent || '')
}, [articleData]) }, [headingContent])
useEffect(() => { const simpleMDEOptions = React.useMemo(
console.log("Trying to udpate") () => ({
//setHeadingPreview(articleData.heading[language] ?? ""); placeholder: 'Введите контент в формате Markdown...',
//setPreview(articleData.body[language] ?? ""); spellChecker: false,
if(articleData.heading[language]) }),
setValue("heading", articleData.heading[language]); [],
if(articleData.body[language]) )
setValue("body", articleData.body[language]);
}, [language]);
function updateTranslations(update: boolean = true) {
const newArticleData = {
heading: {
...articleData.heading,
[language]: watch("heading") ?? "",
},
body: {
...articleData.body,
[language]: watch("body") ?? "",
}
}
if(update) setArticleData(newArticleData);
return newArticleData;
}
const handleLanguageChange = (lang: Languages) => {
updateTranslations();
setLanguageAction(lang);
console.log("Setting preview to", articleData.body[lang] ?? "")
};
const handleFormSubmit = handleSubmit((values: FieldValues) => {
return onFinish({
translations: updateTranslations(false)
});
});
const { data: mediaData, refetch } = useList<MediaItem>({
resource: `article/${articleId}/media`,
});
useEffect(() => {
return () => {
setLanguageAction("ru");
};
}, [setLanguageAction]);
return ( return (
<Edit saveButtonProps={{ <Edit saveButtonProps={saveButtonProps}>
...saveButtonProps, <Box sx={{display: 'flex', gap: 2}}>
onClick: handleFormSubmit
}}
>
<Box sx={{ display: "flex", gap: 2 }}>
{/* Форма редактирования */} {/* Форма редактирования */}
<Box sx={{ display: "flex", flex: 1, flexDirection: "column", gap: 2 }}> <Box component="form" sx={{flex: 1, display: 'flex', flexDirection: 'column'}} autoComplete="off">
<TextField
<LanguageSelector action={handleLanguageChange} /> {...register('heading', {
<Box required: 'Это поле является обязательным',
component="form" })}
sx={{ flex: 1, display: "flex", flexDirection: "column" }} error={!!(errors as any)?.heading}
autoComplete="off" helperText={(errors as any)?.heading?.message}
> margin="normal"
<TextField fullWidth
{...register("heading", { InputLabelProps={{shrink: true}}
required: "Это поле является обязательным", type="text"
})} label="Заголовок *"
error={!!errors?.heading} name="heading"
helperText={errors?.heading?.message as string} />
margin="normal"
fullWidth
slotProps={{inputLabel: {shrink: true}}}
type="text"
label="Заголовок *"
name="heading"
/>
<Controller <Controller
control={control} control={control}
name="body" name="body"
//rules={{ required: "Это поле является обязательным" }} rules={{required: 'Это поле является обязательным'}}
defaultValue="" defaultValue=""
render={({ field: { onChange, value } }) => ( render={({field: {onChange, value}}) => (
<MemoizedSimpleMDE <MemoizedSimpleMDE
value={value} // markdown value={value} // markdown
onChange={onChange} onChange={onChange}
options={simpleMDEOptions} options={simpleMDEOptions}
className="my-markdown-editor" className="my-markdown-editor"
/>
)}
/>
{articleId && (
<LinkedItems<MediaItem>
type="edit"
parentId={articleId}
parentResource="article"
childResource="media"
fields={mediaFields}
title="медиа"
onUpdate={refetch}
/> />
)} )}
</Box> />
{articleId && <LinkedItems<MediaItem> type="edit" parentId={articleId} parentResource="article" childResource="media" fields={mediaFields} title="медиа" />}
</Box> </Box>
{/* Блок предпросмотра */} {/* Блок предпросмотра */}
@ -169,15 +98,14 @@ export const ArticleEdit = observer(() => {
sx={{ sx={{
flex: 1, flex: 1,
p: 2, p: 2,
maxHeight: "calc(100vh - 200px)", maxHeight: 'calc(100vh - 200px)',
overflowY: "auto", overflowY: 'auto',
position: "sticky", position: 'sticky',
top: 16, top: 16,
borderRadius: 2, borderRadius: 2,
border: "1px solid", border: '1px solid',
borderColor: "primary.main", borderColor: 'primary.main',
bgcolor: (theme) => bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : '#fff'),
theme.palette.mode === "dark" ? "background.paper" : "#fff",
}} }}
> >
<Typography variant="h6" gutterBottom color="primary"> <Typography variant="h6" gutterBottom color="primary">
@ -189,69 +117,66 @@ export const ArticleEdit = observer(() => {
variant="h4" variant="h4"
gutterBottom gutterBottom
sx={{ sx={{
color: (theme) => color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'),
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
mb: 3, mb: 3,
}} }}
> >
{headingContent} {headingPreview}
</Typography> </Typography>
{/* Markdown контент */} {/* Markdown контент */}
<Box <Box
sx={{ sx={{
"& img": { '& img': {
maxWidth: "100%", maxWidth: '100%',
height: "auto", height: 'auto',
borderRadius: 1, borderRadius: 1,
}, },
"& h1, & h2, & h3, & h4, & h5, & h6": { '& h1, & h2, & h3, & h4, & h5, & h6': {
color: "primary.main", color: 'primary.main',
mt: 2, mt: 2,
mb: 1, mb: 1,
}, },
"& p": { '& p': {
mb: 2, mb: 2,
color: (theme) => color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'),
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
}, },
"& a": { '& a': {
color: "primary.main", color: 'primary.main',
textDecoration: "none", textDecoration: 'none',
"&:hover": { '&:hover': {
textDecoration: "underline", textDecoration: 'underline',
}, },
}, },
"& blockquote": { '& blockquote': {
borderLeft: "4px solid", borderLeft: '4px solid',
borderColor: "primary.main", borderColor: 'primary.main',
pl: 2, pl: 2,
my: 2, my: 2,
color: "text.secondary", color: 'text.secondary',
}, },
"& code": { '& code': {
bgcolor: (theme) => bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100'),
theme.palette.mode === "dark" ? "grey.900" : "grey.100",
p: 0.5, p: 0.5,
borderRadius: 0.5, borderRadius: 0.5,
color: "primary.main", color: 'primary.main',
}, },
}} }}
> >
<ReactMarkdown rehypePlugins={[rehypeRaw]}>{bodyContent}</ReactMarkdown> <ReactMarkdown>{preview}</ReactMarkdown>
</Box> </Box>
{/* Привязанные медиа */} {/* Привязанные медиа */}
{mediaData?.data && mediaData.data.length > 0 && ( {mediaData?.data && mediaData.data.length > 0 && (
<Box sx={{ mb: 3 }}> <Box sx={{mb: 3}}>
<Typography variant="subtitle1" gutterBottom color="primary"> <Typography variant="subtitle1" gutterBottom color="primary">
Привязанные медиа: Привязанные медиа:
</Typography> </Typography>
<Box <Box
sx={{ sx={{
display: "flex", display: 'flex',
gap: 1, gap: 1,
flexWrap: "wrap", flexWrap: 'wrap',
mb: 2, mb: 2,
}} }}
> >
@ -259,28 +184,23 @@ export const ArticleEdit = observer(() => {
<Box <Box
key={media.id} key={media.id}
sx={{ sx={{
display: "flex", width: 120,
width: "45%", height: 120,
height: "45%",
aspectRatio: "1/1",
borderRadius: 1, borderRadius: 1,
overflow: "hidden", overflow: 'hidden',
border: "1px solid", border: '1px solid',
borderColor: "primary.main", borderColor: 'primary.main',
}} }}
> >
<MediaView media={media} /> <img
{/* <img src={`https://wn.krbl.ru/media/${media.id}/download?token=${localStorage.getItem(TOKEN_KEY)}`}
src={`${import.meta.env.VITE_KRBL_MEDIA}${
media.id
}/download?token=${localStorage.getItem(TOKEN_KEY)}`}
alt={media.media_name} alt={media.media_name}
style={{ style={{
width: "100%", width: '100%',
height: "100%", height: '100%',
objectFit: "cover", objectFit: 'cover',
}} }}
/> */} />
</Box> </Box>
))} ))}
</Box> </Box>
@ -289,5 +209,5 @@ export const ArticleEdit = observer(() => {
</Paper> </Paper>
</Box> </Box>
</Edit> </Edit>
); )
}); }

View File

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

View File

@ -4,7 +4,7 @@ export type MediaItem = {
id: number id: number
filename: string filename: string
media_name: string media_name: string
media_type: number media_type: string
media_order?: number media_order?: number
} }

View File

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

View File

@ -1,208 +1,171 @@
import { Autocomplete, Box, TextField } from "@mui/material"; import {Autocomplete, Box, TextField} from '@mui/material'
import { Edit, useAutocomplete } from "@refinedev/mui"; import {Edit, useAutocomplete} from '@refinedev/mui'
import { useForm } from "@refinedev/react-hook-form"; import {useForm} from '@refinedev/react-hook-form'
import { languageStore, META_LANGUAGE } from "@stores"; import {Controller} from 'react-hook-form'
import { LanguageSelector, MediaView } from "@ui";
import { observer } from "mobx-react-lite";
import { Controller } from "react-hook-form";
export const CarrierEdit = observer(() => { export const CarrierEdit = () => {
const { language } = languageStore;
const { const {
saveButtonProps, saveButtonProps,
register, register,
control, control,
watch, formState: {errors},
formState: { errors }, } = useForm()
} = useForm({
refineCoreProps: META_LANGUAGE(language)
});
const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({ const {autocompleteProps: cityAutocompleteProps} = useAutocomplete({
resource: `city`, resource: 'city',
onSearch: (value) => [ onSearch: (value) => [
{ {
field: "name", field: 'name',
operator: "contains", operator: 'contains',
value, value,
}, },
], ],
...META_LANGUAGE("ru") })
});
const { autocompleteProps: mediaAutocompleteProps } = useAutocomplete({ const {autocompleteProps: mediaAutocompleteProps} = useAutocomplete({
resource: "media", resource: 'media',
onSearch: (value) => [ onSearch: (value) => [
{ {
field: "media_name", field: 'media_name',
operator: "contains", operator: 'contains',
value, value,
}, },
] ],
}); })
return ( return (
<Edit saveButtonProps={saveButtonProps}> <Edit saveButtonProps={saveButtonProps}>
<Box <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
component="form"
sx={{ display: "flex", flexDirection: "column" }}
autoComplete="off"
>
<LanguageSelector />
<Controller <Controller
control={control} control={control}
name="city_id" name="city_id"
rules={{ required: "Это поле является обязательным" }} rules={{required: 'Это поле является обязательным'}}
defaultValue={null} defaultValue={null}
render={({ field }) => ( render={({field}) => (
<Autocomplete <Autocomplete
{...cityAutocompleteProps} {...cityAutocompleteProps}
value={ value={cityAutocompleteProps.options.find((option) => option.id === field.value) || null}
cityAutocompleteProps.options.find(
(option) => option.id === field.value
) ?? null
}
onChange={(_, value) => { onChange={(_, value) => {
field.onChange(value?.id ?? ""); field.onChange(value?.id || '')
}} }}
getOptionLabel={(item) => { getOptionLabel={(item) => {
return item ? item.name : ""; return item ? item.name : ''
}} }}
isOptionEqualToValue={(option, value) => { isOptionEqualToValue={(option, value) => {
return option.id === value?.id; return option.id === value?.id
}} }}
filterOptions={(options, { inputValue }) => { filterOptions={(options, {inputValue}) => {
return options.filter((option) => return options.filter((option) => option.name.toLowerCase().includes(inputValue.toLowerCase()))
option.name.toLowerCase().includes(inputValue.toLowerCase())
);
}} }}
renderInput={(params) => ( renderInput={(params) => <TextField {...params} label="Выберите город" margin="normal" variant="outlined" error={!!errors.city_id} helperText={(errors as any)?.city_id?.message} required />}
<TextField
{...params}
label="Выберите город"
margin="normal"
variant="outlined"
error={!!errors.city_id}
helperText={(errors as any)?.city_id?.message}
required
/>
)}
/> />
)} )}
/> />
<TextField <TextField
{...register("full_name", { {...register('full_name', {
required: "Это поле является обязательным", required: 'Это поле является обязательным',
})} })}
error={!!(errors as any)?.full_name} error={!!(errors as any)?.full_name}
helperText={(errors as any)?.full_name?.message} helperText={(errors as any)?.full_name?.message}
margin="normal" margin="normal"
fullWidth fullWidth
slotProps={{inputLabel: {shrink: true}}} InputLabelProps={{shrink: true}}
type="text" type="text"
label={"Полное имя *"} label={'Полное имя *'}
name="full_name" name="full_name"
/> />
<TextField <TextField
{...register("short_name", { {...register('short_name', {
//required: "Это поле является обязательным", required: 'Это поле является обязательным',
})} })}
error={!!(errors as any)?.short_name} error={!!(errors as any)?.short_name}
helperText={(errors as any)?.short_name?.message} helperText={(errors as any)?.short_name?.message}
margin="normal" margin="normal"
fullWidth fullWidth
slotProps={{inputLabel: {shrink: true}}} InputLabelProps={{shrink: true}}
type="text" type="text"
label={"Короткое имя"} label={'Короткое имя *'}
name="short_name" name="short_name"
/> />
<Box component="form" <TextField
sx={{ display: "flex" }} {...register('main_color', {
autoComplete="off" // required: 'Это поле является обязательным',
> })}
<TextField error={!!(errors as any)?.main_color}
{...register("main_color", { helperText={(errors as any)?.main_color?.message}
// required: 'Это поле является обязательным', margin="normal"
})} fullWidth
error={!!(errors as any)?.main_color} InputLabelProps={{shrink: true}}
helperText={(errors as any)?.main_color?.message} type="color"
margin="normal" label={'Основной цвет'}
fullWidth name="main_color"
slotProps={{inputLabel: {shrink: true}}} sx={{
type="color" '& input': {
label={"Основной цвет"} height: '50px',
name="main_color" paddingBlock: '14px',
sx={{ paddingInline: '14px',
"& input": { cursor: 'pointer',
height: "50px", },
paddingBlock: "14px", }}
paddingInline: "14px", />
cursor: "pointer",
},
}}
/>
<TextField
{...register("left_color", {
// required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.left_color}
helperText={(errors as any)?.left_color?.message}
margin="normal"
fullWidth
slotProps={{inputLabel: {shrink: true}}}
type="color"
label={"Цвет левого виджета"}
name="left_color"
sx={{
marginLeft: "16px",
marginRight: "16px",
"& input": {
height: "50px",
paddingBlock: "14px",
paddingInline: "14px",
cursor: "pointer",
},
}}
/>
<TextField
{...register("right_color", {
// required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.right_color}
helperText={(errors as any)?.right_color?.message}
margin="normal"
fullWidth
slotProps={{inputLabel: {shrink: true}}}
type="color"
label={"Цвет правого виджета"}
name="right_color"
sx={{
"& input": {
height: "50px",
paddingBlock: "14px",
paddingInline: "14px",
cursor: "pointer",
},
}}
/>
</Box>
<TextField <TextField
{...register("slogan", { {...register('left_color', {
// required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.left_color}
helperText={(errors as any)?.left_color?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
type="color"
label={'Цвет левого виджета'}
name="left_color"
sx={{
'& input': {
height: '50px',
paddingBlock: '14px',
paddingInline: '14px',
cursor: 'pointer',
},
}}
/>
<TextField
{...register('right_color', {
// required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.right_color}
helperText={(errors as any)?.right_color?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
type="color"
label={'Цвет правого виджета'}
name="right_color"
sx={{
'& input': {
height: '50px',
paddingBlock: '14px',
paddingInline: '14px',
cursor: 'pointer',
},
}}
/>
<TextField
{...register('slogan', {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
})} })}
error={!!(errors as any)?.slogan} error={!!(errors as any)?.slogan}
helperText={(errors as any)?.slogan?.message} helperText={(errors as any)?.slogan?.message}
margin="normal" margin="normal"
fullWidth fullWidth
slotProps={{inputLabel: {shrink: true}}} InputLabelProps={{shrink: true}}
type="text" type="text"
label={"Слоган"} label={'Слоган'}
name="slogan" name="slogan"
/> />
@ -211,48 +174,27 @@ export const CarrierEdit = observer(() => {
name="logo" name="logo"
// rules={{required: 'Это поле является обязательным'}} // rules={{required: 'Это поле является обязательным'}}
defaultValue={null} defaultValue={null}
render={({ field }) => ( render={({field}) => (
<Autocomplete <Autocomplete
{...mediaAutocompleteProps} {...mediaAutocompleteProps}
value={ value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null}
mediaAutocompleteProps.options.find(
(option) => option.id === field.value
) ?? null
}
onChange={(_, value) => { onChange={(_, value) => {
field.onChange(value?.id ?? ""); field.onChange(value?.id || '')
}} }}
getOptionLabel={(item) => { getOptionLabel={(item) => {
return item ? item.media_name : ""; return item ? item.media_name : ''
}} }}
isOptionEqualToValue={(option, value) => { isOptionEqualToValue={(option, value) => {
return option.id === value?.id; return option.id === value?.id
}} }}
filterOptions={(options, { inputValue }) => { filterOptions={(options, {inputValue}) => {
return options.filter((option) => return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase()))
option.media_name
.toLowerCase()
.includes(inputValue.toLowerCase()) &&
option.media_type == 3
);
}} }}
renderInput={(params) => ( renderInput={(params) => <TextField {...params} label="Выберите логотип" margin="normal" variant="outlined" error={!!errors.logo} helperText={(errors as any)?.logo?.message} />}
<TextField
{...params}
label="Выберите логотип"
margin="normal"
variant="outlined"
error={!!errors.logo}
helperText={(errors as any)?.logo?.message}
/>
)}
/> />
)} )}
/> />
<Box height={150} sx={{display: "flex", justifyContent: "start"}}>
<MediaView media={{id: watch("logo"), media_type: 1}} />
</Box>
</Box> </Box>
</Edit> </Edit>
); )
}); }

View File

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

View File

@ -1,111 +1,60 @@
import { Box, Stack, Typography } from "@mui/material"; import {Box, Stack, Typography} from '@mui/material'
import { useShow } from "@refinedev/core"; import {useShow} from '@refinedev/core'
import { Show, TextFieldComponent as TextField } from "@refinedev/mui"; import {Show, TextFieldComponent as TextField} from '@refinedev/mui'
import { TOKEN_KEY } from "@providers"; import {TOKEN_KEY} from '../../authProvider'
import { MediaView } from "@ui";
export type FieldType = { export type FieldType = {
label: string; label: string
data: any; data: any
render?: (value: any) => React.ReactNode; render?: (value: any) => React.ReactNode
}; }
export const CarrierShow = () => { export const CarrierShow = () => {
const { query } = useShow({}); const {query} = useShow({})
const { data, isLoading } = query; const {data, isLoading} = query
const record = data?.data; const record = data?.data
const fields: FieldType[] = [ const fields: FieldType[] = [
{ label: "Полное имя", data: "full_name" }, {label: 'Полное имя', data: 'full_name'},
{ label: "Короткое имя", data: "short_name" }, {label: 'Короткое имя', data: 'short_name'},
{ label: "Город", data: "city" }, {label: 'Город', data: 'city'},
{ {
label: "Основной цвет", label: 'Основной цвет',
data: "main_color", data: 'main_color',
render: (value: string) => ( render: (value: string) => <Box sx={{display: 'grid', placeItems: 'center', width: 'fit-content', paddingInline: '6px', height: '100%', backgroundColor: `${value}20`, borderRadius: 1}}>{value}</Box>,
<Box
sx={{
display: "grid",
placeItems: "center",
width: "fit-content",
paddingInline: "6px",
height: "100%",
backgroundColor: `${value}20`,
borderRadius: 1,
}}
>
{value}
</Box>
),
}, },
{ {
label: "Цвет левого виджета", label: 'Цвет левого виджета',
data: "left_color", data: 'left_color',
render: (value: string) => ( render: (value: string) => <Box sx={{display: 'grid', placeItems: 'center', width: 'fit-content', paddingInline: '6px', height: '100%', backgroundColor: `${value}20`, borderRadius: 1}}>{value}</Box>,
<Box
sx={{
display: "grid",
placeItems: "center",
width: "fit-content",
paddingInline: "6px",
height: "100%",
backgroundColor: `${value}20`,
borderRadius: 1,
}}
>
{value}
</Box>
),
}, },
{ {
label: "Цвет правого виджета", label: 'Цвет правого виджета',
data: "right_color", data: 'right_color',
render: (value: string) => ( render: (value: string) => <Box sx={{display: 'grid', placeItems: 'center', width: 'fit-content', paddingInline: '6px', height: '100%', backgroundColor: `${value}20`, borderRadius: 1}}>{value}</Box>,
<Box
sx={{
display: "grid",
placeItems: "center",
width: "fit-content",
paddingInline: "6px",
height: "100%",
backgroundColor: `${value}20`,
borderRadius: 1,
}}
>
{value}
</Box>
),
}, },
{ label: "Слоган", data: "slogan" }, {label: 'Слоган', data: 'slogan'},
{ {
label: "Логотип", label: 'Логотип',
data: "logo", data: 'logo',
render: (value: number) => ( 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}} />,
<Box height={150} sx={{display: "flex", justifyContent: "start"}}>
<MediaView media={{id: value, media_type: 1}} />
</Box>
),
}, },
]; ]
return ( return (
<Show isLoading={isLoading}> <Show isLoading={isLoading}>
<Stack gap={4}> <Stack gap={4}>
{fields.map(({ label, data, render }) => ( {fields.map(({label, data, render}) => (
<Stack key={data} gap={1}> <Stack key={data} gap={1}>
<Typography variant="body1" fontWeight="bold"> <Typography variant="body1" fontWeight="bold">
{label} {label}
</Typography> </Typography>
{render ? ( {render ? render(record?.[data]) : <TextField value={record?.[data]} />}
render(record?.[data])
) : (
<TextField value={record?.[data]} />
)}
</Stack> </Stack>
))} ))}
</Stack> </Stack>
</Show> </Show>
); )
}; }

View File

@ -1,26 +1,18 @@
import {Autocomplete, Box, TextField} from '@mui/material' import {Autocomplete, Box, TextField} from '@mui/material'
import {Edit, useAutocomplete} from '@refinedev/mui' import {Edit, useAutocomplete} from '@refinedev/mui'
import {useForm} from '@refinedev/react-hook-form' import {useForm} from '@refinedev/react-hook-form'
import { EVERY_LANGUAGE, Languages, languageStore, META_LANGUAGE } from '@stores'
import { LanguageSelector } from '@ui'
import { observer } from 'mobx-react-lite'
import { useEffect, useState } from 'react'
import {Controller} from 'react-hook-form' import {Controller} from 'react-hook-form'
export const CityEdit = observer(() => { export const CityEdit = () => {
const { language } = languageStore;
const { const {
saveButtonProps, saveButtonProps,
register, register,
control, control,
formState: {errors}, formState: {errors},
} = useForm({ } = useForm({})
refineCoreProps: META_LANGUAGE(language)
})
const {autocompleteProps: countryAutocompleteProps} = useAutocomplete({ const {autocompleteProps: countryAutocompleteProps} = useAutocomplete({
resource: 'country', resource: 'country',
...META_LANGUAGE(language)
}) })
const {autocompleteProps: mediaAutocompleteProps} = useAutocomplete({ const {autocompleteProps: mediaAutocompleteProps} = useAutocomplete({
@ -32,14 +24,11 @@ export const CityEdit = observer(() => {
value, value,
}, },
], ],
...META_LANGUAGE(language) })
});
return ( return (
<Edit saveButtonProps={saveButtonProps}> <Edit saveButtonProps={saveButtonProps}>
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off"> <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
<LanguageSelector/>
<Controller <Controller
control={control} control={control}
name="country_code" name="country_code"
@ -105,4 +94,4 @@ export const CityEdit = observer(() => {
</Box> </Box>
</Edit> </Edit>
) )
}) }

View File

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

View File

@ -1,51 +1,35 @@
import { Stack, Typography } from "@mui/material"; import {Stack, Typography} from '@mui/material'
import { useShow } from "@refinedev/core"; import {useShow} from '@refinedev/core'
import { Show, TextFieldComponent as TextField } from "@refinedev/mui"; import {Show, TextFieldComponent as TextField} from '@refinedev/mui'
import { TOKEN_KEY } from "@providers"; import {TOKEN_KEY} from '../../authProvider'
export const CityShow = () => { export const CityShow = () => {
const { query } = useShow({}); const {query} = useShow({})
const { data, isLoading } = query; const {data, isLoading} = query
const record = data?.data; const record = data?.data
const fields = [ const fields = [
// {label: 'ID', data: 'id'}, // {label: 'ID', data: 'id'},
{ label: "Название", data: "name" }, {label: 'Название', data: 'name'},
// {label: 'Код страны', data: 'country_code'}, // {label: 'Код страны', data: 'country_code'},
{ label: "Страна", data: "country" }, {label: 'Страна', data: 'country'},
{ {label: 'Герб', data: 'arms', render: (value: number) => <img src={`https://wn.krbl.ru/media/${value}/download?token=${localStorage.getItem(TOKEN_KEY)}`} alt={String(value)} style={{maxWidth: '10%', objectFit: 'contain', borderRadius: 8}} />},
label: "Герб", ]
data: "arms",
render: (value: number) => (
<img
src={`${
import.meta.env.VITE_KRBL_MEDIA
}${value}/download?token=${localStorage.getItem(TOKEN_KEY)}`}
alt={String(value)}
style={{ maxWidth: "10%", objectFit: "contain", borderRadius: 8 }}
/>
),
},
];
return ( return (
<Show isLoading={isLoading}> <Show isLoading={isLoading}>
<Stack gap={4}> <Stack gap={4}>
{fields.map(({ label, data, render }) => ( {fields.map(({label, data, render}) => (
<Stack key={data} gap={1}> <Stack key={data} gap={1}>
<Typography variant="body1" fontWeight="bold"> <Typography variant="body1" fontWeight="bold">
{label} {label}
</Typography> </Typography>
{render ? ( {render ? render(record?.[data]) : <TextField value={record?.[data]} />}
render(record?.[data])
) : (
<TextField value={record?.[data]} />
)}
</Stack> </Stack>
))} ))}
</Stack> </Stack>
</Show> </Show>
); )
}; }

View File

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

View File

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

View File

@ -1,58 +1,39 @@
import { import {Box, TextField, Button, Typography, Autocomplete} from '@mui/material'
Box, import {Create} from '@refinedev/mui'
TextField, import {useForm} from '@refinedev/react-hook-form'
Button, import {Controller} from 'react-hook-form'
Typography,
Autocomplete,
} from "@mui/material";
import { Create } from "@refinedev/mui";
import { useForm } from "@refinedev/react-hook-form";
import { Controller } from "react-hook-form";
import { MEDIA_TYPES } from "../../lib/constants"; import {MEDIA_TYPES} from '../../lib/constants'
import { import {ALLOWED_IMAGE_TYPES, ALLOWED_VIDEO_TYPES, useMediaFileUpload} from '../../components/media/MediaFormUtils'
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 "@ui";
type MediaFormValues = { type MediaFormValues = {
media_name: string; media_name: string
media_type: number; media_type: number
file?: File; file?: File
}; }
export const MediaCreate = () => { export const MediaCreate = () => {
const { const {
saveButtonProps, saveButtonProps,
refineCore: { formLoading, onFinish }, refineCore: {formLoading, onFinish},
register, register,
control, control,
formState: { errors }, formState: {errors},
setValue, setValue,
handleSubmit, handleSubmit,
watch, watch,
setError, setError,
clearErrors, clearErrors,
getValues, } = useForm<MediaFormValues>({})
} = useForm<MediaFormValues>({});
const selectedMediaType = watch("media_type"); const selectedMediaType = watch('media_type')
const file = getValues("file");
const { selectedFile, previewUrl, handleFileChange, handleMediaTypeChange } = const {selectedFile, previewUrl, handleFileChange, handleMediaTypeChange} = useMediaFileUpload({
useMediaFileUpload({ selectedMediaType,
selectedMediaType, setError,
setError, clearErrors,
clearErrors, setValue,
setValue, })
});
return ( return (
<Create <Create
@ -61,20 +42,19 @@ export const MediaCreate = () => {
...saveButtonProps, ...saveButtonProps,
disabled: !!errors.file || !selectedFile, disabled: !!errors.file || !selectedFile,
onClick: handleSubmit((data) => { onClick: handleSubmit((data) => {
console.log(data);
if (data.file) { if (data.file) {
const formData = new FormData(); const formData = new FormData()
formData.append("media_name", data.media_name); formData.append('media_name', data.media_name)
formData.append("filename", data.file.name); formData.append('filename', data.file.name)
formData.append("type", String(data.media_type)); formData.append('type', String(data.media_type))
formData.append("file", data.file); formData.append('file', data.file)
console.log("Отправляемые данные:"); console.log('Отправляемые данные:')
for (const pair of formData.entries()) { for (const pair of formData.entries()) {
console.log(pair[0] + ": " + pair[1]); console.log(pair[0] + ': ' + pair[1])
} }
onFinish(formData); onFinish(formData)
} }
}), }),
}} }}
@ -83,97 +63,47 @@ export const MediaCreate = () => {
control={control} control={control}
name="media_type" name="media_type"
rules={{ rules={{
required: "Это поле является обязательным", required: 'Это поле является обязательным',
}} }}
render={({ field }) => ( render={({field}) => (
<Autocomplete <Autocomplete
options={MEDIA_TYPES} options={MEDIA_TYPES}
value={ value={MEDIA_TYPES.find((option) => option.value === field.value) || null}
MEDIA_TYPES.find((option) => option.value === field.value) || null
}
onChange={(_, value) => { onChange={(_, value) => {
field.onChange(value?.value || null); field.onChange(value?.value || null)
handleMediaTypeChange(value?.value || null); handleMediaTypeChange(value?.value || null)
}} }}
getOptionLabel={(item) => { getOptionLabel={(item) => {
return item ? item.label : ""; return item ? item.label : ''
}} }}
isOptionEqualToValue={(option, value) => { isOptionEqualToValue={(option, value) => {
return option.value === value?.value; return option.value === value?.value
}} }}
renderInput={(params) => ( renderInput={(params) => <TextField {...params} label="Тип" margin="normal" variant="outlined" error={!!errors.media_type} helperText={(errors as any)?.media_type?.message} required />}
<TextField
{...params}
label="Тип"
margin="normal"
variant="outlined"
error={!!errors.media_type}
helperText={(errors as any)?.media_type?.message}
required
/>
)}
/> />
)} )}
/> />
<TextField <TextField
{...register("media_name", { {...register('media_name', {
required: "Это поле является обязательным", required: 'Это поле является обязательным',
})} })}
error={!!(errors as any)?.media_name} error={!!(errors as any)?.media_name}
helperText={(errors as any)?.media_name?.message} helperText={(errors as any)?.media_name?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{ shrink: true }} InputLabelProps={{shrink: true}}
type="text" type="text"
label="Название *" label="Название *"
name="media_name" name="media_name"
/> />
<Box <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off" style={{marginTop: 10}}>
component="form" <Box display="flex" flexDirection="column-reverse" alignItems="center" gap={6}>
sx={{ display: "flex", flexDirection: "column" }} <Box display="flex" flexDirection="column" alignItems="center" gap={2}>
autoComplete="off" <Button variant="contained" component="label" disabled={!selectedMediaType}>
style={{ marginTop: 10 }} {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={6}
>
<Box
display="flex"
flexDirection="column"
alignItems="center"
gap={2}
>
<Button
variant="contained"
component="label"
disabled={!selectedMediaType}
>
{selectedFile ? "Изменить файл" : "Загрузить файл"}
<input
type="file"
hidden
onChange={handleFileChange}
accept={
selectedMediaType === 6
? ALLOWED_3D_MODEL_TYPES.join(",")
: selectedMediaType === 1
? ALLOWED_IMAGE_TYPES.join(",")
: selectedMediaType === 2
? ALLOWED_VIDEO_TYPES.join(",")
: selectedMediaType === 3
? ALLOWED_ICON_TYPES.join(",")
: selectedMediaType === 4
? ALLOWED_WATERMARK_TYPES.join(",")
: selectedMediaType === 5
? ALLOWED_PANORAMA_TYPES.join(",")
: ""
}
/>
</Button> </Button>
{selectedFile && ( {selectedFile && (
@ -191,53 +121,11 @@ export const MediaCreate = () => {
{previewUrl && selectedMediaType === 1 && ( {previewUrl && selectedMediaType === 1 && (
<Box mt={2} display="flex" justifyContent="center"> <Box mt={2} display="flex" justifyContent="center">
<img <img src={previewUrl} alt="Preview" style={{maxWidth: '200px', borderRadius: 8}} />
src={previewUrl}
alt="Preview"
style={{ maxWidth: "200px", borderRadius: 8 }}
/>
</Box> </Box>
)} )}
{file && selectedMediaType === 2 && (
<Box mt={2} display="flex" justifyContent="center">
<video src={URL.createObjectURL(file)} autoPlay controls />
</Box>
)}
{previewUrl && selectedMediaType === 3 && (
<Box mt={2} display="flex" justifyContent="center">
<img
src={previewUrl}
alt="Preview"
style={{ maxWidth: "200px", borderRadius: 8 }}
/>
</Box>
)}
{previewUrl && selectedMediaType === 4 && (
<Box mt={2} display="flex" justifyContent="center">
<img
src={previewUrl}
alt="Preview"
style={{ maxWidth: "200px", borderRadius: 8 }}
/>
</Box>
)}
{file && selectedMediaType === 5 && (
<ReactPhotoSphereViewer
src={URL.createObjectURL(file)}
width={"100%"}
height={"80vh"}
/>
)}
{file && previewUrl && selectedMediaType === 6 && (
<ModelViewer fileUrl={URL.createObjectURL(file)} />
)}
</Box> </Box>
</Box> </Box>
</Create> </Create>
); )
}; }

View File

@ -1,44 +1,26 @@
import { import {Box, TextField, Button, Typography, Autocomplete} from '@mui/material'
Box, import {Edit} from '@refinedev/mui'
TextField, import {useForm} from '@refinedev/react-hook-form'
Button, import {useEffect} from 'react'
Typography, import {useShow} from '@refinedev/core'
Autocomplete, import {Controller} from 'react-hook-form'
} 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 { TOKEN_KEY } from "@providers"; import {MEDIA_TYPES} from '../../lib/constants'
import { MEDIA_TYPES } from "@lib"; import {ALLOWED_IMAGE_TYPES, ALLOWED_VIDEO_TYPES, useMediaFileUpload} from '../../components/media/MediaFormUtils'
import { import {TOKEN_KEY} from '../../authProvider'
ALLOWED_IMAGE_TYPES,
ALLOWED_VIDEO_TYPES,
ALLOWED_ICON_TYPES,
ALLOWED_WATERMARK_TYPES,
ALLOWED_PANORAMA_TYPES,
ALLOWED_3D_MODEL_TYPES,
useMediaFileUpload,
} from "../../components/media/MediaFormUtils";
import { languageStore, META_LANGUAGE } from "@stores";
import { observer } from "mobx-react-lite";
import { LanguageSelector, MediaData, MediaView } from "@ui";
type MediaFormValues = { type MediaFormValues = {
media_name: string; media_name: string
media_type: number; media_type: number
file?: File; file?: File
}; }
export const MediaEdit = observer(() => { export const MediaEdit = () => {
const { language } = languageStore;
const { const {
saveButtonProps, saveButtonProps,
refineCore: { onFinish }, refineCore: {onFinish},
register, register,
formState: { errors }, formState: {errors},
setValue, setValue,
handleSubmit, handleSubmit,
watch, watch,
@ -47,43 +29,32 @@ export const MediaEdit = observer(() => {
control, control,
} = useForm<MediaFormValues>({ } = useForm<MediaFormValues>({
defaultValues: { defaultValues: {
media_name: "", media_name: '',
media_type: "", media_type: '',
file: undefined, file: undefined,
}, },
refineCoreProps: META_LANGUAGE(language) })
});
const { query } = useShow(); const {query} = useShow()
const { data } = query; const {data} = query
const record = data?.data; const record = data?.data
const selectedMediaType = watch("media_type"); const selectedMediaType = watch('media_type')
const { const {selectedFile, previewUrl, setPreviewUrl, handleFileChange, handleMediaTypeChange} = useMediaFileUpload({
selectedFile,
previewUrl,
setPreviewUrl,
handleFileChange,
handleMediaTypeChange,
} = useMediaFileUpload({
selectedMediaType, selectedMediaType,
setError, setError,
clearErrors, clearErrors,
setValue, setValue,
}); })
useEffect(() => { useEffect(() => {
if (record?.id) { if (record?.id) {
setPreviewUrl( setPreviewUrl(`https://wn.krbl.ru/media/${record.id}/download?token=${localStorage.getItem(TOKEN_KEY)}`)
`${import.meta.env.VITE_KRBL_MEDIA}${ setValue('media_name', record?.media_name || '')
record.id setValue('media_type', record?.media_type)
}/download?token=${localStorage.getItem(TOKEN_KEY)}`
);
setValue("media_name", record?.media_name || "");
setValue("media_type", record?.media_type);
} }
}, [record, setValue, setPreviewUrl]); }, [record, setValue, setPreviewUrl])
return ( return (
<Edit <Edit
@ -95,109 +66,57 @@ export const MediaEdit = observer(() => {
media_name: data.media_name, media_name: data.media_name,
filename: selectedFile?.name || record?.filename, filename: selectedFile?.name || record?.filename,
type: Number(data.media_type), type: Number(data.media_type),
}; }
onFinish(formData); onFinish(formData)
}), }),
}} }}
> >
<Box <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
component="form"
sx={{ display: "flex", flexDirection: "column" }}
autoComplete="off"
>
<LanguageSelector />
<Controller <Controller
control={control} control={control}
name="media_type" name="media_type"
rules={{ rules={{
required: "Это поле является обязательным", required: 'Это поле является обязательным',
}} }}
defaultValue={null} defaultValue={null}
render={({ field }) => ( render={({field}) => (
<Autocomplete <Autocomplete
options={MEDIA_TYPES} options={MEDIA_TYPES}
value={ value={MEDIA_TYPES.find((option) => option.value === field.value) || null}
MEDIA_TYPES.find((option) => option.value === field.value) ||
null
}
onChange={(_, value) => { onChange={(_, value) => {
field.onChange(value?.value || null); field.onChange(value?.value || null)
handleMediaTypeChange(value?.value || null); handleMediaTypeChange(value?.value || null)
}} }}
getOptionLabel={(item) => { getOptionLabel={(item) => {
return item ? item.label : ""; return item ? item.label : ''
}} }}
isOptionEqualToValue={(option, value) => { isOptionEqualToValue={(option, value) => {
return option.value === value?.value; return option.value === value?.value
}} }}
renderInput={(params) => ( renderInput={(params) => <TextField {...params} label="Тип" margin="normal" variant="outlined" error={!!errors.media_type} helperText={(errors as any)?.media_type?.message} required />}
<TextField
{...params}
label="Тип"
margin="normal"
variant="outlined"
error={!!errors.media_type}
helperText={(errors as any)?.media_type?.message}
required
/>
)}
/> />
)} )}
/> />
<TextField <TextField
{...register("media_name", { {...register('media_name', {
required: "Это поле является обязательным", required: 'Это поле является обязательным',
})} })}
error={!!(errors as any)?.media_name} error={!!(errors as any)?.media_name}
helperText={(errors as any)?.media_name?.message} helperText={(errors as any)?.media_name?.message}
margin="normal" margin="normal"
fullWidth fullWidth
slotProps={{inputLabel: {shrink: true}}} InputLabelProps={{shrink: true}}
type="text" type="text"
label="Название *" label="Название *"
name="media_name" name="media_name"
/> />
<Box <Box display="flex" flexDirection="column-reverse" alignItems="center" gap={4} style={{marginTop: 10}}>
display="flex" <Box display="flex" flexDirection="column" alignItems="center" gap={2}>
flexDirection="column-reverse" <Button variant="contained" component="label" disabled={!selectedMediaType}>
alignItems="center" {selectedFile ? 'Изменить файл' : 'Загрузить файл'}
gap={4} <input type="file" hidden onChange={handleFileChange} accept={selectedMediaType === 1 ? ALLOWED_IMAGE_TYPES.join(',') : ALLOWED_VIDEO_TYPES.join(',')} />
style={{ marginTop: 10 }}
>
<Box
display="flex"
flexDirection="column"
alignItems="center"
gap={2}
>
<Button
variant="contained"
component="label"
disabled={!selectedMediaType}
>
{selectedFile ? "Изменить файл" : "Загрузить файл"}
<input
type="file"
hidden
onChange={handleFileChange}
accept={
selectedMediaType === 1
? ALLOWED_IMAGE_TYPES.join(",")
: selectedMediaType === 2
? ALLOWED_VIDEO_TYPES.join(",")
: selectedMediaType === 3
? ALLOWED_ICON_TYPES.join(",")
: selectedMediaType === 4
? ALLOWED_WATERMARK_TYPES.join(",")
: selectedMediaType === 5
? ALLOWED_PANORAMA_TYPES.join(",")
: selectedMediaType === 6
? ALLOWED_3D_MODEL_TYPES.join(",")
: ""
}
/>
</Button> </Button>
{selectedFile && ( {selectedFile && (
@ -213,19 +132,13 @@ export const MediaEdit = observer(() => {
)} )}
</Box> </Box>
{previewUrl && selectedMediaType === 1 && (
<MediaView media={record as MediaData} />
{/* {previewUrl && selectedMediaType === 1 && (
<Box mt={2} display="flex" justifyContent="center"> <Box mt={2} display="flex" justifyContent="center">
<img <img src={previewUrl} alt="Preview" style={{maxWidth: '200px', borderRadius: 8}} />
src={previewUrl}
alt="Preview"
style={{ maxWidth: "200px", borderRadius: 8 }}
/>
</Box> </Box>
)} */} )}
</Box> </Box>
</Box> </Box>
</Edit> </Edit>
); )
}); }

View File

@ -3,16 +3,11 @@ import {CustomDataGrid} from '../../components/CustomDataGrid'
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui' import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
import React from 'react' import React from 'react'
import {MEDIA_TYPES} from '../../lib/constants' import {MEDIA_TYPES} from '../../lib/constants'
import { observer } from "mobx-react-lite"
import {localeText} from '../../locales/ru/localeText' import {localeText} from '../../locales/ru/localeText'
import { languageStore, META_LANGUAGE } from '@stores'
export const MediaList = observer(() => { export const MediaList = () => {
const { language } = languageStore; const {dataGridProps} = useDataGrid({})
const {dataGridProps} = useDataGrid({
...META_LANGUAGE(language)
})
const columns = React.useMemo<GridColDef[]>( const columns = React.useMemo<GridColDef[]>(
() => [ () => [
@ -82,7 +77,7 @@ export const MediaList = observer(() => {
return ( return (
<List> <List>
<CustomDataGrid {...dataGridProps} columns={columns} localeText={localeText} getRowId={(row: any) => row.id} languageEnabled/> <CustomDataGrid {...dataGridProps} columns={columns} localeText={localeText} getRowId={(row: any) => row.id} />
</List> </List>
) )
}); }

View File

@ -1,73 +1,78 @@
import { Stack, Typography, Box, Button } from "@mui/material"; import {Stack, Typography, Box, Button} from '@mui/material'
import { useShow } from "@refinedev/core"; import {useShow} from '@refinedev/core'
import { Show, TextFieldComponent as TextField } from "@refinedev/mui"; import {Show, TextFieldComponent as TextField} from '@refinedev/mui'
import { MEDIA_TYPES } from "@lib";
import { TOKEN_KEY } from "@providers"; import {MEDIA_TYPES} from '../../lib/constants'
import { MediaData, MediaView } from "@ui"; import {TOKEN_KEY} from '../../authProvider'
export const MediaShow = () => { export const MediaShow = () => {
const { query } = useShow({}); const {query} = useShow({})
const { data, isLoading } = query; const {data, isLoading} = query
const record = data?.data; const record = data?.data
const token = localStorage.getItem(TOKEN_KEY); const token = localStorage.getItem(TOKEN_KEY)
const fields = [ const fields = [
// {label: 'Название файла', data: 'filename'}, // {label: 'Название файла', data: 'filename'},
{ label: "Название", data: "media_name" }, {label: 'Название', data: 'media_name'},
{ {
label: "Тип", label: 'Тип',
data: "media_type", data: 'media_type',
render: (value: number) => render: (value: number) => MEDIA_TYPES.find((type) => type.value === value)?.label || value,
MEDIA_TYPES.find((type) => type.value === value)?.label || value,
}, },
// {label: 'ID', data: 'id'}, // {label: 'ID', data: 'id'},
]; ]
return ( return (
<Show isLoading={isLoading}> <Show isLoading={isLoading}>
<Stack gap={4}> <Stack gap={4}>
<MediaView media={record as MediaData} /> {record && record.media_type === 1 && (
{fields.map(({ label, data, render }) => ( <img
src={`https://wn.krbl.ru/media/${record?.id}/download?token=${token}`}
alt={record?.filename}
style={{
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',
}}
>
<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>
)}
{fields.map(({label, data, render}) => (
<Stack key={data} gap={1}> <Stack key={data} gap={1}>
<Typography variant="body1" fontWeight="bold"> <Typography variant="body1" fontWeight="bold">
{label} {label}
</Typography> </Typography>
<TextField <TextField value={render ? render(record?.[data]) : record?.[data]} />
value={render ? render(record?.[data]) : record?.[data]}
/>
</Stack> </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> </Stack>
</Show> </Show>
); )
}; }

View File

@ -1,9 +0,0 @@
export const UP_SCALE = 30000;
export const PATH_WIDTH = 15;
export const STATION_RADIUS = 20;
export const STATION_OUTLINE_WIDTH = 10;
export const SIGHT_SIZE = 60;
export const SCALE_FACTOR = 50;
export const BACKGROUND_COLOR = 0x111111;
export const PATH_COLOR = 0xff4d4d;

View File

@ -1,179 +0,0 @@
import { FederatedMouseEvent, FederatedWheelEvent } from "pixi.js";
import { Component, ReactNode, useEffect, useState } from "react";
import { useTransform } from "./TransformContext";
import { useMapData } from "./MapDataContext";
import { SCALE_FACTOR } from "./Constants";
import { useApplication } from "@pixi/react";
class ErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("Error caught:", error, info);
}
render() {
return this.state.hasError ? <p>Whoopsie Daisy!</p> : this.props.children;
}
}
export function InfiniteCanvas({children} : Readonly<{children?: ReactNode}>) {
const { position, setPosition, scale, setScale, rotation, setRotation, setScreenCenter, screenCenter } = useTransform();
const { routeData, originalRouteData } = useMapData();
const applicationRef = useApplication();
const [isDragging, setIsDragging] = useState(false);
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
const [startRotation, setStartRotation] = useState(0);
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const canvas = applicationRef?.app.canvas;
if (!canvas) return;
const canvasRect = canvas.getBoundingClientRect();
const canvasLeft = canvasRect?.left ?? 0;
const canvasTop = canvasRect?.top ?? 0;
const centerX = window.innerWidth / 2 - canvasLeft;
const centerY = window.innerHeight / 2 - canvasTop;
setScreenCenter({x: centerX, y: centerY});
}, [applicationRef?.app.canvas, window.innerWidth, window.innerHeight]);
const handlePointerDown = (e: FederatedMouseEvent) => {
setIsDragging(true);
setStartPosition({
x: position.x,
y: position.y
});
setStartMousePosition({
x: e.globalX,
y: e.globalY
});
setStartRotation(rotation);
e.stopPropagation();
};
useEffect(() => {
setRotation((originalRouteData?.rotate ?? 0) * Math.PI / 180);
}, [originalRouteData?.rotate]);
// Get canvas element and its dimensions/position
const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isDragging) return;
if (e.shiftKey) {
const center = screenCenter ?? {x: 0, y: 0};
const startAngle = Math.atan2(startMousePosition.y - center.y, startMousePosition.x - center.x);
const currentAngle = Math.atan2(e.globalY - center.y, e.globalX - center.x);
// Calculate rotation difference in radians
const rotationDiff = currentAngle - startAngle;
// Update rotation
setRotation(startRotation + rotationDiff);
const cosDelta = Math.cos(rotationDiff);
const sinDelta = Math.sin(rotationDiff);
setPosition({
x: center.x * (1 - cosDelta) + startPosition.x * cosDelta + (center.y - startPosition.y) * sinDelta,
y: center.y * (1 - cosDelta) + startPosition.y * cosDelta + (startPosition.x - center.x) * sinDelta
});
} else {
setRotation(startRotation);
setPosition({
x: startPosition.x - startMousePosition.x + e.globalX,
y: startPosition.y - startMousePosition.y + e.globalY
});
}
e.stopPropagation();
};
// Handle mouse up
const handlePointerUp = (e: FederatedMouseEvent) => {
setIsDragging(false);
e.stopPropagation();
};
// Handle mouse wheel for zooming
const handleWheel = (e: FederatedWheelEvent) => {
e.stopPropagation();
// Get mouse position relative to canvas
const mouseX = e.globalX - position.x;
const mouseY = e.globalY - position.y;
// Calculate new scale
const scaleMin = (routeData?.scale_min ?? 10)/SCALE_FACTOR;
const scaleMax = (routeData?.scale_max ?? 20)/SCALE_FACTOR;
let zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; // Zoom out/in
//const newScale = scale * zoomFactor;
const newScale = Math.max(scaleMin, Math.min(scaleMax, scale * zoomFactor));
zoomFactor = newScale / scale;
if (scale === newScale) {
return;
}
// Update position to zoom towards mouse cursor
setPosition({
x: position.x + mouseX * (1 - zoomFactor),
y: position.y + mouseY * (1 - zoomFactor)
});
setScale(newScale);
};
useEffect(() => {
applicationRef?.app.render();
console.log(position, scale, rotation);
}, [position, scale, rotation]);
return (
<ErrorBoundary>
{applicationRef?.app && (
<pixiGraphics
draw={(g) => {
const canvas = applicationRef.app.canvas;
g.clear();
g.rect(0, 0, canvas?.width ?? 0, canvas?.height ?? 0);
g.fill("#111");
}}
eventMode={'static'}
interactive
onPointerDown={handlePointerDown}
onGlobalPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerUpOutside={handlePointerUp}
onWheel={handleWheel}
/>
)}
<pixiContainer
x={position.x}
y={position.y}
scale={scale}
rotation={rotation}
>
{children}
</pixiContainer>
{/* Show center of the screen.
<pixiGraphics
eventMode="none"
draw={(g) => {
g.clear();
const center = screenCenter ?? {x: 0, y: 0};
g.circle(center.x, center.y, 1);
g.fill("#fff");
}}
/> */}
</ErrorBoundary>
);
}

View File

@ -1,33 +0,0 @@
import { Stack, Typography, Button } from "@mui/material";
export function LeftSidebar() {
return (
<Stack direction="column" width="300px" p={2} bgcolor="primary.main">
<Stack direction="column" alignItems="center" justifyContent="center" my={10}>
<img src={"/Emblem.svg"} alt="logo" width={100} height={100} />
<Typography sx={{ mb: 2 }} textAlign="center">
При поддержке Правительства
Санкт-Петербурга
</Typography>
</Stack>
<Stack direction="column" alignItems="center" justifyContent="center" my={10} spacing={2}>
<Button variant="outlined" color="warning" fullWidth>
Достопримечательности
</Button>
<Button variant="outlined" color="warning" fullWidth>
Остановки
</Button>
</Stack>
<Stack direction="column" alignItems="center" justifyContent="center" my={10}>
<img src={"/GET.png"} alt="logo" width="80%" style={{margin: "0 auto"}}/>
</Stack>
<Typography variant="h6" textAlign="center" mt="auto">#ВсемПоПути</Typography>
</Stack>
);
}

View File

@ -1,271 +0,0 @@
import { useCustom, useApiUrl } from "@refinedev/core";
import { useParams } from "react-router";
import {
createContext,
ReactNode,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import {
RouteData,
SightData,
SightPatchData,
StationData,
StationPatchData,
} from "./types";
import { axiosInstance } from "../../providers/data";
const MapDataContext = createContext<{
originalRouteData?: RouteData;
originalStationData?: StationData[];
originalSightData?: SightData[];
routeData?: RouteData;
stationData?: StationData[];
sightData?: SightData[];
isRouteLoading: boolean;
isStationLoading: boolean;
isSightLoading: boolean;
setScaleRange: (min: number, max: number) => void;
setMapRotation: (rotation: number) => void;
setMapCenter: (x: number, y: number) => void;
setStationOffset: (stationId: number, x: number, y: number) => void;
setSightCoordinates: (
sightId: number,
latitude: number,
longitude: number
) => void;
saveChanges: () => void;
}>({
originalRouteData: undefined,
originalStationData: undefined,
originalSightData: undefined,
routeData: undefined,
stationData: undefined,
sightData: undefined,
isRouteLoading: true,
isStationLoading: true,
isSightLoading: true,
setScaleRange: () => {},
setMapRotation: () => {},
setMapCenter: () => {},
setStationOffset: () => {},
setSightCoordinates: () => {},
saveChanges: () => {},
});
export function MapDataProvider({
children,
}: Readonly<{ children: ReactNode }>) {
const { id: routeId } = useParams<{ id: string }>();
const apiUrl = useApiUrl();
const [originalRouteData, setOriginalRouteData] = useState<RouteData>();
const [originalStationData, setOriginalStationData] =
useState<StationData[]>();
const [originalSightData, setOriginalSightData] = useState<SightData[]>();
const [routeData, setRouteData] = useState<RouteData>();
const [stationData, setStationData] = useState<StationData[]>();
const [sightData, setSightData] = useState<SightData[]>();
const [routeChanges, setRouteChanges] = useState<RouteData>({} as RouteData);
const [stationChanges, setStationChanges] = useState<StationPatchData[]>([]);
const [sightChanges, setSightChanges] = useState<SightPatchData[]>([]);
const { data: routeQuery, isLoading: isRouteLoading } = useCustom({
url: `${apiUrl}/route/${routeId}`,
method: "get",
});
const { data: stationQuery, isLoading: isStationLoading } = useCustom({
url: `${apiUrl}/route/${routeId}/station`,
method: "get",
});
const { data: sightQuery, isLoading: isSightLoading } = useCustom({
url: `${apiUrl}/route/${routeId}/sight`,
method: "get",
});
useEffect(() => {
// if not undefined, set original data
if (routeQuery?.data) setOriginalRouteData(routeQuery.data as RouteData);
if (stationQuery?.data)
setOriginalStationData(stationQuery.data as StationData[]);
if (sightQuery?.data) setOriginalSightData(sightQuery.data as SightData[]);
console.log("queries", routeQuery, stationQuery, sightQuery);
}, [routeQuery, stationQuery, sightQuery]);
useEffect(() => {
// combine changes with original data
if (originalRouteData)
setRouteData({ ...originalRouteData, ...routeChanges });
if (originalStationData) setStationData(originalStationData);
if (originalSightData) setSightData(originalSightData);
}, [
originalRouteData,
originalStationData,
originalSightData,
routeChanges,
stationChanges,
sightChanges,
]);
function setScaleRange(min: number, max: number) {
setRouteChanges((prev) => {
return { ...prev, scale_min: min, scale_max: max };
});
}
function setMapRotation(rotation: number) {
setRouteChanges((prev) => {
return { ...prev, rotate: rotation };
});
}
function setMapCenter(x: number, y: number) {
setRouteChanges((prev) => {
return { ...prev, center_latitude: x, center_longitude: y };
});
}
async function saveChanges() {
await axiosInstance.patch(`/route/${routeId}`, routeData);
await saveStationChanges();
await saveSightChanges();
}
async function saveStationChanges() {
for (const station of stationChanges) {
const response = await axiosInstance.patch(
`/route/${routeId}/station`,
station
);
}
}
async function saveSightChanges() {
console.log("sightChanges", sightChanges);
for (const sight of sightChanges) {
const response = await axiosInstance.patch(
`/route/${routeId}/sight`,
sight
);
}
}
function setStationOffset(stationId: number, x: number, y: number) {
setStationChanges((prev) => {
let found = prev.find((station) => station.station_id === stationId);
if (found) {
found.offset_x = x;
found.offset_y = y;
return prev.map((station) => {
if (station.station_id === stationId) {
return found;
}
return station;
});
} else {
const foundStation = stationData?.find(
(station) => station.id === stationId
);
if (foundStation) {
return [
...prev,
{
station_id: stationId,
offset_x: x,
offset_y: y,
transfers: foundStation.transfers,
},
];
}
return prev;
}
});
}
function setSightCoordinates(
sightId: number,
latitude: number,
longitude: number
) {
setSightChanges((prev) => {
let found = prev.find((sight) => sight.sight_id === sightId);
if (found) {
found.latitude = latitude;
found.longitude = longitude;
return prev.map((sight) => {
if (sight.sight_id === sightId) {
return found;
}
return sight;
});
} else {
const foundSight = sightData?.find((sight) => sight.id === sightId);
if (foundSight) {
return [
...prev,
{
sight_id: sightId,
latitude,
longitude,
},
];
}
return prev;
}
});
}
useEffect(() => {
console.log("sightChanges", sightChanges);
}, [sightChanges]);
const value = useMemo(
() => ({
originalRouteData: originalRouteData,
originalStationData: originalStationData,
originalSightData: originalSightData,
routeData: routeData,
stationData: stationData,
sightData: sightData,
isRouteLoading,
isStationLoading,
isSightLoading,
setScaleRange,
setMapRotation,
setMapCenter,
saveChanges,
setStationOffset,
setSightCoordinates,
}),
[
originalRouteData,
originalStationData,
originalSightData,
routeData,
stationData,
sightData,
isRouteLoading,
isStationLoading,
isSightLoading,
]
);
return (
<MapDataContext.Provider value={value}>{children}</MapDataContext.Provider>
);
}
export const useMapData = () => {
const context = useContext(MapDataContext);
if (!context) {
throw new Error("useMapData must be used within a MapDataProvider");
}
return context;
};

View File

@ -1,191 +0,0 @@
import { Button, Stack, TextField, Typography } from "@mui/material";
import { useMapData } from "./MapDataContext";
import { useEffect, useState } from "react";
import { useTransform } from "./TransformContext";
import { coordinatesToLocal, localToCoordinates } from "./utils";
export function RightSidebar() {
const { routeData, setScaleRange, saveChanges, originalRouteData, setMapRotation, setMapCenter } = useMapData();
const { rotation, position, screenToLocal, screenCenter, rotateToAngle, setTransform } = useTransform();
const [minScale, setMinScale] = useState<number>(1);
const [maxScale, setMaxScale] = useState<number>(10);
const [localCenter, setLocalCenter] = useState<{x: number, y: number}>({x: 0, y: 0});
const [rotationDegrees, setRotationDegrees] = useState<number>(0);
useEffect(() => {
if(originalRouteData) {
setMinScale(originalRouteData.scale_min ?? 1);
setMaxScale(originalRouteData.scale_max ?? 10);
setRotationDegrees(originalRouteData.rotate ?? 0);
setLocalCenter({x: originalRouteData.center_latitude ?? 0, y: originalRouteData.center_longitude ?? 0});
}
}, [originalRouteData]);
useEffect(() => {
if(minScale && maxScale) {
setScaleRange(minScale, maxScale);
}
}, [minScale, maxScale]);
useEffect(() => {
setRotationDegrees((Math.round(rotation * 180 / Math.PI) % 360 + 360) % 360);
}, [rotation]);
useEffect(() => {
setMapRotation(rotationDegrees);
}, [rotationDegrees]);
useEffect(() => {
const center = screenCenter ?? {x: 0, y: 0};
const localCenter = screenToLocal(center.x, center.y);
const coordinates = localToCoordinates(localCenter.x, localCenter.y);
setLocalCenter({x: coordinates.latitude, y: coordinates.longitude});
}, [position]);
useEffect(() => {
setMapCenter(localCenter.x, localCenter.y);
}, [localCenter]);
function setRotationFromDegrees(degrees: number) {
rotateToAngle(degrees * Math.PI / 180);
}
function pan({x, y}: {x: number, y: number}) {
const coordinates = coordinatesToLocal(x,y);
setTransform(coordinates.x, coordinates.y);
}
if(!routeData) {
console.error("routeData is null");
return null;
}
return (
<Stack
position="absolute" right={8} top={8} bottom={8} p={2}
gap={1}
minWidth="400px" bgcolor="primary.main"
border="1px solid #e0e0e0" borderRadius={2}
>
<Typography variant="h6" sx={{ mb: 2 }} textAlign="center">
Детали о достопримечательностях
</Typography>
<Stack spacing={2} direction="row" alignItems="center">
<TextField
type="number"
label="Минимальный масштаб"
variant="filled"
value={minScale}
onChange={(e) => setMinScale(Number(e.target.value))}
style={{backgroundColor: "#222", borderRadius: 4}}
sx={{
'& .MuiInputLabel-root.Mui-focused': {
color: "#fff"
}
}}
slotProps={{
input: {
min: 0.1
}
}}
/>
<TextField
type="number"
label="Максимальный масштаб"
variant="filled"
value={maxScale}
onChange={(e) => setMaxScale(Number(e.target.value))}
style={{backgroundColor: "#222", borderRadius: 4}}
sx={{
'& .MuiInputLabel-root.Mui-focused': {
color: "#fff"
}
}}
slotProps={{
input: {
min: 0.1
}
}}
/>
</Stack>
<TextField
type="number"
label="Поворот (в градусах)"
variant="filled"
value={rotationDegrees}
onChange={(e) => {
const value = Number(e.target.value);
if (!isNaN(value)) {
setRotationFromDegrees(value);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
}
}}
style={{backgroundColor: "#222", borderRadius: 4}}
sx={{
'& .MuiInputLabel-root.Mui-focused': {
color: "#fff"
}
}}
slotProps={{
input: {
min: 0,
max: 360
}
}}
/>
<Stack direction="row" spacing={2}>
<TextField
type="number"
label="Центр карты, широта"
variant="filled"
value={Math.round(localCenter.x*100000)/100000}
onChange={(e) => {
setLocalCenter(prev => ({...prev, x: Number(e.target.value)}))
pan({x: Number(e.target.value), y: localCenter.y});
}}
style={{backgroundColor: "#222", borderRadius: 4}}
sx={{
'& .MuiInputLabel-root.Mui-focused': {
color: "#fff"
}
}}
/>
<TextField
type="number"
label="Центр карты, высота"
variant="filled"
value={Math.round(localCenter.y*100000)/100000}
onChange={(e) => {
setLocalCenter(prev => ({...prev, y: Number(e.target.value)}))
pan({x: localCenter.x, y: Number(e.target.value)});
}}
style={{backgroundColor: "#222", borderRadius: 4}}
sx={{
'& .MuiInputLabel-root.Mui-focused': {
color: "#fff"
}
}}
/>
</Stack>
<Button
variant="contained"
color="secondary"
sx={{ mt: 2 }}
onClick={() => {
saveChanges();
}}
>
Сохранить изменения
</Button>
</Stack>
);
}

View File

@ -1,119 +0,0 @@
import { useEffect, useState } from "react";
import { useTransform } from "./TransformContext";
import { SightData } from "./types";
import { Assets, FederatedMouseEvent, Graphics, Texture } from "pixi.js";
import { COLORS } from "../../contexts/color-mode/theme";
import { SIGHT_SIZE, UP_SCALE } from "./Constants";
import { coordinatesToLocal, localToCoordinates } from "./utils";
import { useMapData } from "./MapDataContext";
interface SightProps {
sight: SightData;
id: number;
}
export function Sight({
sight, id
}: Readonly<SightProps>) {
const { rotation, scale } = useTransform();
const { setSightCoordinates } = useMapData();
const [position, setPosition] = useState(coordinatesToLocal(sight.latitude, sight.longitude));
const [isDragging, setIsDragging] = useState(false);
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
const handlePointerDown = (e: FederatedMouseEvent) => {
setIsDragging(true);
setStartPosition({
x: position.x,
y: position.y
});
setStartMousePosition({
x: e.globalX,
y: e.globalY
});
e.stopPropagation();
};
const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isDragging) return;
const dx = (e.globalX - startMousePosition.x) / scale / UP_SCALE;
const dy = (e.globalY - startMousePosition.y) / scale / UP_SCALE;
const cos = Math.cos(rotation);
const sin = Math.sin(rotation);
const newPosition = {
x: startPosition.x + dx * cos + dy * sin,
y: startPosition.y - dx * sin + dy * cos
};
setPosition(newPosition);
const coordinates = localToCoordinates(newPosition.x, newPosition.y);
setSightCoordinates(sight.id, coordinates.latitude, coordinates.longitude);
e.stopPropagation();
};
const handlePointerUp = (e: FederatedMouseEvent) => {
setIsDragging(false);
e.stopPropagation();
};
const [texture, setTexture] = useState(Texture.EMPTY);
useEffect(() => {
if (texture === Texture.EMPTY) {
Assets
.load('/SightIcon.png')
.then((result) => {
setTexture(result)
});
}
}, [texture]);
function draw(g: Graphics) {
g.clear();
g.circle(0, 0, 20);
g.fill({color: COLORS.primary}); // Fill circle with primary color
}
if(!sight) {
console.error("sight is null");
return null;
}
const coordinates = coordinatesToLocal(sight.latitude, sight.longitude);
return (
<pixiContainer rotation={-rotation}
eventMode='static'
interactive
onPointerDown={handlePointerDown}
onGlobalPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerUpOutside={handlePointerUp}
x={position.x * UP_SCALE - SIGHT_SIZE/2} // Offset by half width to center
y={position.y * UP_SCALE - SIGHT_SIZE/2} // Offset by half height to center
>
<pixiSprite
texture={texture}
width={SIGHT_SIZE}
height={SIGHT_SIZE}
/>
<pixiGraphics
draw={draw}
x={SIGHT_SIZE}
y={0}
/>
<pixiText
text={`${id+1}`}
x={SIGHT_SIZE+1}
y={0}
anchor={0.5}
style={{
fontSize: 24,
fontWeight: 'bold',
fill: "#ffffff",
}}
/>
</pixiContainer>
);
}

View File

@ -1,109 +0,0 @@
import { FederatedMouseEvent, Graphics } from "pixi.js";
import { BACKGROUND_COLOR, PATH_COLOR, STATION_RADIUS, STATION_OUTLINE_WIDTH, UP_SCALE } from "./Constants";
import { useTransform } from "./TransformContext";
import { useCallback, useEffect, useRef, useState } from "react";
import { StationData } from "./types";
import { useMapData } from "./MapDataContext";
import { coordinatesToLocal } from "./utils";
interface StationProps {
station: StationData;
}
export function Station({
station
}: Readonly<StationProps>) {
const draw = useCallback((g: Graphics) => {
g.clear();
const coordinates = coordinatesToLocal(station.latitude, station.longitude);
g.circle(coordinates.x * UP_SCALE, coordinates.y * UP_SCALE, STATION_RADIUS);
g.fill({color: PATH_COLOR});
g.stroke({color: BACKGROUND_COLOR, width: STATION_OUTLINE_WIDTH});
}, []);
return (
<pixiContainer>
<pixiGraphics draw={draw}/>
<StationLabel station={station}/>
</pixiContainer>
);
}
export function StationLabel({
station
}: Readonly<StationProps>) {
const { rotation, scale } = useTransform();
const { setStationOffset } = useMapData();
const [position, setPosition] = useState({ x: station.offset_x, y: station.offset_y });
const [isDragging, setIsDragging] = useState(false);
const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
const [startMousePosition, setStartMousePosition] = useState({ x: 0, y: 0 });
if(!station) {
console.error("station is null");
return null;
}
const handlePointerDown = (e: FederatedMouseEvent) => {
setIsDragging(true);
setStartPosition({
x: position.x,
y: position.y
});
setStartMousePosition({
x: e.globalX,
y: e.globalY
});
e.stopPropagation();
};
const handlePointerMove = (e: FederatedMouseEvent) => {
if (!isDragging) return;
const dx = (e.globalX - startMousePosition.x);
const dy = (e.globalY - startMousePosition.y);
const newPosition = {
x: startPosition.x + dx,
y: startPosition.y + dy
};
setPosition(newPosition);
setStationOffset(station.id, newPosition.x, newPosition.y);
e.stopPropagation();
};
const handlePointerUp = (e: FederatedMouseEvent) => {
setIsDragging(false);
e.stopPropagation();
};
const coordinates = coordinatesToLocal(station.latitude, station.longitude);
return (
<pixiContainer
eventMode='static'
interactive
onPointerDown={handlePointerDown}
onGlobalPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerUpOutside={handlePointerUp}
width={48}
height={48}
x={coordinates.x * UP_SCALE}
y={coordinates.y * UP_SCALE}
rotation={-rotation}
>
<pixiText
anchor={{x: 0.5, y: 0.5}}
text={station.name}
position={{
x: position.x/scale,
y: position.y/scale
}}
style={{
fontSize: 48,
fontWeight: 'bold',
fill: "#ffffff"
}}
/>
</pixiContainer>
);
}

View File

@ -1,150 +0,0 @@
import { createContext, ReactNode, useContext, useMemo, useState } from "react";
import { SCALE_FACTOR, UP_SCALE } from "./Constants";
const TransformContext = createContext<{
position: { x: number, y: number },
scale: number,
rotation: number,
screenCenter?: { x: number, y: number },
setPosition: React.Dispatch<React.SetStateAction<{ x: number, y: number }>>,
setScale: React.Dispatch<React.SetStateAction<number>>,
setRotation: React.Dispatch<React.SetStateAction<number>>,
screenToLocal: (x: number, y: number) => { x: number, y: number },
localToScreen: (x: number, y: number) => { x: number, y: number },
rotateToAngle: (to: number, fromPosition?: {x: number, y: number}) => void,
setTransform: (latitude: number, longitude: number, rotationDegrees?: number, scale?: number) => void,
setScreenCenter: React.Dispatch<React.SetStateAction<{ x: number, y: number } | undefined>>
}>({
position: { x: 0, y: 0 },
scale: 1,
rotation: 0,
screenCenter: undefined,
setPosition: () => {},
setScale: () => {},
setRotation: () => {},
screenToLocal: () => ({ x: 0, y: 0 }),
localToScreen: () => ({ x: 0, y: 0 }),
rotateToAngle: () => {},
setTransform: () => {},
setScreenCenter: () => {}
});
// Provider component
export const TransformProvider = ({ children }: { children: ReactNode }) => {
const [position, setPosition] = useState({ x: 0, y: 0 });
const [scale, setScale] = useState(1);
const [rotation, setRotation] = useState(0);
const [screenCenter, setScreenCenter] = useState<{x: number, y: number}>();
function screenToLocal(screenX: number, screenY: number) {
// Translate point relative to current pan position
const translatedX = (screenX - position.x) / scale;
const translatedY = (screenY - position.y) / scale;
// Rotate point around center
const cosRotation = Math.cos(-rotation); // Negative rotation to reverse transform
const sinRotation = Math.sin(-rotation);
const rotatedX = translatedX * cosRotation - translatedY * sinRotation;
const rotatedY = translatedX * sinRotation + translatedY * cosRotation;
return {
x: rotatedX / UP_SCALE,
y: rotatedY / UP_SCALE
};
}
// Inverse of screenToLocal
function localToScreen(localX: number, localY: number) {
const upscaledX = localX * UP_SCALE;
const upscaledY = localY * UP_SCALE;
const cosRotation = Math.cos(rotation);
const sinRotation = Math.sin(rotation);
const rotatedX = upscaledX * cosRotation - upscaledY * sinRotation;
const rotatedY = upscaledX * sinRotation + upscaledY * cosRotation;
const translatedX = rotatedX*scale + position.x;
const translatedY = rotatedY*scale + position.y;
return {
x: translatedX,
y: translatedY
};
}
function rotateToAngle(to: number, fromPosition?: {x: number, y: number}) {
setRotation(to);
const rotationDiff = to - rotation;
const center = screenCenter ?? {x: 0, y: 0};
const cosDelta = Math.cos(rotationDiff);
const sinDelta = Math.sin(rotationDiff);
fromPosition ??= position;
setPosition({
x: center.x * (1 - cosDelta) + fromPosition.x * cosDelta + (center.y - fromPosition.y) * sinDelta,
y: center.y * (1 - cosDelta) + fromPosition.y * cosDelta + (fromPosition.x - center.x) * sinDelta
});
}
function setTransform(latitude: number, longitude: number, rotationDegrees?: number, useScale ?: number) {
const selectedRotation = rotationDegrees ? (rotationDegrees * Math.PI / 180) : rotation;
const selectedScale = useScale ? useScale/SCALE_FACTOR : scale;
const center = screenCenter ?? {x: 0, y: 0};
console.log("center", center.x, center.y);
const newPosition = {
x: -latitude * UP_SCALE * selectedScale,
y: -longitude * UP_SCALE * selectedScale
};
const cos = Math.cos(selectedRotation);
const sin = Math.sin(selectedRotation);
// Translate point relative to center, rotate, then translate back
const dx = newPosition.x;
const dy = newPosition.y;
newPosition.x = (dx * cos - dy * sin) + center.x;
newPosition.y = (dx * sin + dy * cos) + center.y;
setPosition(newPosition);
setRotation(selectedRotation);
setScale(selectedScale);
}
const value = useMemo(() => ({
position,
scale,
rotation,
screenCenter,
setPosition,
setScale,
setRotation,
rotateToAngle,
screenToLocal,
localToScreen,
setTransform,
setScreenCenter
}), [position, scale, rotation, screenCenter]);
return (
<TransformContext.Provider value={value}>
{children}
</TransformContext.Provider>
);
};
// Custom hook for easy access to transform values
export const useTransform = () => {
const context = useContext(TransformContext);
if (!context) {
throw new Error('useTransform must be used within a TransformProvider');
}
return context;
};

View File

@ -1,39 +0,0 @@
import { Graphics } from "pixi.js";
import { useCallback } from "react";
import { PATH_COLOR, PATH_WIDTH } from "./Constants";
import { coordinatesToLocal } from "./utils";
interface TravelPathProps {
points: {x: number, y: number}[];
}
export function TravelPath({
points
}: Readonly<TravelPathProps>) {
const draw = useCallback((g: Graphics) => {
g.clear();
const coordStart = coordinatesToLocal(points[0].x, points[0].y);
g.moveTo(coordStart.x, coordStart.y);
for (let i = 1; i < points.length - 1; i++) {
const coordinates = coordinatesToLocal(points[i].x, points[i].y);
g.lineTo(coordinates.x, coordinates.y);
}
g.stroke({
color: PATH_COLOR,
width: PATH_WIDTH
});
}, [points]);
if(points.length === 0) {
console.error("points is empty");
return null;
}
return (
<pixiGraphics
draw={draw}
/>
);
}

View File

@ -1,31 +0,0 @@
import { Stack, Typography } from "@mui/material";
export function Widgets() {
return (
<Stack
direction="column" spacing={2}
position="absolute"
top={32} left={32}
sx={{ pointerEvents: 'none' }}
>
<Stack bgcolor="primary.main"
width={361} height={96}
p={2} m={2}
borderRadius={2}
alignItems="center"
justifyContent="center"
>
<Typography variant="h6">Станция</Typography>
</Stack>
<Stack bgcolor="primary.main"
width={223} height={262}
p={2} m={2}
borderRadius={2}
alignItems="center"
justifyContent="center"
>
<Typography variant="h6">Погода</Typography>
</Stack>
</Stack>
)
}

View File

@ -1,152 +0,0 @@
import { useRef, useEffect, useState } from "react";
import {
Application,
ApplicationRef,
extend
} from '@pixi/react';
import {
Container,
Graphics,
Sprite,
Texture,
TilingSprite,
Text
} from 'pixi.js';
import { Stack } from "@mui/material";
import { MapDataProvider, useMapData } from "./MapDataContext";
import { TransformProvider, useTransform } from "./TransformContext";
import { InfiniteCanvas } from "./InfiniteCanvas";
import { Sight } from "./Sight";
import { UP_SCALE } from "./Constants";
import { Station } from "./Station";
import { TravelPath } from "./TravelPath";
import { LeftSidebar } from "./LeftSidebar";
import { RightSidebar } from "./RightSidebar";
import { Widgets } from "./Widgets";
import { coordinatesToLocal } from "./utils";
extend({
Container,
Graphics,
Sprite,
Texture,
TilingSprite,
Text
});
export const RoutePreview = () => {
return (
<MapDataProvider>
<TransformProvider>
<Stack direction="row" height="100vh" width="100vw" overflow="hidden">
<LeftSidebar />
<Stack direction="row" flex={1} position="relative" height="100%">
<Widgets />
<RouteMap />
<RightSidebar />
</Stack>
</Stack>
</TransformProvider>
</MapDataProvider>
);
};
export function RouteMap() {
const { setPosition, screenToLocal, setTransform, screenCenter } = useTransform();
const {
routeData, stationData, sightData, originalRouteData
} = useMapData();
const [points, setPoints] = useState<{x: number, y: number}[]>([]);
const [isSetup, setIsSetup] = useState(false);
const parentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (originalRouteData) {
const path = originalRouteData?.path;
const points = path?.map(([x, y]: [number, number]) => ({x: x * UP_SCALE, y: y * UP_SCALE})) ?? [];
setPoints(points);
}
}, [originalRouteData]);
useEffect(() => {
if(isSetup || !screenCenter) {
return;
}
if (
originalRouteData?.center_latitude === originalRouteData?.center_longitude &&
originalRouteData?.center_latitude === 0
) {
if (points.length > 0) {
let boundingBox = {
from: {x: Infinity, y: Infinity},
to: {x: -Infinity, y: -Infinity}
};
for (const point of points) {
boundingBox.from.x = Math.min(boundingBox.from.x, point.x);
boundingBox.from.y = Math.min(boundingBox.from.y, point.y);
boundingBox.to.x = Math.max(boundingBox.to.x, point.x);
boundingBox.to.y = Math.max(boundingBox.to.y, point.y);
}
const newCenter = {
x: -(boundingBox.from.x + boundingBox.to.x) / 2,
y: -(boundingBox.from.y + boundingBox.to.y) / 2
};
setPosition(newCenter);
setIsSetup(true);
}
} else if (
originalRouteData?.center_latitude &&
originalRouteData?.center_longitude
) {
const coordinates = coordinatesToLocal(originalRouteData?.center_latitude, originalRouteData?.center_longitude);
setTransform(
coordinates.x,
coordinates.y,
originalRouteData?.rotate,
originalRouteData?.scale_min
);
setIsSetup(true);
}
}, [points, originalRouteData?.center_latitude, originalRouteData?.center_longitude, originalRouteData?.rotate, isSetup, screenCenter]);
if (!routeData || !stationData || !sightData) {
console.error("routeData, stationData or sightData is null");
return <div>Loading...</div>;
}
return (
<div style={{width: "100%", height:"100%"}} ref={parentRef}>
<Application
resizeTo={parentRef}
background="#fff"
>
<InfiniteCanvas>
<TravelPath points={points}/>
{stationData?.map((obj) => (
<Station station={obj} key={obj.id}/>
))}
{sightData?.map((obj, index) => (
<Sight sight={obj} id={index} key={obj.id}/>
))}
<pixiGraphics
draw={(g) => {
g.clear();
const localCenter = screenToLocal(0,0);
g.circle(localCenter.x, localCenter.y, 10);
g.fill("#fff");
}}
/>
</InfiniteCanvas>
</Application>
</div>
)
}

View File

@ -1,69 +0,0 @@
export interface RouteData {
carrier: string;
carrier_id: number;
center_latitude: number;
center_longitude: number;
governor_appeal: number;
id: number;
path: [number, number][];
rotate: number;
route_direction: boolean;
route_number: string;
route_sys_number: string;
scale_max: number;
scale_min: number;
}
export interface StationTransferData {
bus: string;
metro_blue: string;
metro_green: string;
metro_orange: string;
metro_purple: string;
metro_red: string;
train: string;
tram: string;
trolleybus: string;
}
export interface StationData {
address: string;
city_id?: number;
description: string;
id: number;
latitude: number;
longitude: number;
name: string;
offset_x: number;
offset_y: number;
system_name: string;
transfers: StationTransferData;
}
export interface StationPatchData {
station_id: number;
offset_x: number;
offset_y: number;
transfers: StationTransferData;
}
export interface SightPatchData {
sight_id: number;
latitude: number;
longitude: number;
}
export interface SightData {
address: string;
city: string;
city_id: number;
id: number;
latitude: number;
left_article: number;
longitude: number;
name: string;
preview_media: number;
thumbnail: string; // uuid
watermark_lu: string; // uuid
watermark_rd: string; // uuid
}

View File

@ -1,14 +0,0 @@
// approximation
export function coordinatesToLocal(latitude: number, longitude: number) {
return {
x: longitude,
y: -latitude*2,
}
}
export function localToCoordinates(x: number, y: number) {
return {
longitude: x,
latitude: -y/2,
}
}

View File

@ -1,357 +1,217 @@
import { import {Autocomplete, Box, TextField, FormControlLabel, Checkbox, Typography} from '@mui/material'
Autocomplete, import {Create, useAutocomplete} from '@refinedev/mui'
Box, import {useForm} from '@refinedev/react-hook-form'
TextField, import {Controller} from 'react-hook-form'
FormControlLabel,
Checkbox,
Typography,
} from "@mui/material";
import { Create, useAutocomplete } from "@refinedev/mui";
import { useForm } from "@refinedev/react-hook-form";
import { useState } from "react";
import { Controller } from "react-hook-form";
export const RouteCreate = () => { export const RouteCreate = () => {
const { const {
saveButtonProps, saveButtonProps,
refineCore: { formLoading }, refineCore: {formLoading},
register, register,
control, control,
setValue, formState: {errors},
formState: { errors },
} = useForm({ } = useForm({
refineCoreProps: { refineCoreProps: {
resource: "route", resource: 'route/',
}, },
}); })
const directions = [ const {autocompleteProps: carrierAutocompleteProps} = useAutocomplete({
{ resource: 'carrier',
label: "Прямой",
value: true,
},
{
label: "Обратный",
value: false,
},
];
const { autocompleteProps: carrierAutocompleteProps } = useAutocomplete({
resource: "carrier",
onSearch: (value) => [ onSearch: (value) => [
{ {
field: "short_name", field: 'short_name',
operator: "contains", operator: 'contains',
value, value,
}, },
], ],
}); })
const { autocompleteProps: governorAppealAutocompleteProps } =
useAutocomplete({
resource: "article",
onSearch: (value) => [
{
field: "heading",
operator: "contains",
value,
},
{
field: "media_type",
operator: "contains",
value,
},
],
});
const [routeDirection, setRouteDirection] = useState(false);
return ( return (
<Create isLoading={formLoading} saveButtonProps={saveButtonProps}> <Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
<Box <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
component="form"
sx={{ display: "flex", flexDirection: "column" }}
autoComplete="off"
>
<Controller <Controller
control={control} control={control}
name="carrier_id" name="carrier_id"
rules={{ required: "Это поле является обязательным" }} rules={{required: 'Это поле является обязательным'}}
defaultValue={null} defaultValue={null}
render={({ field }) => ( render={({field}) => (
<Autocomplete <Autocomplete
{...carrierAutocompleteProps} {...carrierAutocompleteProps}
value={ value={carrierAutocompleteProps.options.find((option) => option.id === field.value) || null}
carrierAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => { onChange={(_, value) => {
field.onChange(value?.id || ""); field.onChange(value?.id || '')
}} }}
getOptionLabel={(item) => { getOptionLabel={(item) => {
return item ? item.short_name : ""; return item ? item.short_name : ''
}} }}
isOptionEqualToValue={(option, value) => { isOptionEqualToValue={(option, value) => {
return option.id === value?.id; return option.id === value?.id
}} }}
filterOptions={(options, { inputValue }) => { filterOptions={(options, {inputValue}) => {
return options.filter((option) => return options.filter((option) => option.short_name.toLowerCase().includes(inputValue.toLowerCase()))
option.short_name
.toLowerCase()
.includes(inputValue.toLowerCase())
);
}} }}
renderInput={(params) => ( renderInput={(params) => <TextField {...params} label="Выберите перевозчика" margin="normal" variant="outlined" error={!!errors.carrier_id} helperText={(errors as any)?.carrier_id?.message} required />}
<TextField
{...params}
label="Выберите перевозчика"
margin="normal"
variant="outlined"
error={!!errors.carrier_id}
helperText={(errors as any)?.carrier_id?.message}
required
/>
)}
/> />
)} )}
/> />
<TextField <TextField
{...register("route_number", { {...register('route_number', {
required: "Это поле является обязательным", required: 'Это поле является обязательным',
setValueAs: (value) => String(value), setValueAs: (value) => String(value),
})} })}
error={!!(errors as any)?.route_number} error={!!(errors as any)?.route_number}
helperText={(errors as any)?.route_number?.message} helperText={(errors as any)?.route_number?.message}
margin="normal" margin="normal"
fullWidth fullWidth
slotProps={{ inputLabel: { shrink: true } }} InputLabelProps={{shrink: true}}
type="text" type="text"
label={"Номер маршрута *"} label={'Номер маршрута *'}
name="route_number" 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>
<TextField <TextField
{...register("path", { {...register('path', {
required: "Это поле является обязательным", required: 'Это поле является обязательным',
setValueAs: (value: string) => { setValueAs: (value: string) => {
try { try {
// Разбиваем строку на строки и парсим каждую строку как пару координат // Парсим строку в массив массивов
const lines = value.trim().split("\n"); return JSON.parse(value)
return lines.map((line) => {
const [lat, lon] = line
.trim()
.split(/[\s,]+/)
.map(Number);
if (isNaN(lat) || isNaN(lon)) {
throw new Error("Invalid coordinates");
}
return [lat, lon];
});
} catch { } catch {
return []; return []
} }
}, },
validate: (value: unknown) => { validate: (value: unknown) => {
if (!Array.isArray(value)) return "Неверный формат"; if (!Array.isArray(value)) return 'Неверный формат'
if (value.length === 0) if (!value.every((point: unknown) => Array.isArray(point) && point.length === 2)) {
return "Введите хотя бы одну пару координат"; return 'Каждая точка должна быть массивом из двух координат'
if (
!value.every(
(point: unknown) => Array.isArray(point) && point.length === 2
)
) {
return "Каждая строка должна содержать две координаты";
} }
if ( if (!value.every((point: unknown[]) => point.every((coord: unknown) => !isNaN(Number(coord)) && typeof coord === 'number'))) {
!value.every((point: unknown[]) => return 'Координаты должны быть числами'
point.every(
(coord: unknown) =>
!isNaN(Number(coord)) && typeof coord === "number"
)
)
) {
return "Координаты должны быть числами";
} }
return true; return true
}, },
})} })}
error={!!(errors as any)?.path} error={!!(errors as any)?.path}
helperText={(errors as any)?.path?.message} helperText={(errors as any)?.path?.message} // 'Формат: [[lat1,lon1], [lat2,lon2], ...]'
margin="normal" margin="normal"
fullWidth fullWidth
slotProps={{ inputLabel: { shrink: true } }} InputLabelProps={{shrink: true}}
type="text" type="text"
label={"Координаты маршрута *"} label={'Координаты маршрута *'}
name="path" name="path"
placeholder="55.7558 37.6173 placeholder="[[1.1, 2.2], [2.1, 4.5]]"
55.7539 37.6208"
multiline
rows={4}
/> />
<TextField <TextField
{...register("route_sys_number", { {...register('route_sys_number', {
required: "Это поле является обязательным", required: 'Это поле является обязательным',
})} })}
error={!!(errors as any)?.route_sys_number} error={!!(errors as any)?.route_sys_number}
helperText={(errors as any)?.route_sys_number?.message} helperText={(errors as any)?.route_sys_number?.message}
margin="normal" margin="normal"
fullWidth fullWidth
slotProps={{ inputLabel: { shrink: true } }} InputLabelProps={{shrink: true}}
type="number" type="number"
label={"Номер маршрута в Говорящем Городе *"} label={'Системный номер маршрута *'}
name="route_sys_number" name="route_sys_number"
/> />
<Controller <TextField
control={control} {...register('governor_appeal', {
name="governor_appeal" // required: 'Это поле является обязательным',
defaultValue={null}
render={({ field }) => (
<Autocomplete
{...governorAppealAutocompleteProps}
value={
governorAppealAutocompleteProps.options.find(
(option) => option.id === field.value
) ?? null
}
onChange={(_, value) => {
field.onChange(value?.id ?? "");
}}
getOptionLabel={(item) => {
return item ? item.heading : "";
}}
isOptionEqualToValue={(option, value) => {
return option.id === value?.id;
}}
filterOptions={(options, { inputValue }) => {
return options.filter((option) =>
option.heading
.toLowerCase()
.includes(inputValue.toLowerCase())
);
}}
renderInput={(params) => (
<TextField
{...params}
label="Обращение губернатора"
margin="normal"
variant="outlined"
error={!!errors.arms}
helperText={(errors as any)?.arms?.message}
required
/>
)}
/>
)}
/>
<input
type="hidden"
{...register("route_direction", {
value: routeDirection,
})} })}
/> error={!!(errors as any)?.governor_appeal}
helperText={(errors as any)?.governor_appeal?.message}
<Autocomplete margin="normal"
options={directions} fullWidth
defaultValue={directions.find((el) => el.value == false)} InputLabelProps={{shrink: true}}
onChange={(_, element) => { type="number"
if (element) { label={'Обращение губернатора'}
setValue("route_direction", element.value); name="governor_appeal"
setRouteDirection(element.value);
}
}}
renderInput={(params) => (
<TextField
{...params}
label="Прямой/обратный маршрут"
margin="normal"
variant="outlined"
error={!!errors.arms}
helperText={(errors as any)?.arms?.message}
required
/>
)}
/> />
<TextField <TextField
{...register("scale_min", { {...register('scale_min', {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})} })}
error={!!(errors as any)?.scale_min} error={!!(errors as any)?.scale_min}
helperText={(errors as any)?.scale_min?.message} helperText={(errors as any)?.scale_min?.message}
margin="normal" margin="normal"
fullWidth fullWidth
slotProps={{ inputLabel: { shrink: true } }} InputLabelProps={{shrink: true}}
type="number" type="number"
label={"Масштаб (мин)"} label={'Масштаб (мин)'}
name="scale_min" name="scale_min"
/> />
<TextField <TextField
{...register("scale_max", { {...register('scale_max', {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})} })}
error={!!(errors as any)?.scale_max} error={!!(errors as any)?.scale_max}
helperText={(errors as any)?.scale_max?.message} helperText={(errors as any)?.scale_max?.message}
margin="normal" margin="normal"
fullWidth fullWidth
slotProps={{ inputLabel: { shrink: true } }} InputLabelProps={{shrink: true}}
type="number" type="number"
label={"Масштаб (макс)"} label={'Масштаб (макс)'}
name="scale_max" name="scale_max"
/> />
<TextField <TextField
{...register("rotate", { {...register('rotate', {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})} })}
error={!!(errors as any)?.rotate} error={!!(errors as any)?.rotate}
helperText={(errors as any)?.rotate?.message} helperText={(errors as any)?.rotate?.message}
margin="normal" margin="normal"
fullWidth fullWidth
slotProps={{ inputLabel: { shrink: true } }} InputLabelProps={{shrink: true}}
type="number" type="number"
label={"Поворот"} label={'Поворот'}
name="rotate" name="rotate"
/> />
<TextField <TextField
{...register("center_latitude", { {...register('center_latitude', {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})} })}
error={!!(errors as any)?.center_latitude} error={!!(errors as any)?.center_latitude}
helperText={(errors as any)?.center_latitude?.message} helperText={(errors as any)?.center_latitude?.message}
margin="normal" margin="normal"
fullWidth fullWidth
slotProps={{ inputLabel: { shrink: true } }} InputLabelProps={{shrink: true}}
type="number" type="number"
label={"Центр. широта"} label={'Центр. широта'}
name="center_latitude" name="center_latitude"
/> />
<TextField <TextField
{...register("center_longitude", { {...register('center_longitude', {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})} })}
error={!!(errors as any)?.center_longitude} error={!!(errors as any)?.center_longitude}
helperText={(errors as any)?.center_longitude?.message} helperText={(errors as any)?.center_longitude?.message}
margin="normal" margin="normal"
fullWidth fullWidth
slotProps={{ inputLabel: { shrink: true } }} InputLabelProps={{shrink: true}}
type="number" type="number"
label={"Центр. долгота"} label={'Центр. долгота'}
name="center_longitude" name="center_longitude"
/> />
</Box> </Box>
</Create> </Create>
); )
}; }

View File

@ -1,423 +1,236 @@
import { import {Autocomplete, Box, TextField, FormControlLabel, Checkbox, Typography} from '@mui/material'
Autocomplete, import {Edit, useAutocomplete} from '@refinedev/mui'
Box, import {useForm} from '@refinedev/react-hook-form'
TextField, import {Controller} from 'react-hook-form'
FormControlLabel, import {useParams} from 'react-router'
Checkbox, import {LinkedItems} from '../../components/LinkedItems'
Typography, import {StationItem, VehicleItem, stationFields, vehicleFields} from './types'
Button,
} from "@mui/material";
import { Edit, useAutocomplete } from "@refinedev/mui";
import { useForm } from "@refinedev/react-hook-form";
import { Controller } from "react-hook-form";
import { useNavigate, useParams } from "react-router";
import { LinkedItems } from "../../components/LinkedItems";
import {
StationItem,
VehicleItem,
stationFields,
vehicleFields,
} from "./types";
import { useEffect, useState } from "react";
import { META_LANGUAGE, languageStore } from "@stores";
import { observer } from "mobx-react-lite";
import { LanguageSelector } from "@ui";
export const RouteEdit = observer(() => { export const RouteEdit = () => {
const { language } = languageStore;
const { const {
saveButtonProps, saveButtonProps,
register, register,
control, control,
formState: { errors }, formState: {errors},
refineCore: { queryResult }, } = useForm({})
setValue,
getValues,
watch,
} = useForm({
refineCoreProps: META_LANGUAGE(language),
});
const routeDirectionFromServer = watch("route_direction");
const [routeDirection, setRouteDirection] = useState(false); const {id: routeId} = useParams<{id: string}>()
const navigate = useNavigate();
const directions = [
{
label: "Прямой",
value: true,
},
{
label: "Обратный",
value: false,
},
];
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) => [ onSearch: (value) => [
{ {
field: "short_name", field: 'short_name',
operator: "contains", operator: 'contains',
value, value,
}, },
], ],
...META_LANGUAGE(language), })
});
const { autocompleteProps: governorAppealAutocompleteProps } =
useAutocomplete({
resource: "article",
onSearch: (value) => [
{
field: "heading",
operator: "contains",
value,
},
{
field: "media_type",
operator: "contains",
value,
},
],
...META_LANGUAGE(language),
});
useEffect(() => {
if (routeDirectionFromServer) {
setRouteDirection(routeDirectionFromServer);
}
}, [routeDirectionFromServer]);
return ( return (
<Edit saveButtonProps={saveButtonProps}> <Edit saveButtonProps={saveButtonProps}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}> <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
<Box <Controller
component="form" control={control}
sx={{ display: "flex", flexDirection: "column" }} name="carrier_id"
autoComplete="off" rules={{required: 'Это поле является обязательным'}}
> defaultValue={null}
<LanguageSelector /> render={({field}) => (
<Controller <Autocomplete
control={control} {...carrierAutocompleteProps}
name="carrier_id" value={carrierAutocompleteProps.options.find((option) => option.id === field.value) || null}
rules={{ required: "Это поле является обязательным" }} onChange={(_, value) => {
defaultValue={null} field.onChange(value?.id || '')
render={({ field }) => ( }}
<Autocomplete getOptionLabel={(item) => {
{...carrierAutocompleteProps} return item ? item.short_name : ''
value={ }}
carrierAutocompleteProps.options.find( isOptionEqualToValue={(option, value) => {
(option) => option.id === field.value return option.id === value?.id
) || null }}
} filterOptions={(options, {inputValue}) => {
onChange={(_, value) => { return options.filter((option) => option.short_name.toLowerCase().includes(inputValue.toLowerCase()))
field.onChange(value?.id || ""); }}
}} renderInput={(params) => <TextField {...params} label="Выберите перевозчика" margin="normal" variant="outlined" error={!!errors.carrier_id} helperText={(errors as any)?.carrier_id?.message} required />}
getOptionLabel={(item) => { />
return item ? item.short_name : ""; )}
}} />
isOptionEqualToValue={(option, value) => {
return option.id === value?.id;
}}
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
/>
)}
/>
)}
/>
<TextField <TextField
{...register("route_number", { {...register('route_number', {
required: "Это поле является обязательным", required: 'Это поле является обязательным',
setValueAs: (value) => String(value), setValueAs: (value) => String(value),
})} })}
error={!!(errors as any)?.route_number} error={!!(errors as any)?.route_number}
helperText={(errors as any)?.route_number?.message} helperText={(errors as any)?.route_number?.message}
margin="normal" margin="normal"
fullWidth fullWidth
slotProps={{ inputLabel: { shrink: true } }} InputLabelProps={{shrink: true}}
type="text" type="text"
label={"Номер маршрута"} label={'Номер маршрута'}
name="route_number" 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)} />} />}
/>
<input type="hidden" {...register("route_direction")} /> <Typography variant="caption" color="textSecondary" sx={{mt: 0, mb: 1}}>
(Прямой / Обратный)
</Typography>
<Autocomplete <Controller
options={directions} name="path"
value={directions.find((el) => el.value == routeDirection)} control={control}
onChange={(_, element) => { defaultValue={[]}
if (element) { rules={{
setValue("route_direction", element.value); required: 'Это поле является обязательным',
setRouteDirection(element.value); validate: (value: unknown) => {
if (!Array.isArray(value)) return 'Неверный формат'
if (!value.every((point: unknown) => Array.isArray(point) && point.length === 2)) {
return 'Каждая точка должна быть массивом из двух координат'
} }
}} if (!value.every((point: unknown[]) => point.every((coord: unknown) => !isNaN(Number(coord)) && typeof coord === 'number'))) {
renderInput={(params) => ( return 'Координаты должны быть числами'
<TextField }
{...params} return true
label="Прямой/обратный маршрут" },
margin="normal" }}
variant="outlined" render={({field, fieldState: {error}}) => (
error={!!errors.arms} <TextField
helperText={(errors as any)?.arms?.message} {...field}
required value={Array.isArray(field.value) ? JSON.stringify(field.value) : ''}
/> onChange={(e) => {
)}
/>
<TextField
{...register("path", {
required: "Это поле является обязательным",
setValueAs: (value: string) => {
try { try {
const lines = value.trim().split("\n"); const parsed = JSON.parse(e.target.value)
return lines.map((line) => { field.onChange(parsed)
const [lat, lon] = line
.trim()
.split(/[\s,]+/)
.map(Number);
return [lat, lon];
});
} catch { } catch {
return []; field.onChange([])
} }
}, }}
validate: (value: unknown) => { error={!!error}
if (!Array.isArray(value)) return "Неверный формат"; helperText={error?.message} // 'Формат: [[lat1,lon1], [lat2,lon2], ...]'
if (value.length === 0) margin="normal"
return "Введите хотя бы одну пару координат"; fullWidth
if ( InputLabelProps={{shrink: true}}
!value.every( type="text"
(point: unknown) => label={'Координаты маршрута'}
Array.isArray(point) && point.length === 2 placeholder="[[1.1, 2.2], [2.1, 4.5]]"
) sx={{
) { marginBottom: 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
slotProps={{ inputLabel: { 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
slotProps={{ inputLabel: { shrink: true } }}
type="number"
label={"Номер маршрута в Говорящем Городе *"}
name="route_sys_number"
/>
<Controller
control={control}
name="governor_appeal"
defaultValue={null}
render={({ field }) => (
<Autocomplete
{...governorAppealAutocompleteProps}
value={
governorAppealAutocompleteProps.options.find(
(option) => option.id === field.value
) ?? null
}
onChange={(_, value) => {
field.onChange(value?.id ?? "");
}}
getOptionLabel={(item) => {
return item ? item.heading : "";
}}
isOptionEqualToValue={(option, value) => {
return option.id === value?.id;
}}
filterOptions={(options, { inputValue }) => {
return options.filter((option) =>
option.heading
.toLowerCase()
.includes(inputValue.toLowerCase())
);
}}
renderInput={(params) => (
<TextField
{...params}
label="Обращение губернатора"
margin="normal"
variant="outlined"
error={!!errors.arms}
helperText={(errors as any)?.arms?.message}
required
/>
)}
/>
)}
/>
<TextField
{...register("scale_min", {
// required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})}
error={!!(errors as any)?.scale_min}
helperText={(errors as any)?.scale_min?.message}
margin="normal"
fullWidth
slotProps={{ inputLabel: { shrink: true } }}
type="number"
label={"Масштаб (мин)"}
name="scale_min"
/>
<TextField
{...register("scale_max", {
// required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})}
error={!!(errors as any)?.scale_max}
helperText={(errors as any)?.scale_max?.message}
margin="normal"
fullWidth
slotProps={{ inputLabel: { shrink: true } }}
type="number"
label={"Масштаб (макс)"}
name="scale_max"
/>
<TextField
{...register("rotate", {
// required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})}
error={!!(errors as any)?.rotate}
helperText={(errors as any)?.rotate?.message}
margin="normal"
fullWidth
slotProps={{ inputLabel: { shrink: true } }}
type="number"
label={"Поворот"}
name="rotate"
/>
<TextField
{...register("center_latitude", {
// required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})}
error={!!(errors as any)?.center_latitude}
helperText={(errors as any)?.center_latitude?.message}
margin="normal"
fullWidth
slotProps={{ inputLabel: { shrink: true } }}
type="number"
label={"Центр. широта"}
name="center_latitude"
/>
<TextField
{...register("center_longitude", {
// required: 'Это поле является обязательным',
setValueAs: (value) => Number(value),
})}
error={!!(errors as any)?.center_longitude}
helperText={(errors as any)?.center_longitude?.message}
margin="normal"
fullWidth
slotProps={{ inputLabel: { shrink: true } }}
type="number"
label={"Центр. долгота"}
name="center_longitude"
/>
</Box>
{routeId && (
<>
<LinkedItems<StationItem>
type="edit"
parentId={routeId}
parentResource="route"
childResource="station"
fields={stationFields}
title="станции"
dragAllowed={true}
/> />
)}
/>
<LinkedItems<VehicleItem> <TextField
type="edit" {...register('route_sys_number', {
parentId={routeId} required: 'Это поле является обязательным',
parentResource="route" })}
childResource="vehicle" error={!!(errors as any)?.route_sys_number}
fields={vehicleFields} helperText={(errors as any)?.route_sys_number?.message}
title="транспортные средства" margin="normal"
/> fullWidth
</> InputLabelProps={{shrink: true}}
)} type="number"
label={'Системный номер маршрута *'}
name="route_sys_number"
/>
<Box sx={{ display: "flex", justifyContent: "flex-start" }}> <TextField
<Button {...register('governor_appeal', {
variant="contained" // required: 'Это поле является обязательным',
color="primary" })}
onClick={() => navigate(`/route-preview/${routeId}`)} error={!!(errors as any)?.governor_appeal}
> helperText={(errors as any)?.governor_appeal?.message}
Предпросмотр маршрута margin="normal"
</Button> fullWidth
</Box> InputLabelProps={{shrink: true}}
type="number"
label={'Обращение губернатора'}
name="governor_appeal"
/>
<TextField
{...register('scale_min', {
// required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.scale_min}
helperText={(errors as any)?.scale_min?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
type="number"
label={'Масштаб (мин)'}
name="scale_min"
/>
<TextField
{...register('scale_max', {
// required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.scale_max}
helperText={(errors as any)?.scale_max?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
type="number"
label={'Масштаб (макс)'}
name="scale_max"
/>
<TextField
{...register('rotate', {
// required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.rotate}
helperText={(errors as any)?.rotate?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
type="number"
label={'Поворот'}
name="rotate"
/>
<TextField
{...register('center_latitude', {
// required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.center_latitude}
helperText={(errors as any)?.center_latitude?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
type="number"
label={'Центр. широта'}
name="center_latitude"
/>
<TextField
{...register('center_longitude', {
// required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.center_longitude}
helperText={(errors as any)?.center_longitude?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
type="number"
label={'Центр. долгота'}
name="center_longitude"
/>
</Box> </Box>
{routeId && (
<>
<LinkedItems<StationItem> type="edit" parentId={routeId} parentResource="route" childResource="station" fields={stationFields} title="станции" />
<LinkedItems<VehicleItem> type="edit" parentId={routeId} parentResource="route" childResource="vehicle" fields={vehicleFields} title="транспортные средства" />
</>
)}
</Edit> </Edit>
); )
}); }

View File

@ -1,188 +1,156 @@
import { type GridColDef } from "@mui/x-data-grid"; import {type GridColDef} from '@mui/x-data-grid'
import { CustomDataGrid } from "../../components/CustomDataGrid"; import {CustomDataGrid} from '../../components/CustomDataGrid'
import { import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
DeleteButton, import {Typography} from '@mui/material'
EditButton, import React from 'react'
List,
ShowButton,
useDataGrid,
} from "@refinedev/mui";
import { Button, Typography } from "@mui/material";
import React from "react";
import MapIcon from "@mui/icons-material/Map";
import { localeText } from "../../locales/ru/localeText"; import {localeText} from '../../locales/ru/localeText'
import { useLink } from "@refinedev/core";
import { observer } from "mobx-react-lite";
import { languageStore, META_LANGUAGE } from "@stores";
export const RouteList = observer(() => { export const RouteList = () => {
const Link = useLink(); const {dataGridProps} = useDataGrid({
const { language } = languageStore; resource: 'route/',
const { dataGridProps } = useDataGrid({ })
resource: "route/",
meta: META_LANGUAGE(language),
});
const columns = React.useMemo<GridColDef[]>( const columns = React.useMemo<GridColDef[]>(
() => [ () => [
{ {
field: "id", field: 'id',
headerName: "ID", headerName: 'ID',
type: "number", type: 'number',
minWidth: 70, minWidth: 70,
display: "flex", display: 'flex',
align: "left", align: 'left',
headerAlign: "left", headerAlign: 'left',
}, },
{ {
field: "carrier_id", field: 'carrier_id',
headerName: "ID перевозчика", headerName: 'ID перевозчика',
type: "number", type: 'number',
minWidth: 150, minWidth: 150,
display: "flex", display: 'flex',
align: "left", align: 'left',
headerAlign: "left", headerAlign: 'left',
}, },
{ {
field: "carrier", field: 'carrier',
headerName: "Перевозчик", headerName: 'Перевозчик',
type: "string", type: 'string',
minWidth: 150, minWidth: 150,
display: "flex", display: 'flex',
align: "left", align: 'left',
headerAlign: "left", headerAlign: 'left',
}, },
{ {
field: "route_number", field: 'route_number',
headerName: "Номер маршрута", headerName: 'Номер маршрута',
type: "string", type: 'string',
minWidth: 150, minWidth: 150,
display: "flex", display: 'flex',
align: "left", align: 'left',
headerAlign: "left", headerAlign: 'left',
}, },
{ {
field: "route_sys_number", field: 'route_sys_number',
headerName: "Номер маршрута в Говорящем Городе", headerName: 'Системный номер маршрута',
type: "string", type: 'string',
minWidth: 120, minWidth: 120,
display: "flex", display: 'flex',
align: "left", align: 'left',
headerAlign: "left", headerAlign: 'left',
}, },
{ {
field: "governor_appeal", field: 'governor_appeal',
headerName: "Обращение губернатора", headerName: 'Обращение губернатора',
type: "number", type: 'number',
minWidth: 120, minWidth: 120,
display: "flex", display: 'flex',
align: "left", align: 'left',
headerAlign: "left", headerAlign: 'left',
}, },
{ {
field: "scale_min", field: 'scale_min',
headerName: "Масштаб (мин)", headerName: 'Масштаб (мин)',
type: "number", type: 'number',
minWidth: 120, minWidth: 120,
display: "flex", display: 'flex',
align: "left", align: 'left',
headerAlign: "left", headerAlign: 'left',
}, },
{ {
field: "scale_max", field: 'scale_max',
headerName: "Масштаб (макс)", headerName: 'Масштаб (макс)',
type: "number", type: 'number',
minWidth: 120, minWidth: 120,
display: "flex", display: 'flex',
align: "left", align: 'left',
headerAlign: "left", headerAlign: 'left',
}, },
{ {
field: "rotate", field: 'rotate',
headerName: "Поворот", headerName: 'Поворот',
type: "number", type: 'number',
minWidth: 120, minWidth: 120,
display: "flex", display: 'flex',
align: "left", align: 'left',
headerAlign: "left", headerAlign: 'left',
}, },
{ {
field: "center_latitude", field: 'center_latitude',
headerName: "Центр. широта", headerName: 'Центр. широта',
type: "number", type: 'number',
minWidth: 120, minWidth: 120,
display: "flex", display: 'flex',
align: "left", align: 'left',
headerAlign: "left", headerAlign: 'left',
}, },
{ {
field: "center_longitude", field: 'center_longitude',
headerName: "Центр. долгота", headerName: 'Центр. долгота',
type: "number", type: 'number',
minWidth: 120, minWidth: 120,
display: "flex", display: 'flex',
align: "left", align: 'left',
headerAlign: "left", headerAlign: 'left',
}, },
{ {
field: "route_direction", field: 'route_direction',
headerName: "Направление маршрута", headerName: 'Направление маршрута',
type: "boolean", type: 'boolean',
display: "flex", display: 'flex',
align: 'left',
headerAlign: 'left',
minWidth: 120,
flex: 1, flex: 1,
align: "left", renderCell: ({value}) => <Typography style={{color: value ? '#48989f' : '#7f6b58'}}>{value ? 'прямое' : 'обратное'}</Typography>,
headerAlign: "left",
minWidth: 120,
renderCell: ({ value }) => (
<Typography style={{ color: value ? "#48989f" : "#7f6b58" }}>
{value ? "прямое" : "обратное"}
</Typography>
),
}, },
{ {
field: "actions", field: 'actions',
headerName: "Действия", headerName: 'Действия',
cellClassName: "route-actions", cellClassName: 'route-actions',
align: "right", align: 'right',
headerAlign: "center", headerAlign: 'center',
minWidth: 160, minWidth: 120,
display: "flex", display: 'flex',
sortable: false, sortable: false,
filterable: false, filterable: false,
disableColumnMenu: true, disableColumnMenu: true,
renderCell: function render({ row }) { renderCell: function render({row}) {
return ( return (
<> <>
<EditButton hideText recordItemId={row.id} /> <EditButton hideText recordItemId={row.id} />
<Link to={`/route-preview/${row.id}`}>
<Button sx={{ minWidth: 0 }}>
<MapIcon fontSize="small" />
</Button>
</Link>
<ShowButton hideText recordItemId={row.id} /> <ShowButton hideText recordItemId={row.id} />
<DeleteButton <DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} />
hideText
confirmTitle="Вы уверены?"
recordItemId={row.id}
/>
</> </>
); )
}, },
}, },
], ],
[] [],
); )
return ( return (
<List> <List>
<CustomDataGrid <CustomDataGrid {...dataGridProps} columns={columns} localeText={localeText} getRowId={(row: any) => row.id} />
{...dataGridProps}
columns={columns}
localeText={localeText}
getRowId={(row: any) => row.id}
languageEnabled
/>
</List> </List>
); )
}); }

View File

@ -1,119 +1,67 @@
import { Stack, Typography, Box, Button } from "@mui/material"; import {Stack, Typography, Box} from '@mui/material'
import { useShow } from "@refinedev/core"; import {useShow} from '@refinedev/core'
import { Show, TextFieldComponent as TextField } from "@refinedev/mui"; import {Show, TextFieldComponent as TextField} from '@refinedev/mui'
import { LinkedItems } from "../../components/LinkedItems"; import {LinkedItems} from '../../components/LinkedItems'
import { import {StationItem, VehicleItem, stationFields, vehicleFields} from './types'
StationItem,
VehicleItem,
SightItem,
sightFields,
stationFields,
vehicleFields,
} from "./types";
import { useNavigate, useParams } from "react-router";
import { observer } from "mobx-react-lite";
export const RouteShow = observer(() => { export const RouteShow = () => {
const { query } = useShow({}); const {query} = useShow({})
const { data, isLoading } = query; const {data, isLoading} = query
const record = data?.data; const record = data?.data
const { id } = useParams();
const navigate = useNavigate();
const fields = [ const fields = [
{ label: "Перевозчик", data: "carrier" }, {label: 'Перевозчик', data: 'carrier'},
{ label: "Номер маршрута", data: "route_number" }, {label: 'Номер маршрута', data: 'route_number'},
{ {
label: "Направление маршрута", label: 'Направление маршрута',
data: "route_direction", data: 'route_direction',
render: (value: number[][]) => ( render: (value: number[][]) => <Typography style={{color: value ? '#48989f' : '#7f6b58'}}>{value ? 'прямое' : 'обратное'}</Typography>,
<Typography style={{ color: value ? "#48989f" : "#7f6b58" }}>
{value ? "прямое" : "обратное"}
</Typography>
),
}, },
{ {
label: "Координаты маршрута", label: 'Координаты маршрута',
data: "path", data: 'path',
render: (value: number[][]) => ( render: (value: number[][]) => (
<Box <Box
sx={{ sx={{
fontFamily: "monospace", fontFamily: 'monospace',
bgcolor: (theme) => theme.palette.background.paper, bgcolor: (theme) => theme.palette.background.paper,
p: 2, p: 2,
borderRadius: 1, borderRadius: 1,
maxHeight: "200px", maxHeight: '200px',
overflow: "auto", overflow: 'auto',
}} }}
> >
{value?.map((point, index) => ( {JSON.stringify(value)}
<Typography key={index} sx={{ mb: 0.5 }}> {/* {value?.map((point, index) => (
{point[0]}, {point[1]} <Typography key={index} sx={{mb: 0.5}}>
Точка {index + 1}: [{point[0]}, {point[1]}]
</Typography> </Typography>
))} ))} */}
</Box> </Box>
), ),
}, },
]; ]
return ( return (
<Show isLoading={isLoading}> <Show isLoading={isLoading}>
<Stack gap={4}> <Stack gap={4}>
{fields.map(({ label, data, render }) => ( {fields.map(({label, data, render}) => (
<Stack key={data} gap={1}> <Stack key={data} gap={1}>
<Typography variant="body1" fontWeight="bold"> <Typography variant="body1" fontWeight="bold">
{label} {label}
</Typography> </Typography>
{render ? ( {render ? render(record?.[data]) : <TextField value={record?.[data]} />}
render(record?.[data])
) : (
<TextField value={record?.[data]} />
)}
</Stack> </Stack>
))} ))}
{record?.id && ( {record?.id && (
<> <>
<LinkedItems<StationItem> <LinkedItems<StationItem> type="show" parentId={record.id} parentResource="route" childResource="station" fields={stationFields} title="станции" />
type="show"
parentId={record.id}
parentResource="route"
childResource="station"
fields={stationFields}
title="станции"
/>
<LinkedItems<VehicleItem> <LinkedItems<VehicleItem> type="show" parentId={record.id} parentResource="route" childResource="vehicle" fields={vehicleFields} title="транспортные средства" />
type="show"
parentId={record.id}
parentResource="route"
childResource="vehicle"
fields={vehicleFields}
title="транспортные средства"
/>
<LinkedItems<SightItem>
type="show"
parentId={record.id}
parentResource="route"
childResource="sight"
fields={sightFields}
title="достопримечательности"
/>
</> </>
)} )}
<Box sx={{ display: 'flex', justifyContent: 'flex-start' }}>
<Button
variant="contained"
color="primary"
onClick={() => navigate(`/route-preview/${id}`)}
>
Предпросмотр маршрута
</Button>
</Box>
</Stack> </Stack>
</Show> </Show>
); )
}); }

View File

@ -1,53 +1,35 @@
import { VEHICLE_TYPES } from "../../lib/constants"; import {VEHICLE_TYPES} from '../../lib/constants'
export type StationItem = { export type StationItem = {
id: number; id: number
name: string; name: string
description: string; description: string
offset_x: number; [key: string]: string | number
offset_y: number; }
[key: string]: string | number;
};
export type VehicleItem = { export type VehicleItem = {
id: number; id: number
tail_number: number; tail_number: number
type: number; type: number
[key: string]: string | number; [key: string]: string | number
}; }
export type SightItem = {
id: number;
name: string;
city: string;
city_id: number;
address: string;
[key: string]: string | number;
};
export type FieldType<T> = { export type FieldType<T> = {
label: string; label: string
data: keyof T; data: keyof T
render?: (value: any) => React.ReactNode; render?: (value: any) => React.ReactNode
}; }
export const stationFields: Array<FieldType<StationItem>> = [ export const stationFields: Array<FieldType<StationItem>> = [
{ label: "Название", data: "name" }, {label: 'Название', data: 'system_name'},
{ label: "Описание", data: "description" }, {label: 'Описание', data: 'description'},
]; ]
export const sightFields: Array<FieldType<SightItem>> = [
{ label: "Название", data: "name" },
{ label: "Город", data: "city" },
{ label: "Адрес", data: "address" },
];
export const vehicleFields: Array<FieldType<VehicleItem>> = [ export const vehicleFields: Array<FieldType<VehicleItem>> = [
{ label: "Бортовой номер", data: "tail_number" }, {label: 'Бортовой номер', data: 'tail_number'},
{ {
label: "Тип", label: 'Тип',
data: "type", data: 'type',
render: (value: number) => render: (value: number) => VEHICLE_TYPES.find((type) => type.value === value)?.label || value,
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,168 +1,128 @@
import React from "react"; import React from 'react'
import { type GridColDef } from "@mui/x-data-grid"; import {type GridColDef} from '@mui/x-data-grid'
import { import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
DeleteButton, import {Stack} from '@mui/material'
EditButton, import {CustomDataGrid} from '../../components/CustomDataGrid'
List, import {localeText} from '../../locales/ru/localeText'
ShowButton,
useDataGrid,
} from "@refinedev/mui";
import { Stack } from "@mui/material";
import { CustomDataGrid } from "@components";
import { localeText } from "../../locales/ru/localeText";
import { cityStore, languageStore } from "@stores";
import { observer } from "mobx-react-lite";
export const SightList = observer(() => { export const SightList = () => {
const { city_id } = cityStore; const {dataGridProps} = useDataGrid({resource: 'sight/'})
const { language } = languageStore;
const { dataGridProps } = useDataGrid({
resource: "sight",
meta: {
headers: {
"Accept-Language": language,
},
},
filters: {
permanent: [
{
field: "cityID",
operator: "eq",
value: city_id === "0" ? null : city_id,
},
],
},
});
const columns = React.useMemo<GridColDef[]>( const columns = React.useMemo<GridColDef[]>(
() => [ () => [
{ {
field: "id", field: 'id',
headerName: "ID", headerName: 'ID',
type: "number", type: 'number',
minWidth: 70, minWidth: 70,
display: "flex", display: 'flex',
align: "left", align: 'left',
headerAlign: "left", headerAlign: 'left',
}, },
{ {
field: "name", field: 'name',
headerName: "Название", headerName: 'Название',
type: "string", type: 'string',
minWidth: 150, minWidth: 150,
display: "flex", display: 'flex',
align: "left", align: 'left',
headerAlign: "left", headerAlign: 'left',
}, },
{ {
field: "latitude", field: 'latitude',
headerName: "Широта", headerName: 'Широта',
type: "number", type: 'number',
minWidth: 150, minWidth: 150,
display: "flex", display: 'flex',
align: "left", align: 'left',
headerAlign: "left", headerAlign: 'left',
}, },
{ {
field: "longitude", field: 'longitude',
headerName: "Долгота", headerName: 'Долгота',
type: "number", type: 'number',
minWidth: 150, minWidth: 150,
display: "flex", display: 'flex',
align: "left", align: 'left',
headerAlign: "left", headerAlign: 'left',
}, },
{ {
field: "city_id", field: 'city_id',
headerName: "ID города", headerName: 'ID города',
type: "number", type: 'number',
minWidth: 70, minWidth: 70,
display: "flex", display: 'flex',
align: "left", align: 'left',
headerAlign: "left", headerAlign: 'left',
}, },
{ {
field: "city", field: 'city',
headerName: "Город", headerName: 'Город',
type: "string", type: 'string',
minWidth: 100, minWidth: 100,
align: "left", align: 'left',
headerAlign: "left", headerAlign: 'left',
flex: 1, flex: 1,
}, },
{ {
field: "thumbnail", field: 'thumbnail',
headerName: "Карточка", headerName: 'Карточка',
type: "string", type: 'string',
minWidth: 150, minWidth: 150,
}, },
{ {
field: "watermark_lu", field: 'watermark_lu',
headerName: "Вод. знак (lu)", headerName: 'Вод. знак (lu)',
type: "string", type: 'string',
minWidth: 150, minWidth: 150,
}, },
{ {
field: "watermark_rd", field: 'watermark_rd',
headerName: "Вод. знак (rd)", headerName: 'Вод. знак (rd)',
type: "string", type: 'string',
minWidth: 150, minWidth: 150,
}, },
{ {
field: "left_article", field: 'left_article',
headerName: "Левая статья", headerName: 'Левая статья',
type: "number", type: 'number',
minWidth: 150, minWidth: 150,
}, },
{ {
field: "preview_article", field: 'preview_article',
headerName: "Пред. просмотр статьи", headerName: 'Пред. просмотр статьи',
type: "number", type: 'number',
minWidth: 150, minWidth: 150,
}, },
{ {
field: "actions", field: 'actions',
headerName: "Действия", headerName: 'Действия',
minWidth: 120, minWidth: 120,
display: "flex", display: 'flex',
align: "right", align: 'right',
headerAlign: "center", headerAlign: 'center',
sortable: false, sortable: false,
filterable: false, filterable: false,
disableColumnMenu: true, disableColumnMenu: true,
renderCell: function render({ row }) { renderCell: function render({row}) {
return ( return (
<> <>
<EditButton hideText recordItemId={row.id} /> <EditButton hideText recordItemId={row.id} />
<ShowButton hideText recordItemId={row.id} /> <ShowButton hideText recordItemId={row.id} />
<DeleteButton <DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} />
hideText
confirmTitle="Вы уверены?"
recordItemId={row.id}
/>
</> </>
); )
}, },
}, },
], ],
[] [],
); )
return ( return (
<List> <List>
<Stack gap={2.5}> <Stack gap={2.5}>
<CustomDataGrid <CustomDataGrid {...dataGridProps} columns={columns} localeText={localeText} getRowId={(row: any) => row.id} hasCoordinates />
{...dataGridProps}
languageEnabled
columns={columns}
localeText={localeText}
getRowId={(row: any) => row.id}
hasCoordinates
/>
</Stack> </Stack>
</List> </List>
); )
}); }

View File

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

View File

@ -1,61 +0,0 @@
import { Box, TextField, Typography, Paper } from "@mui/material";
import { Create } from "@refinedev/mui";
import { useForm } from "@refinedev/react-hook-form";
import { Controller, FieldValues } from "react-hook-form";
import React, { useState, useEffect } from "react";
import ReactMarkdown from "react-markdown";
import { MarkdownEditor } from "../../components/MarkdownEditor";
import "easymde/dist/easymde.min.css";
import { LanguageSelector } from "@ui";
import { observer } from "mobx-react-lite";
import {
EVERY_LANGUAGE,
Languages,
languageStore,
META_LANGUAGE,
} from "@stores";
import rehypeRaw from "rehype-raw";
const MemoizedSimpleMDE = React.memo(MarkdownEditor);
export const SnapshotCreate = observer(() => {
const {
saveButtonProps,
refineCore: { formLoading, onFinish },
register,
control,
watch,
formState: { errors },
setValue,
handleSubmit,
} = useForm();
return (
<Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
<Box sx={{ display: "flex", flex: 1, gap: 2 }}>
{/* Форма создания */}
<Box sx={{ display: "flex", flex: 1, flexDirection: "column", gap: 2 }}>
<Box
component="form"
sx={{ flex: 1, display: "flex", flexDirection: "column" }}
autoComplete="off"
>
<TextField
{...register("Name", {
required: "Это поле является обязательным",
})}
error={!!(errors as any)?.name}
helperText={(errors as any)?.name?.message}
margin="normal"
fullWidth
InputLabelProps={{ shrink: true }}
type="text"
label={"Название *"}
name="Name"
/>
</Box>
</Box>
</Box>
</Create>
);
});

View File

@ -1,3 +0,0 @@
export * from "./create";
export * from "./list";
export * from "./show";

View File

@ -1,149 +0,0 @@
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";
import { localeText } from "../../locales/ru/localeText";
import { observer } from "mobx-react-lite";
import { useMany } from "@refinedev/core";
import { DatabaseBackup } from "lucide-react";
import axios from "axios";
import { TOKEN_KEY } from "../../providers/authProvider";
import { toast } from "react-toastify";
import { useNotification } from "@refinedev/core";
export const SnapshotList = observer(() => {
const notification = useNotification();
const { dataGridProps } = useDataGrid({
resource: "snapshots",
hasPagination: false,
});
// Получаем список уникальных ParentID
const parentIds = React.useMemo(() => {
return (
dataGridProps?.rows
?.map((row: any) => row.ParentID)
.filter((id) => id !== null && id !== undefined)
.filter((value, index, self) => self.indexOf(value) === index) || []
);
}, [dataGridProps?.rows]);
// Загружаем родительские снапшоты
const { data: parentsData } = useMany({
resource: "snapshots",
ids: parentIds,
queryOptions: {
enabled: parentIds.length > 0,
},
});
// Создаем мапу ID → Name
const parentNameMap = React.useMemo(() => {
const map: Record<number, string> = {};
parentsData?.data?.forEach((parent) => {
map[parent.ID] = parent.Name;
});
return map;
}, [parentsData]);
const handleBackup = async (id: number) => {
try {
const response = await axios.post(
`${import.meta.env.VITE_KRBL_API}/snapshots/${id}/restore`,
{},
{
headers: {
Authorization: `Bearer ${localStorage.getItem(TOKEN_KEY)}`,
},
}
);
if (notification && typeof notification.open === "function") {
notification.open({
message: "Cнапшот восстановлен",
type: "success",
});
}
} catch (error) {
if (notification && typeof notification.open === "function") {
notification.open({
message: "Ошибка при восстановлении снимка",
type: "error",
});
}
}
};
const columns = React.useMemo<GridColDef[]>(
() => [
{
field: "Name",
headerName: "Название",
type: "string",
minWidth: 150,
flex: 1,
align: "left",
headerAlign: "left",
},
{
field: "ParentID",
headerName: "Родитель",
minWidth: 150,
flex: 1,
renderCell: ({ value }) => parentNameMap[value] || "—",
align: "left",
headerAlign: "left",
},
{
field: "actions",
headerName: "Действия",
minWidth: 150,
display: "flex",
align: "center",
headerAlign: "center",
sortable: false,
filterable: false,
disableColumnMenu: true,
renderCell: function render({ row }) {
return (
<>
<button
className="backup-button"
onClick={() => handleBackup(row.ID)}
>
<DatabaseBackup />
</button>
<ShowButton hideText recordItemId={row.ID} />
<DeleteButton
hideText
confirmTitle="Вы уверены?"
recordItemId={row.ID}
/>
</>
);
},
},
],
[parentNameMap]
);
return (
<List>
<Stack gap={2.5}>
<CustomDataGrid
{...dataGridProps}
columns={columns}
localeText={localeText}
getRowId={(row: any) => row.ID}
/>
</Stack>
</List>
);
});

View File

@ -1,26 +0,0 @@
import { Stack, Typography } from "@mui/material";
import { useShow } from "@refinedev/core";
import { Show, TextFieldComponent } from "@refinedev/mui";
export const SnapshotShow = () => {
const { query } = useShow({});
const { data, isLoading } = query;
const record = data?.data;
const fields = [{ label: "Название", data: "Name" }];
return (
<Show isLoading={isLoading} canEdit={false}>
<Stack gap={4}>
{fields.map(({ label, data }) => (
<Stack key={data} gap={1}>
<Typography variant="body1" fontWeight="bold">
{label}
</Typography>
<TextFieldComponent value={record?.[data]} />
</Stack>
))}
</Stack>
</Show>
);
};

View File

@ -1,334 +1,183 @@
import { import {Autocomplete, Box, TextField, Typography, Paper, Grid} from '@mui/material'
Autocomplete, import {Create, useAutocomplete} from '@refinedev/mui'
Box, import {useForm} from '@refinedev/react-hook-form'
TextField, import {Controller} from 'react-hook-form'
Typography,
FormControlLabel,
Checkbox,
Grid,
Paper,
} from "@mui/material";
import { Create, useAutocomplete } from "@refinedev/mui";
import { useForm } from "@refinedev/react-hook-form";
import { useEffect, useState } from "react";
import { Controller } from "react-hook-form";
const TRANSFER_FIELDS = [ const TRANSFER_FIELDS = [
{ name: "bus", label: "Автобус" }, {name: 'bus', label: 'Автобус'},
{ name: "metro_blue", label: "Метро (синяя)" }, {name: 'metro_blue', label: 'Метро (синяя)'},
{ name: "metro_green", label: "Метро (зеленая)" }, {name: 'metro_green', label: 'Метро (зеленая)'},
{ name: "metro_orange", label: "Метро (оранжевая)" }, {name: 'metro_orange', label: 'Метро (оранжевая)'},
{ name: "metro_purple", label: "Метро (фиолетовая)" }, {name: 'metro_purple', label: 'Метро (фиолетовая)'},
{ name: "metro_red", label: "Метро (красная)" }, {name: 'metro_red', label: 'Метро (красная)'},
{ name: "train", label: "Электричка" }, {name: 'train', label: 'Электричка'},
{ name: "tram", label: "Трамвай" }, {name: 'tram', label: 'Трамвай'},
{ name: "trolleybus", label: "Троллейбус" }, {name: 'trolleybus', label: 'Троллейбус'},
]; ]
export const StationCreate = () => { export const StationCreate = () => {
const { const {
saveButtonProps, saveButtonProps,
refineCore: { formLoading }, refineCore: {formLoading},
register, register,
setValue,
control, control,
getValues, formState: {errors},
watch,
formState: { errors },
} = useForm({ } = useForm({
refineCoreProps: { refineCoreProps: {
resource: "station", resource: 'station/',
}, },
}); })
const [coordinatesPreview, setCoordinatesPreview] = useState({ const {autocompleteProps: cityAutocompleteProps} = useAutocomplete({
latitude: "", resource: 'city',
longitude: "",
});
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]);
useEffect(() => {
const latitude = getValues("latitude");
const longitude = getValues("longitude");
if (latitude && longitude) {
setCoordinatesPreview({
latitude: latitude,
longitude: longitude,
});
}
}, [getValues]);
const directions = [
{
label: "Прямой",
value: true,
},
{
label: "Обратный",
value: false,
},
];
const [routeDirection, setRouteDirection] = useState(false);
const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({
resource: "city",
onSearch: (value) => [ onSearch: (value) => [
{ {
field: "name", field: 'name',
operator: "contains", operator: 'contains',
value, value,
}, },
], ],
}); })
return ( return (
<Create isLoading={formLoading} saveButtonProps={saveButtonProps}> <Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
<Box <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
component="form"
sx={{ display: "flex", flexDirection: "column" }}
autoComplete="off"
>
<TextField <TextField
{...register("name", { {...register('name', {
required: "Это поле является обязательным", required: 'Это поле является обязательным',
})} })}
error={!!(errors as any)?.name} error={!!(errors as any)?.name}
helperText={(errors as any)?.name?.message} helperText={(errors as any)?.name?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{ shrink: true }} InputLabelProps={{shrink: true}}
type="text" type="text"
label={"Название *"} label={'Название *'}
name="name" name="name"
/> />
<TextField <TextField
{...register("system_name", { {...register('system_name', {
required: "Это поле является обязательным", required: 'Это поле является обязательным',
})} })}
error={!!(errors as any)?.system_name} error={!!(errors as any)?.system_name}
helperText={(errors as any)?.system_name?.message} helperText={(errors as any)?.system_name?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{ shrink: true }} InputLabelProps={{shrink: true}}
type="text" type="text"
label={"Системное название *"} label={'Системное название *'}
name="system_name" name="system_name"
/> />
<TextField <TextField
{...register("address", { {...register('description', {
// required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.address}
helperText={(errors as any)?.address?.message}
margin="normal"
fullWidth
InputLabelProps={{ shrink: true }}
type="text"
label={"Адрес"}
name="address"
/>
<TextField
{...register("description", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
})} })}
error={!!(errors as any)?.description} error={!!(errors as any)?.description}
helperText={(errors as any)?.description?.message} helperText={(errors as any)?.description?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{ shrink: true }} InputLabelProps={{shrink: true}}
type="text" type="text"
label={"Описание"} label={'Описание'}
name="description" name="description"
/> />
<input
type="hidden"
{...register("direction", {
value: routeDirection,
})}
/>
<Autocomplete
options={directions}
defaultValue={directions.find((el) => el.value == false)}
onChange={(_, element) => {
if (element) {
setValue("direction", element.value);
setRouteDirection(element.value);
}
}}
renderInput={(params) => (
<TextField
{...params}
label="Прямой/обратный маршрут"
margin="normal"
variant="outlined"
error={!!errors.arms}
helperText={(errors as any)?.arms?.message}
required
/>
)}
/>
<TextField <TextField
value={`${coordinatesPreview.latitude}, ${coordinatesPreview.longitude}`} {...register('latitude', {
onChange={handleCoordinatesChange} required: 'Это поле является обязательным',
valueAsNumber: true,
})}
error={!!(errors as any)?.latitude} error={!!(errors as any)?.latitude}
helperText={(errors as any)?.latitude?.message} helperText={(errors as any)?.latitude?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{ shrink: true }} InputLabelProps={{shrink: true}}
type="text" type="number"
label={"Координаты *"} label={'Широта *'}
name="latitude"
/> />
<input <TextField
type="hidden" {...register('longitude', {
{...register("latitude", { required: 'Это поле является обязательным',
value: coordinatesPreview.latitude, valueAsNumber: true,
setValueAs: (value) => {
if (value === "") {
return 0;
}
return Number(value);
},
})}
/>
<input
type="hidden"
{...register("longitude", {
value: coordinatesPreview.longitude,
setValueAs: (value) => {
if (value === "") {
return 0;
}
return Number(value);
},
})} })}
error={!!(errors as any)?.longitude}
helperText={(errors as any)?.longitude?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
type="number"
label={'Долгота *'}
name="longitude"
/> />
<Controller <Controller
control={control} control={control}
name="city_id" name="city_id"
rules={{ required: "Это поле является обязательным" }} rules={{required: 'Это поле является обязательным'}}
defaultValue={null} defaultValue={null}
render={({ field }) => ( render={({field}) => (
<Autocomplete <Autocomplete
{...cityAutocompleteProps} {...cityAutocompleteProps}
value={ value={cityAutocompleteProps.options.find((option) => option.id === field.value) || null}
cityAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => { onChange={(_, value) => {
field.onChange(value?.id || ""); field.onChange(value?.id || '')
}} }}
getOptionLabel={(item) => { getOptionLabel={(item) => {
return item ? item.name : ""; return item ? item.name : ''
}} }}
isOptionEqualToValue={(option, value) => { isOptionEqualToValue={(option, value) => {
return option.id === value?.id; return option.id === value?.id
}} }}
filterOptions={(options, { inputValue }) => { filterOptions={(options, {inputValue}) => {
return options.filter((option) => return options.filter((option) => option.name.toLowerCase().includes(inputValue.toLowerCase()))
option.name.toLowerCase().includes(inputValue.toLowerCase())
);
}} }}
renderInput={(params) => ( renderInput={(params) => <TextField {...params} label="Выберите город" margin="normal" variant="outlined" error={!!errors.city_id} helperText={(errors as any)?.city_id?.message} required />}
<TextField
{...params}
label="Выберите город"
margin="normal"
variant="outlined"
error={!!errors.city_id}
helperText={(errors as any)?.city_id?.message}
required
/>
)}
/> />
)} )}
/> />
<Box sx={{ visibility: "hidden" }}> <TextField
<TextField {...register('offset_x', {
{...register("offset_x", { // required: 'Это поле является обязательным',
// required: 'Это поле является обязательным', })}
setValueAs: (value) => { error={!!(errors as any)?.offset_x}
if (value === "") { helperText={(errors as any)?.offset_x?.message}
return 0; margin="normal"
} fullWidth
}, InputLabelProps={{shrink: true}}
})} type="number"
error={!!(errors as any)?.offset_x} label={'Смещение (X)'}
helperText={(errors as any)?.offset_x?.message} name="offset_x"
margin="normal" />
fullWidth
InputLabelProps={{ shrink: true }}
type="number"
label={"Смещение (X)"}
name="offset_x"
/>
<TextField <TextField
{...register("offset_y", { {...register('offset_y', {
setValueAs: (value) => { // required: 'Это поле является обязательным',
if (value === "") { })}
return 0; error={!!(errors as any)?.offset_y}
} helperText={(errors as any)?.offset_y?.message}
}, margin="normal"
// required: 'Это поле является обязательным', fullWidth
})} InputLabelProps={{shrink: true}}
error={!!(errors as any)?.offset_y} type="number"
helperText={(errors as any)?.offset_y?.message} label={'Смещение (Y)'}
margin="normal" name="offset_y"
fullWidth />
InputLabelProps={{ shrink: true }}
type="number" {/* Группа полей пересадок */}
label={"Смещение (Y)"} <Paper sx={{p: 2, mt: 2}}>
name="offset_y" <Typography variant="h6" gutterBottom>
/> Пересадки
</Box> </Typography>
<Grid container spacing={2}>
{TRANSFER_FIELDS.map((field) => (
<Grid item xs={12} sm={6} md={4} key={field.name}>
<TextField {...register(`transfers.${field.name}`)} error={!!(errors as any)?.transfers?.[field.name]} helperText={(errors as any)?.transfers?.[field.name]?.message} margin="normal" fullWidth InputLabelProps={{shrink: true}} type="text" label={field.label} name={`transfers.${field.name}`} />
</Grid>
))}
</Grid>
</Paper>
</Box> </Box>
{/* Группа полей пересадок */}
<Paper hidden 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>
</Create> </Create>
); )
}; }

View File

@ -1,347 +1,183 @@
import { import {Autocomplete, Box, TextField, Typography, Paper, Grid} from '@mui/material'
Autocomplete, import {Edit, useAutocomplete} from '@refinedev/mui'
Box, import {useForm} from '@refinedev/react-hook-form'
TextField, import {Controller} from 'react-hook-form'
Typography,
FormControlLabel,
Paper,
Grid,
Checkbox,
} from "@mui/material";
import { Edit, useAutocomplete } from "@refinedev/mui";
import { useForm } from "@refinedev/react-hook-form";
import { Controller } from "react-hook-form";
import { useParams } from "react-router"; import {useParams} from 'react-router'
import { LinkedItems } from "../../components/LinkedItems"; import {LinkedItems} from '../../components/LinkedItems'
import { type SightItem, sightFields } from "./types"; import {type SightItem, sightFields} from './types'
import { observer } from "mobx-react-lite";
import { languageStore } from "../../store/LanguageStore";
import { useEffect, useState } from "react";
import { LanguageSwitch } from "../../components/LanguageSwitch/index";
const TRANSFER_FIELDS = [ const TRANSFER_FIELDS = [
{ name: "bus", label: "Автобус" }, {name: 'bus', label: 'Автобус'},
{ name: "metro_blue", label: "Метро (синяя)" }, {name: 'metro_blue', label: 'Метро (синяя)'},
{ name: "metro_green", label: "Метро (зеленая)" }, {name: 'metro_green', label: 'Метро (зеленая)'},
{ name: "metro_orange", label: "Метро (оранжевая)" }, {name: 'metro_orange', label: 'Метро (оранжевая)'},
{ name: "metro_purple", label: "Метро (фиолетовая)" }, {name: 'metro_purple', label: 'Метро (фиолетовая)'},
{ name: "metro_red", label: "Метро (красная)" }, {name: 'metro_red', label: 'Метро (красная)'},
{ name: "train", label: "Электричка" }, {name: 'train', label: 'Электричка'},
{ name: "tram", label: "Трамвай" }, {name: 'tram', label: 'Трамвай'},
{ name: "trolleybus", label: "Троллейбус" }, {name: 'trolleybus', label: 'Троллейбус'},
]; ]
export const StationEdit = observer(() => {
const { language, setLanguageAction } = languageStore;
const [stationData, setStationData] = useState({
ru: {
name: "",
system_name: "",
description: "",
address: "",
latitude: "",
longitude: "",
},
en: {
name: "",
system_name: "",
description: "",
address: "",
latitude: "",
longitude: "",
},
zh: {
name: "",
system_name: "",
description: "",
address: "",
latitude: "",
longitude: "",
},
});
const handleLanguageChange = () => {
setStationData((prevData) => ({
...prevData,
[language]: {
name: watch("name") ?? "",
system_name: watch("system_name") ?? "",
description: watch("description") ?? "",
address: watch("address") ?? "",
latitude: watch("latitude") ?? "",
longitude: watch("longitude") ?? "",
},
}));
};
const [coordinatesPreview, setCoordinatesPreview] = useState({
latitude: "",
longitude: "",
});
export const StationEdit = () => {
const { const {
saveButtonProps, saveButtonProps,
register, register,
control, control,
getValues, formState: {errors},
setValue, } = useForm({})
watch,
formState: { errors },
} = useForm({
refineCoreProps: {
meta: {
headers: { "Accept-Language": language },
},
},
});
const directions = [ const {id: stationId} = useParams<{id: string}>()
{
label: "Прямой",
value: true,
},
{
label: "Обратный",
value: false,
},
];
const directionContent = watch("direction"); const {autocompleteProps: cityAutocompleteProps} = useAutocomplete({
const [routeDirection, setRouteDirection] = useState(false); resource: 'city',
useEffect(() => {
if (directionContent) {
setRouteDirection(directionContent);
}
}, [directionContent]);
useEffect(() => {
if (stationData[language as keyof typeof stationData]?.name) {
setValue("name", stationData[language as keyof typeof stationData]?.name);
}
if (stationData[language as keyof typeof stationData]?.address) {
setValue(
"system_name",
stationData[language as keyof typeof stationData]?.system_name || ""
);
}
if (stationData[language as keyof typeof stationData]?.description) {
setValue(
"description",
stationData[language as keyof typeof stationData]?.description || ""
);
}
if (stationData[language as keyof typeof stationData]?.latitude) {
setValue(
"latitude",
stationData[language as keyof typeof stationData]?.latitude || ""
);
}
if (stationData[language as keyof typeof stationData]?.longitude) {
setValue(
"longitude",
stationData[language as keyof typeof stationData]?.longitude || ""
);
}
}, [language, stationData, setValue]);
useEffect(() => {
setLanguageAction("ru");
}, []);
const { id: stationId } = useParams<{ id: string }>();
const handleCoordinatesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const [lat, lon] = e.target.value.split(",").map((s) => s.trim());
setCoordinatesPreview({
latitude: lat,
longitude: lon,
});
setValue("latitude", lat);
setValue("longitude", lon);
};
const latitudeContent = watch("latitude");
const longitudeContent = watch("longitude");
useEffect(() => {
setCoordinatesPreview({
latitude: latitudeContent || "",
longitude: longitudeContent || "",
});
}, [latitudeContent, longitudeContent]);
const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({
resource: "city",
onSearch: (value) => [ onSearch: (value) => [
{ {
field: "name", field: 'name',
operator: "contains", operator: 'contains',
value, value,
}, },
], ],
})
meta: {
headers: {
"Accept-Language": "ru",
},
},
queryOptions: {
queryKey: ["city"],
},
});
return ( return (
<Edit saveButtonProps={saveButtonProps}> <Edit saveButtonProps={saveButtonProps}>
<Box <Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
component="form"
sx={{ display: "flex", flexDirection: "column" }}
autoComplete="off"
>
<LanguageSwitch action={handleLanguageChange} />
<TextField <TextField
{...register("name", { {...register('name', {
required: "Это поле является обязательным", required: 'Это поле является обязательным',
})} })}
error={!!(errors as any)?.name} error={!!(errors as any)?.name}
helperText={(errors as any)?.name?.message} helperText={(errors as any)?.name?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{ shrink: true }} InputLabelProps={{shrink: true}}
type="text" type="text"
label={"Название *"} label={'Название *'}
name="name" name="name"
/> />
<TextField <TextField
{...register("system_name", { {...register('system_name', {
required: "Это поле является обязательным", required: 'Это поле является обязательным',
})} })}
error={!!(errors as any)?.system_name} error={!!(errors as any)?.system_name}
helperText={(errors as any)?.system_name?.message} helperText={(errors as any)?.system_name?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{ shrink: true }} InputLabelProps={{shrink: true}}
type="text" type="text"
label={"Системное название *"} label={'Системное название *'}
name="system_name" name="system_name"
/> />
<input
type="hidden"
{...register("direction", { value: routeDirection })}
/>
<Autocomplete
options={directions}
value={directions.find((el) => el.value == routeDirection)}
onChange={(_, element) => {
if (element) {
setValue("direction", element.value);
setRouteDirection(element.value);
}
}}
renderInput={(params) => (
<TextField
{...params}
label="Прямой/обратный маршрут"
margin="normal"
variant="outlined"
error={!!errors.direction}
helperText={(errors as any)?.direction?.message}
required
/>
)}
/>
<TextField <TextField
{...register("description", { {...register('description', {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
})} })}
error={!!(errors as any)?.description} error={!!(errors as any)?.description}
helperText={(errors as any)?.description?.message} helperText={(errors as any)?.description?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{ shrink: true }} InputLabelProps={{shrink: true}}
type="text" type="text"
label={"Описание"} label={'Описание'}
name="description" name="description"
/> />
<TextField <TextField
{...register("address", { {...register('latitude', {
// required: 'Это поле является обязательным', required: 'Это поле является обязательным',
valueAsNumber: true,
})} })}
error={!!(errors as any)?.address}
helperText={(errors as any)?.address?.message}
margin="normal"
fullWidth
InputLabelProps={{ shrink: true }}
type="text"
label={"Адрес"}
name="address"
/>
<TextField
value={`${coordinatesPreview.latitude}, ${coordinatesPreview.longitude}`}
onChange={handleCoordinatesChange}
error={!!(errors as any)?.latitude} error={!!(errors as any)?.latitude}
helperText={(errors as any)?.latitude?.message} helperText={(errors as any)?.latitude?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{ shrink: true }} InputLabelProps={{shrink: true}}
type="text" type="number"
label={"Координаты *"} label={'Широта *'}
name="latitude"
/> />
<input <TextField
type="hidden" {...register('longitude', {
{...register("latitude", { required: 'Это поле является обязательным',
value: coordinatesPreview.latitude, valueAsNumber: true,
})} })}
/> error={!!(errors as any)?.longitude}
<input helperText={(errors as any)?.longitude?.message}
type="hidden" margin="normal"
{...register("longitude", { value: coordinatesPreview.longitude })} fullWidth
InputLabelProps={{shrink: true}}
type="number"
label={'Долгота *'}
name="longitude"
/> />
<Controller <Controller
control={control} control={control}
name="city_id" name="city_id"
rules={{ required: "Это поле является обязательным" }} rules={{required: 'Это поле является обязательным'}}
defaultValue={null} defaultValue={null}
render={({ field }) => ( render={({field}) => (
<Autocomplete <Autocomplete
{...cityAutocompleteProps} {...cityAutocompleteProps}
value={ value={cityAutocompleteProps.options.find((option) => option.id === field.value) || null}
cityAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => { onChange={(_, value) => {
field.onChange(value?.id || ""); field.onChange(value?.id || '')
}} }}
getOptionLabel={(item) => { getOptionLabel={(item) => {
return item ? item.name : ""; return item ? item.name : ''
}} }}
isOptionEqualToValue={(option, value) => { isOptionEqualToValue={(option, value) => {
return option.id === value?.id; return option.id === value?.id
}} }}
filterOptions={(options, { inputValue }) => { filterOptions={(options, {inputValue}) => {
return options.filter((option) => return options.filter((option) => option.name.toLowerCase().includes(inputValue.toLowerCase()))
option.name.toLowerCase().includes(inputValue.toLowerCase())
);
}} }}
renderInput={(params) => ( renderInput={(params) => <TextField {...params} label="Выберите город" margin="normal" variant="outlined" error={!!errors.city_id} helperText={(errors as any)?.city_id?.message} required />}
<TextField
{...params}
label="Выберите город"
margin="normal"
variant="outlined"
error={!!errors.city_id}
helperText={(errors as any)?.city_id?.message}
required
/>
)}
/> />
)} )}
/> />
<TextField
{...register('offset_x', {
// required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.offset_x}
helperText={(errors as any)?.offset_x?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
type="number"
label={'Смещение (X)'}
name="offset_x"
/>
<TextField
{...register('offset_y', {
// required: 'Это поле является обязательным',
})}
error={!!(errors as any)?.offset_y}
helperText={(errors as any)?.offset_y?.message}
margin="normal"
fullWidth
InputLabelProps={{shrink: true}}
type="number"
label={'Смещение (Y)'}
name="offset_y"
/>
{/* Группа полей пересадок */}
<Paper sx={{p: 2, mt: 2}}>
<Typography variant="h6" gutterBottom>
Пересадки
</Typography>
<Grid container spacing={2}>
{TRANSFER_FIELDS.map((field) => (
<Grid item xs={12} sm={6} md={4} key={field.name}>
<TextField {...register(`transfers.${field.name}`)} error={!!(errors as any)?.transfers?.[field.name]} helperText={(errors as any)?.transfers?.[field.name]?.message} margin="normal" fullWidth InputLabelProps={{shrink: true}} type="text" label={field.label} name={`transfers.${field.name}`} />
</Grid>
))}
</Grid>
</Paper>
</Box> </Box>
{stationId && ( {stationId && (
@ -352,9 +188,8 @@ export const StationEdit = observer(() => {
childResource="sight" childResource="sight"
fields={sightFields} fields={sightFields}
title="достопримечательности" title="достопримечательности"
dragAllowed={false}
/> />
)} )}
</Edit> </Edit>
); )
}); }

View File

@ -1,180 +1,126 @@
import React, { useEffect, useMemo } from "react"; import React from 'react'
import { type GridColDef } from "@mui/x-data-grid"; import {type GridColDef} from '@mui/x-data-grid'
import { import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
DeleteButton, import {Stack} from '@mui/material'
EditButton, import {CustomDataGrid} from '../../components/CustomDataGrid'
List, import {localeText} from '../../locales/ru/localeText'
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 = observer(() => { export const StationList = () => {
const { city_id } = cityStore; const {dataGridProps} = useDataGrid({resource: 'station/'})
const { language } = languageStore;
const { dataGridProps } = useDataGrid({
resource: "station",
meta: {
headers: {
"Accept-Language": language,
},
},
filters: {
permanent: [
{
field: "cityID",
operator: "eq",
value: city_id === "0" ? null : city_id,
},
],
},
});
const columns = React.useMemo<GridColDef[]>( const columns = React.useMemo<GridColDef[]>(
() => [ () => [
{ {
field: "id", field: 'id',
headerName: "ID", headerName: 'ID',
type: "number", type: 'number',
minWidth: 70, minWidth: 70,
display: "flex", display: 'flex',
align: "left", align: 'left',
headerAlign: "left", headerAlign: 'left',
}, },
{ {
field: "name", field: 'name',
headerName: "Название", headerName: 'Название',
type: "string", type: 'string',
minWidth: 300, minWidth: 300,
display: "flex", display: 'flex',
align: "left", align: 'left',
headerAlign: "left", headerAlign: 'left',
},
{
field: 'system_name',
headerName: 'Системное название',
type: 'string',
minWidth: 200,
display: 'flex',
align: 'left',
headerAlign: 'left',
},
{
field: 'latitude',
headerName: 'Широта',
type: 'number',
minWidth: 150,
display: 'flex',
align: 'left',
headerAlign: 'left',
},
{
field: 'longitude',
headerName: 'Долгота',
type: 'number',
minWidth: 150,
display: 'flex',
align: 'left',
headerAlign: 'left',
},
{
field: 'city_id',
headerName: 'ID города',
type: 'number',
minWidth: 120,
display: 'flex',
align: 'left',
headerAlign: 'left',
},
{
field: 'offset_x',
headerName: 'Смещение (X)',
type: 'number',
minWidth: 120,
display: 'flex',
align: 'left',
headerAlign: 'left',
},
{
field: 'offset_y',
headerName: 'Смещение (Y)',
type: 'number',
minWidth: 120,
display: 'flex',
align: 'left',
headerAlign: 'left',
},
{
field: 'description',
headerName: 'Описание',
type: 'string',
display: 'flex',
align: 'left',
headerAlign: 'left',
flex: 1, flex: 1,
}, },
{ {
field: "system_name", field: 'actions',
headerName: "Системное название", headerName: 'Действия',
type: "string", cellClassName: 'station-actions',
minWidth: 200, align: 'right',
display: "flex", headerAlign: 'center',
align: "left",
headerAlign: "left",
flex: 1,
},
{
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",
},
{
field: "longitude",
headerName: "Долгота",
type: "number",
minWidth: 150,
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: "city_id",
headerName: "ID города",
type: "number",
minWidth: 120, minWidth: 120,
display: "flex", display: 'flex',
align: "left",
headerAlign: "left",
},
{
field: "offset_x",
headerName: "Смещение (X)",
type: "number",
minWidth: 120,
display: "flex",
align: "left",
headerAlign: "left",
},
{
field: "offset_y",
headerName: "Смещение (Y)",
type: "number",
minWidth: 120,
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",
minWidth: 120,
display: "flex",
sortable: false, sortable: false,
filterable: false, filterable: false,
disableColumnMenu: true, disableColumnMenu: true,
renderCell: function render({ row }) { renderCell: function render({row}) {
return ( return (
<> <>
<EditButton hideText recordItemId={row.id} /> <EditButton hideText recordItemId={row.id} />
<ShowButton hideText recordItemId={row.id} /> <ShowButton hideText recordItemId={row.id} />
<DeleteButton <DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} />
hideText
confirmTitle="Вы уверены?"
recordItemId={row.id}
/>
</> </>
); )
}, },
}, },
], ],
[] [],
); )
return ( return (
<List key={city_id}> <List>
<Stack gap={2.5}> <Stack gap={2.5}>
<CustomDataGrid <CustomDataGrid {...dataGridProps} columns={columns} localeText={localeText} getRowId={(row: any) => row.id} hasCoordinates />
{...dataGridProps}
columns={columns}
languageEnabled
localeText={localeText}
getRowId={(row: any) => row.id}
hasCoordinates
/>
</Stack> </Stack>
</List> </List>
); )
}); }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,27 +1,25 @@
import dataProvider from "@refinedev/simple-rest"; import dataProvider from '@refinedev/simple-rest'
import axios from 'axios'
import {BACKEND_URL} from '../lib/constants'
import {TOKEN_KEY} from '../authProvider'
import Cookies from 'js-cookie'
import { TOKEN_KEY } from "@providers"; export const axiosInstance = axios.create()
import axios from "axios";
import { languageStore } from "@stores";
export const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_KRBL_API,
});
axiosInstance.interceptors.request.use((config) => { axiosInstance.interceptors.request.use((config) => {
// Добавляем токен авторизации // Добавляем токен авторизации
const token = localStorage.getItem(TOKEN_KEY); const token = localStorage.getItem(TOKEN_KEY)
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`
} }
// Добавляем язык в кастомный заголовок // Добавляем язык в кастомный заголовок
const lang = Cookies.get('lang') || 'ru'
config.headers['X-Language'] = lang // или 'Accept-Language'
config.headers["X-Language"] = config.headers["Accept-Language"]; // console.log('Request headers:', config.headers)
return config; return config
}); })
const apiUrl = import.meta.env.VITE_KRBL_API; export const customDataProvider = dataProvider(BACKEND_URL, axiosInstance)
export const customDataProvider = dataProvider(apiUrl, axiosInstance);

View File

@ -1,3 +0,0 @@
export * from './data'
export * from './authProvider'
export * from './i18nProvider'

View File

@ -1,20 +0,0 @@
import { makeAutoObservable } from "mobx";
class ArticleStore {
articleModalOpen: boolean = false;
selectedArticleId: number | null = null;
constructor() {
makeAutoObservable(this);
}
setArticleIdAction = (id: number) => {
this.selectedArticleId = id;
};
setArticleModalOpenAction = (open: boolean) => {
this.articleModalOpen = open;
};
}
export const articleStore = new ArticleStore();

View File

@ -1,26 +0,0 @@
import { makeAutoObservable } from "mobx";
class CityStore {
city_id: string = "0";
constructor() {
makeAutoObservable(this);
this.initialize();
}
initialize() {
const id = localStorage.getItem("city_id");
if (id) {
this.city_id = id;
} else {
this.city_id = "0";
}
}
setCityIdAction = (city_id: string) => {
this.city_id = city_id;
localStorage.setItem("city_id", city_id);
};
}
export const cityStore = new CityStore();

View File

@ -1,34 +0,0 @@
import { makeAutoObservable } from "mobx";
export type Languages = "en" | "ru" | "zh";
class LanguageStore {
language: Languages = "ru";
constructor() {
makeAutoObservable(this);
}
setLanguageAction = (language: Languages) => {
this.language = language;
};
}
export const languageStore = new LanguageStore();
export const META_LANGUAGE = (language: Languages) => {
return {
meta: {
headers: {
"Accept-Language": language,
},
},
}
}
export const EVERY_LANGUAGE = (data: any) => {
return {
"en": data,
"ru": data,
"zh": data,
}
}

View File

@ -1,25 +0,0 @@
import { makeAutoObservable } from "mobx";
class StationStore {
stationModalOpen: boolean = false;
selectedStationId: number | null = null;
selectedRouteId: number | null = null;
constructor() {
makeAutoObservable(this);
}
setStationIdAction = (id: number) => {
this.selectedStationId = id;
};
setRouteIdAction = (id: number) => {
this.selectedRouteId = id;
};
setStationModalOpenAction = (open: boolean) => {
this.stationModalOpen = open;
};
}
export const stationStore = new StationStore();

View File

@ -1,4 +0,0 @@
export * from './ArticleStore';
export * from './CityStore';
export * from './LanguageStore';
export * from './StationStore';

8
svg.d.ts vendored
View File

@ -1,8 +0,0 @@
declare module "*.svg" {
import * as React from "react";
export const ReactComponent: React.FunctionComponent<
React.SVGProps<SVGSVGElement>
>;
const src: string;
export default src;
}

View File

@ -5,27 +5,18 @@
"lib": ["DOM", "DOM.Iterable", "ESNext"], "lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false, "allowJs": false,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": false,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"strict": true, "strict": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Node", "moduleResolution": "Node",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": false, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx"
"baseUrl": ".",
"paths": {
"@stores": ["./src/store"],
"@ui": ["./src/components/ui"],
"@providers": ["./src/providers"],
"@lib": ["./src/lib"],
"@components": ["./src/components"],
"@/*": ["./src/*"]
}
}, },
"include": ["src", "svg.d.ts"], "include": ["src"],
"references": [ "references": [
{ {
"path": "./tsconfig.node.json" "path": "./tsconfig.node.json"

View File

@ -2,17 +2,7 @@
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "node", "moduleResolution": "node"
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"baseUrl": ".",
"paths": {
"@mt/common-types": ["src/preview/types"],
"@mt/components": ["src/preview/components"],
"@mt/i18n": ["src/preview/i18n"],
"@mt/widgets": ["src/preview/widgets"],
"@mt/utils": ["src/preview/utils"]
}
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts"]
} }

View File

@ -1,18 +1,6 @@
import { defineConfig } from "vite"; import {defineConfig} from 'vite'
import * as path from "path"; import react from '@vitejs/plugin-react'
import svgr from "vite-plugin-svgr";
import react from "@vitejs/plugin-react";
export default defineConfig({ export default defineConfig({
plugins: [svgr(), react()], plugins: [react()],
resolve: { })
alias: {
"@ui": path.resolve(__dirname, "./src/components/ui"),
"@stores": path.resolve(__dirname, "./src/store"),
"@providers": path.resolve(__dirname, "./src/providers"),
"@lib": path.resolve(__dirname, "./src/lib"),
"@components": path.resolve(__dirname, "./src/components"),
"@": path.resolve(__dirname, "./src/"),
},
},
});

7035
yarn.lock

File diff suppressed because it is too large Load Diff