Compare commits
35 Commits
Author | SHA1 | Date | |
---|---|---|---|
db5e9d9fc4 | |||
b1a4edc136 | |||
3110683c7d | |||
16640cb116 | |||
28826123ec | |||
bd19f1dc88 | |||
0fac04be0d | |||
8a443882b5 | |||
|
7c363f1730 | ||
|
d9bbe4f234 | ||
|
6eaa94778b | ||
|
34423b73a3 | ||
|
ab1fd6b22a | ||
|
042b53e6a4 | ||
|
177653d84a | ||
|
fba2fb0f5c | ||
|
86947d6332 | ||
|
7c920eb81e | ||
|
4b20c94b70 | ||
|
fb16891de3 | ||
|
6454af90d3 | ||
|
275eef597b | ||
65532f7074 | |||
dc483d62de | |||
03829aacc6 | |||
a1a2264758 | |||
0d325a3aa6 | |||
abd054b8d4 | |||
9927c0afd6 | |||
463c593a0e | |||
9e34a71e14 | |||
029a2de97e | |||
4dd149f2af | |||
b6449b02c0 | |||
607012bd47 |
2
.env
Normal file
2
.env
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_KRBL_MEDIA = "https://wn.krbl.ru/media/"
|
||||
VITE_KRBL_API = "https://wn.krbl.ru"
|
51
.gitea/workflows/publish.yaml
Normal file
51
.gitea/workflows/publish.yaml
Normal file
@ -0,0 +1,51 @@
|
||||
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 }}
|
31
Dockerfile
31
Dockerfile
@ -1,37 +1,44 @@
|
||||
# This Dockerfile uses `serve` npm package to serve the static files with node process.
|
||||
# You can find the Dockerfile for nginx in the following link:
|
||||
# https://github.com/refinedev/dockerfiles/blob/main/vite/Dockerfile.nginx
|
||||
FROM refinedev/node:18 AS base
|
||||
|
||||
FROM base as deps
|
||||
FROM refinedev/node:20 AS base
|
||||
|
||||
FROM base AS deps
|
||||
|
||||
# Копируем только файлы зависимостей
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
||||
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||
if [ -f yarn.lock ]; then yarn install --frozen-lockfile; \
|
||||
elif [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
elif [ -f pnpm-lock.yaml ]; then npm install -g pnpm && pnpm install --frozen-lockfile; \
|
||||
else echo "❌ Lockfile not found." && exit 1; \
|
||||
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 . .
|
||||
|
||||
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
|
||||
|
||||
WORKDIR /app/refine
|
||||
|
||||
COPY --from=builder /app/refine/dist ./
|
||||
|
||||
USER refine
|
||||
|
||||
CMD ["serve"]
|
||||
CMD ["serve", "-s", ".", "-l", "3000"]
|
||||
|
5
compose.yaml
Normal file
5
compose.yaml
Normal file
@ -0,0 +1,5 @@
|
||||
services:
|
||||
refine:
|
||||
image: white-nights:latest
|
||||
ports:
|
||||
- "3000:3000"
|
13255
package-lock.json
generated
Normal file
13255
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
36
package.json
@ -6,37 +6,63 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.8.2",
|
||||
"@emotion/styled": "^11.8.1",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@mui/icons-material": "^6.1.6",
|
||||
"@mui/lab": "^6.0.0-beta.14",
|
||||
"@mui/material": "^6.1.7",
|
||||
"@mui/x-data-grid": "^7.22.2",
|
||||
"@photo-sphere-viewer/core": "^5.13.2",
|
||||
"@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/core": "^4.47.1",
|
||||
"@refinedev/core": "^4.57.9",
|
||||
"@refinedev/devtools": "^1.1.32",
|
||||
"@refinedev/kbar": "^1.3.6",
|
||||
"@refinedev/kbar": "^1.3.16",
|
||||
"@refinedev/mui": "^6.0.0",
|
||||
"@refinedev/react-hook-form": "^4.8.14",
|
||||
"@refinedev/react-router": "^1.0.0",
|
||||
"@refinedev/simple-rest": "^5.0.1",
|
||||
"@tanstack/react-query": "^5.74.3",
|
||||
"@types/react-beautiful-dnd": "^13.1.8",
|
||||
"@types/react-simple-maps": "^3.0.6",
|
||||
"axios": "^1.7.9",
|
||||
"classnames": "^2.5.1",
|
||||
"d3-geo": "^3.1.1",
|
||||
"easymde": "^2.19.0",
|
||||
"i18next": "^24.2.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"lucide-react": "^0.511.0",
|
||||
"mobx": "^6.13.7",
|
||||
"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-hook-form": "^7.30.0",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-intl": "^7.1.10",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-photo-sphere-viewer": "^6.2.3",
|
||||
"react-router": "^7.0.2",
|
||||
"react-simplemde-editor": "^5.2.0"
|
||||
"react-simple-maps": "^3.0.0",
|
||||
"react-simplemde-editor": "^5.2.0",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"react-toastify": "^11.0.5",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"three": "^0.175.0",
|
||||
"vite-plugin-svgr": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3-geo": "^3.1.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^18.16.2",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"@types/three": "^0.175.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.57.1",
|
||||
"@typescript-eslint/parser": "^5.57.1",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
|
7245
pnpm-lock.yaml
7245
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
373
public/Emblem.svg
Normal file
373
public/Emblem.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 176 KiB |
BIN
public/GET.png
Normal file
BIN
public/GET.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.4 KiB |
BIN
public/SightIcon.png
Normal file
BIN
public/SightIcon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 750 B |
295
src/App.tsx
295
src/App.tsx
@ -1,42 +1,91 @@
|
||||
import {Refine, Authenticated} from '@refinedev/core'
|
||||
import {DevtoolsPanel, DevtoolsProvider} from '@refinedev/devtools'
|
||||
import {RefineKbar, RefineKbarProvider} from '@refinedev/kbar'
|
||||
import { Refine, Authenticated } from "@refinedev/core";
|
||||
import { DevtoolsPanel, DevtoolsProvider } from "@refinedev/devtools";
|
||||
|
||||
import {ErrorComponent, useNotificationProvider, RefineSnackbarProvider, ThemedLayoutV2} from '@refinedev/mui'
|
||||
import {
|
||||
ErrorComponent,
|
||||
useNotificationProvider,
|
||||
RefineSnackbarProvider,
|
||||
ThemedLayoutV2,
|
||||
} from "@refinedev/mui";
|
||||
|
||||
import {customDataProvider} from './providers/data'
|
||||
import CssBaseline from '@mui/material/CssBaseline'
|
||||
import GlobalStyles from '@mui/material/GlobalStyles'
|
||||
import {BrowserRouter, Route, Routes, Outlet} from 'react-router'
|
||||
import routerBindings, {NavigateToResource, CatchAllNavigate, UnsavedChangesNotifier, DocumentTitleHandler} from '@refinedev/react-router'
|
||||
import {ColorModeContextProvider} from './contexts/color-mode'
|
||||
import {Header} from './components/header'
|
||||
import {Login} from './pages/login'
|
||||
import {authProvider} from './authProvider'
|
||||
import {i18nProvider} from './i18nProvider'
|
||||
import { customDataProvider } from "./providers/data";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import GlobalStyles from "@mui/material/GlobalStyles";
|
||||
import { BrowserRouter, Route, Routes, Outlet } from "react-router";
|
||||
import routerBindings, {
|
||||
NavigateToResource,
|
||||
CatchAllNavigate,
|
||||
UnsavedChangesNotifier,
|
||||
DocumentTitleHandler,
|
||||
} from "@refinedev/react-router";
|
||||
import { ColorModeContextProvider } from "./contexts/color-mode";
|
||||
import { Header } from "./components/header";
|
||||
import { Login } from "./pages/login";
|
||||
import { authProvider, i18nProvider } from "@providers";
|
||||
|
||||
import {CountryList, CountryCreate, CountryEdit, CountryShow} from './pages/country'
|
||||
import {CityList, CityCreate, CityEdit, CityShow} from './pages/city'
|
||||
import {CarrierList, CarrierCreate, CarrierEdit, CarrierShow} from './pages/carrier'
|
||||
import {MediaList, MediaCreate, MediaEdit, MediaShow} from './pages/media'
|
||||
import {ArticleList, ArticleCreate, ArticleEdit, ArticleShow} from './pages/article'
|
||||
import {SightList, SightCreate, SightEdit, SightShow} from './pages/sight'
|
||||
import {StationList, StationCreate, StationEdit, StationShow} from './pages/station'
|
||||
import {VehicleList, VehicleCreate, VehicleEdit, VehicleShow} from './pages/vehicle'
|
||||
import {RouteList, RouteCreate, RouteEdit, RouteShow} from './pages/route'
|
||||
import {UserList, UserCreate, UserEdit, UserShow} from './pages/user'
|
||||
import {
|
||||
CountryList,
|
||||
CountryCreate,
|
||||
CountryEdit,
|
||||
CountryShow,
|
||||
} from "./pages/country";
|
||||
import { CityList, CityCreate, CityEdit, CityShow } from "./pages/city";
|
||||
import {
|
||||
CarrierList,
|
||||
CarrierCreate,
|
||||
CarrierEdit,
|
||||
CarrierShow,
|
||||
} from "./pages/carrier";
|
||||
import { MediaList, MediaCreate, MediaEdit, MediaShow } from "./pages/media";
|
||||
import {
|
||||
ArticleList,
|
||||
ArticleCreate,
|
||||
ArticleEdit,
|
||||
ArticleShow,
|
||||
} from "./pages/article";
|
||||
import { SightList, SightCreate, SightEdit, SightShow } from "./pages/sight";
|
||||
import {
|
||||
StationList,
|
||||
StationCreate,
|
||||
StationEdit,
|
||||
StationShow,
|
||||
} from "./pages/station";
|
||||
import {
|
||||
VehicleList,
|
||||
VehicleCreate,
|
||||
VehicleEdit,
|
||||
VehicleShow,
|
||||
} from "./pages/vehicle";
|
||||
import { RouteList, RouteCreate, RouteEdit, RouteShow } from "./pages/route";
|
||||
import { RoutePreview } from "./pages/route-preview";
|
||||
import { UserList, UserCreate, UserEdit, UserShow } from "./pages/user";
|
||||
|
||||
import {CountryIcon, CityIcon, CarrierIcon, MediaIcon, ArticleIcon, SightIcon, StationIcon, VehicleIcon, RouteIcon, UsersIcon} from './components/ui/Icons'
|
||||
import SidebarTitle from './components/ui/SidebarTitle'
|
||||
import {AdminOnly} from './components/AdminOnly'
|
||||
import {
|
||||
CountryIcon,
|
||||
CityIcon,
|
||||
CarrierIcon,
|
||||
MediaIcon,
|
||||
ArticleIcon,
|
||||
SightIcon,
|
||||
StationIcon,
|
||||
VehicleIcon,
|
||||
RouteIcon,
|
||||
UsersIcon,
|
||||
} from "./components/ui/Icons";
|
||||
import SidebarTitle from "./components/ui/SidebarTitle";
|
||||
import { AdminOnly } from "./components/AdminOnly";
|
||||
|
||||
//import { LoadingProvider } from "@mt/utils";
|
||||
import { KBarProvider, RefineKbar } from "@refinedev/kbar";
|
||||
import { GitBranch } from "lucide-react";
|
||||
import { SnapshotList, SnapshotCreate, SnapshotShow } from "./pages/snapshot";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<RefineKbarProvider>
|
||||
<ColorModeContextProvider>
|
||||
<CssBaseline />
|
||||
<GlobalStyles styles={{html: {WebkitFontSmoothing: 'auto'}}} />
|
||||
<GlobalStyles styles={{ html: { WebkitFontSmoothing: "auto" } }} />
|
||||
<RefineSnackbarProvider>
|
||||
<DevtoolsProvider>
|
||||
<Refine
|
||||
@ -47,122 +96,144 @@ function App() {
|
||||
i18nProvider={i18nProvider}
|
||||
resources={[
|
||||
{
|
||||
name: 'country',
|
||||
list: '/country',
|
||||
create: '/country/create',
|
||||
edit: '/country/edit/:id',
|
||||
show: '/country/show/:id',
|
||||
name: "country",
|
||||
list: "/country",
|
||||
create: "/country/create",
|
||||
edit: "/country/edit/:id",
|
||||
show: "/country/show/:id",
|
||||
meta: {
|
||||
canDelete: true,
|
||||
label: 'Страны',
|
||||
label: "Страны",
|
||||
icon: <CountryIcon />,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'city',
|
||||
list: '/city',
|
||||
create: '/city/create',
|
||||
edit: '/city/edit/:id',
|
||||
show: '/city/show/:id',
|
||||
name: "city",
|
||||
list: "/city",
|
||||
create: "/city/create",
|
||||
edit: "/city/edit/:id",
|
||||
show: "/city/show/:id",
|
||||
meta: {
|
||||
canDelete: true,
|
||||
label: 'Города',
|
||||
label: "Города",
|
||||
icon: <CityIcon />,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'carrier',
|
||||
list: '/carrier',
|
||||
create: '/carrier/create',
|
||||
edit: '/carrier/edit/:id',
|
||||
show: '/carrier/show/:id',
|
||||
name: "carrier",
|
||||
list: "/carrier",
|
||||
create: "/carrier/create",
|
||||
edit: "/carrier/edit/:id",
|
||||
show: "/carrier/show/:id",
|
||||
meta: {
|
||||
canDelete: true,
|
||||
label: 'Перевозчики',
|
||||
label: "Перевозчики",
|
||||
icon: <CarrierIcon />,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'media',
|
||||
list: '/media',
|
||||
create: '/media/create',
|
||||
edit: '/media/edit/:id',
|
||||
show: '/media/show/:id',
|
||||
name: "media",
|
||||
list: "/media",
|
||||
create: "/media/create",
|
||||
edit: "/media/edit/:id",
|
||||
show: "/media/show/:id",
|
||||
meta: {
|
||||
canDelete: true,
|
||||
label: 'Медиа',
|
||||
label: "Медиа",
|
||||
icon: <MediaIcon />,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'article',
|
||||
list: '/article',
|
||||
create: '/article/create',
|
||||
edit: '/article/edit/:id',
|
||||
show: '/article/show/:id',
|
||||
name: "article",
|
||||
list: "/article",
|
||||
create: "/article/create",
|
||||
edit: "/article/edit/:id",
|
||||
show: "/article/show/:id",
|
||||
meta: {
|
||||
canDelete: true,
|
||||
label: 'Статьи',
|
||||
label: "Статьи",
|
||||
icon: <ArticleIcon />,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'sight',
|
||||
list: '/sight',
|
||||
create: '/sight/create',
|
||||
edit: '/sight/edit/:id',
|
||||
show: '/sight/show/:id',
|
||||
name: "sight",
|
||||
list: "/sight",
|
||||
create: "/sight/create",
|
||||
edit: "/sight/edit/:id",
|
||||
show: "/sight/show/:id",
|
||||
meta: {
|
||||
canDelete: true,
|
||||
label: 'Достопримечательности',
|
||||
label: "Достопримечательности",
|
||||
icon: <SightIcon />,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'station',
|
||||
list: '/station',
|
||||
create: '/station/create',
|
||||
edit: '/station/edit/:id',
|
||||
show: '/station/show/:id',
|
||||
name: "station",
|
||||
list: "/station",
|
||||
create: "/station/create",
|
||||
edit: "/station/edit/:id",
|
||||
show: "/station/show/:id",
|
||||
meta: {
|
||||
canDelete: true,
|
||||
label: 'Остановки',
|
||||
label: "Остановки",
|
||||
icon: <StationIcon />,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'vehicle',
|
||||
list: '/vehicle',
|
||||
create: '/vehicle/create',
|
||||
edit: '/vehicle/edit/:id',
|
||||
show: '/vehicle/show/:id',
|
||||
name: "snapshots",
|
||||
list: "/snapshot",
|
||||
create: "/snapshot/create",
|
||||
|
||||
show: "/snapshot/show/:id",
|
||||
meta: {
|
||||
canDelete: true,
|
||||
label: 'Транспорт',
|
||||
label: "Снапшоты",
|
||||
icon: <GitBranch />,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "vehicle",
|
||||
list: "/vehicle",
|
||||
create: "/vehicle/create",
|
||||
edit: "/vehicle/edit/:id",
|
||||
show: "/vehicle/show/:id",
|
||||
meta: {
|
||||
canDelete: true,
|
||||
label: "Транспорт",
|
||||
icon: <VehicleIcon />,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'route',
|
||||
list: '/route',
|
||||
create: '/route/create',
|
||||
edit: '/route/edit/:id',
|
||||
show: '/route/show/:id',
|
||||
name: "route",
|
||||
list: "/route",
|
||||
create: "/route/create",
|
||||
edit: "/route/edit/:id",
|
||||
show: "/route/show/:id",
|
||||
meta: {
|
||||
canDelete: true,
|
||||
label: 'Маршруты',
|
||||
label: "Маршруты",
|
||||
icon: <RouteIcon />,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
list: '/user',
|
||||
create: '/user/create',
|
||||
edit: '/user/edit/:id',
|
||||
show: '/user/show/:id',
|
||||
name: "route-preview",
|
||||
list: "/route",
|
||||
show: "/route/:id/station",
|
||||
meta: {
|
||||
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: 'Пользователи',
|
||||
label: "Пользователи",
|
||||
icon: <UsersIcon />,
|
||||
},
|
||||
},
|
||||
@ -171,20 +242,31 @@ function App() {
|
||||
syncWithLocation: true,
|
||||
warnWhenUnsavedChanges: true, // Включаем глобально
|
||||
useNewQueryKeys: true,
|
||||
projectId: 'Wv044J-t53S3s-PcbJGe',
|
||||
projectId: "Wv044J-t53S3s-PcbJGe",
|
||||
}}
|
||||
>
|
||||
<KBarProvider>
|
||||
<Routes>
|
||||
<Route path="/route-preview">
|
||||
<Route path=":id" element={<RoutePreview />} />
|
||||
</Route>
|
||||
|
||||
<Route
|
||||
element={
|
||||
<Authenticated key="authenticated-inner" fallback={<CatchAllNavigate to="/login" />}>
|
||||
<Authenticated
|
||||
key="authenticated-inner"
|
||||
fallback={<CatchAllNavigate to="/login" />}
|
||||
>
|
||||
<ThemedLayoutV2 Header={Header} Title={SidebarTitle}>
|
||||
<Outlet />
|
||||
</ThemedLayoutV2>
|
||||
</Authenticated>
|
||||
}
|
||||
>
|
||||
<Route index element={<NavigateToResource resource="country" />} />
|
||||
<Route
|
||||
index
|
||||
element={<NavigateToResource resource="country" />}
|
||||
/>
|
||||
|
||||
<Route path="/country">
|
||||
<Route index element={<CountryList />} />
|
||||
@ -207,6 +289,20 @@ function App() {
|
||||
<Route path="show/:id" element={<CountryShow />} />
|
||||
</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 index element={<CityList />} />
|
||||
<Route
|
||||
@ -344,7 +440,10 @@ function App() {
|
||||
</Route>
|
||||
<Route
|
||||
element={
|
||||
<Authenticated key="authenticated-outer" fallback={<Outlet />}>
|
||||
<Authenticated
|
||||
key="authenticated-outer"
|
||||
fallback={<Outlet />}
|
||||
>
|
||||
<NavigateToResource />
|
||||
</Authenticated>
|
||||
}
|
||||
@ -353,23 +452,23 @@ function App() {
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
<RefineKbar />
|
||||
<UnsavedChangesNotifier />
|
||||
<DocumentTitleHandler
|
||||
handler={() => {
|
||||
// const cleanedTitle = title.autoGeneratedTitle.split('|')[0].trim()
|
||||
// return `${cleanedTitle} — Белые ночи`
|
||||
return 'Белые ночи'
|
||||
return "Белые ночи";
|
||||
}}
|
||||
/>
|
||||
<RefineKbar />
|
||||
</KBarProvider>
|
||||
</Refine>
|
||||
<DevtoolsPanel />
|
||||
</DevtoolsProvider>
|
||||
</RefineSnackbarProvider>
|
||||
</ColorModeContextProvider>
|
||||
</RefineKbarProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
@ -1,174 +0,0 @@
|
||||
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}
|
||||
},
|
||||
}
|
@ -1,218 +1,354 @@
|
||||
import {Typography, Button, Box, Accordion, AccordionSummary, AccordionDetails, useTheme, TextField} from '@mui/material'
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
|
||||
import {axiosInstance} from '../providers/data'
|
||||
import {BACKEND_URL} from '../lib/constants'
|
||||
import {useForm, Controller} from 'react-hook-form'
|
||||
import {MarkdownEditor} from './MarkdownEditor'
|
||||
import React, {useState, useCallback} from 'react'
|
||||
import {useDropzone} from 'react-dropzone'
|
||||
import {ALLOWED_IMAGE_TYPES, ALLOWED_VIDEO_TYPES} from '../components/media/MediaFormUtils'
|
||||
import {
|
||||
Typography,
|
||||
Button,
|
||||
Box,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
useTheme,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import { axiosInstance } from "../providers/data";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import React, { useState, useCallback, 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 = {
|
||||
file: File
|
||||
preview: string
|
||||
uploading: boolean
|
||||
mediaId?: number
|
||||
}
|
||||
file: File;
|
||||
preview: string;
|
||||
uploading: boolean;
|
||||
mediaId?: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
parentId: string | number
|
||||
parentResource: string
|
||||
childResource: string
|
||||
title: string
|
||||
}
|
||||
parentId?: string | number;
|
||||
parentResource: string;
|
||||
childResource: string;
|
||||
title: string;
|
||||
left?: boolean;
|
||||
language: Languages;
|
||||
setHeadingParent?: (heading: string) => void;
|
||||
setBodyParent?: (body: string) => void;
|
||||
onSave?: (something: any) => void;
|
||||
noReset?: boolean;
|
||||
};
|
||||
|
||||
export const CreateSightArticle = ({parentId, parentResource, childResource, title}: Props) => {
|
||||
const theme = useTheme()
|
||||
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([])
|
||||
export const CreateSightArticle = ({
|
||||
parentId,
|
||||
parentResource,
|
||||
childResource,
|
||||
title,
|
||||
left,
|
||||
language,
|
||||
setHeadingParent,
|
||||
setBodyParent,
|
||||
onSave,
|
||||
noReset,
|
||||
}: Props) => {
|
||||
const notification = useNotification();
|
||||
const theme = useTheme();
|
||||
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]);
|
||||
const [workingLanguage, setWorkingLanguage] = useState<Languages>(language);
|
||||
|
||||
const {
|
||||
register: registerItem,
|
||||
watch,
|
||||
control: controlItem,
|
||||
handleSubmit: handleSubmitItem,
|
||||
reset: resetItem,
|
||||
setValue,
|
||||
formState: { errors: itemErrors },
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
heading: '',
|
||||
body: '',
|
||||
heading: "",
|
||||
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(
|
||||
() => ({
|
||||
placeholder: 'Введите контент в формате Markdown...',
|
||||
placeholder: "Введите контент в формате Markdown...",
|
||||
spellChecker: false,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
[]
|
||||
);
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
const newFiles = acceptedFiles.map((file) => ({
|
||||
file,
|
||||
preview: URL.createObjectURL(file),
|
||||
uploading: false,
|
||||
}))
|
||||
setMediaFiles((prev) => [...prev, ...newFiles])
|
||||
}, [])
|
||||
}));
|
||||
setMediaFiles((prev) => [...prev, ...newFiles]);
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'image/*': ALLOWED_IMAGE_TYPES,
|
||||
'video/*': ALLOWED_VIDEO_TYPES,
|
||||
"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 formData = new FormData();
|
||||
formData.append("media_name", mediaFile.file.name);
|
||||
formData.append("filename", mediaFile.file.name);
|
||||
formData.append(
|
||||
"type",
|
||||
mediaFile.file.type.startsWith("image/") ? "1" : "2"
|
||||
);
|
||||
formData.append("file", mediaFile.file);
|
||||
|
||||
const response = await axiosInstance.post(`${BACKEND_URL}/media`, formData)
|
||||
return response.data.id
|
||||
}
|
||||
const response = await axiosInstance.post(
|
||||
`${import.meta.env.VITE_KRBL_API}/media`,
|
||||
formData
|
||||
);
|
||||
return response.data.id;
|
||||
};
|
||||
|
||||
const handleCreate = async (data: { heading: string; body: string }) => {
|
||||
try {
|
||||
// Создаем статью
|
||||
const response = await axiosInstance.post(`${BACKEND_URL}/${childResource}`, data)
|
||||
const itemId = response.data.id
|
||||
const response = await axiosInstance.post(
|
||||
`${import.meta.env.VITE_KRBL_API}/${childResource}`,
|
||||
{
|
||||
...data,
|
||||
translations: updateTranslations(),
|
||||
}
|
||||
);
|
||||
const itemId = response.data.id;
|
||||
|
||||
if (parentId) {
|
||||
// Получаем существующие статьи для определения порядкового номера
|
||||
const existingItemsResponse = await axiosInstance.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`)
|
||||
const existingItems = existingItemsResponse.data || []
|
||||
const nextPageNum = existingItems.length + 1
|
||||
const existingItemsResponse = await axiosInstance.get(
|
||||
`${
|
||||
import.meta.env.VITE_KRBL_API
|
||||
}/${parentResource}/${parentId}/${childResource}`
|
||||
);
|
||||
const existingItems = existingItemsResponse.data ?? [];
|
||||
const nextPageNum = existingItems.length + 1;
|
||||
|
||||
// Привязываем статью к достопримечательности
|
||||
await axiosInstance.post(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}/`, {
|
||||
if (!left) {
|
||||
await axiosInstance.post(
|
||||
`${
|
||||
import.meta.env.VITE_KRBL_API
|
||||
}/${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
|
||||
const mediaIds = await Promise.all(
|
||||
mediaFiles.map(async (mediaFile) => {
|
||||
return await uploadMedia(mediaFile)
|
||||
}),
|
||||
)
|
||||
return await uploadMedia(mediaFile);
|
||||
})
|
||||
);
|
||||
|
||||
// Привязываем все медиа к статье
|
||||
await Promise.all(
|
||||
mediaIds.map((mediaId, index) =>
|
||||
axiosInstance.post(`${BACKEND_URL}/article/${itemId}/media/`, {
|
||||
axiosInstance.post(
|
||||
`${import.meta.env.VITE_KRBL_API}/article/${itemId}/media/`,
|
||||
{
|
||||
media_id: mediaId,
|
||||
media_order: index + 1,
|
||||
}),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
resetItem()
|
||||
setMediaFiles([])
|
||||
window.location.reload()
|
||||
)
|
||||
);
|
||||
if (noReset) {
|
||||
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) {
|
||||
console.error('Error creating item:', err)
|
||||
}
|
||||
console.error("Error creating item:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const removeMedia = (index: number) => {
|
||||
setMediaFiles((prev) => {
|
||||
const newFiles = [...prev]
|
||||
URL.revokeObjectURL(newFiles[index].preview)
|
||||
newFiles.splice(index, 1)
|
||||
return newFiles
|
||||
})
|
||||
}
|
||||
const newFiles = [...prev];
|
||||
URL.revokeObjectURL(newFiles[index].preview);
|
||||
newFiles.splice(index, 1);
|
||||
return newFiles;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Accordion>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
sx={{
|
||||
marginTop: 2,
|
||||
background: theme.palette.background.paper,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight="bold">
|
||||
Создать {title}
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{background: theme.palette.background.paper}}>
|
||||
<Box component="form" onSubmit={handleSubmitItem(handleCreate)}>
|
||||
<Box>
|
||||
<TextField
|
||||
{...registerItem('heading', {
|
||||
required: 'Это поле является обязательным',
|
||||
{...registerItem("heading", {
|
||||
required: "Это поле является обязательным",
|
||||
})}
|
||||
error={!!(itemErrors as any)?.heading}
|
||||
helperText={(itemErrors as any)?.heading?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
type="text"
|
||||
sx={{
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
}}
|
||||
label="Заголовок *"
|
||||
/>
|
||||
|
||||
<Controller control={controlItem} name="body" rules={{required: 'Это поле является обязательным'}} defaultValue="" render={({field: {onChange, value}}) => <MemoizedSimpleMDE value={value} onChange={onChange} options={simpleMDEOptions} className="my-markdown-editor" />} />
|
||||
<Controller
|
||||
control={controlItem}
|
||||
name="body"
|
||||
rules={{ required: "Это поле является обязательным" }}
|
||||
defaultValue=""
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<MemoizedSimpleMDE
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={simpleMDEOptions}
|
||||
className="my-markdown-editor"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Dropzone для медиа файлов */}
|
||||
<Box sx={{ mt: 2, mb: 2 }}>
|
||||
<Box
|
||||
{...getRootProps()}
|
||||
sx={{
|
||||
border: '2px dashed',
|
||||
borderColor: isDragActive ? 'primary.main' : 'grey.300',
|
||||
border: "2px dashed",
|
||||
borderColor: isDragActive ? "primary.main" : "grey.300",
|
||||
borderRadius: 1,
|
||||
p: 2,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
textAlign: "center",
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
borderColor: "primary.main",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Typography>{isDragActive ? 'Перетащите файлы сюда...' : 'Перетащите файлы сюда или кликните для выбора'}</Typography>
|
||||
<Typography>
|
||||
{isDragActive
|
||||
? "Перетащите файлы сюда..."
|
||||
: "Перетащите файлы сюда или кликните для выбора"}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Превью загруженных файлов */}
|
||||
<Box sx={{mt: 2, display: 'flex', flexWrap: 'wrap', gap: 1}}>
|
||||
<Box sx={{ mt: 2, display: "flex", flexWrap: "wrap", gap: 1 }}>
|
||||
{mediaFiles.map((mediaFile, index) => (
|
||||
<Box
|
||||
key={mediaFile.preview}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
position: "relative",
|
||||
width: 100,
|
||||
height: 100,
|
||||
}}
|
||||
>
|
||||
{mediaFile.file.type.startsWith('image/') ? (
|
||||
{mediaFile.file.type.startsWith("image/") ? (
|
||||
<img
|
||||
src={mediaFile.preview}
|
||||
alt={mediaFile.file.name}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'grey.200',
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
bgcolor: "grey.200",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption">{mediaFile.file.name}</Typography>
|
||||
<Typography variant="caption">
|
||||
{mediaFile.file.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Button
|
||||
@ -220,10 +356,10 @@ export const CreateSightArticle = ({parentId, parentResource, childResource, tit
|
||||
color="error"
|
||||
onClick={() => removeMedia(index)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
minWidth: 'auto',
|
||||
minWidth: "auto",
|
||||
width: 20,
|
||||
height: 20,
|
||||
p: 0,
|
||||
@ -236,23 +372,26 @@ export const CreateSightArticle = ({parentId, parentResource, childResource, tit
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{mt: 2, display: 'flex', gap: 2}}>
|
||||
<Button variant="contained" color="primary" type="submit">
|
||||
<Box sx={{ mt: 2, display: "flex", gap: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
type="submit"
|
||||
onClick={handleSubmitItem(handleCreate)}
|
||||
>
|
||||
Создать
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
resetItem()
|
||||
mediaFiles.forEach((file) => URL.revokeObjectURL(file.preview))
|
||||
setMediaFiles([])
|
||||
resetItem();
|
||||
mediaFiles.forEach((file) => URL.revokeObjectURL(file.preview));
|
||||
setMediaFiles([]);
|
||||
}}
|
||||
>
|
||||
Очистить
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
@ -1,83 +1,142 @@
|
||||
import {DataGrid, type DataGridProps, type GridColumnVisibilityModel} from '@mui/x-data-grid'
|
||||
import {Stack, Button, Typography} from '@mui/material'
|
||||
import {ExportButton} from '@refinedev/mui'
|
||||
import {useExport} from '@refinedev/core'
|
||||
import React, {useState, useEffect, useMemo} from 'react'
|
||||
import Cookies from 'js-cookie'
|
||||
import {
|
||||
DataGrid,
|
||||
type DataGridProps,
|
||||
type GridColumnVisibilityModel,
|
||||
} from "@mui/x-data-grid";
|
||||
import { Stack, Button, Typography, Box } from "@mui/material";
|
||||
import { ExportButton } from "@refinedev/mui";
|
||||
import { useExport } from "@refinedev/core";
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
import {localeText} from '../locales/ru/localeText'
|
||||
import { localeText } from "../locales/ru/localeText";
|
||||
import { languageStore } from "../store/LanguageStore";
|
||||
import { LanguageSwitch } from "./LanguageSwitch";
|
||||
|
||||
interface CustomDataGridProps extends DataGridProps {
|
||||
hasCoordinates?: boolean
|
||||
resource?: string // Add this prop
|
||||
hasCoordinates?: boolean;
|
||||
resource?: string; // Add this prop
|
||||
languageEnabled?: boolean;
|
||||
}
|
||||
|
||||
const DEV_FIELDS = ['id', 'code', 'country_code', 'city_id', 'carrier_id', 'main_color', 'left_color', 'right_color', 'logo', 'slogan', 'filename', 'arms', 'thumbnail', 'route_sys_number', 'governor_appeal', 'scale_min', 'scale_max', 'rotate', 'center_latitude', 'center_longitude', 'watermark_lu', 'watermark_rd', 'left_article', 'preview_article', 'offset_x', 'offset_y'] as const
|
||||
const DEV_FIELDS = [
|
||||
"id",
|
||||
"code",
|
||||
"country_code",
|
||||
"city_id",
|
||||
"carrier_id",
|
||||
"main_color",
|
||||
"left_color",
|
||||
"right_color",
|
||||
"logo",
|
||||
"slogan",
|
||||
"filename",
|
||||
"arms",
|
||||
"thumbnail",
|
||||
"route_sys_number",
|
||||
"governor_appeal",
|
||||
"scale_min",
|
||||
"scale_max",
|
||||
"rotate",
|
||||
"center_latitude",
|
||||
"center_longitude",
|
||||
"watermark_lu",
|
||||
"watermark_rd",
|
||||
"left_article",
|
||||
"preview_article",
|
||||
"offset_x",
|
||||
"offset_y",
|
||||
] as const;
|
||||
|
||||
export const CustomDataGrid = ({hasCoordinates = false, columns = [], resource, ...props}: CustomDataGridProps) => {
|
||||
export const CustomDataGrid = ({
|
||||
languageEnabled = false,
|
||||
hasCoordinates = false,
|
||||
columns = [],
|
||||
resource,
|
||||
...props
|
||||
}: CustomDataGridProps) => {
|
||||
// const isDev = import.meta.env.DEV
|
||||
const { triggerExport, isLoading: exportLoading } = useExport({
|
||||
resource: resource ?? '',
|
||||
resource: resource ?? "",
|
||||
// pageSize: 100, #*
|
||||
// maxItemCount: 100, #*
|
||||
})
|
||||
});
|
||||
|
||||
const initialShowCoordinates = Cookies.get('showCoordinates') === 'true'
|
||||
const initialShowDevData = false // Default to false in both prod and dev
|
||||
const [showCoordinates, setShowCoordinates] = useState(initialShowCoordinates)
|
||||
const [showDevData, setShowDevData] = useState(Cookies.get('showDevData') === 'true')
|
||||
const initialShowCoordinates = Cookies.get("showCoordinates") === "true";
|
||||
const initialShowDevData = false; // Default to false in both prod and dev
|
||||
const [showCoordinates, setShowCoordinates] = useState(
|
||||
initialShowCoordinates
|
||||
);
|
||||
const [showDevData, setShowDevData] = useState(
|
||||
Cookies.get("showDevData") === "true"
|
||||
);
|
||||
|
||||
const availableDevFields = useMemo(() => DEV_FIELDS.filter((field) => columns.some((column) => column.field === field)), [columns])
|
||||
const availableDevFields = useMemo(
|
||||
() =>
|
||||
DEV_FIELDS.filter((field) =>
|
||||
columns.some((column) => column.field === field)
|
||||
),
|
||||
[columns]
|
||||
);
|
||||
|
||||
const initialVisibilityModel = useMemo(() => {
|
||||
const model: GridColumnVisibilityModel = {}
|
||||
const model: GridColumnVisibilityModel = {};
|
||||
|
||||
availableDevFields.forEach((field) => {
|
||||
model[field] = initialShowDevData
|
||||
})
|
||||
model[field] = initialShowDevData;
|
||||
});
|
||||
|
||||
if (hasCoordinates) {
|
||||
model.latitude = initialShowCoordinates
|
||||
model.longitude = initialShowCoordinates
|
||||
model.latitude = initialShowCoordinates;
|
||||
model.longitude = initialShowCoordinates;
|
||||
}
|
||||
|
||||
return model
|
||||
}, [availableDevFields, hasCoordinates, initialShowCoordinates, initialShowDevData])
|
||||
return model;
|
||||
}, [
|
||||
availableDevFields,
|
||||
hasCoordinates,
|
||||
initialShowCoordinates,
|
||||
initialShowDevData,
|
||||
]);
|
||||
|
||||
const [columnVisibilityModel, setColumnVisibilityModel] = useState<GridColumnVisibilityModel>(initialVisibilityModel)
|
||||
const [columnVisibilityModel, setColumnVisibilityModel] =
|
||||
useState<GridColumnVisibilityModel>(initialVisibilityModel);
|
||||
|
||||
useEffect(() => {
|
||||
setColumnVisibilityModel((prevModel) => {
|
||||
const newModel = {...prevModel}
|
||||
const newModel = { ...prevModel };
|
||||
|
||||
availableDevFields.forEach((field) => {
|
||||
newModel[field] = showDevData
|
||||
})
|
||||
newModel[field] = showDevData;
|
||||
});
|
||||
|
||||
if (hasCoordinates) {
|
||||
newModel.latitude = showCoordinates
|
||||
newModel.longitude = showCoordinates
|
||||
newModel.latitude = showCoordinates;
|
||||
newModel.longitude = showCoordinates;
|
||||
}
|
||||
|
||||
return newModel
|
||||
})
|
||||
return newModel;
|
||||
});
|
||||
|
||||
if (hasCoordinates) {
|
||||
Cookies.set('showCoordinates', String(showCoordinates))
|
||||
Cookies.set("showCoordinates", String(showCoordinates));
|
||||
}
|
||||
Cookies.set('showDevData', String(showDevData))
|
||||
}, [showCoordinates, showDevData, hasCoordinates, availableDevFields])
|
||||
Cookies.set("showDevData", String(showDevData));
|
||||
}, [showCoordinates, showDevData, hasCoordinates, availableDevFields]);
|
||||
|
||||
const toggleCoordinates = () => {
|
||||
setShowCoordinates((prev) => !prev)
|
||||
}
|
||||
setShowCoordinates((prev) => !prev);
|
||||
};
|
||||
|
||||
const toggleDevData = () => {
|
||||
setShowDevData((prev) => !prev)
|
||||
}
|
||||
setShowDevData((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ visibility: languageEnabled ? "visible" : "hidden" }}>
|
||||
<LanguageSwitch />
|
||||
</Box>
|
||||
<DataGrid
|
||||
{...props}
|
||||
columns={columns}
|
||||
@ -92,31 +151,37 @@ export const CustomDataGrid = ({hasCoordinates = false, columns = [], resource,
|
||||
// paginationModel: {pageSize: 25, page: 0},
|
||||
// },
|
||||
sorting: {
|
||||
sortModel: [{field: 'id', sort: 'asc'}],
|
||||
sortModel: [{ field: "id", sort: "asc" }],
|
||||
},
|
||||
}}
|
||||
pageSizeOptions={[10, 25, 50, 100]}
|
||||
/>
|
||||
|
||||
<Stack direction="row" spacing={2} justifyContent="space-between" mb={2}>
|
||||
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
|
||||
{hasCoordinates && (
|
||||
<Button variant="contained" onClick={toggleCoordinates}>
|
||||
{showCoordinates ? 'Скрыть координаты' : 'Показать координаты'}
|
||||
{showCoordinates ? "Скрыть координаты" : "Показать координаты"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(import.meta.env.DEV || showDevData) && availableDevFields.length > 0 && (
|
||||
{(import.meta.env.DEV || showDevData) &&
|
||||
availableDevFields.length > 0 && (
|
||||
<Button variant="contained" onClick={toggleDevData}>
|
||||
{showDevData ? 'Скрыть служебные данные' : 'Показать служебные данные'}
|
||||
{showDevData
|
||||
? "Скрыть служебные данные"
|
||||
: "Показать служебные данные"}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<ExportButton onClick={triggerExport} loading={exportLoading} hideText={false}>
|
||||
<Typography sx={{marginLeft: '-2px'}}>Экспорт</Typography>
|
||||
<ExportButton
|
||||
onClick={triggerExport}
|
||||
loading={exportLoading}
|
||||
hideText={false}
|
||||
>
|
||||
<Typography sx={{ marginLeft: "-2px" }}>Экспорт</Typography>
|
||||
</ExportButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
68
src/components/LanguageSwitch/index.tsx
Normal file
68
src/components/LanguageSwitch/index.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
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>
|
||||
);
|
||||
});
|
@ -1,129 +1,91 @@
|
||||
import {useState, useEffect} from 'react'
|
||||
import {Stack, Typography, Button, FormControl, Grid, Box, Accordion, AccordionSummary, AccordionDetails, useTheme, TextField, Autocomplete} from '@mui/material'
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
|
||||
import {axiosInstance} from '../providers/data'
|
||||
import {BACKEND_URL} from '../lib/constants'
|
||||
import {Link} from 'react-router'
|
||||
import {TOKEN_KEY} from '../authProvider'
|
||||
import { useState, useEffect } from "react";
|
||||
import { languageStore } from "../store/LanguageStore";
|
||||
import {
|
||||
Stack,
|
||||
Typography,
|
||||
Button,
|
||||
FormControl,
|
||||
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> = {
|
||||
label: string
|
||||
data: keyof T
|
||||
render?: (value: any) => React.ReactNode
|
||||
}
|
||||
label: string;
|
||||
data: keyof T;
|
||||
render?: (value: any) => React.ReactNode;
|
||||
};
|
||||
|
||||
type ExtraFieldConfig = {
|
||||
type: 'number'
|
||||
label: string
|
||||
minValue: number
|
||||
maxValue: (linkedItems: any[]) => number
|
||||
}
|
||||
type: "number";
|
||||
label: string;
|
||||
minValue: number;
|
||||
maxValue: (linkedItems: any[]) => number;
|
||||
};
|
||||
|
||||
type LinkedItemsProps<T> = {
|
||||
parentId: string | number
|
||||
parentResource: string
|
||||
childResource: string
|
||||
fields: Field<T>[]
|
||||
title: string
|
||||
type: 'show' | 'edit'
|
||||
extraField?: ExtraFieldConfig
|
||||
}
|
||||
parentId: string | number;
|
||||
parentResource: string;
|
||||
childResource: string;
|
||||
fields: Field<T>[];
|
||||
setItemsParent?: (items: T[]) => void;
|
||||
title: string;
|
||||
type: "show" | "edit";
|
||||
extraField?: ExtraFieldConfig;
|
||||
dragAllowed?: boolean;
|
||||
onSave?: (items: T[]) => void;
|
||||
onUpdate?: () => void;
|
||||
dontRecurse?: boolean;
|
||||
disableCreation?: boolean;
|
||||
updatedLinkedItems?: T[];
|
||||
refresh?: number;
|
||||
};
|
||||
|
||||
export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentId, parentResource, childResource, fields, title, type}: LinkedItemsProps<T>) => {
|
||||
const [items, setItems] = useState<T[]>([])
|
||||
const [linkedItems, setLinkedItems] = useState<T[]>([])
|
||||
const [selectedItemId, setSelectedItemId] = useState<number | null>(null)
|
||||
const [pageNum, setPageNum] = useState<number>(1)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true)
|
||||
const [mediaOrder, setMediaOrder] = useState<number>(1)
|
||||
const theme = useTheme()
|
||||
const reorder = (list: any[], startIndex: number, endIndex: number) => {
|
||||
const result = Array.from(list);
|
||||
const [removed] = result.splice(startIndex, 1);
|
||||
result.splice(endIndex, 0, removed);
|
||||
return result;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (parentId) {
|
||||
axiosInstance
|
||||
.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`)
|
||||
.then((response) => {
|
||||
setLinkedItems(response?.data || [])
|
||||
})
|
||||
.catch(() => {
|
||||
setLinkedItems([])
|
||||
})
|
||||
}
|
||||
}, [parentId, parentResource, childResource])
|
||||
|
||||
useEffect(() => {
|
||||
if (type === 'edit') {
|
||||
axiosInstance
|
||||
.get(`${BACKEND_URL}/${childResource}/`)
|
||||
.then((response) => {
|
||||
setItems(response?.data || [])
|
||||
setIsLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
setItems([])
|
||||
setIsLoading(false)
|
||||
})
|
||||
} else {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [childResource, type])
|
||||
|
||||
useEffect(() => {
|
||||
if (childResource === 'article' && parentResource === 'sight') {
|
||||
setPageNum(linkedItems.length + 1)
|
||||
}
|
||||
}, [linkedItems, childResource, parentResource])
|
||||
|
||||
const availableItems = items.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
|
||||
|
||||
const linkItem = () => {
|
||||
if (selectedItemId !== null) {
|
||||
const requestData =
|
||||
childResource === 'article'
|
||||
? {
|
||||
[`${childResource}_id`]: selectedItemId,
|
||||
page_num: pageNum,
|
||||
}
|
||||
: childResource === 'media'
|
||||
? {
|
||||
[`${childResource}_id`]: selectedItemId,
|
||||
media_order: mediaOrder,
|
||||
}
|
||||
: {
|
||||
[`${childResource}_id`]: selectedItemId,
|
||||
}
|
||||
|
||||
axiosInstance
|
||||
.post(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`, requestData)
|
||||
.then(() => {
|
||||
axiosInstance.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`).then((response) => {
|
||||
setLinkedItems(response?.data || [])
|
||||
setSelectedItemId(null)
|
||||
if (childResource === 'article') {
|
||||
setPageNum(pageNum + 1)
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error linking item:', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deleteItem = (itemId: number) => {
|
||||
axiosInstance
|
||||
.delete(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`, {
|
||||
data: {[`${childResource}_id`]: itemId},
|
||||
})
|
||||
.then(() => {
|
||||
setLinkedItems((prev) => prev.filter((item) => item.id !== itemId))
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error unlinking item:', error)
|
||||
})
|
||||
}
|
||||
export const LinkedItems = <T extends { id: number; [key: string]: any }>(
|
||||
props: LinkedItemsProps<T>
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Accordion>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
@ -133,129 +95,427 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight="bold">
|
||||
Привязанные {title}
|
||||
Привязанные {props.title}
|
||||
</Typography>
|
||||
</AccordionSummary>
|
||||
|
||||
<AccordionDetails sx={{ background: theme.palette.background.paper }}>
|
||||
<Stack gap={2}>
|
||||
<Grid container gap={1.25}>
|
||||
{isLoading ? (
|
||||
<Typography>Загрузка...</Typography>
|
||||
) : linkedItems.length > 0 ? (
|
||||
linkedItems.map((item, index) => (
|
||||
<Box
|
||||
component={Link}
|
||||
to={`/${childResource}/show/${item.id}`}
|
||||
key={index}
|
||||
sx={{
|
||||
marginTop: '8px',
|
||||
padding: '14px',
|
||||
borderRadius: 2,
|
||||
border: `2px solid ${theme.palette.divider}`,
|
||||
width: childResource === 'article' ? '100%' : 'auto',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
display: 'block',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack gap={0.25}>
|
||||
{childResource === 'media' && item.id && (
|
||||
<img
|
||||
src={`https://wn.krbl.ru/media/${item.id}/download?token=${localStorage.getItem(TOKEN_KEY)}`}
|
||||
alt={String(item.media_name)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '120px',
|
||||
objectFit: 'contain',
|
||||
marginBottom: '8px',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
<LinkedItemsContents {...props} />
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{!props.dontRecurse && (
|
||||
<>
|
||||
<ArticleEditModal />
|
||||
<StationEditModal />
|
||||
</>
|
||||
)}
|
||||
{fields.map(({label, data, render}) => (
|
||||
<Typography variant="body2" color="textSecondary" key={String(data)}>
|
||||
<strong>{label}:</strong> {render ? render(item[data]) : item[data]}
|
||||
</Typography>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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(() => {
|
||||
if (parentId) {
|
||||
axiosInstance
|
||||
.get(
|
||||
`${
|
||||
import.meta.env.VITE_KRBL_API
|
||||
}/${parentResource}/${parentId}/${childResource}`
|
||||
)
|
||||
.then((response) => {
|
||||
setLinkedItems(response?.data || []);
|
||||
})
|
||||
.catch(() => {
|
||||
setLinkedItems([]);
|
||||
});
|
||||
}
|
||||
}, [parentId, parentResource, childResource, language, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (type === "edit") {
|
||||
axiosInstance
|
||||
.get(`${import.meta.env.VITE_KRBL_API}/${childResource}/`)
|
||||
.then((response) => {
|
||||
setItems(response?.data || []);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setItems([]);
|
||||
setIsLoading(false);
|
||||
});
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [childResource, type]);
|
||||
|
||||
useEffect(() => {
|
||||
if (childResource === "article" && parentResource === "sight") {
|
||||
setPageNum(linkedItems.length + 1);
|
||||
}
|
||||
}, [linkedItems, childResource, parentResource]);
|
||||
|
||||
const linkItem = () => {
|
||||
if (selectedItemId !== null) {
|
||||
const requestData =
|
||||
childResource === "article"
|
||||
? {
|
||||
[`${childResource}_id`]: selectedItemId,
|
||||
page_num: pageNum,
|
||||
}
|
||||
: childResource === "media"
|
||||
? {
|
||||
[`${childResource}_id`]: selectedItemId,
|
||||
media_order: mediaOrder,
|
||||
}
|
||||
: childResource === "station"
|
||||
? {
|
||||
stations: insertAtPosition(
|
||||
linkedItems.map((item) => ({
|
||||
id: item.id,
|
||||
})),
|
||||
position,
|
||||
{
|
||||
id: selectedItemId,
|
||||
}
|
||||
),
|
||||
}
|
||||
: { [`${childResource}_id`]: selectedItemId };
|
||||
|
||||
axiosInstance
|
||||
.post(
|
||||
`${
|
||||
import.meta.env.VITE_KRBL_API
|
||||
}/${parentResource}/${parentId}/${childResource}`,
|
||||
requestData
|
||||
)
|
||||
.then(() => {
|
||||
axiosInstance
|
||||
.get(
|
||||
`${
|
||||
import.meta.env.VITE_KRBL_API
|
||||
}/${parentResource}/${parentId}/${childResource}`
|
||||
)
|
||||
.then((response) => {
|
||||
setLinkedItems(response?.data || []);
|
||||
setSelectedItemId(null);
|
||||
if (childResource === "article") {
|
||||
setPageNum(pageNum + 1);
|
||||
}
|
||||
onUpdate?.();
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error linking item:", error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItem = (itemId: number) => {
|
||||
axiosInstance
|
||||
.delete(
|
||||
`${
|
||||
import.meta.env.VITE_KRBL_API
|
||||
}/${parentResource}/${parentId}/${childResource}`,
|
||||
{
|
||||
data: { [`${childResource}_id`]: itemId },
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
setLinkedItems((prev) => prev.filter((item) => item.id !== itemId));
|
||||
onUpdate?.();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error unlinking item:", error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{linkedItems?.length > 0 && (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{type === "edit" && dragAllowed && (
|
||||
<TableCell width="40px"></TableCell>
|
||||
)}
|
||||
<TableCell key="id">№</TableCell>
|
||||
{fields.map((field) => (
|
||||
<TableCell key={String(field.data)}>
|
||||
{field.label}
|
||||
</TableCell>
|
||||
))}
|
||||
{type === 'edit' && (
|
||||
|
||||
{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.preventDefault()
|
||||
deleteItem(item.id)
|
||||
e.stopPropagation();
|
||||
deleteItem(item.id);
|
||||
}}
|
||||
sx={{mt: 1.5}}
|
||||
>
|
||||
Отвязать
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
))
|
||||
) : (
|
||||
<Typography color="textSecondary">{title} не найдены</Typography>
|
||||
</TableRow>
|
||||
)}
|
||||
</Grid>
|
||||
</Draggable>
|
||||
))}
|
||||
|
||||
{type === 'edit' && (
|
||||
<Stack gap={2}>
|
||||
{provided.placeholder}
|
||||
</TableBody>
|
||||
)}
|
||||
</Droppable>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</DragDropContext>
|
||||
)}
|
||||
|
||||
{linkedItems.length === 0 && !isLoading && (
|
||||
<Typography color="textSecondary" textAlign="center" py={2}>
|
||||
{title} не найдены
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{type === "edit" && !disableCreation && (
|
||||
<Stack gap={2} mt={2}>
|
||||
<Typography variant="subtitle1">Добавить {title}</Typography>
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
value={availableItems.find((item) => item.id === selectedItemId) || null}
|
||||
value={
|
||||
availableItems?.find((item) => item.id === selectedItemId) || null
|
||||
}
|
||||
onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)}
|
||||
options={availableItems}
|
||||
getOptionLabel={(item) => String(item[fields[0].data])}
|
||||
renderInput={(params) => <TextField {...params} label={`Выберите ${title}`} fullWidth />}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label={`Выберите ${title}`} fullWidth />
|
||||
)}
|
||||
isOptionEqualToValue={(option, value) => option.id === value?.id}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
// return options.filter((option) => String(option[fields[0].data]).toLowerCase().includes(inputValue.toLowerCase()))
|
||||
const searchWords = inputValue
|
||||
.toLowerCase()
|
||||
.split(' ')
|
||||
.filter((word) => word.length > 0)
|
||||
.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)))
|
||||
})
|
||||
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' && (
|
||||
{/* {childResource === "article" && (
|
||||
<FormControl fullWidth>
|
||||
<TextField
|
||||
type="number"
|
||||
label="Номер страницы"
|
||||
label="Позиция добавляемой статьи"
|
||||
name="page_num"
|
||||
value={pageNum}
|
||||
onChange={(e) => {
|
||||
const newValue = Number(e.target.value)
|
||||
const minValue = linkedItems.length + 1 // page number on articles lenght
|
||||
setPageNum(newValue < minValue ? minValue : newValue)
|
||||
const newValue = Number(e.target.value);
|
||||
const minValue = linkedItems.length + 1;
|
||||
setPageNum(newValue < minValue ? minValue : newValue);
|
||||
}}
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
)} */}
|
||||
|
||||
{childResource === 'media' && type === 'edit' && (
|
||||
{childResource === "media" && (
|
||||
<FormControl fullWidth>
|
||||
<TextField
|
||||
type="number"
|
||||
type="text"
|
||||
label="Порядок отображения медиа"
|
||||
value={mediaOrder}
|
||||
onChange={(e) => {
|
||||
const newValue = Number(e.target.value)
|
||||
const maxValue = linkedItems.length + 1
|
||||
const value = Math.max(1, Math.min(newValue, maxValue))
|
||||
setMediaOrder(value)
|
||||
const rawValue = e.target.value;
|
||||
const numericValue = Number(rawValue);
|
||||
const maxValue = linkedItems.length + 1;
|
||||
|
||||
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 }}
|
||||
@ -263,13 +523,33 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
<Button variant="contained" onClick={linkItem} disabled={!selectedItemId}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={linkItem}
|
||||
disabled={
|
||||
!selectedItemId || (childResource == "media" && mediaOrder == 0)
|
||||
}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</Stack>
|
||||
{childResource == "station" && (
|
||||
<TextField
|
||||
type="text"
|
||||
label="Позиция добавляемой остановки к маршруту"
|
||||
value={position}
|
||||
onChange={(e) => {
|
||||
const newValue = Number(e.target.value);
|
||||
setPosition(
|
||||
newValue > linkedItems.length + 1
|
||||
? linkedItems.length + 1
|
||||
: newValue
|
||||
);
|
||||
}}
|
||||
></TextField>
|
||||
)}
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -1,6 +1,6 @@
|
||||
import {styled} from '@mui/material/styles'
|
||||
import zIndex from '@mui/material/styles/zIndex'
|
||||
import SimpleMDE, {SimpleMDEReactProps} from 'react-simplemde-editor'
|
||||
import SimpleMDE, {SimpleMDEReactProps, default as SimpleMDEDefault} from 'react-simplemde-editor'
|
||||
|
||||
const StyledMarkdownEditor = styled('div')(({theme}) => ({
|
||||
'& .editor-toolbar': {
|
||||
@ -63,10 +63,46 @@ const StyledMarkdownEditor = styled('div')(({theme}) => ({
|
||||
'& .guide': {
|
||||
display: 'none',
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
||||
export const MarkdownEditor = (props: SimpleMDEReactProps) => (
|
||||
export const MarkdownEditor = (props: SimpleMDEReactProps) => {
|
||||
if(props.options)
|
||||
props.options.toolbar = [
|
||||
"bold",
|
||||
"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>
|
||||
)
|
||||
}
|
||||
|
@ -1,150 +1,194 @@
|
||||
import DarkModeOutlined from '@mui/icons-material/DarkModeOutlined'
|
||||
import LightModeOutlined from '@mui/icons-material/LightModeOutlined'
|
||||
import AppBar from '@mui/material/AppBar'
|
||||
import Avatar from '@mui/material/Avatar'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Toolbar from '@mui/material/Toolbar'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import {useGetIdentity, usePermissions, useWarnAboutChange} from '@refinedev/core'
|
||||
import {HamburgerMenu, RefineThemedLayoutV2HeaderProps} from '@refinedev/mui'
|
||||
import React, {useContext, useEffect} from 'react'
|
||||
import {ColorModeContext} from '../../contexts/color-mode'
|
||||
import Cookies from 'js-cookie'
|
||||
import {useTranslation} from 'react-i18next'
|
||||
import {Button} from '@mui/material'
|
||||
import {useNavigate} from 'react-router'
|
||||
|
||||
import DarkModeOutlined from "@mui/icons-material/DarkModeOutlined";
|
||||
import LightModeOutlined from "@mui/icons-material/LightModeOutlined";
|
||||
import AppBar from "@mui/material/AppBar";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { languageStore } from "../../store/LanguageStore";
|
||||
import {
|
||||
useGetIdentity,
|
||||
useList,
|
||||
usePermissions,
|
||||
useWarnAboutChange,
|
||||
} from "@refinedev/core";
|
||||
import { HamburgerMenu, RefineThemedLayoutV2HeaderProps } from "@refinedev/mui";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { ColorModeContext } from "../../contexts/color-mode";
|
||||
import Cookies from "js-cookie";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
SelectChangeEvent,
|
||||
} from "@mui/material";
|
||||
import { useNavigate } from "react-router";
|
||||
import { cityStore } from "../../store/CityStore";
|
||||
import { observer } from "mobx-react-lite";
|
||||
type IUser = {
|
||||
id: number
|
||||
name: string
|
||||
avatar: string
|
||||
is_admin: boolean
|
||||
}
|
||||
id: number;
|
||||
name: string;
|
||||
avatar: string;
|
||||
is_admin: boolean;
|
||||
};
|
||||
|
||||
export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = ({sticky = true}) => {
|
||||
const {mode, setMode} = useContext(ColorModeContext)
|
||||
const {data: user} = useGetIdentity<IUser>()
|
||||
const {data: permissions} = usePermissions<string[]>()
|
||||
const isAdmin = permissions?.includes('admin')
|
||||
const {i18n} = useTranslation()
|
||||
const {setWarnWhen, warnWhen} = useWarnAboutChange()
|
||||
export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = observer(
|
||||
({ sticky }) => {
|
||||
const { city_id, setCityIdAction } = cityStore;
|
||||
const { language } = languageStore;
|
||||
const { data: cities } = useList({
|
||||
resource: "city",
|
||||
});
|
||||
|
||||
const navigate = useNavigate()
|
||||
const { mode, setMode } = useContext(ColorModeContext);
|
||||
const { data: user } = useGetIdentity<IUser>();
|
||||
const { data: permissions } = usePermissions<string[]>();
|
||||
const isAdmin = permissions?.includes("admin");
|
||||
const { i18n } = useTranslation();
|
||||
const { setWarnWhen, warnWhen } = useWarnAboutChange();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleChange = (event: SelectChangeEvent<string>) => {
|
||||
setCityIdAction(event.target.value);
|
||||
};
|
||||
|
||||
const handleLanguageChange = async (lang: string) => {
|
||||
// console.log('Language change requested:', lang)
|
||||
// console.log('Current warnWhen state:', warnWhen)
|
||||
|
||||
const form = document.querySelector('form')
|
||||
const inputs = form?.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>('input, textarea, select')
|
||||
const saveButton = document.querySelector('.refine-save-button') as HTMLButtonElement
|
||||
const form = document.querySelector("form");
|
||||
const inputs = form?.querySelectorAll<
|
||||
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||
>("input, textarea, select");
|
||||
const saveButton = document.querySelector(
|
||||
".refine-save-button"
|
||||
) as HTMLButtonElement;
|
||||
|
||||
// Сохраняем текущий URL перед любыми действиями
|
||||
const currentLocation = window.location.pathname + window.location.search
|
||||
const currentLocation = window.location.pathname + window.location.search;
|
||||
|
||||
if (form && saveButton) {
|
||||
const hasChanges = Array.from(inputs || []).some((input) => {
|
||||
if (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) {
|
||||
return input.value !== input.defaultValue
|
||||
if (
|
||||
input instanceof HTMLInputElement ||
|
||||
input instanceof HTMLTextAreaElement
|
||||
) {
|
||||
return input.value !== input.defaultValue;
|
||||
}
|
||||
if (input instanceof HTMLSelectElement) {
|
||||
return input.value !== input.options[input.selectedIndex].defaultSelected.toString()
|
||||
return (
|
||||
input.value !==
|
||||
input.options[input.selectedIndex].defaultSelected.toString()
|
||||
);
|
||||
}
|
||||
return false
|
||||
})
|
||||
return false;
|
||||
});
|
||||
|
||||
if (hasChanges || warnWhen) {
|
||||
try {
|
||||
// console.log('Attempting to save changes...')
|
||||
setWarnWhen(false)
|
||||
saveButton.click()
|
||||
setWarnWhen(false);
|
||||
saveButton.click();
|
||||
// console.log('Save button clicked')
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// После сохранения меняем язык и возвращаемся на ту же страницу
|
||||
Cookies.set('lang', lang)
|
||||
i18n.changeLanguage(lang)
|
||||
navigate(currentLocation)
|
||||
return
|
||||
Cookies.set("lang", lang);
|
||||
|
||||
i18n.changeLanguage(lang);
|
||||
navigate(currentLocation);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('Failed to save form:', error)
|
||||
setWarnWhen(true)
|
||||
return
|
||||
console.error("Failed to save form:", error);
|
||||
setWarnWhen(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Если нет формы или изменений, просто меняем язык
|
||||
// console.log('Setting language cookie:', lang)
|
||||
Cookies.set('lang', lang)
|
||||
Cookies.set("lang", lang);
|
||||
|
||||
// console.log('Changing i18n language')
|
||||
i18n.changeLanguage(lang)
|
||||
i18n.changeLanguage(lang);
|
||||
|
||||
// Используем текущий URL для навигации
|
||||
navigate(0)
|
||||
}
|
||||
navigate(0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const savedLang = Cookies.get('lang') || 'ru'
|
||||
i18n.changeLanguage(savedLang)
|
||||
}, [i18n])
|
||||
const savedLang = Cookies.get("lang") || "ru";
|
||||
i18n.changeLanguage(savedLang);
|
||||
}, [i18n]);
|
||||
|
||||
return (
|
||||
<AppBar position={sticky ? 'sticky' : 'relative'}>
|
||||
<AppBar position={sticky ? "sticky" : "relative"}>
|
||||
<Toolbar>
|
||||
<Stack direction="row" width="100%" justifyContent="flex-end" alignItems="center">
|
||||
<HamburgerMenu />
|
||||
<Stack direction="row" width="100%" justifyContent="flex-end" alignItems="center" spacing={2}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{
|
||||
backgroundColor: 'background.paper',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
width="100%"
|
||||
justifyContent="flex-end"
|
||||
alignItems="center"
|
||||
>
|
||||
{['ru', 'en', 'zh'].map((lang) => (
|
||||
<Button
|
||||
key={lang}
|
||||
onClick={() => handleLanguageChange(lang)}
|
||||
variant={i18n.language === lang ? 'contained' : 'outlined'}
|
||||
size="small"
|
||||
sx={{
|
||||
minWidth: '30px',
|
||||
padding: '2px 0px',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
<HamburgerMenu />
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
width="100%"
|
||||
justifyContent="flex-end"
|
||||
alignItems="center"
|
||||
spacing={2}
|
||||
>
|
||||
{lang}
|
||||
</Button>
|
||||
<FormControl variant="standard" sx={{ width: "min-content" }}>
|
||||
{city_id && cities && (
|
||||
<Select
|
||||
defaultValue={city_id}
|
||||
value={city_id}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<MenuItem value={String(0)} key={0}>
|
||||
Все города
|
||||
</MenuItem>
|
||||
{cities.data?.map((city) => (
|
||||
<MenuItem value={String(city.id)} key={city.id}>
|
||||
{city.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Stack>
|
||||
</Select>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
setMode()
|
||||
setMode();
|
||||
}}
|
||||
sx={{
|
||||
marginRight: '2px',
|
||||
marginRight: "2px",
|
||||
}}
|
||||
>
|
||||
{mode === 'dark' ? <LightModeOutlined /> : <DarkModeOutlined />}
|
||||
{mode === "dark" ? <LightModeOutlined /> : <DarkModeOutlined />}
|
||||
</IconButton>
|
||||
|
||||
{(user?.avatar || user?.name) && (
|
||||
<Stack direction="row" gap="16px" alignItems="center" justifyContent="center">
|
||||
<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',
|
||||
xs: "none",
|
||||
sm: "inline-block",
|
||||
},
|
||||
}}
|
||||
variant="subtitle2"
|
||||
@ -155,18 +199,18 @@ export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = ({sticky = true
|
||||
<Typography
|
||||
sx={{
|
||||
display: {
|
||||
xs: 'none',
|
||||
sm: 'inline-block',
|
||||
xs: "none",
|
||||
sm: "inline-block",
|
||||
},
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
padding: '1px 4px',
|
||||
backgroundColor: "primary.main",
|
||||
color: "rgba(255, 255, 255, 0.7)",
|
||||
padding: "1px 4px",
|
||||
borderRadius: 1,
|
||||
fontSize: '0.6rem',
|
||||
fontSize: "0.6rem",
|
||||
}}
|
||||
variant="subtitle2"
|
||||
>
|
||||
{isAdmin ? 'Администратор' : 'Пользователь'}
|
||||
{isAdmin ? "Администратор" : "Пользователь"}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
@ -177,5 +221,6 @@ export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = ({sticky = true
|
||||
</Stack>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
@ -1 +1,5 @@
|
||||
export {Header} from './header'
|
||||
export * from './AdminOnly'
|
||||
export * from './CreateSightArticle'
|
||||
export * from './CustomDataGrid'
|
||||
export * from './LinkedItems'
|
||||
export * from './MarkdownEditor'
|
@ -1,72 +1,142 @@
|
||||
import {useState} from 'react'
|
||||
import {UseFormSetError, UseFormClearErrors, UseFormSetValue} from 'react-hook-form'
|
||||
import { useState } from "react";
|
||||
import {
|
||||
UseFormSetError,
|
||||
UseFormClearErrors,
|
||||
UseFormSetValue,
|
||||
} from "react-hook-form";
|
||||
|
||||
export const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||
export const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/ogg']
|
||||
export const ALLOWED_IMAGE_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
|
||||
export const ALLOWED_VIDEO_TYPES = ["video/mp4", "video/webm", "video/ogg"];
|
||||
|
||||
export const ALLOWED_PANORAMA_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
|
||||
export const ALLOWED_ICON_TYPES = [
|
||||
"image/svg+xml",
|
||||
"image/png",
|
||||
"image/jpg",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
];
|
||||
|
||||
export const ALLOWED_WATERMARK_TYPES = [
|
||||
"image/svg+xml",
|
||||
"image/png",
|
||||
"image/jpg",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
];
|
||||
|
||||
export const ALLOWED_3D_MODEL_TYPES = [
|
||||
".glb",
|
||||
"glb",
|
||||
".gltf",
|
||||
"gltf",
|
||||
"model/gltf-binary",
|
||||
".vnd.ms-3d",
|
||||
];
|
||||
|
||||
export const validateFileType = (file: File, mediaType: number) => {
|
||||
if (mediaType === 1 && !ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||
return 'Для типа "Фото" разрешены только форматы: JPG, PNG, GIF, WEBP'
|
||||
return 'Для типа "Фото" разрешены только форматы: JPG, PNG, GIF, WEBP';
|
||||
}
|
||||
|
||||
if (mediaType === 2 && !ALLOWED_VIDEO_TYPES.includes(file.type)) {
|
||||
return 'Для типа "Видео" разрешены только форматы: MP4, WEBM, OGG'
|
||||
return 'Для типа "Видео" разрешены только форматы: MP4, WEBM, OGG';
|
||||
}
|
||||
|
||||
return null
|
||||
if (mediaType === 3 && !ALLOWED_ICON_TYPES.includes(file.type)) {
|
||||
return 'Для типа "Иконка" разрешены только форматы: SVG, PNG, JPG, JPEG, WEBP';
|
||||
}
|
||||
|
||||
if (mediaType === 4 && !ALLOWED_WATERMARK_TYPES.includes(file.type)) {
|
||||
return 'Для типа "Водяной знак" разрешены только форматы: SVG, PNG, JPG, JPEG, WEBP';
|
||||
}
|
||||
|
||||
if (mediaType === 5 && !ALLOWED_PANORAMA_TYPES.includes(file.type)) {
|
||||
return 'Для типа "Панорама" разрешены только форматы: JPG, PNG, GIF, WEBP';
|
||||
}
|
||||
|
||||
if (mediaType === 6 && !ALLOWED_3D_MODEL_TYPES.includes(file.type)) {
|
||||
const extension = file.name.split(".").pop();
|
||||
const isMimeTypeValid = ["model/gltf-binary"].includes(file.type);
|
||||
const isExtensionValid =
|
||||
extension && ALLOWED_3D_MODEL_TYPES.includes(extension);
|
||||
if (!isMimeTypeValid && !isExtensionValid) {
|
||||
return 'Для типа "3D-модель" разрешены только форматы: GLB, GLTF';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
type UseMediaFileUploadProps = {
|
||||
selectedMediaType: number
|
||||
setError: UseFormSetError<any>
|
||||
clearErrors: UseFormClearErrors<any>
|
||||
setValue: UseFormSetValue<any>
|
||||
}
|
||||
selectedMediaType: number;
|
||||
setError: UseFormSetError<any>;
|
||||
clearErrors: UseFormClearErrors<any>;
|
||||
setValue: UseFormSetValue<any>;
|
||||
};
|
||||
|
||||
export const useMediaFileUpload = ({selectedMediaType, setError, clearErrors, setValue}: UseMediaFileUploadProps) => {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
export const useMediaFileUpload = ({
|
||||
selectedMediaType,
|
||||
setError,
|
||||
clearErrors,
|
||||
setValue,
|
||||
}: UseMediaFileUploadProps) => {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (selectedMediaType) {
|
||||
const error = validateFileType(file, selectedMediaType)
|
||||
const error = validateFileType(file, selectedMediaType);
|
||||
if (error) {
|
||||
setError('file', {type: 'manual', message: error})
|
||||
event.target.value = ''
|
||||
return
|
||||
setError("file", { type: "manual", message: error });
|
||||
event.target.value = "";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
clearErrors('file')
|
||||
setValue('file', file)
|
||||
setSelectedFile(file)
|
||||
clearErrors("file");
|
||||
setValue("file", file);
|
||||
setSelectedFile(file);
|
||||
|
||||
if (file.type.startsWith('image/')) {
|
||||
const url = URL.createObjectURL(file)
|
||||
setPreviewUrl(url)
|
||||
if (file.type.startsWith("image/")) {
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreviewUrl(url);
|
||||
} else {
|
||||
setPreviewUrl(null)
|
||||
}
|
||||
setPreviewUrl(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMediaTypeChange = (newMediaType: number | null) => {
|
||||
setValue('media_type', newMediaType || null)
|
||||
setValue("media_type", newMediaType || null);
|
||||
|
||||
if (selectedFile && newMediaType) {
|
||||
const error = validateFileType(selectedFile, newMediaType)
|
||||
const error = validateFileType(selectedFile, newMediaType);
|
||||
if (error) {
|
||||
setError('file', {type: 'manual', message: error})
|
||||
setValue('file', null)
|
||||
setSelectedFile(null)
|
||||
setPreviewUrl(null)
|
||||
setError("file", { type: "manual", message: error });
|
||||
setValue("file", null);
|
||||
setSelectedFile(null);
|
||||
setPreviewUrl(null);
|
||||
} else {
|
||||
clearErrors('file')
|
||||
}
|
||||
clearErrors("file");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
selectedFile,
|
||||
@ -75,5 +145,5 @@ export const useMediaFileUpload = ({selectedMediaType, setError, clearErrors, se
|
||||
setPreviewUrl,
|
||||
handleFileChange,
|
||||
handleMediaTypeChange,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
410
src/components/modals/ArticleEditModal/index.tsx
Normal file
410
src/components/modals/ArticleEditModal/index.tsx
Normal file
@ -0,0 +1,410 @@
|
||||
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>
|
||||
);
|
||||
});
|
191
src/components/modals/StationEditModal/index.tsx
Normal file
191
src/components/modals/StationEditModal/index.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
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>
|
||||
);
|
||||
});
|
71
src/components/ui/LanguageSelector.tsx
Normal file
71
src/components/ui/LanguageSelector.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
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>
|
||||
);
|
||||
});
|
105
src/components/ui/MediaView.tsx
Normal file
105
src/components/ui/MediaView.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
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>
|
||||
);
|
||||
}
|
22
src/components/ui/ModelViewer.tsx
Normal file
22
src/components/ui/ModelViewer.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
|
||||
|
||||
type ModelViewerProps = {
|
||||
fileUrl: string;
|
||||
height?: string;
|
||||
};
|
||||
|
||||
export const ModelViewer = ({ fileUrl, height = "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>
|
||||
);
|
||||
};
|
5
src/components/ui/index.ts
Normal file
5
src/components/ui/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './Icons';
|
||||
export * from './LanguageSelector';
|
||||
export * from './SidebarTitle';
|
||||
export * from './MediaView';
|
||||
export * from './ModelViewer';
|
@ -1,7 +1,7 @@
|
||||
import {createTheme} from '@mui/material/styles'
|
||||
import {RefineThemes} from '@refinedev/mui'
|
||||
|
||||
const COLORS = {
|
||||
export const COLORS = {
|
||||
primary: '#7f6b58',
|
||||
secondary: '#48989f',
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
@import './stylesheets/hidden-functionality.css';
|
||||
@import './stylesheets/roles-functionality.css';
|
||||
@import "./stylesheets/hidden-functionality.css";
|
||||
@import "./stylesheets/roles-functionality.css";
|
||||
|
||||
.limited-text {
|
||||
overflow: hidden;
|
||||
@ -7,3 +7,19 @@
|
||||
-webkit-box-orient: vertical;
|
||||
-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);
|
||||
}
|
||||
|
@ -1,14 +1,9 @@
|
||||
import React from 'react'
|
||||
import {createRoot} from 'react-dom/client'
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import App from './App'
|
||||
import './globals.css'
|
||||
import App from "./App";
|
||||
import "./globals.css";
|
||||
|
||||
const container = document.getElementById('root') as HTMLElement
|
||||
const root = createRoot(container)
|
||||
const container = document.getElementById("root") as HTMLElement;
|
||||
const root = createRoot(container);
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
root.render(<App />);
|
||||
|
@ -1,11 +1,13 @@
|
||||
export const BACKEND_URL = 'https://wn.krbl.ru'
|
||||
|
||||
export const MEDIA_TYPES = [
|
||||
{label: 'Фото', value: 1},
|
||||
{label: 'Видео', value: 2},
|
||||
]
|
||||
{ label: "Фото", value: 1 },
|
||||
{ label: "Видео", value: 2 },
|
||||
{ label: "Иконка", value: 3 },
|
||||
{ label: "Водяной знак", value: 4 },
|
||||
{ label: "Панорама", value: 5 },
|
||||
{ label: "3Д-модель", value: 6 },
|
||||
];
|
||||
|
||||
export const VEHICLE_TYPES = [
|
||||
{label: 'Трамвай', value: 1},
|
||||
{label: 'Троллейбус', value: 2},
|
||||
]
|
||||
{ label: "Трамвай", value: 1 },
|
||||
{ label: "Троллейбус", value: 2 },
|
||||
];
|
1
src/lib/index.ts
Normal file
1
src/lib/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { MEDIA_TYPES, VEHICLE_TYPES } from './constants'
|
@ -99,6 +99,13 @@
|
||||
"show": "Показать станцию"
|
||||
}
|
||||
},
|
||||
"snapshots": {
|
||||
"titles": {
|
||||
"create": "Создать снапшот",
|
||||
"show": "Показать снапшот"
|
||||
}
|
||||
},
|
||||
|
||||
"vehicle": {
|
||||
"titles": {
|
||||
"create": "Создать транспорт",
|
||||
|
@ -1,72 +1,138 @@
|
||||
import {Box, TextField, Typography, Paper} from '@mui/material'
|
||||
import {Create} from '@refinedev/mui'
|
||||
import {useForm} from '@refinedev/react-hook-form'
|
||||
import {Controller} from 'react-hook-form'
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Box, TextField, Typography, Paper } from "@mui/material";
|
||||
import { Create } from "@refinedev/mui";
|
||||
import { useForm } from "@refinedev/react-hook-form";
|
||||
import { Controller, 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";
|
||||
|
||||
import {MarkdownEditor} from '../../components/MarkdownEditor'
|
||||
import 'easymde/dist/easymde.min.css'
|
||||
const MemoizedSimpleMDE = React.memo(MarkdownEditor);
|
||||
|
||||
const MemoizedSimpleMDE = React.memo(MarkdownEditor)
|
||||
export const ArticleCreate = observer(() => {
|
||||
const { language, setLanguageAction } = languageStore;
|
||||
const [articleData, setArticleData] = useState({
|
||||
heading: EVERY_LANGUAGE(""),
|
||||
body: EVERY_LANGUAGE("")
|
||||
});
|
||||
|
||||
export const ArticleCreate = () => {
|
||||
const {
|
||||
saveButtonProps,
|
||||
refineCore: {formLoading},
|
||||
refineCore: { formLoading, onFinish },
|
||||
register,
|
||||
control,
|
||||
watch,
|
||||
formState: { errors },
|
||||
setValue,
|
||||
handleSubmit,
|
||||
} = useForm({
|
||||
refineCoreProps: {
|
||||
resource: 'article/',
|
||||
},
|
||||
})
|
||||
|
||||
const [preview, setPreview] = useState('')
|
||||
const [headingPreview, setHeadingPreview] = useState('')
|
||||
resource: "article",
|
||||
...META_LANGUAGE(language)
|
||||
}
|
||||
});
|
||||
|
||||
// Следим за изменениями в полях body и heading
|
||||
const bodyContent = watch('body')
|
||||
const headingContent = watch('heading')
|
||||
const bodyContent = watch("body");
|
||||
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(() => {
|
||||
setPreview(bodyContent || '')
|
||||
}, [bodyContent])
|
||||
setValue("heading", articleData.heading[language] ?? "");
|
||||
setValue("body", articleData.body[language] ?? "");
|
||||
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(() => {
|
||||
setHeadingPreview(headingContent || '')
|
||||
}, [headingContent])
|
||||
setPreview(bodyContent ?? "");
|
||||
}, [bodyContent]);
|
||||
|
||||
const simpleMDEOptions = React.useMemo(
|
||||
() => ({
|
||||
placeholder: 'Введите контент в формате Markdown...',
|
||||
useEffect(() => {
|
||||
setHeadingPreview(headingContent ?? "");
|
||||
}, [headingContent]);
|
||||
|
||||
const simpleMDEOptions = React.useMemo(() => ({
|
||||
placeholder: "Введите контент в формате Markdown...",
|
||||
spellChecker: false,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
}), []);
|
||||
|
||||
return (
|
||||
<Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
|
||||
<Box sx={{display: 'flex', gap: 2}}>
|
||||
<Create isLoading={formLoading} saveButtonProps={{
|
||||
onClick: handleFormSubmit
|
||||
}}>
|
||||
<Box sx={{ display: "flex", flex: 1, gap: 2 }}>
|
||||
{/* Форма создания */}
|
||||
<Box component="form" sx={{flex: 1, display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
||||
<Box sx={{ display: "flex", flex: 1, flexDirection: "column", gap: 2 }}>
|
||||
<LanguageSelector action={handleLanguageChange} />
|
||||
<Box
|
||||
component="form"
|
||||
sx={{ flex: 1, display: "flex", flexDirection: "column" }}
|
||||
autoComplete="off"
|
||||
>
|
||||
<TextField
|
||||
{...register('heading', {
|
||||
required: 'Это поле является обязательным',
|
||||
{...register("heading", {
|
||||
required: "Это поле является обязательным",
|
||||
})}
|
||||
error={!!(errors as any)?.heading}
|
||||
helperText={(errors as any)?.heading?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{inputLabel: {shrink: true}}}
|
||||
type="text"
|
||||
label="Заголовок *"
|
||||
name="heading"
|
||||
/>
|
||||
|
||||
<Controller control={control} name="body" rules={{required: 'Это поле является обязательным'}} defaultValue="" render={({field: {onChange, value}}) => <MemoizedSimpleMDE value={value} onChange={onChange} options={simpleMDEOptions} className="my-markdown-editor" />} />
|
||||
<Controller
|
||||
control={control}
|
||||
name="body"
|
||||
//rules={{ required: "Это поле является обязательным" }}
|
||||
defaultValue=""
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<MemoizedSimpleMDE
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={simpleMDEOptions}
|
||||
className="my-markdown-editor"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Блок предпросмотра */}
|
||||
@ -74,14 +140,15 @@ export const ArticleCreate = () => {
|
||||
sx={{
|
||||
flex: 1,
|
||||
p: 2,
|
||||
maxHeight: 'calc(100vh - 200px)',
|
||||
overflowY: 'auto',
|
||||
position: 'sticky',
|
||||
maxHeight: "calc(100vh - 200px)",
|
||||
overflowY: "auto",
|
||||
position: "sticky",
|
||||
top: 16,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.main',
|
||||
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : '#fff'),
|
||||
border: "1px solid",
|
||||
borderColor: "primary.main",
|
||||
bgcolor: (theme) =>
|
||||
theme.palette.mode === "dark" ? "background.paper" : "#fff",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom color="primary">
|
||||
@ -93,7 +160,8 @@ export const ArticleCreate = () => {
|
||||
variant="h4"
|
||||
gutterBottom
|
||||
sx={{
|
||||
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'),
|
||||
color: (theme) =>
|
||||
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
@ -103,46 +171,48 @@ export const ArticleCreate = () => {
|
||||
{/* Markdown контент */}
|
||||
<Box
|
||||
sx={{
|
||||
'& img': {
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
"& img": {
|
||||
maxWidth: "100%",
|
||||
height: "auto",
|
||||
borderRadius: 1,
|
||||
},
|
||||
'& h1, & h2, & h3, & h4, & h5, & h6': {
|
||||
color: 'primary.main',
|
||||
"& h1, & h2, & h3, & h4, & h5, & h6": {
|
||||
color: "primary.main",
|
||||
mt: 2,
|
||||
mb: 1,
|
||||
},
|
||||
'& p': {
|
||||
"& p": {
|
||||
mb: 2,
|
||||
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'),
|
||||
color: (theme) =>
|
||||
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
|
||||
},
|
||||
'& a': {
|
||||
color: 'primary.main',
|
||||
textDecoration: 'none',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
"& a": {
|
||||
color: "primary.main",
|
||||
textDecoration: "none",
|
||||
"&:hover": {
|
||||
textDecoration: "underline",
|
||||
},
|
||||
},
|
||||
'& blockquote': {
|
||||
borderLeft: '4px solid',
|
||||
borderColor: 'primary.main',
|
||||
"& blockquote": {
|
||||
borderLeft: "4px solid",
|
||||
borderColor: "primary.main",
|
||||
pl: 2,
|
||||
my: 2,
|
||||
color: 'text.secondary',
|
||||
color: "text.secondary",
|
||||
},
|
||||
'& code': {
|
||||
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100'),
|
||||
"& code": {
|
||||
bgcolor: (theme) =>
|
||||
theme.palette.mode === "dark" ? "grey.900" : "grey.100",
|
||||
p: 0.5,
|
||||
borderRadius: 0.5,
|
||||
color: 'primary.main',
|
||||
color: "primary.main",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown>{preview}</ReactMarkdown>
|
||||
<ReactMarkdown rehypePlugins={[rehypeRaw]}>{preview}</ReactMarkdown>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Create>
|
||||
)
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -1,75 +1,135 @@
|
||||
import {Box, TextField, Typography, Paper} from '@mui/material'
|
||||
import {Edit} from '@refinedev/mui'
|
||||
import {useForm} from '@refinedev/react-hook-form'
|
||||
import {Controller} from 'react-hook-form'
|
||||
import {useParams} from 'react-router'
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import {useList} from '@refinedev/core'
|
||||
import { Box, TextField, Typography, Paper } from "@mui/material";
|
||||
import { Edit } from "@refinedev/mui";
|
||||
import { useForm } from "@refinedev/react-hook-form";
|
||||
import { Controller, FieldValues } from "react-hook-form";
|
||||
import { useParams } from "react-router";
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { useList } from "@refinedev/core";
|
||||
|
||||
import {MarkdownEditor} from '../../components/MarkdownEditor'
|
||||
import {LinkedItems} from '../../components/LinkedItems'
|
||||
import {MediaItem, mediaFields} from './types'
|
||||
import {TOKEN_KEY} from '../../authProvider'
|
||||
import 'easymde/dist/easymde.min.css'
|
||||
import { MarkdownEditor, LinkedItems } from "@components";
|
||||
import { MediaItem, mediaFields } from "./types";
|
||||
import "easymde/dist/easymde.min.css";
|
||||
import { EVERY_LANGUAGE, Languages, languageStore, META_LANGUAGE } from "@stores";
|
||||
import { observer } from "mobx-react-lite";
|
||||
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 {
|
||||
saveButtonProps,
|
||||
refineCore: { onFinish },
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm()
|
||||
setValue,
|
||||
getValues,
|
||||
} = useForm<{ heading: string; body: string }>({
|
||||
refineCoreProps: META_LANGUAGE(language)
|
||||
});
|
||||
|
||||
const {id: articleId} = useParams<{id: string}>()
|
||||
const [preview, setPreview] = useState('')
|
||||
const [headingPreview, setHeadingPreview] = useState('')
|
||||
|
||||
// Получаем привязанные медиа
|
||||
const {data: mediaData} = useList<MediaItem>({
|
||||
resource: `article/${articleId}/media`,
|
||||
queryOptions: {
|
||||
enabled: !!articleId,
|
||||
},
|
||||
})
|
||||
|
||||
// Следим за изменениями в полях body и heading
|
||||
const bodyContent = watch('body')
|
||||
const headingContent = watch('heading')
|
||||
const bodyContent = watch("body");
|
||||
const headingContent = watch("heading");
|
||||
|
||||
useEffect(() => {
|
||||
setPreview(bodyContent || '')
|
||||
console.log(bodyContent)
|
||||
}, [bodyContent])
|
||||
|
||||
useEffect(() => {
|
||||
setHeadingPreview(headingContent || '')
|
||||
}, [headingContent])
|
||||
console.log(articleData)
|
||||
}, [articleData])
|
||||
|
||||
const simpleMDEOptions = React.useMemo(
|
||||
() => ({
|
||||
placeholder: 'Введите контент в формате Markdown...',
|
||||
spellChecker: false,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
useEffect(() => {
|
||||
console.log("Trying to udpate")
|
||||
//setHeadingPreview(articleData.heading[language] ?? "");
|
||||
//setPreview(articleData.body[language] ?? "");
|
||||
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 (
|
||||
<Edit saveButtonProps={saveButtonProps}>
|
||||
<Box sx={{display: 'flex', gap: 2}}>
|
||||
<Edit saveButtonProps={{
|
||||
...saveButtonProps,
|
||||
onClick: handleFormSubmit
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
{/* Форма редактирования */}
|
||||
<Box component="form" sx={{flex: 1, display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
||||
<Box sx={{ display: "flex", flex: 1, flexDirection: "column", gap: 2 }}>
|
||||
|
||||
<LanguageSelector action={handleLanguageChange} />
|
||||
<Box
|
||||
component="form"
|
||||
sx={{ flex: 1, display: "flex", flexDirection: "column" }}
|
||||
autoComplete="off"
|
||||
>
|
||||
<TextField
|
||||
{...register('heading', {
|
||||
required: 'Это поле является обязательным',
|
||||
{...register("heading", {
|
||||
required: "Это поле является обязательным",
|
||||
})}
|
||||
error={!!(errors as any)?.heading}
|
||||
helperText={(errors as any)?.heading?.message}
|
||||
error={!!errors?.heading}
|
||||
helperText={errors?.heading?.message as string}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{inputLabel: {shrink: true}}}
|
||||
type="text"
|
||||
label="Заголовок *"
|
||||
name="heading"
|
||||
@ -78,7 +138,7 @@ export const ArticleEdit = () => {
|
||||
<Controller
|
||||
control={control}
|
||||
name="body"
|
||||
rules={{required: 'Это поле является обязательным'}}
|
||||
//rules={{ required: "Это поле является обязательным" }}
|
||||
defaultValue=""
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<MemoizedSimpleMDE
|
||||
@ -90,7 +150,18 @@ export const ArticleEdit = () => {
|
||||
)}
|
||||
/>
|
||||
|
||||
{articleId && <LinkedItems<MediaItem> type="edit" parentId={articleId} parentResource="article" childResource="media" fields={mediaFields} title="медиа" />}
|
||||
{articleId && (
|
||||
<LinkedItems<MediaItem>
|
||||
type="edit"
|
||||
parentId={articleId}
|
||||
parentResource="article"
|
||||
childResource="media"
|
||||
fields={mediaFields}
|
||||
title="медиа"
|
||||
onUpdate={refetch}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Блок предпросмотра */}
|
||||
@ -98,14 +169,15 @@ export const ArticleEdit = () => {
|
||||
sx={{
|
||||
flex: 1,
|
||||
p: 2,
|
||||
maxHeight: 'calc(100vh - 200px)',
|
||||
overflowY: 'auto',
|
||||
position: 'sticky',
|
||||
maxHeight: "calc(100vh - 200px)",
|
||||
overflowY: "auto",
|
||||
position: "sticky",
|
||||
top: 16,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.main',
|
||||
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : '#fff'),
|
||||
border: "1px solid",
|
||||
borderColor: "primary.main",
|
||||
bgcolor: (theme) =>
|
||||
theme.palette.mode === "dark" ? "background.paper" : "#fff",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom color="primary">
|
||||
@ -117,53 +189,56 @@ export const ArticleEdit = () => {
|
||||
variant="h4"
|
||||
gutterBottom
|
||||
sx={{
|
||||
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'),
|
||||
color: (theme) =>
|
||||
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
{headingPreview}
|
||||
{headingContent}
|
||||
</Typography>
|
||||
|
||||
{/* Markdown контент */}
|
||||
<Box
|
||||
sx={{
|
||||
'& img': {
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
"& img": {
|
||||
maxWidth: "100%",
|
||||
height: "auto",
|
||||
borderRadius: 1,
|
||||
},
|
||||
'& h1, & h2, & h3, & h4, & h5, & h6': {
|
||||
color: 'primary.main',
|
||||
"& h1, & h2, & h3, & h4, & h5, & h6": {
|
||||
color: "primary.main",
|
||||
mt: 2,
|
||||
mb: 1,
|
||||
},
|
||||
'& p': {
|
||||
"& p": {
|
||||
mb: 2,
|
||||
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'),
|
||||
color: (theme) =>
|
||||
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
|
||||
},
|
||||
'& a': {
|
||||
color: 'primary.main',
|
||||
textDecoration: 'none',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
"& a": {
|
||||
color: "primary.main",
|
||||
textDecoration: "none",
|
||||
"&:hover": {
|
||||
textDecoration: "underline",
|
||||
},
|
||||
},
|
||||
'& blockquote': {
|
||||
borderLeft: '4px solid',
|
||||
borderColor: 'primary.main',
|
||||
"& blockquote": {
|
||||
borderLeft: "4px solid",
|
||||
borderColor: "primary.main",
|
||||
pl: 2,
|
||||
my: 2,
|
||||
color: 'text.secondary',
|
||||
color: "text.secondary",
|
||||
},
|
||||
'& code': {
|
||||
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100'),
|
||||
"& code": {
|
||||
bgcolor: (theme) =>
|
||||
theme.palette.mode === "dark" ? "grey.900" : "grey.100",
|
||||
p: 0.5,
|
||||
borderRadius: 0.5,
|
||||
color: 'primary.main',
|
||||
color: "primary.main",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown>{preview}</ReactMarkdown>
|
||||
<ReactMarkdown rehypePlugins={[rehypeRaw]}>{bodyContent}</ReactMarkdown>
|
||||
</Box>
|
||||
|
||||
{/* Привязанные медиа */}
|
||||
@ -174,9 +249,9 @@ export const ArticleEdit = () => {
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
display: "flex",
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
flexWrap: "wrap",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
@ -184,23 +259,28 @@ export const ArticleEdit = () => {
|
||||
<Box
|
||||
key={media.id}
|
||||
sx={{
|
||||
width: 120,
|
||||
height: 120,
|
||||
display: "flex",
|
||||
width: "45%",
|
||||
height: "45%",
|
||||
aspectRatio: "1/1",
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.main',
|
||||
overflow: "hidden",
|
||||
border: "1px solid",
|
||||
borderColor: "primary.main",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`https://wn.krbl.ru/media/${media.id}/download?token=${localStorage.getItem(TOKEN_KEY)}`}
|
||||
<MediaView media={media} />
|
||||
{/* <img
|
||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
media.id
|
||||
}/download?token=${localStorage.getItem(TOKEN_KEY)}`}
|
||||
alt={media.media_name}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
/> */}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
@ -209,5 +289,5 @@ export const ArticleEdit = () => {
|
||||
</Paper>
|
||||
</Box>
|
||||
</Edit>
|
||||
)
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -1,34 +1,45 @@
|
||||
import {type GridColDef} from '@mui/x-data-grid'
|
||||
import {CustomDataGrid} from '../../components/CustomDataGrid'
|
||||
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
|
||||
import React from 'react'
|
||||
import { type GridColDef } from "@mui/x-data-grid";
|
||||
import { CustomDataGrid } from "../../components/CustomDataGrid";
|
||||
import {
|
||||
DeleteButton,
|
||||
EditButton,
|
||||
List,
|
||||
ShowButton,
|
||||
useDataGrid,
|
||||
} from "@refinedev/mui";
|
||||
import React from "react";
|
||||
|
||||
import {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(() => {
|
||||
const { language } = languageStore;
|
||||
|
||||
export const ArticleList = () => {
|
||||
const { dataGridProps } = useDataGrid({
|
||||
resource: 'article/',
|
||||
})
|
||||
resource: "article",
|
||||
...META_LANGUAGE(language)
|
||||
});
|
||||
|
||||
const columns = React.useMemo<GridColDef[]>(
|
||||
() => [
|
||||
{
|
||||
field: 'id',
|
||||
headerName: 'ID',
|
||||
type: 'number',
|
||||
field: "id",
|
||||
headerName: "ID",
|
||||
type: "number",
|
||||
minWidth: 70,
|
||||
display: 'flex',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
display: "flex",
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'heading',
|
||||
headerName: 'Заголовок',
|
||||
type: 'string',
|
||||
field: "heading",
|
||||
headerName: "Заголовок",
|
||||
type: "string",
|
||||
minWidth: 300,
|
||||
display: 'flex',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
display: "flex",
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
flex: 1,
|
||||
},
|
||||
// {
|
||||
@ -41,12 +52,12 @@ export const ArticleList = () => {
|
||||
// flex: 1,
|
||||
// },
|
||||
{
|
||||
field: 'actions',
|
||||
headerName: 'Действия',
|
||||
align: 'right',
|
||||
headerAlign: 'center',
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
align: "right",
|
||||
headerAlign: "center",
|
||||
minWidth: 120,
|
||||
display: 'flex',
|
||||
display: "flex",
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
disableColumnMenu: true,
|
||||
@ -55,18 +66,27 @@ export const ArticleList = () => {
|
||||
<>
|
||||
<EditButton hideText recordItemId={row.id} />
|
||||
<ShowButton hideText recordItemId={row.id} />
|
||||
<DeleteButton hideText recordItemId={row.id} />
|
||||
<DeleteButton
|
||||
hideText
|
||||
recordItemId={row.id}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<List>
|
||||
<CustomDataGrid {...dataGridProps} columns={columns} localeText={localeText} getRowId={(row: any) => row.id} />
|
||||
<CustomDataGrid
|
||||
{...dataGridProps}
|
||||
languageEnabled
|
||||
columns={columns}
|
||||
localeText={localeText}
|
||||
getRowId={(row: any) => row.id}
|
||||
/>
|
||||
</List>
|
||||
)
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -4,7 +4,7 @@ export type MediaItem = {
|
||||
id: number
|
||||
filename: string
|
||||
media_name: string
|
||||
media_type: string
|
||||
media_type: number
|
||||
media_order?: number
|
||||
}
|
||||
|
||||
|
@ -1,172 +1,203 @@
|
||||
import {Autocomplete, Box, TextField} from '@mui/material'
|
||||
import {Create, useAutocomplete} from '@refinedev/mui'
|
||||
import {useForm} from '@refinedev/react-hook-form'
|
||||
import {Controller} from 'react-hook-form'
|
||||
|
||||
export const CarrierCreate = () => {
|
||||
import { Autocomplete, Box, TextField } from "@mui/material";
|
||||
import { Create, useAutocomplete } from "@refinedev/mui";
|
||||
import { useForm } from "@refinedev/react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { languageStore, META_LANGUAGE } from "../../store/LanguageStore";
|
||||
export const CarrierCreate = observer(() => {
|
||||
const { language } = languageStore;
|
||||
const {
|
||||
saveButtonProps,
|
||||
refineCore: { formLoading },
|
||||
register,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm({})
|
||||
} = useForm({
|
||||
refineCoreProps: META_LANGUAGE(language)
|
||||
});
|
||||
|
||||
const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({
|
||||
resource: 'city',
|
||||
resource: "city",
|
||||
onSearch: (value) => [
|
||||
{
|
||||
field: 'name',
|
||||
operator: 'contains',
|
||||
field: "name",
|
||||
operator: "contains",
|
||||
value,
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
const { autocompleteProps: mediaAutocompleteProps } = useAutocomplete({
|
||||
resource: 'media',
|
||||
resource: "media",
|
||||
onSearch: (value) => [
|
||||
{
|
||||
field: 'media_name',
|
||||
operator: 'contains',
|
||||
field: "media_name",
|
||||
operator: "contains",
|
||||
value,
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
|
||||
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
||||
<Box
|
||||
component="form"
|
||||
sx={{ display: "flex", flexDirection: "column" }}
|
||||
autoComplete="off"
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="city_id"
|
||||
rules={{required: 'Это поле является обязательным'}}
|
||||
rules={{ required: "Это поле является обязательным" }}
|
||||
defaultValue={null}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
{...cityAutocompleteProps}
|
||||
value={cityAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
||||
value={
|
||||
cityAutocompleteProps.options.find(
|
||||
(option) => option.id === field.value
|
||||
) ?? null
|
||||
}
|
||||
onChange={(_, value) => {
|
||||
field.onChange(value?.id || '')
|
||||
field.onChange(value?.id ?? "");
|
||||
}}
|
||||
getOptionLabel={(item) => {
|
||||
return item ? item.name : ''
|
||||
return item ? item.name : "";
|
||||
}}
|
||||
isOptionEqualToValue={(option, value) => {
|
||||
return option.id === value?.id
|
||||
return option.id === value?.id;
|
||||
}}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
return options.filter((option) => option.name.toLowerCase().includes(inputValue.toLowerCase()))
|
||||
return options.filter((option) =>
|
||||
option.name.toLowerCase().includes(inputValue.toLowerCase())
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => <TextField {...params} label="Выберите город" margin="normal" variant="outlined" error={!!errors.city_id} helperText={(errors as any)?.city_id?.message} required />}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Выберите город"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
error={!!errors.city_id}
|
||||
helperText={(errors as any)?.city_id?.message}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('full_name', {
|
||||
required: 'Это поле является обязательным',
|
||||
{...register("full_name", {
|
||||
required: "Это поле является обязательным",
|
||||
})}
|
||||
error={!!(errors as any)?.full_name}
|
||||
helperText={(errors as any)?.full_name?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{inputLabel: {shrink: true}}}
|
||||
type="text"
|
||||
label={'Полное имя *'}
|
||||
label={"Полное имя *"}
|
||||
name="full_name"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('short_name', {
|
||||
required: 'Это поле является обязательным',
|
||||
{...register("short_name", {
|
||||
//required: "Это поле является обязательным",
|
||||
})}
|
||||
error={!!(errors as any)?.short_name}
|
||||
helperText={(errors as any)?.short_name?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{inputLabel: {shrink: true}}}
|
||||
type="text"
|
||||
label={'Короткое имя *'}
|
||||
label={"Короткое имя"}
|
||||
name="short_name"
|
||||
/>
|
||||
|
||||
<Box component="form"
|
||||
sx={{ display: "flex" }}
|
||||
autoComplete="off"
|
||||
>
|
||||
<TextField
|
||||
{...register('main_color', {
|
||||
{...register("main_color", {
|
||||
// required: 'Это поле является обязательным',
|
||||
})}
|
||||
error={!!(errors as any)?.main_color}
|
||||
helperText={(errors as any)?.main_color?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{inputLabel: {shrink: true}}}
|
||||
type="color"
|
||||
label={'Основной цвет'}
|
||||
label={"Основной цвет"}
|
||||
name="main_color"
|
||||
sx={{
|
||||
'& input': {
|
||||
height: '50px',
|
||||
paddingBlock: '14px',
|
||||
paddingInline: '14px',
|
||||
cursor: 'pointer',
|
||||
"& input": {
|
||||
height: "50px",
|
||||
paddingBlock: "14px",
|
||||
paddingInline: "14px",
|
||||
cursor: "pointer",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('left_color', {
|
||||
{...register("left_color", {
|
||||
// required: 'Это поле является обязательным',
|
||||
})}
|
||||
error={!!(errors as any)?.left_color}
|
||||
helperText={(errors as any)?.left_color?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{inputLabel: {shrink: true}}}
|
||||
type="color"
|
||||
label={'Цвет левого виджета'}
|
||||
label={"Цвет левого виджета"}
|
||||
name="left_color"
|
||||
sx={{
|
||||
'& input': {
|
||||
height: '50px',
|
||||
paddingBlock: '14px',
|
||||
paddingInline: '14px',
|
||||
cursor: 'pointer',
|
||||
marginLeft: "16px",
|
||||
marginRight: "16px",
|
||||
"& input": {
|
||||
height: "50px",
|
||||
paddingBlock: "14px",
|
||||
paddingInline: "14px",
|
||||
cursor: "pointer",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
{...register('right_color', {
|
||||
{...register("right_color", {
|
||||
// required: 'Это поле является обязательным',
|
||||
})}
|
||||
error={!!(errors as any)?.right_color}
|
||||
helperText={(errors as any)?.right_color?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{inputLabel: {shrink: true}}}
|
||||
type="color"
|
||||
label={'Цвет правого виджета'}
|
||||
label={"Цвет правого виджета"}
|
||||
name="right_color"
|
||||
sx={{
|
||||
'& input': {
|
||||
height: '50px',
|
||||
paddingBlock: '14px',
|
||||
paddingInline: '14px',
|
||||
cursor: 'pointer',
|
||||
"& input": {
|
||||
height: "50px",
|
||||
paddingBlock: "14px",
|
||||
paddingInline: "14px",
|
||||
cursor: "pointer",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
{...register('slogan', {
|
||||
{...register("slogan", {
|
||||
// required: 'Это поле является обязательным',
|
||||
})}
|
||||
error={!!(errors as any)?.slogan}
|
||||
helperText={(errors as any)?.slogan?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{inputLabel: {shrink: true}}}
|
||||
type="text"
|
||||
label={'Слоган'}
|
||||
label={"Слоган"}
|
||||
name="slogan"
|
||||
/>
|
||||
|
||||
@ -178,24 +209,41 @@ export const CarrierCreate = () => {
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
{...mediaAutocompleteProps}
|
||||
value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
||||
value={
|
||||
mediaAutocompleteProps.options.find(
|
||||
(option) => option.id === field.value
|
||||
) ?? null
|
||||
}
|
||||
onChange={(_, value) => {
|
||||
field.onChange(value?.id || '')
|
||||
field.onChange(value?.id ?? "");
|
||||
}}
|
||||
getOptionLabel={(item) => {
|
||||
return item ? item.media_name : ''
|
||||
return item ? item.media_name : "";
|
||||
}}
|
||||
isOptionEqualToValue={(option, value) => {
|
||||
return option.id === value?.id
|
||||
return option.id === value?.id;
|
||||
}}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase()))
|
||||
return options.filter((option) =>
|
||||
option.media_name
|
||||
.toLowerCase()
|
||||
.includes(inputValue.toLowerCase())
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => <TextField {...params} label="Выберите логотип" margin="normal" variant="outlined" error={!!errors.logo} helperText={(errors as any)?.logo?.message} />}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Выберите логотип"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
error={!!errors.logo}
|
||||
helperText={(errors as any)?.logo?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
</Create>
|
||||
)
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -1,171 +1,208 @@
|
||||
import {Autocomplete, Box, TextField} from '@mui/material'
|
||||
import {Edit, useAutocomplete} from '@refinedev/mui'
|
||||
import {useForm} from '@refinedev/react-hook-form'
|
||||
import {Controller} from 'react-hook-form'
|
||||
import { Autocomplete, Box, TextField } from "@mui/material";
|
||||
import { Edit, useAutocomplete } from "@refinedev/mui";
|
||||
import { useForm } from "@refinedev/react-hook-form";
|
||||
import { languageStore, META_LANGUAGE } from "@stores";
|
||||
import { LanguageSelector, MediaView } from "@ui";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Controller } from "react-hook-form";
|
||||
|
||||
export const CarrierEdit = () => {
|
||||
export const CarrierEdit = observer(() => {
|
||||
const { language } = languageStore;
|
||||
const {
|
||||
saveButtonProps,
|
||||
register,
|
||||
control,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm()
|
||||
} = useForm({
|
||||
refineCoreProps: META_LANGUAGE(language)
|
||||
});
|
||||
|
||||
const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({
|
||||
resource: 'city',
|
||||
resource: `city`,
|
||||
onSearch: (value) => [
|
||||
{
|
||||
field: 'name',
|
||||
operator: 'contains',
|
||||
field: "name",
|
||||
operator: "contains",
|
||||
value,
|
||||
},
|
||||
],
|
||||
})
|
||||
...META_LANGUAGE("ru")
|
||||
});
|
||||
|
||||
const { autocompleteProps: mediaAutocompleteProps } = useAutocomplete({
|
||||
resource: 'media',
|
||||
resource: "media",
|
||||
onSearch: (value) => [
|
||||
{
|
||||
field: 'media_name',
|
||||
operator: 'contains',
|
||||
field: "media_name",
|
||||
operator: "contains",
|
||||
value,
|
||||
},
|
||||
],
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<Edit saveButtonProps={saveButtonProps}>
|
||||
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
||||
<Box
|
||||
component="form"
|
||||
sx={{ display: "flex", flexDirection: "column" }}
|
||||
autoComplete="off"
|
||||
>
|
||||
<LanguageSelector />
|
||||
<Controller
|
||||
control={control}
|
||||
name="city_id"
|
||||
rules={{required: 'Это поле является обязательным'}}
|
||||
rules={{ required: "Это поле является обязательным" }}
|
||||
defaultValue={null}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
{...cityAutocompleteProps}
|
||||
value={cityAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
||||
value={
|
||||
cityAutocompleteProps.options.find(
|
||||
(option) => option.id === field.value
|
||||
) ?? null
|
||||
}
|
||||
onChange={(_, value) => {
|
||||
field.onChange(value?.id || '')
|
||||
field.onChange(value?.id ?? "");
|
||||
}}
|
||||
getOptionLabel={(item) => {
|
||||
return item ? item.name : ''
|
||||
return item ? item.name : "";
|
||||
}}
|
||||
isOptionEqualToValue={(option, value) => {
|
||||
return option.id === value?.id
|
||||
return option.id === value?.id;
|
||||
}}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
return options.filter((option) => option.name.toLowerCase().includes(inputValue.toLowerCase()))
|
||||
return options.filter((option) =>
|
||||
option.name.toLowerCase().includes(inputValue.toLowerCase())
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => <TextField {...params} label="Выберите город" margin="normal" variant="outlined" error={!!errors.city_id} helperText={(errors as any)?.city_id?.message} required />}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Выберите город"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
error={!!errors.city_id}
|
||||
helperText={(errors as any)?.city_id?.message}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('full_name', {
|
||||
required: 'Это поле является обязательным',
|
||||
{...register("full_name", {
|
||||
required: "Это поле является обязательным",
|
||||
})}
|
||||
error={!!(errors as any)?.full_name}
|
||||
helperText={(errors as any)?.full_name?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{inputLabel: {shrink: true}}}
|
||||
type="text"
|
||||
label={'Полное имя *'}
|
||||
label={"Полное имя *"}
|
||||
name="full_name"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('short_name', {
|
||||
required: 'Это поле является обязательным',
|
||||
{...register("short_name", {
|
||||
//required: "Это поле является обязательным",
|
||||
})}
|
||||
error={!!(errors as any)?.short_name}
|
||||
helperText={(errors as any)?.short_name?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{inputLabel: {shrink: true}}}
|
||||
type="text"
|
||||
label={'Короткое имя *'}
|
||||
label={"Короткое имя"}
|
||||
name="short_name"
|
||||
/>
|
||||
|
||||
<Box component="form"
|
||||
sx={{ display: "flex" }}
|
||||
autoComplete="off"
|
||||
>
|
||||
<TextField
|
||||
{...register('main_color', {
|
||||
{...register("main_color", {
|
||||
// required: 'Это поле является обязательным',
|
||||
})}
|
||||
error={!!(errors as any)?.main_color}
|
||||
helperText={(errors as any)?.main_color?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{inputLabel: {shrink: true}}}
|
||||
type="color"
|
||||
label={'Основной цвет'}
|
||||
label={"Основной цвет"}
|
||||
name="main_color"
|
||||
sx={{
|
||||
'& input': {
|
||||
height: '50px',
|
||||
paddingBlock: '14px',
|
||||
paddingInline: '14px',
|
||||
cursor: 'pointer',
|
||||
"& input": {
|
||||
height: "50px",
|
||||
paddingBlock: "14px",
|
||||
paddingInline: "14px",
|
||||
cursor: "pointer",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('left_color', {
|
||||
{...register("left_color", {
|
||||
// required: 'Это поле является обязательным',
|
||||
})}
|
||||
error={!!(errors as any)?.left_color}
|
||||
helperText={(errors as any)?.left_color?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{inputLabel: {shrink: true}}}
|
||||
type="color"
|
||||
label={'Цвет левого виджета'}
|
||||
label={"Цвет левого виджета"}
|
||||
name="left_color"
|
||||
sx={{
|
||||
'& input': {
|
||||
height: '50px',
|
||||
paddingBlock: '14px',
|
||||
paddingInline: '14px',
|
||||
cursor: 'pointer',
|
||||
marginLeft: "16px",
|
||||
marginRight: "16px",
|
||||
"& input": {
|
||||
height: "50px",
|
||||
paddingBlock: "14px",
|
||||
paddingInline: "14px",
|
||||
cursor: "pointer",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
{...register('right_color', {
|
||||
{...register("right_color", {
|
||||
// required: 'Это поле является обязательным',
|
||||
})}
|
||||
error={!!(errors as any)?.right_color}
|
||||
helperText={(errors as any)?.right_color?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{inputLabel: {shrink: true}}}
|
||||
type="color"
|
||||
label={'Цвет правого виджета'}
|
||||
label={"Цвет правого виджета"}
|
||||
name="right_color"
|
||||
sx={{
|
||||
'& input': {
|
||||
height: '50px',
|
||||
paddingBlock: '14px',
|
||||
paddingInline: '14px',
|
||||
cursor: 'pointer',
|
||||
"& input": {
|
||||
height: "50px",
|
||||
paddingBlock: "14px",
|
||||
paddingInline: "14px",
|
||||
cursor: "pointer",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
{...register('slogan', {
|
||||
{...register("slogan", {
|
||||
// required: 'Это поле является обязательным',
|
||||
})}
|
||||
error={!!(errors as any)?.slogan}
|
||||
helperText={(errors as any)?.slogan?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{inputLabel: {shrink: true}}}
|
||||
type="text"
|
||||
label={'Слоган'}
|
||||
label={"Слоган"}
|
||||
name="slogan"
|
||||
/>
|
||||
|
||||
@ -177,24 +214,45 @@ export const CarrierEdit = () => {
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
{...mediaAutocompleteProps}
|
||||
value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
||||
value={
|
||||
mediaAutocompleteProps.options.find(
|
||||
(option) => option.id === field.value
|
||||
) ?? null
|
||||
}
|
||||
onChange={(_, value) => {
|
||||
field.onChange(value?.id || '')
|
||||
field.onChange(value?.id ?? "");
|
||||
}}
|
||||
getOptionLabel={(item) => {
|
||||
return item ? item.media_name : ''
|
||||
return item ? item.media_name : "";
|
||||
}}
|
||||
isOptionEqualToValue={(option, value) => {
|
||||
return option.id === value?.id
|
||||
return option.id === value?.id;
|
||||
}}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase()))
|
||||
return options.filter((option) =>
|
||||
option.media_name
|
||||
.toLowerCase()
|
||||
.includes(inputValue.toLowerCase()) &&
|
||||
option.media_type == 3
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => <TextField {...params} label="Выберите логотип" margin="normal" variant="outlined" error={!!errors.logo} helperText={(errors as any)?.logo?.message} />}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Выберите логотип"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
error={!!errors.logo}
|
||||
helperText={(errors as any)?.logo?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Box height={150} sx={{display: "flex", justifyContent: "start"}}>
|
||||
<MediaView media={{id: watch("logo"), media_type: 1}} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Edit>
|
||||
)
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -1,90 +1,157 @@
|
||||
import {type GridColDef} from '@mui/x-data-grid'
|
||||
import {CustomDataGrid} from '../../components/CustomDataGrid'
|
||||
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
|
||||
import React from 'react'
|
||||
import { type GridColDef } from "@mui/x-data-grid";
|
||||
import { CustomDataGrid } from "../../components/CustomDataGrid";
|
||||
import {
|
||||
DeleteButton,
|
||||
EditButton,
|
||||
List,
|
||||
ShowButton,
|
||||
useDataGrid,
|
||||
} from "@refinedev/mui";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { cityStore } from "../../store/CityStore";
|
||||
import { languageStore } from "../../store/LanguageStore";
|
||||
|
||||
export const CarrierList = () => {
|
||||
const {dataGridProps} = useDataGrid({})
|
||||
export const CarrierList = observer(() => {
|
||||
const { city_id } = cityStore;
|
||||
const { language } = languageStore;
|
||||
|
||||
const { dataGridProps } = useDataGrid({
|
||||
resource: "carrier",
|
||||
meta: {
|
||||
headers: {
|
||||
"Accept-Language": language,
|
||||
},
|
||||
},
|
||||
filters: {
|
||||
permanent: [
|
||||
{
|
||||
field: "cityID",
|
||||
operator: "eq",
|
||||
value: city_id === "0" ? null : city_id,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const columns = React.useMemo<GridColDef[]>(
|
||||
() => [
|
||||
{
|
||||
field: 'id',
|
||||
headerName: 'ID',
|
||||
type: 'number',
|
||||
field: "id",
|
||||
headerName: "ID",
|
||||
type: "number",
|
||||
minWidth: 50,
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'city_id',
|
||||
headerName: 'ID Города',
|
||||
type: 'number',
|
||||
field: "city_id",
|
||||
headerName: "ID Города",
|
||||
type: "number",
|
||||
minWidth: 100,
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'full_name',
|
||||
headerName: 'Полное имя',
|
||||
type: 'string',
|
||||
field: "full_name",
|
||||
headerName: "Полное имя",
|
||||
type: "string",
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'short_name',
|
||||
headerName: 'Короткое имя',
|
||||
type: 'string',
|
||||
field: "short_name",
|
||||
headerName: "Короткое имя",
|
||||
type: "string",
|
||||
minWidth: 125,
|
||||
},
|
||||
{
|
||||
field: 'city',
|
||||
headerName: 'Город',
|
||||
type: 'string',
|
||||
field: "city",
|
||||
headerName: "Город",
|
||||
type: "string",
|
||||
minWidth: 125,
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
field: 'main_color',
|
||||
headerName: 'Основной цвет',
|
||||
type: 'string',
|
||||
field: "main_color",
|
||||
headerName: "Основной цвет",
|
||||
type: "string",
|
||||
minWidth: 150,
|
||||
renderCell: ({value}) => <div style={{display: 'grid', placeItems: 'center', width: '100%', height: '100%', backgroundColor: `${value}10`, borderRadius: 10}}>{value}</div>,
|
||||
renderCell: ({ value }) => (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: `${value}10`,
|
||||
borderRadius: 10,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'left_color',
|
||||
headerName: 'Цвет левого виджета',
|
||||
type: 'string',
|
||||
field: "left_color",
|
||||
headerName: "Цвет левого виджета",
|
||||
type: "string",
|
||||
minWidth: 150,
|
||||
renderCell: ({value}) => <div style={{display: 'grid', placeItems: 'center', width: '100%', height: '100%', backgroundColor: `${value}10`, borderRadius: 10}}>{value}</div>,
|
||||
renderCell: ({ value }) => (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: `${value}10`,
|
||||
borderRadius: 10,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'right_color',
|
||||
headerName: 'Цвет правого виджета',
|
||||
type: 'string',
|
||||
field: "right_color",
|
||||
headerName: "Цвет правого виджета",
|
||||
type: "string",
|
||||
minWidth: 150,
|
||||
renderCell: ({value}) => <div style={{display: 'grid', placeItems: 'center', width: '100%', height: '100%', backgroundColor: `${value}10`, borderRadius: 10}}>{value}</div>,
|
||||
renderCell: ({ value }) => (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: `${value}10`,
|
||||
borderRadius: 10,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'logo',
|
||||
headerName: 'Лого',
|
||||
type: 'string',
|
||||
field: "logo",
|
||||
headerName: "Лого",
|
||||
type: "string",
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'slogan',
|
||||
headerName: 'Слоган',
|
||||
type: 'string',
|
||||
field: "slogan",
|
||||
headerName: "Слоган",
|
||||
type: "string",
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
headerName: 'Действия',
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
minWidth: 120,
|
||||
display: 'flex',
|
||||
align: 'right',
|
||||
headerAlign: 'center',
|
||||
display: "flex",
|
||||
align: "right",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
disableColumnMenu: true,
|
||||
@ -93,18 +160,22 @@ export const CarrierList = () => {
|
||||
<>
|
||||
<EditButton hideText recordItemId={row.id} />
|
||||
<ShowButton hideText recordItemId={row.id} />
|
||||
<DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} />
|
||||
<DeleteButton
|
||||
hideText
|
||||
confirmTitle="Вы уверены?"
|
||||
recordItemId={row.id}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<List>
|
||||
<CustomDataGrid {...dataGridProps} columns={columns} />
|
||||
<CustomDataGrid {...dataGridProps} languageEnabled columns={columns} />
|
||||
</List>
|
||||
)
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -1,46 +1,93 @@
|
||||
import {Box, Stack, Typography} from '@mui/material'
|
||||
import {useShow} from '@refinedev/core'
|
||||
import {Show, TextFieldComponent as TextField} from '@refinedev/mui'
|
||||
import {TOKEN_KEY} from '../../authProvider'
|
||||
import { Box, Stack, Typography } from "@mui/material";
|
||||
import { useShow } from "@refinedev/core";
|
||||
import { Show, TextFieldComponent as TextField } from "@refinedev/mui";
|
||||
import { TOKEN_KEY } from "@providers";
|
||||
import { MediaView } from "@ui";
|
||||
|
||||
export type FieldType = {
|
||||
label: string
|
||||
data: any
|
||||
render?: (value: any) => React.ReactNode
|
||||
}
|
||||
label: string;
|
||||
data: any;
|
||||
render?: (value: any) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const CarrierShow = () => {
|
||||
const {query} = useShow({})
|
||||
const {data, isLoading} = query
|
||||
const { query } = useShow({});
|
||||
const { data, isLoading } = query;
|
||||
|
||||
const record = data?.data
|
||||
const record = data?.data;
|
||||
|
||||
const fields: FieldType[] = [
|
||||
{label: 'Полное имя', data: 'full_name'},
|
||||
{label: 'Короткое имя', data: 'short_name'},
|
||||
{label: 'Город', data: 'city'},
|
||||
{ label: "Полное имя", data: "full_name" },
|
||||
{ label: "Короткое имя", data: "short_name" },
|
||||
{ label: "Город", data: "city" },
|
||||
{
|
||||
label: 'Основной цвет',
|
||||
data: 'main_color',
|
||||
render: (value: string) => <Box sx={{display: 'grid', placeItems: 'center', width: 'fit-content', paddingInline: '6px', height: '100%', backgroundColor: `${value}20`, borderRadius: 1}}>{value}</Box>,
|
||||
label: "Основной цвет",
|
||||
data: "main_color",
|
||||
render: (value: string) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
width: "fit-content",
|
||||
paddingInline: "6px",
|
||||
height: "100%",
|
||||
backgroundColor: `${value}20`,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Цвет левого виджета',
|
||||
data: 'left_color',
|
||||
render: (value: string) => <Box sx={{display: 'grid', placeItems: 'center', width: 'fit-content', paddingInline: '6px', height: '100%', backgroundColor: `${value}20`, borderRadius: 1}}>{value}</Box>,
|
||||
label: "Цвет левого виджета",
|
||||
data: "left_color",
|
||||
render: (value: string) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
width: "fit-content",
|
||||
paddingInline: "6px",
|
||||
height: "100%",
|
||||
backgroundColor: `${value}20`,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Цвет правого виджета',
|
||||
data: 'right_color',
|
||||
render: (value: string) => <Box sx={{display: 'grid', placeItems: 'center', width: 'fit-content', paddingInline: '6px', height: '100%', backgroundColor: `${value}20`, borderRadius: 1}}>{value}</Box>,
|
||||
label: "Цвет правого виджета",
|
||||
data: "right_color",
|
||||
render: (value: string) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
width: "fit-content",
|
||||
paddingInline: "6px",
|
||||
height: "100%",
|
||||
backgroundColor: `${value}20`,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{label: 'Слоган', data: 'slogan'},
|
||||
{ label: "Слоган", data: "slogan" },
|
||||
{
|
||||
label: 'Логотип',
|
||||
data: 'logo',
|
||||
render: (value: number) => <img src={`https://wn.krbl.ru/media/${value}/download?token=${localStorage.getItem(TOKEN_KEY)}`} alt={String(value)} style={{maxWidth: '10%', objectFit: 'contain', borderRadius: 8}} />,
|
||||
label: "Логотип",
|
||||
data: "logo",
|
||||
render: (value: number) => (
|
||||
<Box height={150} sx={{display: "flex", justifyContent: "start"}}>
|
||||
<MediaView media={{id: value, media_type: 1}} />
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
return (
|
||||
<Show isLoading={isLoading}>
|
||||
@ -51,10 +98,14 @@ export const CarrierShow = () => {
|
||||
{label}
|
||||
</Typography>
|
||||
|
||||
{render ? render(record?.[data]) : <TextField value={record?.[data]} />}
|
||||
{render ? (
|
||||
render(record?.[data])
|
||||
) : (
|
||||
<TextField value={record?.[data]} />
|
||||
)}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
@ -1,18 +1,26 @@
|
||||
import {Autocomplete, Box, TextField} from '@mui/material'
|
||||
import {Edit, useAutocomplete} from '@refinedev/mui'
|
||||
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'
|
||||
|
||||
export const CityEdit = () => {
|
||||
export const CityEdit = observer(() => {
|
||||
const { language } = languageStore;
|
||||
const {
|
||||
saveButtonProps,
|
||||
register,
|
||||
control,
|
||||
formState: {errors},
|
||||
} = useForm({})
|
||||
} = useForm({
|
||||
refineCoreProps: META_LANGUAGE(language)
|
||||
})
|
||||
|
||||
const {autocompleteProps: countryAutocompleteProps} = useAutocomplete({
|
||||
resource: 'country',
|
||||
...META_LANGUAGE(language)
|
||||
})
|
||||
|
||||
const {autocompleteProps: mediaAutocompleteProps} = useAutocomplete({
|
||||
@ -24,11 +32,14 @@ export const CityEdit = () => {
|
||||
value,
|
||||
},
|
||||
],
|
||||
})
|
||||
...META_LANGUAGE(language)
|
||||
});
|
||||
|
||||
return (
|
||||
<Edit saveButtonProps={saveButtonProps}>
|
||||
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
||||
<LanguageSelector/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="country_code"
|
||||
@ -94,4 +105,4 @@ export const CityEdit = () => {
|
||||
</Box>
|
||||
</Edit>
|
||||
)
|
||||
}
|
||||
})
|
@ -1,58 +1,76 @@
|
||||
import {type GridColDef} from '@mui/x-data-grid'
|
||||
import {CustomDataGrid} from '../../components/CustomDataGrid'
|
||||
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
|
||||
import React from 'react'
|
||||
import { type GridColDef } from "@mui/x-data-grid";
|
||||
import { CustomDataGrid } from "../../components/CustomDataGrid";
|
||||
import {
|
||||
DeleteButton,
|
||||
EditButton,
|
||||
List,
|
||||
ShowButton,
|
||||
useDataGrid,
|
||||
} from "@refinedev/mui";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
export const CityList = () => {
|
||||
const {dataGridProps} = useDataGrid({})
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { languageStore } from "../../store/LanguageStore";
|
||||
|
||||
export const CityList = observer(() => {
|
||||
const { language } = languageStore;
|
||||
|
||||
const { dataGridProps } = useDataGrid({
|
||||
resource: "city",
|
||||
meta: {
|
||||
headers: {
|
||||
"Accept-Language": language,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const columns = React.useMemo<GridColDef[]>(
|
||||
() => [
|
||||
{
|
||||
field: 'id',
|
||||
headerName: 'ID',
|
||||
type: 'number',
|
||||
field: "id",
|
||||
headerName: "ID",
|
||||
type: "number",
|
||||
minWidth: 50,
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'country_code',
|
||||
headerName: 'Код страны',
|
||||
type: 'string',
|
||||
field: "country_code",
|
||||
headerName: "Код страны",
|
||||
type: "string",
|
||||
minWidth: 150,
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'country',
|
||||
headerName: 'Cтрана',
|
||||
type: 'string',
|
||||
field: "country",
|
||||
headerName: "Cтрана",
|
||||
type: "string",
|
||||
minWidth: 150,
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
headerName: 'Название',
|
||||
type: 'string',
|
||||
field: "name",
|
||||
headerName: "Название",
|
||||
type: "string",
|
||||
minWidth: 150,
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
field: 'arms',
|
||||
headerName: 'Герб',
|
||||
type: 'string',
|
||||
field: "arms",
|
||||
headerName: "Герб",
|
||||
type: "string",
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
headerName: 'Действия',
|
||||
cellClassName: 'city-actions',
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
cellClassName: "city-actions",
|
||||
minWidth: 120,
|
||||
display: 'flex',
|
||||
align: 'right',
|
||||
headerAlign: 'center',
|
||||
display: "flex",
|
||||
align: "right",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
disableColumnMenu: true,
|
||||
@ -61,18 +79,22 @@ export const CityList = () => {
|
||||
<>
|
||||
<EditButton hideText recordItemId={row.id} />
|
||||
<ShowButton hideText recordItemId={row.id} />
|
||||
<DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} />
|
||||
<DeleteButton
|
||||
hideText
|
||||
confirmTitle="Вы уверены?"
|
||||
recordItemId={row.id}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<List>
|
||||
<CustomDataGrid {...dataGridProps} columns={columns} />
|
||||
<CustomDataGrid {...dataGridProps} columns={columns} languageEnabled />
|
||||
</List>
|
||||
)
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -1,21 +1,33 @@
|
||||
import {Stack, Typography} from '@mui/material'
|
||||
import {useShow} from '@refinedev/core'
|
||||
import {Show, TextFieldComponent as TextField} from '@refinedev/mui'
|
||||
import {TOKEN_KEY} from '../../authProvider'
|
||||
import { Stack, Typography } from "@mui/material";
|
||||
import { useShow } from "@refinedev/core";
|
||||
import { Show, TextFieldComponent as TextField } from "@refinedev/mui";
|
||||
import { TOKEN_KEY } from "@providers";
|
||||
|
||||
export const CityShow = () => {
|
||||
const {query} = useShow({})
|
||||
const {data, isLoading} = query
|
||||
const { query } = useShow({});
|
||||
const { data, isLoading } = query;
|
||||
|
||||
const record = data?.data
|
||||
const record = data?.data;
|
||||
|
||||
const fields = [
|
||||
// {label: 'ID', data: 'id'},
|
||||
{label: 'Название', data: 'name'},
|
||||
{ label: "Название", data: "name" },
|
||||
// {label: 'Код страны', data: 'country_code'},
|
||||
{label: 'Страна', data: 'country'},
|
||||
{label: 'Герб', data: 'arms', render: (value: number) => <img src={`https://wn.krbl.ru/media/${value}/download?token=${localStorage.getItem(TOKEN_KEY)}`} alt={String(value)} style={{maxWidth: '10%', objectFit: 'contain', borderRadius: 8}} />},
|
||||
]
|
||||
{ label: "Страна", data: "country" },
|
||||
{
|
||||
label: "Герб",
|
||||
data: "arms",
|
||||
render: (value: number) => (
|
||||
<img
|
||||
src={`${
|
||||
import.meta.env.VITE_KRBL_MEDIA
|
||||
}${value}/download?token=${localStorage.getItem(TOKEN_KEY)}`}
|
||||
alt={String(value)}
|
||||
style={{ maxWidth: "10%", objectFit: "contain", borderRadius: 8 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Show isLoading={isLoading}>
|
||||
@ -26,10 +38,14 @@ export const CityShow = () => {
|
||||
{label}
|
||||
</Typography>
|
||||
|
||||
{render ? render(record?.[data]) : <TextField value={record?.[data]} />}
|
||||
{render ? (
|
||||
render(record?.[data])
|
||||
) : (
|
||||
<TextField value={record?.[data]} />
|
||||
)}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
@ -1,20 +1,31 @@
|
||||
import {Box, TextField} from '@mui/material'
|
||||
import {Edit} from '@refinedev/mui'
|
||||
import {useForm} from '@refinedev/react-hook-form'
|
||||
import { Box, TextField } from "@mui/material";
|
||||
import { Edit } from "@refinedev/mui";
|
||||
import { useForm } from "@refinedev/react-hook-form";
|
||||
import { languageStore, META_LANGUAGE } from "@stores";
|
||||
import { LanguageSelector } from "@ui";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
export const CountryEdit = () => {
|
||||
export const CountryEdit = observer(() => {
|
||||
const { language } = languageStore;
|
||||
const {
|
||||
saveButtonProps,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useForm({})
|
||||
} = useForm({
|
||||
refineCoreProps: META_LANGUAGE(language)
|
||||
});
|
||||
|
||||
return (
|
||||
<Edit saveButtonProps={saveButtonProps}>
|
||||
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
||||
<Box
|
||||
component="form"
|
||||
sx={{ display: "flex", flexDirection: "column" }}
|
||||
autoComplete="off"
|
||||
>
|
||||
<LanguageSelector />
|
||||
<TextField
|
||||
{...register('code', {
|
||||
required: 'Это поле является обязательным',
|
||||
{...register("code", {
|
||||
required: "Это поле является обязательным",
|
||||
})}
|
||||
error={!!(errors as any)?.code}
|
||||
helperText={(errors as any)?.code?.message}
|
||||
@ -22,12 +33,13 @@ export const CountryEdit = () => {
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
type="text"
|
||||
label={'Код *'}
|
||||
label={"Код *"}
|
||||
disabled
|
||||
name="code"
|
||||
/>
|
||||
<TextField
|
||||
{...register('name', {
|
||||
required: 'Это поле является обязательным',
|
||||
{...register("name", {
|
||||
required: "Это поле является обязательным",
|
||||
})}
|
||||
error={!!(errors as any)?.name}
|
||||
helperText={(errors as any)?.name?.message}
|
||||
@ -35,10 +47,10 @@ export const CountryEdit = () => {
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
type="text"
|
||||
label={'Название *'}
|
||||
label={"Название *"}
|
||||
name="name"
|
||||
/>
|
||||
</Box>
|
||||
</Edit>
|
||||
)
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -1,36 +1,53 @@
|
||||
import {type GridColDef} from '@mui/x-data-grid'
|
||||
import {CustomDataGrid} from '../../components/CustomDataGrid'
|
||||
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
|
||||
import React from 'react'
|
||||
import { type GridColDef } from "@mui/x-data-grid";
|
||||
import { CustomDataGrid } from "../../components/CustomDataGrid";
|
||||
import {
|
||||
DeleteButton,
|
||||
EditButton,
|
||||
List,
|
||||
ShowButton,
|
||||
useDataGrid,
|
||||
} from "@refinedev/mui";
|
||||
import React from "react";
|
||||
import { languageStore } from "../../store/LanguageStore";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
export const CountryList = () => {
|
||||
const {dataGridProps} = useDataGrid({})
|
||||
export const CountryList = observer(() => {
|
||||
const { language } = languageStore;
|
||||
|
||||
const { dataGridProps } = useDataGrid({
|
||||
resource: "country",
|
||||
meta: {
|
||||
headers: {
|
||||
"Accept-Language": language,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const columns = React.useMemo<GridColDef[]>(
|
||||
() => [
|
||||
{
|
||||
field: 'code',
|
||||
headerName: 'Код',
|
||||
type: 'string',
|
||||
field: "code",
|
||||
headerName: "Код",
|
||||
type: "string",
|
||||
minWidth: 150,
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
headerName: 'Название',
|
||||
type: 'string',
|
||||
field: "name",
|
||||
headerName: "Название",
|
||||
type: "string",
|
||||
minWidth: 150,
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
headerName: 'Действия',
|
||||
cellClassName: 'country-actions',
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
cellClassName: "country-actions",
|
||||
minWidth: 120,
|
||||
display: 'flex',
|
||||
align: 'right',
|
||||
headerAlign: 'center',
|
||||
display: "flex",
|
||||
align: "right",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
disableColumnMenu: true,
|
||||
@ -39,18 +56,27 @@ export const CountryList = () => {
|
||||
<>
|
||||
<EditButton hideText recordItemId={row.code} />
|
||||
<ShowButton hideText recordItemId={row.code} />
|
||||
<DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.code} />
|
||||
<DeleteButton
|
||||
hideText
|
||||
confirmTitle="Вы уверены?"
|
||||
recordItemId={row.code}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<List>
|
||||
<CustomDataGrid {...dataGridProps} columns={columns} getRowId={(row: any) => row.code} />
|
||||
<CustomDataGrid
|
||||
{...dataGridProps}
|
||||
languageEnabled
|
||||
columns={columns}
|
||||
getRowId={(row: any) => row.code}
|
||||
/>
|
||||
</List>
|
||||
)
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -1,16 +1,32 @@
|
||||
import {Box, TextField, Button, Typography, Autocomplete} from '@mui/material'
|
||||
import {Create} from '@refinedev/mui'
|
||||
import {useForm} from '@refinedev/react-hook-form'
|
||||
import {Controller} from 'react-hook-form'
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
Typography,
|
||||
Autocomplete,
|
||||
} from "@mui/material";
|
||||
import { Create } from "@refinedev/mui";
|
||||
import { useForm } from "@refinedev/react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
|
||||
import {MEDIA_TYPES} from '../../lib/constants'
|
||||
import {ALLOWED_IMAGE_TYPES, ALLOWED_VIDEO_TYPES, useMediaFileUpload} from '../../components/media/MediaFormUtils'
|
||||
import { MEDIA_TYPES } from "../../lib/constants";
|
||||
import {
|
||||
ALLOWED_IMAGE_TYPES,
|
||||
ALLOWED_ICON_TYPES,
|
||||
ALLOWED_PANORAMA_TYPES,
|
||||
ALLOWED_VIDEO_TYPES,
|
||||
ALLOWED_WATERMARK_TYPES,
|
||||
ALLOWED_3D_MODEL_TYPES,
|
||||
useMediaFileUpload,
|
||||
} from "../../components/media/MediaFormUtils";
|
||||
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
|
||||
import { ModelViewer } from "@ui";
|
||||
|
||||
type MediaFormValues = {
|
||||
media_name: string
|
||||
media_type: number
|
||||
file?: File
|
||||
}
|
||||
media_name: string;
|
||||
media_type: number;
|
||||
file?: File;
|
||||
};
|
||||
|
||||
export const MediaCreate = () => {
|
||||
const {
|
||||
@ -24,16 +40,19 @@ export const MediaCreate = () => {
|
||||
watch,
|
||||
setError,
|
||||
clearErrors,
|
||||
} = useForm<MediaFormValues>({})
|
||||
getValues,
|
||||
} = useForm<MediaFormValues>({});
|
||||
|
||||
const selectedMediaType = watch('media_type')
|
||||
const selectedMediaType = watch("media_type");
|
||||
const file = getValues("file");
|
||||
|
||||
const {selectedFile, previewUrl, handleFileChange, handleMediaTypeChange} = useMediaFileUpload({
|
||||
const { selectedFile, previewUrl, handleFileChange, handleMediaTypeChange } =
|
||||
useMediaFileUpload({
|
||||
selectedMediaType,
|
||||
setError,
|
||||
clearErrors,
|
||||
setValue,
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Create
|
||||
@ -42,19 +61,20 @@ export const MediaCreate = () => {
|
||||
...saveButtonProps,
|
||||
disabled: !!errors.file || !selectedFile,
|
||||
onClick: handleSubmit((data) => {
|
||||
console.log(data);
|
||||
if (data.file) {
|
||||
const formData = new FormData()
|
||||
formData.append('media_name', data.media_name)
|
||||
formData.append('filename', data.file.name)
|
||||
formData.append('type', String(data.media_type))
|
||||
formData.append('file', data.file)
|
||||
const formData = new FormData();
|
||||
formData.append("media_name", data.media_name);
|
||||
formData.append("filename", data.file.name);
|
||||
formData.append("type", String(data.media_type));
|
||||
formData.append("file", data.file);
|
||||
|
||||
console.log('Отправляемые данные:')
|
||||
console.log("Отправляемые данные:");
|
||||
for (const pair of formData.entries()) {
|
||||
console.log(pair[0] + ': ' + pair[1])
|
||||
console.log(pair[0] + ": " + pair[1]);
|
||||
}
|
||||
|
||||
onFinish(formData)
|
||||
onFinish(formData);
|
||||
}
|
||||
}),
|
||||
}}
|
||||
@ -63,30 +83,42 @@ export const MediaCreate = () => {
|
||||
control={control}
|
||||
name="media_type"
|
||||
rules={{
|
||||
required: 'Это поле является обязательным',
|
||||
required: "Это поле является обязательным",
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
options={MEDIA_TYPES}
|
||||
value={MEDIA_TYPES.find((option) => option.value === field.value) || null}
|
||||
value={
|
||||
MEDIA_TYPES.find((option) => option.value === field.value) || null
|
||||
}
|
||||
onChange={(_, value) => {
|
||||
field.onChange(value?.value || null)
|
||||
handleMediaTypeChange(value?.value || null)
|
||||
field.onChange(value?.value || null);
|
||||
handleMediaTypeChange(value?.value || null);
|
||||
}}
|
||||
getOptionLabel={(item) => {
|
||||
return item ? item.label : ''
|
||||
return item ? item.label : "";
|
||||
}}
|
||||
isOptionEqualToValue={(option, value) => {
|
||||
return option.value === value?.value
|
||||
return option.value === value?.value;
|
||||
}}
|
||||
renderInput={(params) => <TextField {...params} label="Тип" margin="normal" variant="outlined" error={!!errors.media_type} helperText={(errors as any)?.media_type?.message} required />}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Тип"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
error={!!errors.media_type}
|
||||
helperText={(errors as any)?.media_type?.message}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('media_name', {
|
||||
required: 'Это поле является обязательным',
|
||||
{...register("media_name", {
|
||||
required: "Это поле является обязательным",
|
||||
})}
|
||||
error={!!(errors as any)?.media_name}
|
||||
helperText={(errors as any)?.media_name?.message}
|
||||
@ -98,12 +130,50 @@ export const MediaCreate = () => {
|
||||
name="media_name"
|
||||
/>
|
||||
|
||||
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off" style={{marginTop: 10}}>
|
||||
<Box display="flex" flexDirection="column-reverse" alignItems="center" gap={6}>
|
||||
<Box display="flex" flexDirection="column" alignItems="center" gap={2}>
|
||||
<Button variant="contained" component="label" disabled={!selectedMediaType}>
|
||||
{selectedFile ? 'Изменить файл' : 'Загрузить файл'}
|
||||
<input type="file" hidden onChange={handleFileChange} accept={selectedMediaType === 1 ? ALLOWED_IMAGE_TYPES.join(',') : ALLOWED_VIDEO_TYPES.join(',')} />
|
||||
<Box
|
||||
component="form"
|
||||
sx={{ display: "flex", flexDirection: "column" }}
|
||||
autoComplete="off"
|
||||
style={{ marginTop: 10 }}
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column-reverse"
|
||||
alignItems="center"
|
||||
gap={6}
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
component="label"
|
||||
disabled={!selectedMediaType}
|
||||
>
|
||||
{selectedFile ? "Изменить файл" : "Загрузить файл"}
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
onChange={handleFileChange}
|
||||
accept={
|
||||
selectedMediaType === 6
|
||||
? ALLOWED_3D_MODEL_TYPES.join(",")
|
||||
: selectedMediaType === 1
|
||||
? ALLOWED_IMAGE_TYPES.join(",")
|
||||
: selectedMediaType === 2
|
||||
? ALLOWED_VIDEO_TYPES.join(",")
|
||||
: selectedMediaType === 3
|
||||
? ALLOWED_ICON_TYPES.join(",")
|
||||
: selectedMediaType === 4
|
||||
? ALLOWED_WATERMARK_TYPES.join(",")
|
||||
: selectedMediaType === 5
|
||||
? ALLOWED_PANORAMA_TYPES.join(",")
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{selectedFile && (
|
||||
@ -121,11 +191,53 @@ export const MediaCreate = () => {
|
||||
|
||||
{previewUrl && selectedMediaType === 1 && (
|
||||
<Box mt={2} display="flex" justifyContent="center">
|
||||
<img src={previewUrl} alt="Preview" style={{maxWidth: '200px', borderRadius: 8}} />
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
style={{ maxWidth: "200px", borderRadius: 8 }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{file && selectedMediaType === 2 && (
|
||||
<Box mt={2} display="flex" justifyContent="center">
|
||||
<video src={URL.createObjectURL(file)} autoPlay controls />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{previewUrl && selectedMediaType === 3 && (
|
||||
<Box mt={2} display="flex" justifyContent="center">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
style={{ maxWidth: "200px", borderRadius: 8 }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{previewUrl && selectedMediaType === 4 && (
|
||||
<Box mt={2} display="flex" justifyContent="center">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
style={{ maxWidth: "200px", borderRadius: 8 }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{file && selectedMediaType === 5 && (
|
||||
<ReactPhotoSphereViewer
|
||||
src={URL.createObjectURL(file)}
|
||||
width={"100%"}
|
||||
height={"80vh"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{file && previewUrl && selectedMediaType === 6 && (
|
||||
<ModelViewer fileUrl={URL.createObjectURL(file)} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Create>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
@ -1,21 +1,39 @@
|
||||
import {Box, TextField, Button, Typography, Autocomplete} from '@mui/material'
|
||||
import {Edit} from '@refinedev/mui'
|
||||
import {useForm} from '@refinedev/react-hook-form'
|
||||
import {useEffect} from 'react'
|
||||
import {useShow} from '@refinedev/core'
|
||||
import {Controller} from 'react-hook-form'
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
Typography,
|
||||
Autocomplete,
|
||||
} from "@mui/material";
|
||||
import { Edit } from "@refinedev/mui";
|
||||
import { useForm } from "@refinedev/react-hook-form";
|
||||
import { useEffect } from "react";
|
||||
import { useShow } from "@refinedev/core";
|
||||
import { Controller } from "react-hook-form";
|
||||
|
||||
import {MEDIA_TYPES} from '../../lib/constants'
|
||||
import {ALLOWED_IMAGE_TYPES, ALLOWED_VIDEO_TYPES, useMediaFileUpload} from '../../components/media/MediaFormUtils'
|
||||
import {TOKEN_KEY} from '../../authProvider'
|
||||
import { TOKEN_KEY } from "@providers";
|
||||
import { MEDIA_TYPES } from "@lib";
|
||||
import {
|
||||
ALLOWED_IMAGE_TYPES,
|
||||
ALLOWED_VIDEO_TYPES,
|
||||
ALLOWED_ICON_TYPES,
|
||||
ALLOWED_WATERMARK_TYPES,
|
||||
ALLOWED_PANORAMA_TYPES,
|
||||
ALLOWED_3D_MODEL_TYPES,
|
||||
useMediaFileUpload,
|
||||
} from "../../components/media/MediaFormUtils";
|
||||
import { languageStore, META_LANGUAGE } from "@stores";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { LanguageSelector, MediaData, MediaView } from "@ui";
|
||||
|
||||
type MediaFormValues = {
|
||||
media_name: string
|
||||
media_type: number
|
||||
file?: File
|
||||
}
|
||||
media_name: string;
|
||||
media_type: number;
|
||||
file?: File;
|
||||
};
|
||||
|
||||
export const MediaEdit = () => {
|
||||
export const MediaEdit = observer(() => {
|
||||
const { language } = languageStore;
|
||||
const {
|
||||
saveButtonProps,
|
||||
refineCore: { onFinish },
|
||||
@ -29,32 +47,43 @@ export const MediaEdit = () => {
|
||||
control,
|
||||
} = useForm<MediaFormValues>({
|
||||
defaultValues: {
|
||||
media_name: '',
|
||||
media_type: '',
|
||||
media_name: "",
|
||||
media_type: "",
|
||||
file: undefined,
|
||||
},
|
||||
})
|
||||
refineCoreProps: META_LANGUAGE(language)
|
||||
});
|
||||
|
||||
const {query} = useShow()
|
||||
const {data} = query
|
||||
const record = data?.data
|
||||
const { query } = useShow();
|
||||
const { data } = query;
|
||||
const record = data?.data;
|
||||
|
||||
const selectedMediaType = watch('media_type')
|
||||
const selectedMediaType = watch("media_type");
|
||||
|
||||
const {selectedFile, previewUrl, setPreviewUrl, handleFileChange, handleMediaTypeChange} = useMediaFileUpload({
|
||||
const {
|
||||
selectedFile,
|
||||
previewUrl,
|
||||
setPreviewUrl,
|
||||
handleFileChange,
|
||||
handleMediaTypeChange,
|
||||
} = useMediaFileUpload({
|
||||
selectedMediaType,
|
||||
setError,
|
||||
clearErrors,
|
||||
setValue,
|
||||
})
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (record?.id) {
|
||||
setPreviewUrl(`https://wn.krbl.ru/media/${record.id}/download?token=${localStorage.getItem(TOKEN_KEY)}`)
|
||||
setValue('media_name', record?.media_name || '')
|
||||
setValue('media_type', record?.media_type)
|
||||
setPreviewUrl(
|
||||
`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
record.id
|
||||
}/download?token=${localStorage.getItem(TOKEN_KEY)}`
|
||||
);
|
||||
setValue("media_name", record?.media_name || "");
|
||||
setValue("media_type", record?.media_type);
|
||||
}
|
||||
}, [record, setValue, setPreviewUrl])
|
||||
}, [record, setValue, setPreviewUrl]);
|
||||
|
||||
return (
|
||||
<Edit
|
||||
@ -66,57 +95,109 @@ export const MediaEdit = () => {
|
||||
media_name: data.media_name,
|
||||
filename: selectedFile?.name || record?.filename,
|
||||
type: Number(data.media_type),
|
||||
}
|
||||
onFinish(formData)
|
||||
};
|
||||
onFinish(formData);
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
||||
<Box
|
||||
component="form"
|
||||
sx={{ display: "flex", flexDirection: "column" }}
|
||||
autoComplete="off"
|
||||
>
|
||||
<LanguageSelector />
|
||||
<Controller
|
||||
control={control}
|
||||
name="media_type"
|
||||
rules={{
|
||||
required: 'Это поле является обязательным',
|
||||
required: "Это поле является обязательным",
|
||||
}}
|
||||
defaultValue={null}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
options={MEDIA_TYPES}
|
||||
value={MEDIA_TYPES.find((option) => option.value === field.value) || null}
|
||||
value={
|
||||
MEDIA_TYPES.find((option) => option.value === field.value) ||
|
||||
null
|
||||
}
|
||||
onChange={(_, value) => {
|
||||
field.onChange(value?.value || null)
|
||||
handleMediaTypeChange(value?.value || null)
|
||||
field.onChange(value?.value || null);
|
||||
handleMediaTypeChange(value?.value || null);
|
||||
}}
|
||||
getOptionLabel={(item) => {
|
||||
return item ? item.label : ''
|
||||
return item ? item.label : "";
|
||||
}}
|
||||
isOptionEqualToValue={(option, value) => {
|
||||
return option.value === value?.value
|
||||
return option.value === value?.value;
|
||||
}}
|
||||
renderInput={(params) => <TextField {...params} label="Тип" margin="normal" variant="outlined" error={!!errors.media_type} helperText={(errors as any)?.media_type?.message} required />}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Тип"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
error={!!errors.media_type}
|
||||
helperText={(errors as any)?.media_type?.message}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('media_name', {
|
||||
required: 'Это поле является обязательным',
|
||||
{...register("media_name", {
|
||||
required: "Это поле является обязательным",
|
||||
})}
|
||||
error={!!(errors as any)?.media_name}
|
||||
helperText={(errors as any)?.media_name?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{inputLabel: {shrink: true}}}
|
||||
type="text"
|
||||
label="Название *"
|
||||
name="media_name"
|
||||
/>
|
||||
|
||||
<Box display="flex" flexDirection="column-reverse" alignItems="center" gap={4} style={{marginTop: 10}}>
|
||||
<Box display="flex" flexDirection="column" alignItems="center" gap={2}>
|
||||
<Button variant="contained" component="label" disabled={!selectedMediaType}>
|
||||
{selectedFile ? 'Изменить файл' : 'Загрузить файл'}
|
||||
<input type="file" hidden onChange={handleFileChange} accept={selectedMediaType === 1 ? ALLOWED_IMAGE_TYPES.join(',') : ALLOWED_VIDEO_TYPES.join(',')} />
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column-reverse"
|
||||
alignItems="center"
|
||||
gap={4}
|
||||
style={{ marginTop: 10 }}
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
component="label"
|
||||
disabled={!selectedMediaType}
|
||||
>
|
||||
{selectedFile ? "Изменить файл" : "Загрузить файл"}
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
onChange={handleFileChange}
|
||||
accept={
|
||||
selectedMediaType === 1
|
||||
? ALLOWED_IMAGE_TYPES.join(",")
|
||||
: selectedMediaType === 2
|
||||
? ALLOWED_VIDEO_TYPES.join(",")
|
||||
: selectedMediaType === 3
|
||||
? ALLOWED_ICON_TYPES.join(",")
|
||||
: selectedMediaType === 4
|
||||
? ALLOWED_WATERMARK_TYPES.join(",")
|
||||
: selectedMediaType === 5
|
||||
? ALLOWED_PANORAMA_TYPES.join(",")
|
||||
: selectedMediaType === 6
|
||||
? ALLOWED_3D_MODEL_TYPES.join(",")
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{selectedFile && (
|
||||
@ -132,13 +213,19 @@ export const MediaEdit = () => {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{previewUrl && selectedMediaType === 1 && (
|
||||
|
||||
<MediaView media={record as MediaData} />
|
||||
{/* {previewUrl && selectedMediaType === 1 && (
|
||||
<Box mt={2} display="flex" justifyContent="center">
|
||||
<img src={previewUrl} alt="Preview" style={{maxWidth: '200px', borderRadius: 8}} />
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
style={{ maxWidth: "200px", borderRadius: 8 }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
)} */}
|
||||
</Box>
|
||||
</Box>
|
||||
</Edit>
|
||||
)
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -3,11 +3,16 @@ import {CustomDataGrid} from '../../components/CustomDataGrid'
|
||||
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
|
||||
import React from 'react'
|
||||
import {MEDIA_TYPES} from '../../lib/constants'
|
||||
import { observer } from "mobx-react-lite"
|
||||
|
||||
import {localeText} from '../../locales/ru/localeText'
|
||||
import { languageStore, META_LANGUAGE } from '@stores'
|
||||
|
||||
export const MediaList = () => {
|
||||
const {dataGridProps} = useDataGrid({})
|
||||
export const MediaList = observer(() => {
|
||||
const { language } = languageStore;
|
||||
const {dataGridProps} = useDataGrid({
|
||||
...META_LANGUAGE(language)
|
||||
})
|
||||
|
||||
const columns = React.useMemo<GridColDef[]>(
|
||||
() => [
|
||||
@ -77,7 +82,7 @@ export const MediaList = () => {
|
||||
|
||||
return (
|
||||
<List>
|
||||
<CustomDataGrid {...dataGridProps} columns={columns} localeText={localeText} getRowId={(row: any) => row.id} />
|
||||
<CustomDataGrid {...dataGridProps} columns={columns} localeText={localeText} getRowId={(row: any) => row.id} languageEnabled/>
|
||||
</List>
|
||||
)
|
||||
}
|
||||
});
|
||||
|
@ -1,78 +1,73 @@
|
||||
import {Stack, Typography, Box, Button} from '@mui/material'
|
||||
import {useShow} from '@refinedev/core'
|
||||
import {Show, TextFieldComponent as TextField} from '@refinedev/mui'
|
||||
|
||||
import {MEDIA_TYPES} from '../../lib/constants'
|
||||
import {TOKEN_KEY} from '../../authProvider'
|
||||
import { Stack, Typography, Box, Button } from "@mui/material";
|
||||
import { useShow } from "@refinedev/core";
|
||||
import { Show, TextFieldComponent as TextField } from "@refinedev/mui";
|
||||
import { MEDIA_TYPES } from "@lib";
|
||||
import { TOKEN_KEY } from "@providers";
|
||||
import { MediaData, MediaView } from "@ui";
|
||||
|
||||
export const MediaShow = () => {
|
||||
const {query} = useShow({})
|
||||
const {data, isLoading} = query
|
||||
const { query } = useShow({});
|
||||
const { data, isLoading } = query;
|
||||
|
||||
const record = data?.data
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
const record = data?.data;
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
|
||||
const fields = [
|
||||
// {label: 'Название файла', data: 'filename'},
|
||||
{label: 'Название', data: 'media_name'},
|
||||
{ label: "Название", data: "media_name" },
|
||||
{
|
||||
label: 'Тип',
|
||||
data: 'media_type',
|
||||
render: (value: number) => MEDIA_TYPES.find((type) => type.value === value)?.label || value,
|
||||
label: "Тип",
|
||||
data: "media_type",
|
||||
render: (value: number) =>
|
||||
MEDIA_TYPES.find((type) => type.value === value)?.label || value,
|
||||
},
|
||||
// {label: 'ID', data: 'id'},
|
||||
]
|
||||
];
|
||||
|
||||
return (
|
||||
<Show isLoading={isLoading}>
|
||||
<Stack gap={4}>
|
||||
{record && record.media_type === 1 && (
|
||||
<img
|
||||
src={`https://wn.krbl.ru/media/${record?.id}/download?token=${token}`}
|
||||
alt={record?.filename}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
height: '40vh',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
<MediaView media={record as MediaData} />
|
||||
{fields.map(({ label, data, render }) => (
|
||||
<Stack key={data} gap={1}>
|
||||
<Typography variant="body1" fontWeight="bold">
|
||||
{label}
|
||||
</Typography>
|
||||
<TextField
|
||||
value={render ? render(record?.[data]) : record?.[data]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{record && record.media_type === 2 && (
|
||||
</Stack>
|
||||
))}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
border: '1px solid text.pimary',
|
||||
border: "1px solid text.pimary",
|
||||
borderRadius: 2,
|
||||
bgcolor: 'primary.light',
|
||||
width: 'fit-content',
|
||||
bgcolor: "primary.light",
|
||||
width: "fit-content",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
gutterBottom
|
||||
sx={{
|
||||
color: '#FFFFFF',
|
||||
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
|
||||
variant="contained"
|
||||
href={`${import.meta.env.VITE_KRBL_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}>
|
||||
<Typography variant="body1" fontWeight="bold">
|
||||
{label}
|
||||
</Typography>
|
||||
<TextField value={render ? render(record?.[data]) : record?.[data]} />
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
9
src/pages/route-preview/Constants.ts
Normal file
9
src/pages/route-preview/Constants.ts
Normal file
@ -0,0 +1,9 @@
|
||||
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;
|
179
src/pages/route-preview/InfiniteCanvas.tsx
Normal file
179
src/pages/route-preview/InfiniteCanvas.tsx
Normal file
@ -0,0 +1,179 @@
|
||||
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>
|
||||
);
|
||||
}
|
33
src/pages/route-preview/LeftSidebar.tsx
Normal file
33
src/pages/route-preview/LeftSidebar.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
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>
|
||||
);
|
||||
}
|
271
src/pages/route-preview/MapDataContext.tsx
Normal file
271
src/pages/route-preview/MapDataContext.tsx
Normal file
@ -0,0 +1,271 @@
|
||||
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;
|
||||
};
|
191
src/pages/route-preview/RightSidebar.tsx
Normal file
191
src/pages/route-preview/RightSidebar.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
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>
|
||||
);
|
||||
}
|
119
src/pages/route-preview/Sight.tsx
Normal file
119
src/pages/route-preview/Sight.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
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>
|
||||
);
|
||||
}
|
109
src/pages/route-preview/Station.tsx
Normal file
109
src/pages/route-preview/Station.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
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>
|
||||
);
|
||||
}
|
150
src/pages/route-preview/TransformContext.tsx
Normal file
150
src/pages/route-preview/TransformContext.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
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;
|
||||
};
|
39
src/pages/route-preview/TravelPath.tsx
Normal file
39
src/pages/route-preview/TravelPath.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
31
src/pages/route-preview/Widgets.tsx
Normal file
31
src/pages/route-preview/Widgets.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
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>
|
||||
)
|
||||
}
|
152
src/pages/route-preview/index.tsx
Normal file
152
src/pages/route-preview/index.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
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>
|
||||
)
|
||||
}
|
69
src/pages/route-preview/types.ts
Normal file
69
src/pages/route-preview/types.ts
Normal file
@ -0,0 +1,69 @@
|
||||
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
|
||||
}
|
14
src/pages/route-preview/utils.ts
Normal file
14
src/pages/route-preview/utils.ts
Normal file
@ -0,0 +1,14 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
@ -1,7 +1,15 @@
|
||||
import {Autocomplete, Box, TextField, FormControlLabel, Checkbox, Typography} from '@mui/material'
|
||||
import {Create, useAutocomplete} from '@refinedev/mui'
|
||||
import {useForm} from '@refinedev/react-hook-form'
|
||||
import {Controller} from 'react-hook-form'
|
||||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
TextField,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { Create, useAutocomplete } from "@refinedev/mui";
|
||||
import { useForm } from "@refinedev/react-hook-form";
|
||||
import { useState } from "react";
|
||||
import { Controller } from "react-hook-form";
|
||||
|
||||
export const RouteCreate = () => {
|
||||
const {
|
||||
@ -9,209 +17,341 @@ export const RouteCreate = () => {
|
||||
refineCore: { formLoading },
|
||||
register,
|
||||
control,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm({
|
||||
refineCoreProps: {
|
||||
resource: 'route/',
|
||||
resource: "route",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const directions = [
|
||||
{
|
||||
label: "Прямой",
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
label: "Обратный",
|
||||
value: false,
|
||||
},
|
||||
];
|
||||
const { autocompleteProps: carrierAutocompleteProps } = useAutocomplete({
|
||||
resource: 'carrier',
|
||||
resource: "carrier",
|
||||
onSearch: (value) => [
|
||||
{
|
||||
field: 'short_name',
|
||||
operator: 'contains',
|
||||
field: "short_name",
|
||||
operator: "contains",
|
||||
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 (
|
||||
<Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
|
||||
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
||||
<Box
|
||||
component="form"
|
||||
sx={{ display: "flex", flexDirection: "column" }}
|
||||
autoComplete="off"
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="carrier_id"
|
||||
rules={{required: 'Это поле является обязательным'}}
|
||||
rules={{ required: "Это поле является обязательным" }}
|
||||
defaultValue={null}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
{...carrierAutocompleteProps}
|
||||
value={carrierAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
||||
value={
|
||||
carrierAutocompleteProps.options.find(
|
||||
(option) => option.id === field.value
|
||||
) || null
|
||||
}
|
||||
onChange={(_, value) => {
|
||||
field.onChange(value?.id || '')
|
||||
field.onChange(value?.id || "");
|
||||
}}
|
||||
getOptionLabel={(item) => {
|
||||
return item ? item.short_name : ''
|
||||
return item ? item.short_name : "";
|
||||
}}
|
||||
isOptionEqualToValue={(option, value) => {
|
||||
return option.id === value?.id
|
||||
return option.id === value?.id;
|
||||
}}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
return options.filter((option) => option.short_name.toLowerCase().includes(inputValue.toLowerCase()))
|
||||
return options.filter((option) =>
|
||||
option.short_name
|
||||
.toLowerCase()
|
||||
.includes(inputValue.toLowerCase())
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => <TextField {...params} label="Выберите перевозчика" margin="normal" variant="outlined" error={!!errors.carrier_id} helperText={(errors as any)?.carrier_id?.message} required />}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Выберите перевозчика"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
error={!!errors.carrier_id}
|
||||
helperText={(errors as any)?.carrier_id?.message}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('route_number', {
|
||||
required: 'Это поле является обязательным',
|
||||
{...register("route_number", {
|
||||
required: "Это поле является обязательным",
|
||||
setValueAs: (value) => String(value),
|
||||
})}
|
||||
error={!!(errors as any)?.route_number}
|
||||
helperText={(errors as any)?.route_number?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
type="text"
|
||||
label={'Номер маршрута *'}
|
||||
label={"Номер маршрута *"}
|
||||
name="route_number"
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="route_direction" // boolean
|
||||
control={control}
|
||||
defaultValue={false}
|
||||
render={({field}: {field: any}) => <FormControlLabel label="Прямой маршрут? *" control={<Checkbox {...field} checked={field.value} onChange={(e) => field.onChange(e.target.checked)} />} />}
|
||||
/>
|
||||
<Typography variant="caption" color="textSecondary" sx={{mt: 0, mb: 1}}>
|
||||
(Прямой / Обратный)
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
{...register('path', {
|
||||
required: 'Это поле является обязательным',
|
||||
{...register("path", {
|
||||
required: "Это поле является обязательным",
|
||||
setValueAs: (value: string) => {
|
||||
try {
|
||||
// Парсим строку в массив массивов
|
||||
return JSON.parse(value)
|
||||
// Разбиваем строку на строки и парсим каждую строку как пару координат
|
||||
const lines = value.trim().split("\n");
|
||||
return lines.map((line) => {
|
||||
const [lat, lon] = line
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map(Number);
|
||||
if (isNaN(lat) || isNaN(lon)) {
|
||||
throw new Error("Invalid coordinates");
|
||||
}
|
||||
return [lat, lon];
|
||||
});
|
||||
} catch {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
},
|
||||
validate: (value: unknown) => {
|
||||
if (!Array.isArray(value)) return 'Неверный формат'
|
||||
if (!value.every((point: unknown) => Array.isArray(point) && point.length === 2)) {
|
||||
return 'Каждая точка должна быть массивом из двух координат'
|
||||
if (!Array.isArray(value)) return "Неверный формат";
|
||||
if (value.length === 0)
|
||||
return "Введите хотя бы одну пару координат";
|
||||
if (
|
||||
!value.every(
|
||||
(point: unknown) => Array.isArray(point) && point.length === 2
|
||||
)
|
||||
) {
|
||||
return "Каждая строка должна содержать две координаты";
|
||||
}
|
||||
if (!value.every((point: unknown[]) => point.every((coord: unknown) => !isNaN(Number(coord)) && typeof coord === 'number'))) {
|
||||
return 'Координаты должны быть числами'
|
||||
if (
|
||||
!value.every((point: unknown[]) =>
|
||||
point.every(
|
||||
(coord: unknown) =>
|
||||
!isNaN(Number(coord)) && typeof coord === "number"
|
||||
)
|
||||
)
|
||||
) {
|
||||
return "Координаты должны быть числами";
|
||||
}
|
||||
return true
|
||||
return true;
|
||||
},
|
||||
})}
|
||||
error={!!(errors as any)?.path}
|
||||
helperText={(errors as any)?.path?.message} // 'Формат: [[lat1,lon1], [lat2,lon2], ...]'
|
||||
helperText={(errors as any)?.path?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
type="text"
|
||||
label={'Координаты маршрута *'}
|
||||
label={"Координаты маршрута *"}
|
||||
name="path"
|
||||
placeholder="[[1.1, 2.2], [2.1, 4.5]]"
|
||||
placeholder="55.7558 37.6173
|
||||
55.7539 37.6208"
|
||||
multiline
|
||||
rows={4}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('route_sys_number', {
|
||||
required: 'Это поле является обязательным',
|
||||
{...register("route_sys_number", {
|
||||
required: "Это поле является обязательным",
|
||||
})}
|
||||
error={!!(errors as any)?.route_sys_number}
|
||||
helperText={(errors as any)?.route_sys_number?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
type="number"
|
||||
label={'Системный номер маршрута *'}
|
||||
label={"Номер маршрута в Говорящем Городе *"}
|
||||
name="route_sys_number"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('governor_appeal', {
|
||||
// required: 'Это поле является обязательным',
|
||||
})}
|
||||
error={!!(errors as any)?.governor_appeal}
|
||||
helperText={(errors as any)?.governor_appeal?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
type="number"
|
||||
label={'Обращение губернатора'}
|
||||
<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
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
{...register("route_direction", {
|
||||
value: routeDirection,
|
||||
})}
|
||||
/>
|
||||
|
||||
<Autocomplete
|
||||
options={directions}
|
||||
defaultValue={directions.find((el) => el.value == false)}
|
||||
onChange={(_, element) => {
|
||||
if (element) {
|
||||
setValue("route_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
|
||||
{...register('scale_min', {
|
||||
{...register("scale_min", {
|
||||
// required: 'Это поле является обязательным',
|
||||
setValueAs: (value) => Number(value),
|
||||
})}
|
||||
error={!!(errors as any)?.scale_min}
|
||||
helperText={(errors as any)?.scale_min?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
type="number"
|
||||
label={'Масштаб (мин)'}
|
||||
label={"Масштаб (мин)"}
|
||||
name="scale_min"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('scale_max', {
|
||||
{...register("scale_max", {
|
||||
// required: 'Это поле является обязательным',
|
||||
setValueAs: (value) => Number(value),
|
||||
})}
|
||||
error={!!(errors as any)?.scale_max}
|
||||
helperText={(errors as any)?.scale_max?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
type="number"
|
||||
label={'Масштаб (макс)'}
|
||||
label={"Масштаб (макс)"}
|
||||
name="scale_max"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('rotate', {
|
||||
{...register("rotate", {
|
||||
// required: 'Это поле является обязательным',
|
||||
setValueAs: (value) => Number(value),
|
||||
})}
|
||||
error={!!(errors as any)?.rotate}
|
||||
helperText={(errors as any)?.rotate?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
type="number"
|
||||
label={'Поворот'}
|
||||
label={"Поворот"}
|
||||
name="rotate"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('center_latitude', {
|
||||
{...register("center_latitude", {
|
||||
// required: 'Это поле является обязательным',
|
||||
setValueAs: (value) => Number(value),
|
||||
})}
|
||||
error={!!(errors as any)?.center_latitude}
|
||||
helperText={(errors as any)?.center_latitude?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
type="number"
|
||||
label={'Центр. широта'}
|
||||
label={"Центр. широта"}
|
||||
name="center_latitude"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('center_longitude', {
|
||||
{...register("center_longitude", {
|
||||
// required: 'Это поле является обязательным',
|
||||
setValueAs: (value) => Number(value),
|
||||
})}
|
||||
error={!!(errors as any)?.center_longitude}
|
||||
helperText={(errors as any)?.center_longitude?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
type="number"
|
||||
label={'Центр. долгота'}
|
||||
label={"Центр. долгота"}
|
||||
name="center_longitude"
|
||||
/>
|
||||
</Box>
|
||||
</Create>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
@ -1,236 +1,423 @@
|
||||
import {Autocomplete, Box, TextField, FormControlLabel, Checkbox, Typography} from '@mui/material'
|
||||
import {Edit, useAutocomplete} from '@refinedev/mui'
|
||||
import {useForm} from '@refinedev/react-hook-form'
|
||||
import {Controller} from 'react-hook-form'
|
||||
import {useParams} from 'react-router'
|
||||
import {LinkedItems} from '../../components/LinkedItems'
|
||||
import {StationItem, VehicleItem, stationFields, vehicleFields} from './types'
|
||||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
TextField,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Typography,
|
||||
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 = () => {
|
||||
export const RouteEdit = observer(() => {
|
||||
const { language } = languageStore;
|
||||
const {
|
||||
saveButtonProps,
|
||||
register,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm({})
|
||||
refineCore: { queryResult },
|
||||
setValue,
|
||||
getValues,
|
||||
watch,
|
||||
} = useForm({
|
||||
refineCoreProps: META_LANGUAGE(language),
|
||||
});
|
||||
const routeDirectionFromServer = watch("route_direction");
|
||||
|
||||
const {id: routeId} = useParams<{id: string}>()
|
||||
const [routeDirection, setRouteDirection] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const directions = [
|
||||
{
|
||||
label: "Прямой",
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
label: "Обратный",
|
||||
value: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { id: routeId } = useParams<{ id: string }>();
|
||||
|
||||
useEffect(() => {
|
||||
if (queryResult?.data?.data && Array.isArray(queryResult.data.data.path)) {
|
||||
const formattedPath = queryResult.data.data.path
|
||||
.map((coords) => coords.join(" "))
|
||||
.join("\n");
|
||||
|
||||
setValue("path", formattedPath);
|
||||
}
|
||||
}, [queryResult?.data?.data, setValue]);
|
||||
|
||||
const { autocompleteProps: carrierAutocompleteProps } = useAutocomplete({
|
||||
resource: 'carrier',
|
||||
resource: "carrier",
|
||||
onSearch: (value) => [
|
||||
{
|
||||
field: 'short_name',
|
||||
operator: 'contains',
|
||||
field: "short_name",
|
||||
operator: "contains",
|
||||
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 (
|
||||
<Edit saveButtonProps={saveButtonProps}>
|
||||
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<Box
|
||||
component="form"
|
||||
sx={{ display: "flex", flexDirection: "column" }}
|
||||
autoComplete="off"
|
||||
>
|
||||
<LanguageSelector />
|
||||
<Controller
|
||||
control={control}
|
||||
name="carrier_id"
|
||||
rules={{required: 'Это поле является обязательным'}}
|
||||
rules={{ required: "Это поле является обязательным" }}
|
||||
defaultValue={null}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
{...carrierAutocompleteProps}
|
||||
value={carrierAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
||||
value={
|
||||
carrierAutocompleteProps.options.find(
|
||||
(option) => option.id === field.value
|
||||
) || null
|
||||
}
|
||||
onChange={(_, value) => {
|
||||
field.onChange(value?.id || '')
|
||||
field.onChange(value?.id || "");
|
||||
}}
|
||||
getOptionLabel={(item) => {
|
||||
return item ? item.short_name : ''
|
||||
return item ? item.short_name : "";
|
||||
}}
|
||||
isOptionEqualToValue={(option, value) => {
|
||||
return option.id === value?.id
|
||||
return option.id === value?.id;
|
||||
}}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
return options.filter((option) => option.short_name.toLowerCase().includes(inputValue.toLowerCase()))
|
||||
return options.filter((option) =>
|
||||
option.short_name
|
||||
.toLowerCase()
|
||||
.includes(inputValue.toLowerCase())
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => <TextField {...params} label="Выберите перевозчика" margin="normal" variant="outlined" error={!!errors.carrier_id} helperText={(errors as any)?.carrier_id?.message} required />}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Выберите перевозчика"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
error={!!errors.carrier_id}
|
||||
helperText={(errors as any)?.carrier_id?.message}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('route_number', {
|
||||
required: 'Это поле является обязательным',
|
||||
{...register("route_number", {
|
||||
required: "Это поле является обязательным",
|
||||
setValueAs: (value) => String(value),
|
||||
})}
|
||||
error={!!(errors as any)?.route_number}
|
||||
helperText={(errors as any)?.route_number?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
type="text"
|
||||
label={'Номер маршрута'}
|
||||
label={"Номер маршрута"}
|
||||
name="route_number"
|
||||
/>
|
||||
<Controller
|
||||
name="route_direction" // boolean
|
||||
control={control}
|
||||
defaultValue={false}
|
||||
render={({field}: {field: any}) => <FormControlLabel label="Прямой маршрут?" control={<Checkbox {...field} checked={field.value} onChange={(e) => field.onChange(e.target.checked)} />} />}
|
||||
/>
|
||||
|
||||
<Typography variant="caption" color="textSecondary" sx={{mt: 0, mb: 1}}>
|
||||
(Прямой / Обратный)
|
||||
</Typography>
|
||||
<input type="hidden" {...register("route_direction")} />
|
||||
|
||||
<Controller
|
||||
name="path"
|
||||
control={control}
|
||||
defaultValue={[]}
|
||||
rules={{
|
||||
required: 'Это поле является обязательным',
|
||||
validate: (value: unknown) => {
|
||||
if (!Array.isArray(value)) return 'Неверный формат'
|
||||
if (!value.every((point: unknown) => Array.isArray(point) && point.length === 2)) {
|
||||
return 'Каждая точка должна быть массивом из двух координат'
|
||||
<Autocomplete
|
||||
options={directions}
|
||||
value={directions.find((el) => el.value == routeDirection)}
|
||||
onChange={(_, element) => {
|
||||
if (element) {
|
||||
setValue("route_direction", element.value);
|
||||
setRouteDirection(element.value);
|
||||
}
|
||||
if (!value.every((point: unknown[]) => point.every((coord: unknown) => !isNaN(Number(coord)) && typeof coord === 'number'))) {
|
||||
return 'Координаты должны быть числами'
|
||||
}
|
||||
return true
|
||||
},
|
||||
}}
|
||||
render={({field, fieldState: {error}}) => (
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...field}
|
||||
value={Array.isArray(field.value) ? JSON.stringify(field.value) : ''}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.target.value)
|
||||
field.onChange(parsed)
|
||||
} catch {
|
||||
field.onChange([])
|
||||
}
|
||||
}}
|
||||
error={!!error}
|
||||
helperText={error?.message} // 'Формат: [[lat1,lon1], [lat2,lon2], ...]'
|
||||
{...params}
|
||||
label="Прямой/обратный маршрут"
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
type="text"
|
||||
label={'Координаты маршрута'}
|
||||
placeholder="[[1.1, 2.2], [2.1, 4.5]]"
|
||||
sx={{
|
||||
marginBottom: 2,
|
||||
}}
|
||||
variant="outlined"
|
||||
error={!!errors.arms}
|
||||
helperText={(errors as any)?.arms?.message}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('route_sys_number', {
|
||||
required: 'Это поле является обязательным',
|
||||
{...register("path", {
|
||||
required: "Это поле является обязательным",
|
||||
setValueAs: (value: string) => {
|
||||
try {
|
||||
const lines = value.trim().split("\n");
|
||||
return lines.map((line) => {
|
||||
const [lat, lon] = line
|
||||
.trim()
|
||||
.split(/[\s,]+/)
|
||||
.map(Number);
|
||||
return [lat, lon];
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
validate: (value: unknown) => {
|
||||
if (!Array.isArray(value)) return "Неверный формат";
|
||||
if (value.length === 0)
|
||||
return "Введите хотя бы одну пару координат";
|
||||
if (
|
||||
!value.every(
|
||||
(point: unknown) =>
|
||||
Array.isArray(point) && point.length === 2
|
||||
)
|
||||
) {
|
||||
return "Каждая строка должна содержать две координаты";
|
||||
}
|
||||
if (
|
||||
!value.every((point: unknown[]) =>
|
||||
point.every(
|
||||
(coord: unknown) =>
|
||||
!isNaN(Number(coord)) && typeof coord === "number"
|
||||
)
|
||||
)
|
||||
) {
|
||||
return "Координаты должны быть числами";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
})}
|
||||
error={!!(errors as any)?.path}
|
||||
helperText={(errors as any)?.path?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
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
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
type="number"
|
||||
label={'Системный номер маршрута *'}
|
||||
label={"Номер маршрута в Говорящем Городе *"}
|
||||
name="route_sys_number"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('governor_appeal', {
|
||||
// required: 'Это поле является обязательным',
|
||||
})}
|
||||
error={!!(errors as any)?.governor_appeal}
|
||||
helperText={(errors as any)?.governor_appeal?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
type="number"
|
||||
label={'Обращение губернатора'}
|
||||
<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', {
|
||||
{...register("scale_min", {
|
||||
// required: 'Это поле является обязательным',
|
||||
setValueAs: (value) => Number(value),
|
||||
})}
|
||||
error={!!(errors as any)?.scale_min}
|
||||
helperText={(errors as any)?.scale_min?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
type="number"
|
||||
label={'Масштаб (мин)'}
|
||||
label={"Масштаб (мин)"}
|
||||
name="scale_min"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('scale_max', {
|
||||
{...register("scale_max", {
|
||||
// required: 'Это поле является обязательным',
|
||||
setValueAs: (value) => Number(value),
|
||||
})}
|
||||
error={!!(errors as any)?.scale_max}
|
||||
helperText={(errors as any)?.scale_max?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
type="number"
|
||||
label={'Масштаб (макс)'}
|
||||
label={"Масштаб (макс)"}
|
||||
name="scale_max"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('rotate', {
|
||||
{...register("rotate", {
|
||||
// required: 'Это поле является обязательным',
|
||||
setValueAs: (value) => Number(value),
|
||||
})}
|
||||
error={!!(errors as any)?.rotate}
|
||||
helperText={(errors as any)?.rotate?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
type="number"
|
||||
label={'Поворот'}
|
||||
label={"Поворот"}
|
||||
name="rotate"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('center_latitude', {
|
||||
{...register("center_latitude", {
|
||||
// required: 'Это поле является обязательным',
|
||||
setValueAs: (value) => Number(value),
|
||||
})}
|
||||
error={!!(errors as any)?.center_latitude}
|
||||
helperText={(errors as any)?.center_latitude?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
type="number"
|
||||
label={'Центр. широта'}
|
||||
label={"Центр. широта"}
|
||||
name="center_latitude"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('center_longitude', {
|
||||
{...register("center_longitude", {
|
||||
// required: 'Это поле является обязательным',
|
||||
setValueAs: (value) => Number(value),
|
||||
})}
|
||||
error={!!(errors as any)?.center_longitude}
|
||||
helperText={(errors as any)?.center_longitude?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
type="number"
|
||||
label={'Центр. долгота'}
|
||||
label={"Центр. долгота"}
|
||||
name="center_longitude"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{routeId && (
|
||||
<>
|
||||
<LinkedItems<StationItem> type="edit" parentId={routeId} parentResource="route" childResource="station" fields={stationFields} title="станции" />
|
||||
<LinkedItems<StationItem>
|
||||
type="edit"
|
||||
parentId={routeId}
|
||||
parentResource="route"
|
||||
childResource="station"
|
||||
fields={stationFields}
|
||||
title="станции"
|
||||
dragAllowed={true}
|
||||
/>
|
||||
|
||||
<LinkedItems<VehicleItem> type="edit" parentId={routeId} parentResource="route" childResource="vehicle" fields={vehicleFields} title="транспортные средства" />
|
||||
<LinkedItems<VehicleItem>
|
||||
type="edit"
|
||||
parentId={routeId}
|
||||
parentResource="route"
|
||||
childResource="vehicle"
|
||||
fields={vehicleFields}
|
||||
title="транспортные средства"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-start" }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => navigate(`/route-preview/${routeId}`)}
|
||||
>
|
||||
Предпросмотр маршрута
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Edit>
|
||||
)
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -1,136 +1,153 @@
|
||||
import {type GridColDef} from '@mui/x-data-grid'
|
||||
import {CustomDataGrid} from '../../components/CustomDataGrid'
|
||||
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
|
||||
import {Typography} from '@mui/material'
|
||||
import React from 'react'
|
||||
import { type GridColDef } from "@mui/x-data-grid";
|
||||
import { CustomDataGrid } from "../../components/CustomDataGrid";
|
||||
import {
|
||||
DeleteButton,
|
||||
EditButton,
|
||||
List,
|
||||
ShowButton,
|
||||
useDataGrid,
|
||||
} from "@refinedev/mui";
|
||||
import { 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 = () => {
|
||||
export const RouteList = observer(() => {
|
||||
const Link = useLink();
|
||||
const { language } = languageStore;
|
||||
const { dataGridProps } = useDataGrid({
|
||||
resource: 'route/',
|
||||
})
|
||||
resource: "route/",
|
||||
meta: META_LANGUAGE(language),
|
||||
});
|
||||
|
||||
const columns = React.useMemo<GridColDef[]>(
|
||||
() => [
|
||||
{
|
||||
field: 'id',
|
||||
headerName: 'ID',
|
||||
type: 'number',
|
||||
field: "id",
|
||||
headerName: "ID",
|
||||
type: "number",
|
||||
minWidth: 70,
|
||||
display: 'flex',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
display: "flex",
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'carrier_id',
|
||||
headerName: 'ID перевозчика',
|
||||
type: 'number',
|
||||
field: "carrier_id",
|
||||
headerName: "ID перевозчика",
|
||||
type: "number",
|
||||
minWidth: 150,
|
||||
display: 'flex',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
display: "flex",
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'carrier',
|
||||
headerName: 'Перевозчик',
|
||||
type: 'string',
|
||||
field: "carrier",
|
||||
headerName: "Перевозчик",
|
||||
type: "string",
|
||||
minWidth: 150,
|
||||
display: 'flex',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
display: "flex",
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'route_number',
|
||||
headerName: 'Номер маршрута',
|
||||
type: 'string',
|
||||
field: "route_number",
|
||||
headerName: "Номер маршрута",
|
||||
type: "string",
|
||||
minWidth: 150,
|
||||
display: 'flex',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
display: "flex",
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'route_sys_number',
|
||||
headerName: 'Системный номер маршрута',
|
||||
type: 'string',
|
||||
field: "route_sys_number",
|
||||
headerName: "Номер маршрута в Говорящем Городе",
|
||||
type: "string",
|
||||
minWidth: 120,
|
||||
display: 'flex',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
display: "flex",
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'governor_appeal',
|
||||
headerName: 'Обращение губернатора',
|
||||
type: 'number',
|
||||
field: "governor_appeal",
|
||||
headerName: "Обращение губернатора",
|
||||
type: "number",
|
||||
minWidth: 120,
|
||||
display: 'flex',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
display: "flex",
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'scale_min',
|
||||
headerName: 'Масштаб (мин)',
|
||||
type: 'number',
|
||||
field: "scale_min",
|
||||
headerName: "Масштаб (мин)",
|
||||
type: "number",
|
||||
minWidth: 120,
|
||||
display: 'flex',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
display: "flex",
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'scale_max',
|
||||
headerName: 'Масштаб (макс)',
|
||||
type: 'number',
|
||||
field: "scale_max",
|
||||
headerName: "Масштаб (макс)",
|
||||
type: "number",
|
||||
minWidth: 120,
|
||||
display: 'flex',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
display: "flex",
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'rotate',
|
||||
headerName: 'Поворот',
|
||||
type: 'number',
|
||||
field: "rotate",
|
||||
headerName: "Поворот",
|
||||
type: "number",
|
||||
minWidth: 120,
|
||||
display: 'flex',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
display: "flex",
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'center_latitude',
|
||||
headerName: 'Центр. широта',
|
||||
type: 'number',
|
||||
field: "center_latitude",
|
||||
headerName: "Центр. широта",
|
||||
type: "number",
|
||||
minWidth: 120,
|
||||
display: 'flex',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
display: "flex",
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'center_longitude',
|
||||
headerName: 'Центр. долгота',
|
||||
type: 'number',
|
||||
field: "center_longitude",
|
||||
headerName: "Центр. долгота",
|
||||
type: "number",
|
||||
minWidth: 120,
|
||||
display: 'flex',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
display: "flex",
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'route_direction',
|
||||
headerName: 'Направление маршрута',
|
||||
type: 'boolean',
|
||||
display: 'flex',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
minWidth: 120,
|
||||
field: "route_direction",
|
||||
headerName: "Направление маршрута",
|
||||
type: "boolean",
|
||||
display: "flex",
|
||||
flex: 1,
|
||||
renderCell: ({value}) => <Typography style={{color: value ? '#48989f' : '#7f6b58'}}>{value ? 'прямое' : 'обратное'}</Typography>,
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
minWidth: 120,
|
||||
renderCell: ({ value }) => (
|
||||
<Typography style={{ color: value ? "#48989f" : "#7f6b58" }}>
|
||||
{value ? "прямое" : "обратное"}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
headerName: 'Действия',
|
||||
cellClassName: 'route-actions',
|
||||
align: 'right',
|
||||
headerAlign: 'center',
|
||||
minWidth: 120,
|
||||
display: 'flex',
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
cellClassName: "route-actions",
|
||||
align: "right",
|
||||
headerAlign: "center",
|
||||
minWidth: 160,
|
||||
display: "flex",
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
disableColumnMenu: true,
|
||||
@ -138,19 +155,34 @@ export const RouteList = () => {
|
||||
return (
|
||||
<>
|
||||
<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} />
|
||||
<DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} />
|
||||
<DeleteButton
|
||||
hideText
|
||||
confirmTitle="Вы уверены?"
|
||||
recordItemId={row.id}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<List>
|
||||
<CustomDataGrid {...dataGridProps} columns={columns} localeText={localeText} getRowId={(row: any) => row.id} />
|
||||
<CustomDataGrid
|
||||
{...dataGridProps}
|
||||
columns={columns}
|
||||
localeText={localeText}
|
||||
getRowId={(row: any) => row.id}
|
||||
languageEnabled
|
||||
/>
|
||||
</List>
|
||||
)
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -1,46 +1,60 @@
|
||||
import {Stack, Typography, Box} from '@mui/material'
|
||||
import {useShow} from '@refinedev/core'
|
||||
import {Show, TextFieldComponent as TextField} from '@refinedev/mui'
|
||||
import {LinkedItems} from '../../components/LinkedItems'
|
||||
import {StationItem, VehicleItem, stationFields, vehicleFields} from './types'
|
||||
import { Stack, Typography, Box, Button } from "@mui/material";
|
||||
import { useShow } from "@refinedev/core";
|
||||
import { Show, TextFieldComponent as TextField } from "@refinedev/mui";
|
||||
import { LinkedItems } from "../../components/LinkedItems";
|
||||
import {
|
||||
StationItem,
|
||||
VehicleItem,
|
||||
SightItem,
|
||||
sightFields,
|
||||
stationFields,
|
||||
vehicleFields,
|
||||
} from "./types";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
export const RouteShow = () => {
|
||||
const {query} = useShow({})
|
||||
const {data, isLoading} = query
|
||||
const record = data?.data
|
||||
export const RouteShow = observer(() => {
|
||||
const { query } = useShow({});
|
||||
const { data, isLoading } = query;
|
||||
const record = data?.data;
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fields = [
|
||||
{label: 'Перевозчик', data: 'carrier'},
|
||||
{label: 'Номер маршрута', data: 'route_number'},
|
||||
{ label: "Перевозчик", data: "carrier" },
|
||||
{ label: "Номер маршрута", data: "route_number" },
|
||||
{
|
||||
label: 'Направление маршрута',
|
||||
data: 'route_direction',
|
||||
render: (value: number[][]) => <Typography style={{color: value ? '#48989f' : '#7f6b58'}}>{value ? 'прямое' : 'обратное'}</Typography>,
|
||||
label: "Направление маршрута",
|
||||
data: "route_direction",
|
||||
render: (value: number[][]) => (
|
||||
<Typography style={{ color: value ? "#48989f" : "#7f6b58" }}>
|
||||
{value ? "прямое" : "обратное"}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Координаты маршрута',
|
||||
data: 'path',
|
||||
label: "Координаты маршрута",
|
||||
data: "path",
|
||||
render: (value: number[][]) => (
|
||||
<Box
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontFamily: "monospace",
|
||||
bgcolor: (theme) => theme.palette.background.paper,
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
maxHeight: '200px',
|
||||
overflow: 'auto',
|
||||
maxHeight: "200px",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(value)}
|
||||
{/* {value?.map((point, index) => (
|
||||
{value?.map((point, index) => (
|
||||
<Typography key={index} sx={{ mb: 0.5 }}>
|
||||
Точка {index + 1}: [{point[0]}, {point[1]}]
|
||||
{point[0]}, {point[1]}
|
||||
</Typography>
|
||||
))} */}
|
||||
))}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
return (
|
||||
<Show isLoading={isLoading}>
|
||||
@ -50,18 +64,56 @@ export const RouteShow = () => {
|
||||
<Typography variant="body1" fontWeight="bold">
|
||||
{label}
|
||||
</Typography>
|
||||
{render ? render(record?.[data]) : <TextField value={record?.[data]} />}
|
||||
{render ? (
|
||||
render(record?.[data])
|
||||
) : (
|
||||
<TextField value={record?.[data]} />
|
||||
)}
|
||||
</Stack>
|
||||
))}
|
||||
|
||||
{record?.id && (
|
||||
<>
|
||||
<LinkedItems<StationItem> type="show" parentId={record.id} parentResource="route" childResource="station" fields={stationFields} title="станции" />
|
||||
<LinkedItems<StationItem>
|
||||
type="show"
|
||||
parentId={record.id}
|
||||
parentResource="route"
|
||||
childResource="station"
|
||||
fields={stationFields}
|
||||
title="станции"
|
||||
/>
|
||||
|
||||
<LinkedItems<VehicleItem> type="show" parentId={record.id} parentResource="route" childResource="vehicle" fields={vehicleFields} title="транспортные средства" />
|
||||
<LinkedItems<VehicleItem>
|
||||
type="show"
|
||||
parentId={record.id}
|
||||
parentResource="route"
|
||||
childResource="vehicle"
|
||||
fields={vehicleFields}
|
||||
title="транспортные средства"
|
||||
/>
|
||||
|
||||
<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>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -1,35 +1,53 @@
|
||||
import {VEHICLE_TYPES} from '../../lib/constants'
|
||||
import { VEHICLE_TYPES } from "../../lib/constants";
|
||||
|
||||
export type StationItem = {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
[key: string]: string | number
|
||||
}
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
offset_x: number;
|
||||
offset_y: number;
|
||||
[key: string]: string | number;
|
||||
};
|
||||
|
||||
export type VehicleItem = {
|
||||
id: number
|
||||
tail_number: number
|
||||
type: number
|
||||
[key: string]: string | number
|
||||
}
|
||||
id: number;
|
||||
tail_number: number;
|
||||
type: number;
|
||||
[key: string]: string | number;
|
||||
};
|
||||
|
||||
export type SightItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
city: string;
|
||||
city_id: number;
|
||||
address: string;
|
||||
[key: string]: string | number;
|
||||
};
|
||||
|
||||
export type FieldType<T> = {
|
||||
label: string
|
||||
data: keyof T
|
||||
render?: (value: any) => React.ReactNode
|
||||
}
|
||||
label: string;
|
||||
data: keyof T;
|
||||
render?: (value: any) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const stationFields: Array<FieldType<StationItem>> = [
|
||||
{label: 'Название', data: 'system_name'},
|
||||
{label: 'Описание', data: 'description'},
|
||||
]
|
||||
{ label: "Название", data: "name" },
|
||||
{ label: "Описание", data: "description" },
|
||||
];
|
||||
|
||||
export const sightFields: Array<FieldType<SightItem>> = [
|
||||
{ label: "Название", data: "name" },
|
||||
{ label: "Город", data: "city" },
|
||||
{ label: "Адрес", data: "address" },
|
||||
];
|
||||
|
||||
export const vehicleFields: Array<FieldType<VehicleItem>> = [
|
||||
{label: 'Бортовой номер', data: 'tail_number'},
|
||||
{ label: "Бортовой номер", data: "tail_number" },
|
||||
{
|
||||
label: 'Тип',
|
||||
data: 'type',
|
||||
render: (value: number) => VEHICLE_TYPES.find((type) => type.value === value)?.label || value,
|
||||
label: "Тип",
|
||||
data: "type",
|
||||
render: (value: number) =>
|
||||
VEHICLE_TYPES.find((type) => type.value === value)?.label || value,
|
||||
},
|
||||
]
|
||||
];
|
||||
|
@ -1,191 +1,360 @@
|
||||
import {Autocomplete, Box, TextField, Typography, Paper} from '@mui/material'
|
||||
import {Create, useAutocomplete} from '@refinedev/mui'
|
||||
import {useForm} from '@refinedev/react-hook-form'
|
||||
import {Controller} from 'react-hook-form'
|
||||
import {Link} from 'react-router'
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import {TOKEN_KEY} from '../../authProvider'
|
||||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
TextField,
|
||||
Typography,
|
||||
Paper,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
} from "@mui/material";
|
||||
import { Create, useAutocomplete } from "@refinedev/mui";
|
||||
import { useForm } from "@refinedev/react-hook-form";
|
||||
import { Controller, FieldValues } from "react-hook-form";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ArticleItem, articleFields } from "./types";
|
||||
import { TOKEN_KEY } from "@providers";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { EVERY_LANGUAGE, Languages, languageStore, cityStore } from "@stores";
|
||||
import { LanguageSelector } from "@ui";
|
||||
import { CreateSightArticle } from "@components";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
|
||||
export const SightCreate = observer(() => {
|
||||
const { language, setLanguageAction } = languageStore;
|
||||
const [sightData, setSightData] = useState({
|
||||
name: EVERY_LANGUAGE(""),
|
||||
address: EVERY_LANGUAGE(""),
|
||||
});
|
||||
|
||||
export const SightCreate = () => {
|
||||
const {
|
||||
saveButtonProps,
|
||||
refineCore: {formLoading},
|
||||
refineCore: { formLoading, onFinish },
|
||||
register,
|
||||
control,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
handleSubmit,
|
||||
} = useForm({
|
||||
refineCoreProps: {
|
||||
resource: 'sight/',
|
||||
resource: "sight",
|
||||
},
|
||||
})
|
||||
});
|
||||
const { city_id } = cityStore;
|
||||
|
||||
// Состояния для предпросмотра
|
||||
const [namePreview, setNamePreview] = useState('')
|
||||
const [coordinatesPreview, setCoordinatesPreview] = useState({latitude: '', longitude: ''})
|
||||
const [cityPreview, setCityPreview] = useState('')
|
||||
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null)
|
||||
const [watermarkLUPreview, setWatermarkLUPreview] = useState<string | null>(null)
|
||||
const [watermarkRDPreview, setWatermarkRDPreview] = useState<string | null>(null)
|
||||
const [leftArticlePreview, setLeftArticlePreview] = useState('')
|
||||
const [previewArticlePreview, setPreviewArticlePreview] = useState('')
|
||||
useEffect(() => {
|
||||
setValue("name", sightData.name[language]);
|
||||
setValue("address", sightData.address[language]);
|
||||
}, [sightData, language, setValue]);
|
||||
|
||||
function updateTranslations(update: boolean = true) {
|
||||
const newSightData = {
|
||||
...sightData,
|
||||
name: {
|
||||
...sightData.name,
|
||||
[language]: watch("name") ?? "",
|
||||
},
|
||||
address: {
|
||||
...sightData.address,
|
||||
[language]: watch("address") ?? "",
|
||||
},
|
||||
};
|
||||
if (update) setSightData(newSightData);
|
||||
return newSightData;
|
||||
}
|
||||
|
||||
const handleLanguageChange = (lang: Languages) => {
|
||||
updateTranslations();
|
||||
setLanguageAction(lang);
|
||||
};
|
||||
|
||||
const handleFormSubmit = handleSubmit((values: FieldValues) => {
|
||||
const newTranslations = updateTranslations(false);
|
||||
console.log(newTranslations);
|
||||
return onFinish({
|
||||
...values,
|
||||
translations: newTranslations,
|
||||
});
|
||||
});
|
||||
|
||||
const [namePreview, setNamePreview] = useState("");
|
||||
const [coordinatesPreview, setCoordinatesPreview] = useState({
|
||||
latitude: "",
|
||||
longitude: "",
|
||||
});
|
||||
|
||||
const [creatingArticleHeading, setCreatingArticleHeading] =
|
||||
useState<string>("");
|
||||
const [creatingArticleBody, setCreatingArticleBody] = useState<string>("");
|
||||
|
||||
const [cityPreview, setCityPreview] = useState("");
|
||||
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
|
||||
const [watermarkLUPreview, setWatermarkLUPreview] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [watermarkRDPreview, setWatermarkRDPreview] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [leftArticlePreview, setLeftArticlePreview] = useState("");
|
||||
const [customOptions, setCustomOptions] = useState<any[]>([]);
|
||||
|
||||
const [previewArticlePreview, setPreviewArticlePreview] = useState("");
|
||||
|
||||
const handleCoordinatesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const [lat, lon] = e.target.value.split(",").map((s) => s.trim());
|
||||
setCoordinatesPreview({
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
});
|
||||
setValue("latitude", lat);
|
||||
setValue("longitude", lon);
|
||||
};
|
||||
|
||||
// Автокомплиты
|
||||
const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({
|
||||
resource: 'city',
|
||||
resource: "city",
|
||||
onSearch: (value) => [
|
||||
{
|
||||
field: 'name',
|
||||
operator: 'contains',
|
||||
field: "name",
|
||||
operator: "contains",
|
||||
value,
|
||||
},
|
||||
],
|
||||
})
|
||||
meta: {
|
||||
headers: {
|
||||
"Accept-Language": language,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { autocompleteProps: mediaAutocompleteProps } = useAutocomplete({
|
||||
resource: 'media',
|
||||
resource: "media",
|
||||
onSearch: (value) => [
|
||||
{
|
||||
field: 'media_name',
|
||||
operator: 'contains',
|
||||
field: "media_name",
|
||||
operator: "contains",
|
||||
value,
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
const { autocompleteProps: articleAutocompleteProps } = useAutocomplete({
|
||||
resource: 'article',
|
||||
resource: "article",
|
||||
|
||||
onSearch: (value) => [
|
||||
{
|
||||
field: 'heading',
|
||||
operator: 'contains',
|
||||
field: "heading",
|
||||
operator: "contains",
|
||||
value,
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
const mergedOptions = [...articleAutocompleteProps.options, ...customOptions];
|
||||
|
||||
// Следим за изменениями во всех полях
|
||||
const nameContent = watch('name')
|
||||
const latitudeContent = watch('latitude')
|
||||
const longitudeContent = watch('longitude')
|
||||
const cityContent = watch('city_id')
|
||||
const thumbnailContent = watch('thumbnail')
|
||||
const watermarkLUContent = watch('watermark_lu')
|
||||
const watermarkRDContent = watch('watermark_rd')
|
||||
const leftArticleContent = watch('left_article')
|
||||
const previewArticleContent = watch('preview_article')
|
||||
const nameContent = watch("name");
|
||||
const addressContent = watch("address");
|
||||
const latitudeContent = watch("latitude");
|
||||
const longitudeContent = watch("longitude");
|
||||
const cityContent = watch("city_id");
|
||||
const thumbnailContent = watch("thumbnail");
|
||||
const watermarkLUContent = watch("watermark_lu");
|
||||
const watermarkRDContent = watch("watermark_rd");
|
||||
const leftArticleContent = watch("left_article");
|
||||
const previewArticleContent = watch("preview_article");
|
||||
|
||||
// Обновляем состояния при изменении полей
|
||||
useEffect(() => {
|
||||
setNamePreview(nameContent || '')
|
||||
}, [nameContent])
|
||||
setNamePreview(nameContent ?? "");
|
||||
}, [nameContent]);
|
||||
|
||||
useEffect(() => {
|
||||
setCoordinatesPreview({
|
||||
latitude: latitudeContent || '',
|
||||
longitude: longitudeContent || '',
|
||||
})
|
||||
}, [latitudeContent, longitudeContent])
|
||||
latitude: latitudeContent || "",
|
||||
longitude: longitudeContent || "",
|
||||
});
|
||||
}, [latitudeContent, longitudeContent]);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedCity = cityAutocompleteProps.options.find((option) => option.id === cityContent)
|
||||
setCityPreview(selectedCity?.name || '')
|
||||
}, [cityContent, cityAutocompleteProps.options])
|
||||
const selectedCity = cityAutocompleteProps.options.find(
|
||||
(option) => option.id === cityContent
|
||||
);
|
||||
setCityPreview(selectedCity?.name ?? "");
|
||||
}, [cityContent, cityAutocompleteProps.options]);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedThumbnail = mediaAutocompleteProps.options.find((option) => option.id === thumbnailContent)
|
||||
setThumbnailPreview(selectedThumbnail ? `https://wn.krbl.ru/media/${selectedThumbnail.id}/download?token=${localStorage.getItem(TOKEN_KEY)}` : null)
|
||||
}, [thumbnailContent, mediaAutocompleteProps.options])
|
||||
const selectedThumbnail = mediaAutocompleteProps.options.find(
|
||||
(option) => option.id === thumbnailContent
|
||||
);
|
||||
setThumbnailPreview(
|
||||
selectedThumbnail
|
||||
? `${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
selectedThumbnail.id
|
||||
}/download?token=${localStorage.getItem(TOKEN_KEY)}`
|
||||
: null
|
||||
);
|
||||
}, [thumbnailContent, mediaAutocompleteProps.options]);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedWatermarkLU = mediaAutocompleteProps.options.find((option) => option.id === watermarkLUContent)
|
||||
setWatermarkLUPreview(selectedWatermarkLU ? `https://wn.krbl.ru/media/${selectedWatermarkLU.id}/download?token=${localStorage.getItem(TOKEN_KEY)}` : null)
|
||||
}, [watermarkLUContent, mediaAutocompleteProps.options])
|
||||
if (city_id) {
|
||||
setValue("city_id", +city_id);
|
||||
}
|
||||
}, [city_id, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedWatermarkRD = mediaAutocompleteProps.options.find((option) => option.id === watermarkRDContent)
|
||||
setWatermarkRDPreview(selectedWatermarkRD ? `https://wn.krbl.ru/media/${selectedWatermarkRD.id}/download?token=${localStorage.getItem(TOKEN_KEY)}` : null)
|
||||
}, [watermarkRDContent, mediaAutocompleteProps.options])
|
||||
const selectedWatermarkLU = mediaAutocompleteProps.options.find(
|
||||
(option) => option.id === watermarkLUContent
|
||||
);
|
||||
setWatermarkLUPreview(
|
||||
selectedWatermarkLU
|
||||
? `${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
selectedWatermarkLU.id
|
||||
}/download?token=${localStorage.getItem(TOKEN_KEY)}`
|
||||
: null
|
||||
);
|
||||
}, [watermarkLUContent, mediaAutocompleteProps.options]);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedLeftArticle = articleAutocompleteProps.options.find((option) => option.id === leftArticleContent)
|
||||
setLeftArticlePreview(selectedLeftArticle?.heading || '')
|
||||
}, [leftArticleContent, articleAutocompleteProps.options])
|
||||
const selectedWatermarkRD = mediaAutocompleteProps.options.find(
|
||||
(option) => option.id === watermarkRDContent
|
||||
);
|
||||
setWatermarkRDPreview(
|
||||
selectedWatermarkRD
|
||||
? `${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
selectedWatermarkRD.id
|
||||
}/download?token=${localStorage.getItem(TOKEN_KEY)}`
|
||||
: null
|
||||
);
|
||||
}, [watermarkRDContent, mediaAutocompleteProps.options]);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedPreviewArticle = articleAutocompleteProps.options.find((option) => option.id === previewArticleContent)
|
||||
setPreviewArticlePreview(selectedPreviewArticle?.heading || '')
|
||||
}, [previewArticleContent, articleAutocompleteProps.options])
|
||||
const selectedLeftArticle = articleAutocompleteProps.options.find(
|
||||
(option) => option.id === leftArticleContent
|
||||
);
|
||||
setLeftArticlePreview(selectedLeftArticle?.heading ?? "");
|
||||
}, [leftArticleContent, articleAutocompleteProps.options]);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedPreviewArticle = articleAutocompleteProps.options.find(
|
||||
(option) => option.id === previewArticleContent
|
||||
);
|
||||
setPreviewArticlePreview(selectedPreviewArticle?.heading ?? "");
|
||||
}, [previewArticleContent, articleAutocompleteProps.options]);
|
||||
|
||||
return (
|
||||
<Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
|
||||
<Box sx={{display: 'flex', gap: 2}}>
|
||||
<Create
|
||||
isLoading={formLoading}
|
||||
saveButtonProps={{
|
||||
...saveButtonProps,
|
||||
onClick: handleFormSubmit,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", flex: 1, gap: 2 }}>
|
||||
{/* Форма создания */}
|
||||
<Box component="form" sx={{flex: 1, display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
||||
<LanguageSelector action={handleLanguageChange} />
|
||||
|
||||
<Box
|
||||
component="form"
|
||||
sx={{ flex: 1, display: "flex", flexDirection: "column" }}
|
||||
autoComplete="off"
|
||||
>
|
||||
<TextField
|
||||
{...register('name', {
|
||||
required: 'Это поле является обязательным',
|
||||
{...register("name", {
|
||||
required: "Это поле является обязательным",
|
||||
})}
|
||||
error={!!(errors as any)?.name}
|
||||
helperText={(errors as any)?.name?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
type="text"
|
||||
label={'Название *'}
|
||||
label={"Название *"}
|
||||
name="name"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('latitude', {
|
||||
required: 'Это поле является обязательным',
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
value={`${coordinatesPreview.latitude}, ${coordinatesPreview.longitude}`}
|
||||
onChange={handleCoordinatesChange}
|
||||
error={!!(errors as any)?.latitude}
|
||||
helperText={(errors as any)?.latitude?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
type="number"
|
||||
label={'Широта *'}
|
||||
name="latitude"
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
type="text"
|
||||
label={"Координаты *"}
|
||||
/>
|
||||
<TextField
|
||||
{...register('longitude', {
|
||||
required: 'Это поле является обязательным',
|
||||
<input
|
||||
type="hidden"
|
||||
{...register("longitude", {
|
||||
value: coordinatesPreview.longitude,
|
||||
required: "Это поле является обязательным",
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
error={!!(errors as any)?.longitude}
|
||||
helperText={(errors as any)?.longitude?.message}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
{...register("latitude", {
|
||||
value: coordinatesPreview.latitude,
|
||||
required: "Это поле является обязательным",
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register("address", {
|
||||
//required: "Это поле является обязательным",
|
||||
})}
|
||||
error={!!(errors as any)?.address}
|
||||
helperText={(errors as any)?.address?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
type="number"
|
||||
label={'Долгота *'}
|
||||
name="longitude"
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
type="text"
|
||||
label={"Адрес"}
|
||||
name="address"
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="city_id"
|
||||
rules={{required: 'Это поле является обязательным'}}
|
||||
defaultValue={null}
|
||||
rules={{ required: "Это поле является обязательным" }}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
{...cityAutocompleteProps}
|
||||
value={cityAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
||||
value={
|
||||
cityAutocompleteProps.options.find(
|
||||
(option) => option.id === field.value
|
||||
) ?? null
|
||||
}
|
||||
onChange={(_, value) => {
|
||||
field.onChange(value?.id || '')
|
||||
field.onChange(value?.id ?? "");
|
||||
}}
|
||||
getOptionLabel={(item) => {
|
||||
return item ? item.name : ''
|
||||
return item ? item.name : "";
|
||||
}}
|
||||
isOptionEqualToValue={(option, value) => {
|
||||
return option.id === value?.id
|
||||
return option.id === value?.id;
|
||||
}}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
return options.filter((option) => option.name.toLowerCase().includes(inputValue.toLowerCase()))
|
||||
return options.filter((option) =>
|
||||
option.name
|
||||
.toLowerCase()
|
||||
.includes(inputValue.toLowerCase())
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => <TextField {...params} label="Выберите город" margin="normal" variant="outlined" error={!!errors.city_id} helperText={(errors as any)?.city_id?.message} required />}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Выберите город"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
error={!!errors.city_id}
|
||||
helperText={(errors as any)?.city_id?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -197,20 +366,38 @@ export const SightCreate = () => {
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
{...mediaAutocompleteProps}
|
||||
value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
||||
value={
|
||||
mediaAutocompleteProps.options.find(
|
||||
(option) => option.id === field.value
|
||||
) ?? null
|
||||
}
|
||||
onChange={(_, value) => {
|
||||
field.onChange(value?.id || '')
|
||||
field.onChange(value?.id ?? "");
|
||||
}}
|
||||
getOptionLabel={(item) => {
|
||||
return item ? item.media_name : ''
|
||||
return item ? item.media_name : "";
|
||||
}}
|
||||
isOptionEqualToValue={(option, value) => {
|
||||
return option.id === value?.id
|
||||
return option.id === value?.id;
|
||||
}}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase()))
|
||||
return options.filter((option) =>
|
||||
option.media_name
|
||||
.toLowerCase()
|
||||
.includes(inputValue.toLowerCase())
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => <TextField {...params} label="Выберите обложку" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Выберите логотип достопримечательности"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
error={!!errors.thumbnail}
|
||||
helperText={(errors as any)?.thumbnail?.message}
|
||||
// required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -222,20 +409,37 @@ export const SightCreate = () => {
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
{...mediaAutocompleteProps}
|
||||
value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
||||
value={
|
||||
mediaAutocompleteProps.options.find(
|
||||
(option) => option.id === field.value
|
||||
) ?? null
|
||||
}
|
||||
onChange={(_, value) => {
|
||||
field.onChange(value?.id || '')
|
||||
field.onChange(value?.id ?? "");
|
||||
}}
|
||||
getOptionLabel={(item) => {
|
||||
return item ? item.media_name : ''
|
||||
return item ? item.media_name : "";
|
||||
}}
|
||||
isOptionEqualToValue={(option, value) => {
|
||||
return option.id === value?.id
|
||||
return option.id === value?.id;
|
||||
}}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase()))
|
||||
return options.filter((option) =>
|
||||
option.media_name
|
||||
.toLowerCase()
|
||||
.includes(inputValue.toLowerCase())
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => <TextField {...params} label="Выберите водный знак (Левый верх)" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Выберите водный знак (Левый верх)"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
error={!!errors.watermark_lu}
|
||||
helperText={(errors as any)?.watermark_lu?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -247,20 +451,37 @@ export const SightCreate = () => {
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
{...mediaAutocompleteProps}
|
||||
value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
||||
value={
|
||||
mediaAutocompleteProps.options.find(
|
||||
(option) => option.id === field.value
|
||||
) ?? null
|
||||
}
|
||||
onChange={(_, value) => {
|
||||
field.onChange(value?.id || '')
|
||||
field.onChange(value?.id ?? "");
|
||||
}}
|
||||
getOptionLabel={(item) => {
|
||||
return item ? item.media_name : ''
|
||||
return item ? item.media_name : "";
|
||||
}}
|
||||
isOptionEqualToValue={(option, value) => {
|
||||
return option.id === value?.id
|
||||
return option.id === value?.id;
|
||||
}}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase()))
|
||||
return options.filter((option) =>
|
||||
option.media_name
|
||||
.toLowerCase()
|
||||
.includes(inputValue.toLowerCase())
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => <TextField {...params} label="Выберите водный знак (Правый низ)" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Выберите водный знак (Правый верх)"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
error={!!errors.watermark_rd}
|
||||
helperText={(errors as any)?.watermark_rd?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -272,110 +493,216 @@ export const SightCreate = () => {
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
{...articleAutocompleteProps}
|
||||
value={articleAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
||||
options={mergedOptions} // ← use merged options
|
||||
value={
|
||||
mergedOptions.find((option) => option.id === field.value) ??
|
||||
null
|
||||
}
|
||||
onChange={(_, value) => {
|
||||
field.onChange(value?.id || '')
|
||||
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 />}
|
||||
getOptionLabel={(item) => (item ? item.heading : "")}
|
||||
isOptionEqualToValue={(option, value) =>
|
||||
option.id === value?.id
|
||||
}
|
||||
filterOptions={(options, { inputValue }) =>
|
||||
options.filter((option) =>
|
||||
option.heading
|
||||
.toLowerCase()
|
||||
.includes(inputValue.toLowerCase())
|
||||
)
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Левая статья"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
error={!!errors.left_article}
|
||||
helperText={(errors as any)?.left_article?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!leftArticleContent && (
|
||||
<Accordion>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon />}
|
||||
aria-controls="create-article-content"
|
||||
id="create-article-header"
|
||||
>
|
||||
<Typography>Создать новую статью</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<CreateSightArticle
|
||||
language={language}
|
||||
parentResource="sight"
|
||||
childResource="article"
|
||||
title="статью"
|
||||
noReset
|
||||
left
|
||||
onSave={(something: any) => {
|
||||
setCustomOptions((prev) => [...prev, something]);
|
||||
}}
|
||||
/>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="preview_article"
|
||||
name="preview_media"
|
||||
defaultValue={null}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
{...articleAutocompleteProps}
|
||||
value={articleAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
||||
{...mediaAutocompleteProps}
|
||||
value={
|
||||
mediaAutocompleteProps.options.find(
|
||||
(option) => option.id === field.value
|
||||
) || null
|
||||
}
|
||||
onChange={(_, value) => {
|
||||
field.onChange(value?.id || '')
|
||||
console.log(value, _);
|
||||
field.onChange(value?.id || "");
|
||||
}}
|
||||
getOptionLabel={(item) => {
|
||||
return item ? item.heading : ''
|
||||
return item ? item.media_name : "";
|
||||
}}
|
||||
isOptionEqualToValue={(option, value) => {
|
||||
return option.id === value?.id
|
||||
return option.id === value?.id;
|
||||
}}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
return options.filter((option) => option.heading.toLowerCase().includes(inputValue.toLowerCase()))
|
||||
return options.filter(
|
||||
(option) =>
|
||||
option.media_name
|
||||
.toLowerCase()
|
||||
.includes(inputValue.toLowerCase()) &&
|
||||
[1, 2, 3, 4, 5, 6].includes(option.media_type)
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => <TextField {...params} label="Cтатья-предпросмотр" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
onClick={() => {
|
||||
//setPreviewSelected(true);
|
||||
//setSelectedMediaIndex(-1);
|
||||
}}
|
||||
label="Медиа-предпросмотр"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
error={!!errors.arms}
|
||||
helperText={(errors as any)?.arms?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Блок предпросмотра */}
|
||||
{/* Preview Panel */}
|
||||
<Paper
|
||||
sx={{
|
||||
flex: 1,
|
||||
p: 2,
|
||||
maxHeight: 'calc(100vh - 200px)',
|
||||
overflowY: 'auto',
|
||||
position: 'sticky',
|
||||
maxHeight: "calc(100vh - 200px)",
|
||||
overflowY: "auto",
|
||||
position: "sticky",
|
||||
top: 16,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.main',
|
||||
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : '#fff'),
|
||||
border: "1px solid",
|
||||
borderColor: "primary.main",
|
||||
bgcolor: (theme) =>
|
||||
theme.palette.mode === "dark" ? "background.paper" : "#fff",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom color="primary">
|
||||
Предпросмотр
|
||||
</Typography>
|
||||
|
||||
{/* Название */}
|
||||
<Typography variant="h4" gutterBottom sx={{color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800')}}>
|
||||
{/* Название достопримечательности */}
|
||||
<Typography
|
||||
variant="h4"
|
||||
gutterBottom
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
{namePreview}
|
||||
</Typography>
|
||||
|
||||
{/* Город */}
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
<Box component="span" sx={{color: 'text.secondary'}}>
|
||||
Город:{' '}
|
||||
<Box component="span" sx={{ color: "text.secondary" }}>
|
||||
Город:{" "}
|
||||
</Box>
|
||||
<Box component="span" sx={{color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800')}}>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
|
||||
}}
|
||||
>
|
||||
{cityPreview}
|
||||
</Box>
|
||||
</Typography>
|
||||
|
||||
{/* Адрес */}
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
<Box component="span" sx={{ color: "text.secondary" }}>
|
||||
Адрес:{" "}
|
||||
</Box>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
|
||||
}}
|
||||
>
|
||||
{addressContent}
|
||||
</Box>
|
||||
</Typography>
|
||||
|
||||
{/* Координаты */}
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
<Box component="span" sx={{color: 'text.secondary'}}>
|
||||
Координаты:{' '}
|
||||
<Box component="span" sx={{ color: "text.secondary" }}>
|
||||
Координаты:{" "}
|
||||
</Box>
|
||||
<Box component="span" sx={{color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800')}}>
|
||||
{coordinatesPreview.latitude}, {coordinatesPreview.longitude}
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
|
||||
}}
|
||||
>
|
||||
{`${coordinatesPreview.latitude}, ${coordinatesPreview.longitude}`}
|
||||
</Box>
|
||||
</Typography>
|
||||
|
||||
{/* Обложка */}
|
||||
{thumbnailPreview && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body1" gutterBottom sx={{color: 'text.secondary'}}>
|
||||
Обложка:
|
||||
<Typography
|
||||
variant="body1"
|
||||
gutterBottom
|
||||
sx={{ color: "text.secondary" }}
|
||||
>
|
||||
Логотип достопримечательности:
|
||||
</Typography>
|
||||
<Box
|
||||
component="img"
|
||||
src={thumbnailPreview}
|
||||
alt="Обложка"
|
||||
alt="Логотип"
|
||||
sx={{
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.main',
|
||||
maxWidth: "100%",
|
||||
height: "40vh",
|
||||
borderRadius: 2,
|
||||
border: "1px solid",
|
||||
borderColor: "primary.main",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
@ -383,13 +710,21 @@ export const SightCreate = () => {
|
||||
|
||||
{/* Водяные знаки */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body1" gutterBottom sx={{color: 'text.secondary'}}>
|
||||
<Typography
|
||||
variant="body1"
|
||||
gutterBottom
|
||||
sx={{ color: "text.secondary" }}
|
||||
>
|
||||
Водяные знаки:
|
||||
</Typography>
|
||||
<Box sx={{display: 'flex', gap: 2}}>
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
{watermarkLUPreview && (
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom sx={{color: 'text.secondary'}}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
gutterBottom
|
||||
sx={{ color: "text.secondary" }}
|
||||
>
|
||||
Левый верхний:
|
||||
</Typography>
|
||||
<Box
|
||||
@ -399,18 +734,22 @@ export const SightCreate = () => {
|
||||
sx={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
objectFit: 'cover',
|
||||
objectFit: "cover",
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.main',
|
||||
border: "1px solid",
|
||||
borderColor: "primary.main",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{watermarkRDPreview && (
|
||||
<Box>
|
||||
<Typography variant="body2" gutterBottom sx={{color: 'text.secondary'}}>
|
||||
Правый нижний:
|
||||
<Typography
|
||||
variant="body2"
|
||||
gutterBottom
|
||||
sx={{ color: "text.secondary" }}
|
||||
>
|
||||
Правый верхний:
|
||||
</Typography>
|
||||
<Box
|
||||
component="img"
|
||||
@ -419,10 +758,10 @@ export const SightCreate = () => {
|
||||
sx={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
objectFit: 'cover',
|
||||
objectFit: "cover",
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.main',
|
||||
border: "1px solid",
|
||||
borderColor: "primary.main",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
@ -432,23 +771,16 @@ export const SightCreate = () => {
|
||||
|
||||
{/* Связанные статьи */}
|
||||
<Box>
|
||||
<Typography variant="body1" gutterBottom sx={{color: 'text.secondary'}}>
|
||||
Связанные статьи:
|
||||
</Typography>
|
||||
{leftArticlePreview && (
|
||||
<Typography variant="body1" gutterBottom>
|
||||
<Box component="span" sx={{color: 'text.secondary'}}>
|
||||
Левая статья:{' '}
|
||||
<Box component="span" sx={{ color: "text.secondary" }}>
|
||||
Левая статья:{" "}
|
||||
</Box>
|
||||
<Box
|
||||
component={Link}
|
||||
to={`/article/show/${watch('left_article')}`}
|
||||
component="span"
|
||||
sx={{
|
||||
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'),
|
||||
textDecoration: 'none',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
color: (theme) =>
|
||||
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
|
||||
}}
|
||||
>
|
||||
{leftArticlePreview}
|
||||
@ -457,18 +789,14 @@ export const SightCreate = () => {
|
||||
)}
|
||||
{previewArticlePreview && (
|
||||
<Typography variant="body1" gutterBottom>
|
||||
<Box component="span" sx={{color: 'text.secondary'}}>
|
||||
Статья-предпросмотр:{' '}
|
||||
<Box component="span" sx={{ color: "text.secondary" }}>
|
||||
Статья-предпросмотр:{" "}
|
||||
</Box>
|
||||
<Box
|
||||
component={Link}
|
||||
to={`/article/show/${watch('preview_article')}`}
|
||||
component="span"
|
||||
sx={{
|
||||
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'),
|
||||
textDecoration: 'none',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
color: (theme) =>
|
||||
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
|
||||
}}
|
||||
>
|
||||
{previewArticlePreview}
|
||||
@ -479,5 +807,5 @@ export const SightCreate = () => {
|
||||
</Paper>
|
||||
</Box>
|
||||
</Create>
|
||||
)
|
||||
}
|
||||
);
|
||||
});
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,106 +1,135 @@
|
||||
import React from 'react'
|
||||
import {type GridColDef} from '@mui/x-data-grid'
|
||||
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
|
||||
import {Stack} from '@mui/material'
|
||||
import {CustomDataGrid} from '../../components/CustomDataGrid'
|
||||
import {localeText} from '../../locales/ru/localeText'
|
||||
import React 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 { cityStore, languageStore } from "@stores";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
export const SightList = () => {
|
||||
const {dataGridProps} = useDataGrid({resource: 'sight/'})
|
||||
export const SightList = observer(() => {
|
||||
const { city_id } = cityStore;
|
||||
const { language } = languageStore;
|
||||
|
||||
const { dataGridProps } = useDataGrid({
|
||||
resource: "sight",
|
||||
|
||||
meta: {
|
||||
headers: {
|
||||
"Accept-Language": language,
|
||||
},
|
||||
},
|
||||
|
||||
filters: {
|
||||
permanent: [
|
||||
{
|
||||
field: "cityID",
|
||||
operator: "eq",
|
||||
value: city_id === "0" ? null : city_id,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const columns = React.useMemo<GridColDef[]>(
|
||||
() => [
|
||||
{
|
||||
field: 'id',
|
||||
headerName: 'ID',
|
||||
type: 'number',
|
||||
field: "id",
|
||||
headerName: "ID",
|
||||
type: "number",
|
||||
minWidth: 70,
|
||||
display: 'flex',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
display: "flex",
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
headerName: 'Название',
|
||||
type: 'string',
|
||||
field: "name",
|
||||
headerName: "Название",
|
||||
type: "string",
|
||||
minWidth: 150,
|
||||
display: 'flex',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
display: "flex",
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'latitude',
|
||||
headerName: 'Широта',
|
||||
type: 'number',
|
||||
field: "latitude",
|
||||
headerName: "Широта",
|
||||
type: "number",
|
||||
minWidth: 150,
|
||||
display: 'flex',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
display: "flex",
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'longitude',
|
||||
headerName: 'Долгота',
|
||||
type: 'number',
|
||||
field: "longitude",
|
||||
headerName: "Долгота",
|
||||
type: "number",
|
||||
minWidth: 150,
|
||||
display: 'flex',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
display: "flex",
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'city_id',
|
||||
headerName: 'ID города',
|
||||
type: 'number',
|
||||
field: "city_id",
|
||||
headerName: "ID города",
|
||||
type: "number",
|
||||
minWidth: 70,
|
||||
display: 'flex',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
display: "flex",
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'city',
|
||||
headerName: 'Город',
|
||||
type: 'string',
|
||||
field: "city",
|
||||
headerName: "Город",
|
||||
type: "string",
|
||||
minWidth: 100,
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
field: 'thumbnail',
|
||||
headerName: 'Карточка',
|
||||
type: 'string',
|
||||
field: "thumbnail",
|
||||
headerName: "Карточка",
|
||||
type: "string",
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'watermark_lu',
|
||||
headerName: 'Вод. знак (lu)',
|
||||
type: 'string',
|
||||
field: "watermark_lu",
|
||||
headerName: "Вод. знак (lu)",
|
||||
type: "string",
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'watermark_rd',
|
||||
headerName: 'Вод. знак (rd)',
|
||||
type: 'string',
|
||||
field: "watermark_rd",
|
||||
headerName: "Вод. знак (rd)",
|
||||
type: "string",
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'left_article',
|
||||
headerName: 'Левая статья',
|
||||
type: 'number',
|
||||
field: "left_article",
|
||||
headerName: "Левая статья",
|
||||
type: "number",
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'preview_article',
|
||||
headerName: 'Пред. просмотр статьи',
|
||||
type: 'number',
|
||||
field: "preview_article",
|
||||
headerName: "Пред. просмотр статьи",
|
||||
type: "number",
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
headerName: 'Действия',
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
minWidth: 120,
|
||||
display: 'flex',
|
||||
align: 'right',
|
||||
headerAlign: 'center',
|
||||
display: "flex",
|
||||
align: "right",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
disableColumnMenu: true,
|
||||
@ -109,20 +138,31 @@ export const SightList = () => {
|
||||
<>
|
||||
<EditButton hideText recordItemId={row.id} />
|
||||
<ShowButton hideText recordItemId={row.id} />
|
||||
<DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} />
|
||||
<DeleteButton
|
||||
hideText
|
||||
confirmTitle="Вы уверены?"
|
||||
recordItemId={row.id}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<List>
|
||||
<Stack gap={2.5}>
|
||||
<CustomDataGrid {...dataGridProps} columns={columns} localeText={localeText} getRowId={(row: any) => row.id} hasCoordinates />
|
||||
<CustomDataGrid
|
||||
{...dataGridProps}
|
||||
languageEnabled
|
||||
columns={columns}
|
||||
localeText={localeText}
|
||||
getRowId={(row: any) => row.id}
|
||||
hasCoordinates
|
||||
/>
|
||||
</Stack>
|
||||
</List>
|
||||
)
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -1,22 +1,23 @@
|
||||
import {Stack, Typography} from '@mui/material'
|
||||
import {useShow} from '@refinedev/core'
|
||||
import {Show, TextFieldComponent} from '@refinedev/mui'
|
||||
import {LinkedItems} from '../../components/LinkedItems'
|
||||
import {ArticleItem, articleFields} from './types'
|
||||
import { Stack, Typography } from "@mui/material";
|
||||
import { useShow } from "@refinedev/core";
|
||||
import { Show, TextFieldComponent } from "@refinedev/mui";
|
||||
import { LinkedItems } from "../../components/LinkedItems";
|
||||
import { ArticleItem, articleFields } from "./types";
|
||||
|
||||
export const SightShow = () => {
|
||||
const {query} = useShow({})
|
||||
const {data, isLoading} = query
|
||||
const record = data?.data
|
||||
const { query } = useShow({});
|
||||
const { data, isLoading } = query;
|
||||
const record = data?.data;
|
||||
|
||||
const fields = [
|
||||
// {label: 'ID', data: 'id'},
|
||||
{label: 'Название', data: 'name'},
|
||||
{ label: "Название", data: "name" },
|
||||
// {label: 'Широта', data: 'latitude'}, #*
|
||||
// {label: 'Долгота', data: 'longitude'}, #*
|
||||
// {label: 'ID города', data: 'city_id'},
|
||||
{label: 'Город', data: 'city'},
|
||||
]
|
||||
{ label: "Адрес", data: "address" },
|
||||
{ label: "Город", data: "city" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Show isLoading={isLoading}>
|
||||
@ -30,8 +31,17 @@ export const SightShow = () => {
|
||||
</Stack>
|
||||
))}
|
||||
|
||||
{record?.id && <LinkedItems<ArticleItem> type="show" parentId={record.id} parentResource="sight" childResource="article" fields={articleFields} title="статьи" />}
|
||||
{record?.id && (
|
||||
<LinkedItems<ArticleItem>
|
||||
type="show"
|
||||
parentId={record.id}
|
||||
parentResource="sight"
|
||||
childResource="article"
|
||||
fields={articleFields}
|
||||
title="статьи"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
61
src/pages/snapshot/create.tsx
Normal file
61
src/pages/snapshot/create.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
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>
|
||||
);
|
||||
});
|
3
src/pages/snapshot/index.ts
Normal file
3
src/pages/snapshot/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./create";
|
||||
export * from "./list";
|
||||
export * from "./show";
|
149
src/pages/snapshot/list.tsx
Normal file
149
src/pages/snapshot/list.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
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>
|
||||
);
|
||||
});
|
26
src/pages/snapshot/show.tsx
Normal file
26
src/pages/snapshot/show.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
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>
|
||||
);
|
||||
};
|
@ -1,50 +1,114 @@
|
||||
import {Autocomplete, Box, TextField, Typography, Paper, Grid} from '@mui/material'
|
||||
import {Create, useAutocomplete} from '@refinedev/mui'
|
||||
import {useForm} from '@refinedev/react-hook-form'
|
||||
import {Controller} from 'react-hook-form'
|
||||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
TextField,
|
||||
Typography,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Grid,
|
||||
Paper,
|
||||
} from "@mui/material";
|
||||
import { Create, useAutocomplete } from "@refinedev/mui";
|
||||
import { useForm } from "@refinedev/react-hook-form";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller } from "react-hook-form";
|
||||
|
||||
const TRANSFER_FIELDS = [
|
||||
{name: 'bus', label: 'Автобус'},
|
||||
{name: 'metro_blue', label: 'Метро (синяя)'},
|
||||
{name: 'metro_green', label: 'Метро (зеленая)'},
|
||||
{name: 'metro_orange', label: 'Метро (оранжевая)'},
|
||||
{name: 'metro_purple', label: 'Метро (фиолетовая)'},
|
||||
{name: 'metro_red', label: 'Метро (красная)'},
|
||||
{name: 'train', label: 'Электричка'},
|
||||
{name: 'tram', label: 'Трамвай'},
|
||||
{name: 'trolleybus', label: 'Троллейбус'},
|
||||
]
|
||||
{ name: "bus", label: "Автобус" },
|
||||
{ name: "metro_blue", label: "Метро (синяя)" },
|
||||
{ name: "metro_green", label: "Метро (зеленая)" },
|
||||
{ name: "metro_orange", label: "Метро (оранжевая)" },
|
||||
{ name: "metro_purple", label: "Метро (фиолетовая)" },
|
||||
{ name: "metro_red", label: "Метро (красная)" },
|
||||
{ name: "train", label: "Электричка" },
|
||||
{ name: "tram", label: "Трамвай" },
|
||||
{ name: "trolleybus", label: "Троллейбус" },
|
||||
];
|
||||
|
||||
export const StationCreate = () => {
|
||||
const {
|
||||
saveButtonProps,
|
||||
refineCore: { formLoading },
|
||||
register,
|
||||
setValue,
|
||||
control,
|
||||
getValues,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm({
|
||||
refineCoreProps: {
|
||||
resource: 'station/',
|
||||
resource: "station",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const [coordinatesPreview, setCoordinatesPreview] = useState({
|
||||
latitude: "",
|
||||
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',
|
||||
resource: "city",
|
||||
onSearch: (value) => [
|
||||
{
|
||||
field: 'name',
|
||||
operator: 'contains',
|
||||
field: "name",
|
||||
operator: "contains",
|
||||
value,
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
|
||||
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
||||
<Box
|
||||
component="form"
|
||||
sx={{ display: "flex", flexDirection: "column" }}
|
||||
autoComplete="off"
|
||||
>
|
||||
<TextField
|
||||
{...register('name', {
|
||||
required: 'Это поле является обязательным',
|
||||
{...register("name", {
|
||||
required: "Это поле является обязательным",
|
||||
})}
|
||||
error={!!(errors as any)?.name}
|
||||
helperText={(errors as any)?.name?.message}
|
||||
@ -52,12 +116,12 @@ export const StationCreate = () => {
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
type="text"
|
||||
label={'Название *'}
|
||||
label={"Название *"}
|
||||
name="name"
|
||||
/>
|
||||
<TextField
|
||||
{...register('system_name', {
|
||||
required: 'Это поле является обязательным',
|
||||
{...register("system_name", {
|
||||
required: "Это поле является обязательным",
|
||||
})}
|
||||
error={!!(errors as any)?.system_name}
|
||||
helperText={(errors as any)?.system_name?.message}
|
||||
@ -65,11 +129,25 @@ export const StationCreate = () => {
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
type="text"
|
||||
label={'Системное название *'}
|
||||
label={"Системное название *"}
|
||||
name="system_name"
|
||||
/>
|
||||
<TextField
|
||||
{...register('description', {
|
||||
{...register("address", {
|
||||
// required: 'Это поле является обязательным',
|
||||
})}
|
||||
error={!!(errors as any)?.address}
|
||||
helperText={(errors as any)?.address?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
type="text"
|
||||
label={"Адрес"}
|
||||
name="address"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register("description", {
|
||||
// required: 'Это поле является обязательным',
|
||||
})}
|
||||
error={!!(errors as any)?.description}
|
||||
@ -78,67 +156,124 @@ export const StationCreate = () => {
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
type="text"
|
||||
label={'Описание'}
|
||||
label={"Описание"}
|
||||
name="description"
|
||||
/>
|
||||
<TextField
|
||||
{...register('latitude', {
|
||||
required: 'Это поле является обязательным',
|
||||
valueAsNumber: true,
|
||||
<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
|
||||
value={`${coordinatesPreview.latitude}, ${coordinatesPreview.longitude}`}
|
||||
onChange={handleCoordinatesChange}
|
||||
error={!!(errors as any)?.latitude}
|
||||
helperText={(errors as any)?.latitude?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
type="number"
|
||||
label={'Широта *'}
|
||||
name="latitude"
|
||||
type="text"
|
||||
label={"Координаты *"}
|
||||
/>
|
||||
<TextField
|
||||
{...register('longitude', {
|
||||
required: 'Это поле является обязательным',
|
||||
valueAsNumber: true,
|
||||
<input
|
||||
type="hidden"
|
||||
{...register("latitude", {
|
||||
value: coordinatesPreview.latitude,
|
||||
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
|
||||
control={control}
|
||||
name="city_id"
|
||||
rules={{required: 'Это поле является обязательным'}}
|
||||
rules={{ required: "Это поле является обязательным" }}
|
||||
defaultValue={null}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
{...cityAutocompleteProps}
|
||||
value={cityAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
||||
value={
|
||||
cityAutocompleteProps.options.find(
|
||||
(option) => option.id === field.value
|
||||
) || null
|
||||
}
|
||||
onChange={(_, value) => {
|
||||
field.onChange(value?.id || '')
|
||||
field.onChange(value?.id || "");
|
||||
}}
|
||||
getOptionLabel={(item) => {
|
||||
return item ? item.name : ''
|
||||
return item ? item.name : "";
|
||||
}}
|
||||
isOptionEqualToValue={(option, value) => {
|
||||
return option.id === value?.id
|
||||
return option.id === value?.id;
|
||||
}}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
return options.filter((option) => option.name.toLowerCase().includes(inputValue.toLowerCase()))
|
||||
return options.filter((option) =>
|
||||
option.name.toLowerCase().includes(inputValue.toLowerCase())
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => <TextField {...params} label="Выберите город" margin="normal" variant="outlined" error={!!errors.city_id} helperText={(errors as any)?.city_id?.message} required />}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Выберите город"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
error={!!errors.city_id}
|
||||
helperText={(errors as any)?.city_id?.message}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Box sx={{ visibility: "hidden" }}>
|
||||
<TextField
|
||||
{...register('offset_x', {
|
||||
{...register("offset_x", {
|
||||
// required: 'Это поле является обязательным',
|
||||
setValueAs: (value) => {
|
||||
if (value === "") {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
})}
|
||||
error={!!(errors as any)?.offset_x}
|
||||
helperText={(errors as any)?.offset_x?.message}
|
||||
@ -146,12 +281,17 @@ export const StationCreate = () => {
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
type="number"
|
||||
label={'Смещение (X)'}
|
||||
label={"Смещение (X)"}
|
||||
name="offset_x"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('offset_y', {
|
||||
{...register("offset_y", {
|
||||
setValueAs: (value) => {
|
||||
if (value === "") {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
// required: 'Это поле является обязательным',
|
||||
})}
|
||||
error={!!(errors as any)?.offset_y}
|
||||
@ -160,24 +300,35 @@ export const StationCreate = () => {
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
type="number"
|
||||
label={'Смещение (Y)'}
|
||||
label={"Смещение (Y)"}
|
||||
name="offset_y"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Группа полей пересадок */}
|
||||
<Paper sx={{p: 2, mt: 2}}>
|
||||
<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}`} />
|
||||
<TextField
|
||||
{...register(`transfers.${field.name}`)}
|
||||
error={!!(errors as any)?.transfers?.[field.name]}
|
||||
helperText={(errors as any)?.transfers?.[field.name]?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
type="text"
|
||||
label={field.label}
|
||||
name={`transfers.${field.name}`}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Create>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
@ -1,51 +1,208 @@
|
||||
import {Autocomplete, Box, TextField, Typography, Paper, Grid} from '@mui/material'
|
||||
import {Edit, useAutocomplete} from '@refinedev/mui'
|
||||
import {useForm} from '@refinedev/react-hook-form'
|
||||
import {Controller} from 'react-hook-form'
|
||||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
TextField,
|
||||
Typography,
|
||||
FormControlLabel,
|
||||
Paper,
|
||||
Grid,
|
||||
Checkbox,
|
||||
} from "@mui/material";
|
||||
import { Edit, useAutocomplete } from "@refinedev/mui";
|
||||
import { useForm } from "@refinedev/react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
|
||||
import {useParams} from 'react-router'
|
||||
import {LinkedItems} from '../../components/LinkedItems'
|
||||
import {type SightItem, sightFields} from './types'
|
||||
import { useParams } from "react-router";
|
||||
import { LinkedItems } from "../../components/LinkedItems";
|
||||
import { type SightItem, sightFields } from "./types";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { languageStore } from "../../store/LanguageStore";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LanguageSwitch } from "../../components/LanguageSwitch/index";
|
||||
|
||||
const TRANSFER_FIELDS = [
|
||||
{name: 'bus', label: 'Автобус'},
|
||||
{name: 'metro_blue', label: 'Метро (синяя)'},
|
||||
{name: 'metro_green', label: 'Метро (зеленая)'},
|
||||
{name: 'metro_orange', label: 'Метро (оранжевая)'},
|
||||
{name: 'metro_purple', label: 'Метро (фиолетовая)'},
|
||||
{name: 'metro_red', label: 'Метро (красная)'},
|
||||
{name: 'train', label: 'Электричка'},
|
||||
{name: 'tram', label: 'Трамвай'},
|
||||
{name: 'trolleybus', label: 'Троллейбус'},
|
||||
]
|
||||
{ name: "bus", label: "Автобус" },
|
||||
{ name: "metro_blue", label: "Метро (синяя)" },
|
||||
{ name: "metro_green", label: "Метро (зеленая)" },
|
||||
{ name: "metro_orange", label: "Метро (оранжевая)" },
|
||||
{ name: "metro_purple", label: "Метро (фиолетовая)" },
|
||||
{ name: "metro_red", label: "Метро (красная)" },
|
||||
{ name: "train", label: "Электричка" },
|
||||
{ name: "tram", label: "Трамвай" },
|
||||
{ name: "trolleybus", label: "Троллейбус" },
|
||||
];
|
||||
|
||||
export const StationEdit = observer(() => {
|
||||
const { language, setLanguageAction } = languageStore;
|
||||
const [stationData, setStationData] = useState({
|
||||
ru: {
|
||||
name: "",
|
||||
system_name: "",
|
||||
description: "",
|
||||
address: "",
|
||||
latitude: "",
|
||||
longitude: "",
|
||||
},
|
||||
en: {
|
||||
name: "",
|
||||
system_name: "",
|
||||
description: "",
|
||||
address: "",
|
||||
latitude: "",
|
||||
longitude: "",
|
||||
},
|
||||
zh: {
|
||||
name: "",
|
||||
system_name: "",
|
||||
description: "",
|
||||
address: "",
|
||||
latitude: "",
|
||||
longitude: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleLanguageChange = () => {
|
||||
setStationData((prevData) => ({
|
||||
...prevData,
|
||||
[language]: {
|
||||
name: watch("name") ?? "",
|
||||
system_name: watch("system_name") ?? "",
|
||||
description: watch("description") ?? "",
|
||||
address: watch("address") ?? "",
|
||||
latitude: watch("latitude") ?? "",
|
||||
longitude: watch("longitude") ?? "",
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const [coordinatesPreview, setCoordinatesPreview] = useState({
|
||||
latitude: "",
|
||||
longitude: "",
|
||||
});
|
||||
|
||||
export const StationEdit = () => {
|
||||
const {
|
||||
saveButtonProps,
|
||||
register,
|
||||
control,
|
||||
getValues,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm({})
|
||||
} = useForm({
|
||||
refineCoreProps: {
|
||||
meta: {
|
||||
headers: { "Accept-Language": language },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const {id: stationId} = useParams<{id: string}>()
|
||||
const directions = [
|
||||
{
|
||||
label: "Прямой",
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
label: "Обратный",
|
||||
value: false,
|
||||
},
|
||||
];
|
||||
|
||||
const directionContent = watch("direction");
|
||||
const [routeDirection, setRouteDirection] = useState(false);
|
||||
|
||||
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',
|
||||
resource: "city",
|
||||
onSearch: (value) => [
|
||||
{
|
||||
field: 'name',
|
||||
operator: 'contains',
|
||||
field: "name",
|
||||
operator: "contains",
|
||||
value,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
meta: {
|
||||
headers: {
|
||||
"Accept-Language": "ru",
|
||||
},
|
||||
},
|
||||
queryOptions: {
|
||||
queryKey: ["city"],
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Edit saveButtonProps={saveButtonProps}>
|
||||
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
||||
<Box
|
||||
component="form"
|
||||
sx={{ display: "flex", flexDirection: "column" }}
|
||||
autoComplete="off"
|
||||
>
|
||||
<LanguageSwitch action={handleLanguageChange} />
|
||||
<TextField
|
||||
{...register('name', {
|
||||
required: 'Это поле является обязательным',
|
||||
{...register("name", {
|
||||
required: "Это поле является обязательным",
|
||||
})}
|
||||
error={!!(errors as any)?.name}
|
||||
helperText={(errors as any)?.name?.message}
|
||||
@ -53,12 +210,12 @@ export const StationEdit = () => {
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
type="text"
|
||||
label={'Название *'}
|
||||
label={"Название *"}
|
||||
name="name"
|
||||
/>
|
||||
<TextField
|
||||
{...register('system_name', {
|
||||
required: 'Это поле является обязательным',
|
||||
{...register("system_name", {
|
||||
required: "Это поле является обязательным",
|
||||
})}
|
||||
error={!!(errors as any)?.system_name}
|
||||
helperText={(errors as any)?.system_name?.message}
|
||||
@ -66,11 +223,37 @@ export const StationEdit = () => {
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
type="text"
|
||||
label={'Системное название *'}
|
||||
label={"Системное название *"}
|
||||
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
|
||||
{...register('description', {
|
||||
{...params}
|
||||
label="Прямой/обратный маршрут"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
error={!!errors.direction}
|
||||
helperText={(errors as any)?.direction?.message}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<TextField
|
||||
{...register("description", {
|
||||
// required: 'Это поле является обязательным',
|
||||
})}
|
||||
error={!!(errors as any)?.description}
|
||||
@ -79,105 +262,86 @@ export const StationEdit = () => {
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
type="text"
|
||||
label={'Описание'}
|
||||
label={"Описание"}
|
||||
name="description"
|
||||
/>
|
||||
<TextField
|
||||
{...register('latitude', {
|
||||
required: 'Это поле является обязательным',
|
||||
valueAsNumber: true,
|
||||
{...register("address", {
|
||||
// required: 'Это поле является обязательным',
|
||||
})}
|
||||
error={!!(errors as any)?.address}
|
||||
helperText={(errors as any)?.address?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
type="text"
|
||||
label={"Адрес"}
|
||||
name="address"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
value={`${coordinatesPreview.latitude}, ${coordinatesPreview.longitude}`}
|
||||
onChange={handleCoordinatesChange}
|
||||
error={!!(errors as any)?.latitude}
|
||||
helperText={(errors as any)?.latitude?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
type="number"
|
||||
label={'Широта *'}
|
||||
name="latitude"
|
||||
type="text"
|
||||
label={"Координаты *"}
|
||||
/>
|
||||
<TextField
|
||||
{...register('longitude', {
|
||||
required: 'Это поле является обязательным',
|
||||
valueAsNumber: true,
|
||||
<input
|
||||
type="hidden"
|
||||
{...register("latitude", {
|
||||
value: coordinatesPreview.latitude,
|
||||
})}
|
||||
error={!!(errors as any)?.longitude}
|
||||
helperText={(errors as any)?.longitude?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
type="number"
|
||||
label={'Долгота *'}
|
||||
name="longitude"
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
{...register("longitude", { value: coordinatesPreview.longitude })}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="city_id"
|
||||
rules={{required: 'Это поле является обязательным'}}
|
||||
rules={{ required: "Это поле является обязательным" }}
|
||||
defaultValue={null}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
{...cityAutocompleteProps}
|
||||
value={cityAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
||||
value={
|
||||
cityAutocompleteProps.options.find(
|
||||
(option) => option.id === field.value
|
||||
) || null
|
||||
}
|
||||
onChange={(_, value) => {
|
||||
field.onChange(value?.id || '')
|
||||
field.onChange(value?.id || "");
|
||||
}}
|
||||
getOptionLabel={(item) => {
|
||||
return item ? item.name : ''
|
||||
return item ? item.name : "";
|
||||
}}
|
||||
isOptionEqualToValue={(option, value) => {
|
||||
return option.id === value?.id
|
||||
return option.id === value?.id;
|
||||
}}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
return options.filter((option) => option.name.toLowerCase().includes(inputValue.toLowerCase()))
|
||||
return options.filter((option) =>
|
||||
option.name.toLowerCase().includes(inputValue.toLowerCase())
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => <TextField {...params} label="Выберите город" margin="normal" variant="outlined" error={!!errors.city_id} helperText={(errors as any)?.city_id?.message} required />}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Выберите город"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
error={!!errors.city_id}
|
||||
helperText={(errors as any)?.city_id?.message}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('offset_x', {
|
||||
// required: 'Это поле является обязательным',
|
||||
})}
|
||||
error={!!(errors as any)?.offset_x}
|
||||
helperText={(errors as any)?.offset_x?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
type="number"
|
||||
label={'Смещение (X)'}
|
||||
name="offset_x"
|
||||
)}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('offset_y', {
|
||||
// required: 'Это поле является обязательным',
|
||||
})}
|
||||
error={!!(errors as any)?.offset_y}
|
||||
helperText={(errors as any)?.offset_y?.message}
|
||||
margin="normal"
|
||||
fullWidth
|
||||
InputLabelProps={{shrink: true}}
|
||||
type="number"
|
||||
label={'Смещение (Y)'}
|
||||
name="offset_y"
|
||||
/>
|
||||
|
||||
{/* Группа полей пересадок */}
|
||||
<Paper sx={{p: 2, mt: 2}}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Пересадки
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{TRANSFER_FIELDS.map((field) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={field.name}>
|
||||
<TextField {...register(`transfers.${field.name}`)} error={!!(errors as any)?.transfers?.[field.name]} helperText={(errors as any)?.transfers?.[field.name]?.message} margin="normal" fullWidth InputLabelProps={{shrink: true}} type="text" label={field.label} name={`transfers.${field.name}`} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{stationId && (
|
||||
@ -188,8 +352,9 @@ export const StationEdit = () => {
|
||||
childResource="sight"
|
||||
fields={sightFields}
|
||||
title="достопримечательности"
|
||||
dragAllowed={false}
|
||||
/>
|
||||
)}
|
||||
</Edit>
|
||||
)
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -1,104 +1,147 @@
|
||||
import React from 'react'
|
||||
import {type GridColDef} from '@mui/x-data-grid'
|
||||
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
|
||||
import {Stack} from '@mui/material'
|
||||
import {CustomDataGrid} from '../../components/CustomDataGrid'
|
||||
import {localeText} from '../../locales/ru/localeText'
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { type GridColDef } from "@mui/x-data-grid";
|
||||
import {
|
||||
DeleteButton,
|
||||
EditButton,
|
||||
List,
|
||||
ShowButton,
|
||||
useDataGrid,
|
||||
} from "@refinedev/mui";
|
||||
import { Stack, Typography } from "@mui/material";
|
||||
import { CustomDataGrid } from "../../components/CustomDataGrid";
|
||||
import { localeText } from "../../locales/ru/localeText";
|
||||
import { cityStore } from "../../store/CityStore";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { languageStore } from "../../store/LanguageStore";
|
||||
|
||||
export const StationList = () => {
|
||||
const {dataGridProps} = useDataGrid({resource: 'station/'})
|
||||
export const StationList = observer(() => {
|
||||
const { city_id } = cityStore;
|
||||
const { language } = languageStore;
|
||||
|
||||
const { dataGridProps } = useDataGrid({
|
||||
resource: "station",
|
||||
meta: {
|
||||
headers: {
|
||||
"Accept-Language": language,
|
||||
},
|
||||
},
|
||||
filters: {
|
||||
permanent: [
|
||||
{
|
||||
field: "cityID",
|
||||
operator: "eq",
|
||||
value: city_id === "0" ? null : city_id,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const columns = React.useMemo<GridColDef[]>(
|
||||
() => [
|
||||
{
|
||||
field: 'id',
|
||||
headerName: 'ID',
|
||||
type: 'number',
|
||||
field: "id",
|
||||
headerName: "ID",
|
||||
type: "number",
|
||||
minWidth: 70,
|
||||
display: 'flex',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
display: "flex",
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
headerName: 'Название',
|
||||
type: 'string',
|
||||
field: "name",
|
||||
headerName: "Название",
|
||||
type: "string",
|
||||
minWidth: 300,
|
||||
display: 'flex',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
},
|
||||
{
|
||||
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',
|
||||
display: "flex",
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
headerName: 'Действия',
|
||||
cellClassName: 'station-actions',
|
||||
align: 'right',
|
||||
headerAlign: 'center',
|
||||
field: "system_name",
|
||||
headerName: "Системное название",
|
||||
type: "string",
|
||||
minWidth: 200,
|
||||
display: "flex",
|
||||
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,
|
||||
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,
|
||||
filterable: false,
|
||||
disableColumnMenu: true,
|
||||
@ -107,20 +150,31 @@ export const StationList = () => {
|
||||
<>
|
||||
<EditButton hideText recordItemId={row.id} />
|
||||
<ShowButton hideText recordItemId={row.id} />
|
||||
<DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} />
|
||||
<DeleteButton
|
||||
hideText
|
||||
confirmTitle="Вы уверены?"
|
||||
recordItemId={row.id}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<List>
|
||||
<List key={city_id}>
|
||||
<Stack gap={2.5}>
|
||||
<CustomDataGrid {...dataGridProps} columns={columns} localeText={localeText} getRowId={(row: any) => row.id} hasCoordinates />
|
||||
<CustomDataGrid
|
||||
{...dataGridProps}
|
||||
columns={columns}
|
||||
languageEnabled
|
||||
localeText={localeText}
|
||||
getRowId={(row: any) => row.id}
|
||||
hasCoordinates
|
||||
/>
|
||||
</Stack>
|
||||
</List>
|
||||
)
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -1,13 +1,13 @@
|
||||
import {useShow} from '@refinedev/core'
|
||||
import {Show, TextFieldComponent as TextField} from '@refinedev/mui'
|
||||
import {Stack, Typography} from '@mui/material'
|
||||
import {LinkedItems} from '../../components/LinkedItems'
|
||||
import {type SightItem, sightFields, stationFields} from './types'
|
||||
import { useShow } from "@refinedev/core";
|
||||
import { Show, TextFieldComponent as TextField } from "@refinedev/mui";
|
||||
import { Box, Stack, Typography } from "@mui/material";
|
||||
import { LinkedItems } from "../../components/LinkedItems";
|
||||
import { type SightItem, sightFields, stationFields } from "./types";
|
||||
|
||||
export const StationShow = () => {
|
||||
const {query} = useShow({})
|
||||
const {data, isLoading} = query
|
||||
const record = data?.data
|
||||
const { query } = useShow({});
|
||||
const { data, isLoading } = query;
|
||||
const record = data?.data;
|
||||
|
||||
return (
|
||||
<Show isLoading={isLoading}>
|
||||
@ -16,8 +16,16 @@ export const StationShow = () => {
|
||||
<Stack key={data} gap={1}>
|
||||
<Typography variant="body1" fontWeight="bold">
|
||||
{label}
|
||||
{label === "Системное название" && (
|
||||
<Box>
|
||||
<TextField
|
||||
value={record?.direction ? "(Прямой)" : "(Обратный)"}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Typography>
|
||||
<TextField value={record?.[data] || ''} />
|
||||
|
||||
<TextField value={record?.[data] || ""} />
|
||||
</Stack>
|
||||
))}
|
||||
|
||||
@ -33,5 +41,5 @@ export const StationShow = () => {
|
||||
)}
|
||||
</Stack>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
@ -1,44 +1,46 @@
|
||||
import React from 'react'
|
||||
import React from "react";
|
||||
|
||||
export type StationItem = {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
[key: string]: string | number
|
||||
}
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
[key: string]: string | number;
|
||||
};
|
||||
|
||||
export type SightItem = {
|
||||
id: number
|
||||
name: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
city_id: number
|
||||
city: string
|
||||
[key: string]: string | number
|
||||
}
|
||||
id: number;
|
||||
name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
city_id: number;
|
||||
city: string;
|
||||
[key: string]: string | number;
|
||||
};
|
||||
|
||||
export type FieldType<T> = {
|
||||
label: string
|
||||
data: keyof T
|
||||
render?: (value: any) => React.ReactNode
|
||||
}
|
||||
label: string;
|
||||
data: keyof T;
|
||||
render?: (value: any) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const stationFields: Array<FieldType<StationItem>> = [
|
||||
// {label: 'ID', data: 'id'},
|
||||
{label: 'Название', data: 'name'},
|
||||
{label: 'Системное название', data: 'system_name'},
|
||||
{ label: "Название", data: "name" },
|
||||
{ label: "Системное название", data: "system_name" },
|
||||
// { label: "Направление", data: "direction" },
|
||||
{ label: "Адрес", data: "address" },
|
||||
// {label: 'Широта', data: 'latitude'},
|
||||
// {label: 'Долгота', data: 'longitude'},
|
||||
{label: 'Описание', data: 'description'},
|
||||
]
|
||||
{ label: "Описание", data: "description" },
|
||||
];
|
||||
|
||||
export const sightFields: Array<FieldType<SightItem>> = [
|
||||
// {label: 'ID', data: 'id'},
|
||||
{label: 'Название', data: 'name'},
|
||||
{ label: "Название", data: "name" },
|
||||
// {label: 'Широта', data: 'latitude'},
|
||||
// {label: 'Долгота', data: 'longitude'},
|
||||
// {label: 'ID города', data: 'city_id'},
|
||||
{label: 'Город', data: 'city'},
|
||||
]
|
||||
{ label: "Город", data: "city" },
|
||||
];
|
||||
|
@ -1,41 +1,50 @@
|
||||
import {Autocomplete, Box, TextField} from '@mui/material'
|
||||
import {Edit, useAutocomplete} from '@refinedev/mui'
|
||||
import {useForm} from '@refinedev/react-hook-form'
|
||||
import {Controller} from 'react-hook-form'
|
||||
import { Autocomplete, Box, TextField } from "@mui/material";
|
||||
import { Edit, useAutocomplete } from "@refinedev/mui";
|
||||
import { useForm } from "@refinedev/react-hook-form";
|
||||
import { Controller } from "react-hook-form";
|
||||
|
||||
import {VEHICLE_TYPES} from '../../lib/constants'
|
||||
import { VEHICLE_TYPES } from "../../lib/constants";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { languageStore, META_LANGUAGE } from "@stores";
|
||||
|
||||
type VehicleFormValues = {
|
||||
tail_number: number
|
||||
type: number
|
||||
city_id: number
|
||||
}
|
||||
tail_number: number;
|
||||
type: number;
|
||||
city_id: number;
|
||||
};
|
||||
|
||||
export const VehicleEdit = () => {
|
||||
export const VehicleEdit = observer(() => {
|
||||
const { language } = languageStore;
|
||||
const {
|
||||
saveButtonProps,
|
||||
register,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm<VehicleFormValues>({})
|
||||
} = useForm<VehicleFormValues>({
|
||||
refineCoreProps: META_LANGUAGE(language)
|
||||
});
|
||||
|
||||
const { autocompleteProps: carrierAutocompleteProps } = useAutocomplete({
|
||||
resource: 'carrier',
|
||||
resource: "carrier",
|
||||
onSearch: (value) => [
|
||||
{
|
||||
field: 'short_name',
|
||||
operator: 'contains',
|
||||
field: "short_name",
|
||||
operator: "contains",
|
||||
value,
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Edit saveButtonProps={saveButtonProps}>
|
||||
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off">
|
||||
<Box
|
||||
component="form"
|
||||
sx={{ display: "flex", flexDirection: "column" }}
|
||||
autoComplete="off"
|
||||
>
|
||||
<TextField
|
||||
{...register('tail_number', {
|
||||
required: 'Это поле является обязательным',
|
||||
{...register("tail_number", {
|
||||
required: "Это поле является обязательным",
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
error={!!(errors as any)?.tail_number}
|
||||
@ -52,23 +61,36 @@ export const VehicleEdit = () => {
|
||||
control={control}
|
||||
name="type"
|
||||
rules={{
|
||||
required: 'Это поле является обязательным',
|
||||
required: "Это поле является обязательным",
|
||||
}}
|
||||
defaultValue={null}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
options={VEHICLE_TYPES}
|
||||
value={VEHICLE_TYPES.find((option) => option.value === field.value) || null}
|
||||
value={
|
||||
VEHICLE_TYPES.find((option) => option.value === field.value) ||
|
||||
null
|
||||
}
|
||||
onChange={(_, value) => {
|
||||
field.onChange(value?.value || null)
|
||||
field.onChange(value?.value || null);
|
||||
}}
|
||||
getOptionLabel={(item) => {
|
||||
return item ? item.label : ''
|
||||
return item ? item.label : "";
|
||||
}}
|
||||
isOptionEqualToValue={(option, value) => {
|
||||
return option.value === value?.value
|
||||
return option.value === value?.value;
|
||||
}}
|
||||
renderInput={(params) => <TextField {...params} label="Выберите тип" margin="normal" variant="outlined" error={!!errors.type} helperText={(errors as any)?.type?.message} required />}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Выберите тип"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
error={!!errors.type}
|
||||
helperText={(errors as any)?.type?.message}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -76,29 +98,47 @@ export const VehicleEdit = () => {
|
||||
<Controller
|
||||
control={control}
|
||||
name="carrier_id"
|
||||
rules={{required: 'Это поле является обязательным'}}
|
||||
rules={{ required: "Это поле является обязательным" }}
|
||||
defaultValue={null}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
{...carrierAutocompleteProps}
|
||||
value={carrierAutocompleteProps.options.find((option) => option.id === field.value) || null}
|
||||
value={
|
||||
carrierAutocompleteProps.options.find(
|
||||
(option) => option.id === field.value
|
||||
) || null
|
||||
}
|
||||
onChange={(_, value) => {
|
||||
field.onChange(value?.id || '')
|
||||
field.onChange(value?.id || "");
|
||||
}}
|
||||
getOptionLabel={(item) => {
|
||||
return item ? item.short_name : ''
|
||||
return item ? item.short_name : "";
|
||||
}}
|
||||
isOptionEqualToValue={(option, value) => {
|
||||
return option.id === value?.id
|
||||
return option.id === value?.id;
|
||||
}}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
return options.filter((option) => option.short_name.toLowerCase().includes(inputValue.toLowerCase()))
|
||||
return options.filter((option) =>
|
||||
option.short_name
|
||||
.toLowerCase()
|
||||
.includes(inputValue.toLowerCase())
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => <TextField {...params} label="Выберите перевозчика" margin="normal" variant="outlined" error={!!errors.city_id} helperText={(errors as any)?.city_id?.message} required />}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Выберите перевозчика"
|
||||
margin="normal"
|
||||
variant="outlined"
|
||||
error={!!errors.city_id}
|
||||
helperText={(errors as any)?.city_id?.message}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
</Edit>
|
||||
)
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@ -1,71 +1,91 @@
|
||||
import {type GridColDef} from '@mui/x-data-grid'
|
||||
import {CustomDataGrid} from '../../components/CustomDataGrid'
|
||||
import {DeleteButton, EditButton, List, ShowButton, useDataGrid} from '@refinedev/mui'
|
||||
import React from 'react'
|
||||
import {VEHICLE_TYPES} from '../../lib/constants'
|
||||
import { type GridColDef } from "@mui/x-data-grid";
|
||||
import { CustomDataGrid } from "../../components/CustomDataGrid";
|
||||
import {
|
||||
DeleteButton,
|
||||
EditButton,
|
||||
List,
|
||||
ShowButton,
|
||||
useDataGrid,
|
||||
} from "@refinedev/mui";
|
||||
import React, { useEffect } from "react";
|
||||
import { VEHICLE_TYPES } from "../../lib/constants";
|
||||
|
||||
import {localeText} from '../../locales/ru/localeText'
|
||||
import { localeText } from "../../locales/ru/localeText";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { languageStore } from "../../store/LanguageStore";
|
||||
|
||||
export const VehicleList = () => {
|
||||
const {dataGridProps} = useDataGrid({})
|
||||
export const VehicleList = observer(() => {
|
||||
const { language } = languageStore;
|
||||
|
||||
const { dataGridProps } = useDataGrid({
|
||||
resource: "vehicle",
|
||||
meta: {
|
||||
headers: {
|
||||
"Accept-Language": language,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const columns = React.useMemo<GridColDef[]>(
|
||||
() => [
|
||||
{
|
||||
field: 'id',
|
||||
headerName: 'ID',
|
||||
type: 'number',
|
||||
field: "id",
|
||||
headerName: "ID",
|
||||
type: "number",
|
||||
minWidth: 70,
|
||||
display: 'flex',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
display: "flex",
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'carrier_id',
|
||||
headerName: 'ID перевозчика',
|
||||
type: 'string',
|
||||
field: "carrier_id",
|
||||
headerName: "ID перевозчика",
|
||||
type: "string",
|
||||
minWidth: 150,
|
||||
display: 'flex',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
display: "flex",
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'tail_number',
|
||||
headerName: 'Бортовой номер',
|
||||
type: 'number',
|
||||
field: "tail_number",
|
||||
headerName: "Бортовой номер",
|
||||
type: "number",
|
||||
minWidth: 150,
|
||||
display: 'flex',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
display: "flex",
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
},
|
||||
{
|
||||
field: 'type',
|
||||
headerName: 'Тип',
|
||||
type: 'string',
|
||||
field: "type",
|
||||
headerName: "Тип",
|
||||
type: "string",
|
||||
minWidth: 200,
|
||||
display: 'flex',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
renderCell: (params) => {
|
||||
const value = params.row.type
|
||||
return VEHICLE_TYPES.find((type) => type.value === value)?.label || value
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'city',
|
||||
headerName: 'Город',
|
||||
type: 'string',
|
||||
align: 'left',
|
||||
headerAlign: 'left',
|
||||
display: "flex",
|
||||
flex: 1,
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
renderCell: (params) => {
|
||||
const value = params.row.type;
|
||||
return (
|
||||
VEHICLE_TYPES.find((type) => type.value === value)?.label || value
|
||||
);
|
||||
},
|
||||
},
|
||||
// {
|
||||
// field: "city",
|
||||
// headerName: "Город",
|
||||
// type: "string",
|
||||
// align: "left",
|
||||
// headerAlign: "left",
|
||||
// flex: 1,
|
||||
// },
|
||||
{
|
||||
field: 'actions',
|
||||
headerName: 'Действия',
|
||||
field: "actions",
|
||||
headerName: "Действия",
|
||||
minWidth: 120,
|
||||
display: 'flex',
|
||||
align: 'right',
|
||||
headerAlign: 'center',
|
||||
display: "flex",
|
||||
align: "right",
|
||||
headerAlign: "center",
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
disableColumnMenu: true,
|
||||
@ -74,18 +94,27 @@ export const VehicleList = () => {
|
||||
<>
|
||||
<EditButton hideText recordItemId={row.id} />
|
||||
<ShowButton hideText recordItemId={row.id} />
|
||||
<DeleteButton hideText confirmTitle="Вы уверены?" recordItemId={row.id} />
|
||||
<DeleteButton
|
||||
hideText
|
||||
confirmTitle="Вы уверены?"
|
||||
recordItemId={row.id}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<List>
|
||||
<CustomDataGrid {...dataGridProps} columns={columns} localeText={localeText} getRowId={(row: any) => row.id} />
|
||||
<CustomDataGrid
|
||||
{...dataGridProps}
|
||||
columns={columns}
|
||||
localeText={localeText}
|
||||
getRowId={(row: any) => row.id}
|
||||
/>
|
||||
</List>
|
||||
)
|
||||
}
|
||||
);
|
||||
});
|
||||
|
182
src/providers/authProvider.ts
Normal file
182
src/providers/authProvider.ts
Normal file
@ -0,0 +1,182 @@
|
||||
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 };
|
||||
},
|
||||
};
|
@ -1,25 +1,27 @@
|
||||
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 dataProvider from "@refinedev/simple-rest";
|
||||
|
||||
export const axiosInstance = axios.create()
|
||||
import { TOKEN_KEY } from "@providers";
|
||||
|
||||
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) => {
|
||||
// Добавляем токен авторизации
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
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'
|
||||
|
||||
// console.log('Request headers:', config.headers)
|
||||
config.headers["X-Language"] = config.headers["Accept-Language"];
|
||||
|
||||
return config
|
||||
})
|
||||
return config;
|
||||
});
|
||||
|
||||
export const customDataProvider = dataProvider(BACKEND_URL, axiosInstance)
|
||||
const apiUrl = import.meta.env.VITE_KRBL_API;
|
||||
|
||||
export const customDataProvider = dataProvider(apiUrl, axiosInstance);
|
||||
|
@ -2,7 +2,7 @@ import i18n from 'i18next'
|
||||
import {initReactI18next} from 'react-i18next'
|
||||
import {I18nProvider} from '@refinedev/core'
|
||||
|
||||
import translationRU from './locales/ru/translation.json'
|
||||
import translationRU from '../locales/ru/translation.json'
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
3
src/providers/index.ts
Normal file
3
src/providers/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './data'
|
||||
export * from './authProvider'
|
||||
export * from './i18nProvider'
|
20
src/store/ArticleStore.ts
Normal file
20
src/store/ArticleStore.ts
Normal file
@ -0,0 +1,20 @@
|
||||
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();
|
26
src/store/CityStore.ts
Normal file
26
src/store/CityStore.ts
Normal file
@ -0,0 +1,26 @@
|
||||
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();
|
34
src/store/LanguageStore.ts
Normal file
34
src/store/LanguageStore.ts
Normal file
@ -0,0 +1,34 @@
|
||||
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,
|
||||
}
|
||||
}
|
25
src/store/StationStore.ts
Normal file
25
src/store/StationStore.ts
Normal file
@ -0,0 +1,25 @@
|
||||
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();
|
4
src/store/index.ts
Normal file
4
src/store/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './ArticleStore';
|
||||
export * from './CityStore';
|
||||
export * from './LanguageStore';
|
||||
export * from './StationStore';
|
8
svg.d.ts
vendored
Normal file
8
svg.d.ts
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
declare module "*.svg" {
|
||||
import * as React from "react";
|
||||
export const ReactComponent: React.FunctionComponent<
|
||||
React.SVGProps<SVGSVGElement>
|
||||
>;
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
@ -5,18 +5,27 @@
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"isolatedModules": false,
|
||||
"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"],
|
||||
"include": ["src", "svg.d.ts"],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
|
@ -2,7 +2,17 @@
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"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"]
|
||||
}
|
||||
|
@ -1,6 +1,18 @@
|
||||
import {defineConfig} from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from "vite";
|
||||
import * as path from "path";
|
||||
import svgr from "vite-plugin-svgr";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
plugins: [svgr(), 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/"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user