Compare commits
No commits in common. "master" and "react" have entirely different histories.
4
.env
@ -1,2 +1,2 @@
|
|||||||
VITE_KRBL_MEDIA = "https://wn.krbl.ru/media/"
|
VITE_REACT_APP ='https://wn.krbl.ru/'
|
||||||
VITE_KRBL_API = "https://wn.krbl.ru"
|
VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
|
@ -1,17 +0,0 @@
|
|||||||
/* eslint-env node */
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
env: { browser: true, es2020: true },
|
|
||||||
extends: [
|
|
||||||
"eslint:recommended",
|
|
||||||
"plugin:@typescript-eslint/recommended",
|
|
||||||
"plugin:react-hooks/recommended",
|
|
||||||
],
|
|
||||||
parser: "@typescript-eslint/parser",
|
|
||||||
parserOptions: { ecmaVersion: "latest", sourceType: "module" },
|
|
||||||
plugins: ["react-refresh"],
|
|
||||||
rules: {
|
|
||||||
"react-refresh/only-export-components": "warn",
|
|
||||||
"@typescript-eslint/no-explicit-any": "off"
|
|
||||||
}
|
|
||||||
};
|
|
@ -1,51 +0,0 @@
|
|||||||
name: release-tag
|
|
||||||
|
|
||||||
on:
|
|
||||||
push
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release-image:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container:
|
|
||||||
image: catthehacker/ubuntu:act-latest
|
|
||||||
env:
|
|
||||||
DOCKER_ORG: krbl
|
|
||||||
DOCKER_LATEST: nightly
|
|
||||||
RUNNER_TOOL_CACHE: /toolcache
|
|
||||||
IMAGE_NAME: white-nights-admin-panel
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v2
|
|
||||||
|
|
||||||
- name: Set up Docker BuildX
|
|
||||||
uses: docker/setup-buildx-action@v2
|
|
||||||
with:
|
|
||||||
config-inline: |
|
|
||||||
[registry."gitea.unprism.ru"]
|
|
||||||
|
|
||||||
- name: Login to DockerHub
|
|
||||||
uses: docker/login-action@v2
|
|
||||||
with:
|
|
||||||
registry: gitea.unprism.ru
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Get Meta
|
|
||||||
id: meta
|
|
||||||
run: |
|
|
||||||
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
|
|
||||||
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
uses: docker/build-push-action@v4
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile
|
|
||||||
platforms: |
|
|
||||||
linux/amd64
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
gitea.unprism.ru/${{ env.DOCKER_ORG }}/${{ env.IMAGE_NAME }}:${{ env.DOCKER_LATEST }}
|
|
21
.gitignore
vendored
@ -1,8 +1,14 @@
|
|||||||
# Logs
|
# Logs
|
||||||
|
<<<<<<< HEAD
|
||||||
|
|
||||||
logs
|
logs
|
||||||
_.log
|
_.log
|
||||||
npm-debug.log_
|
npm-debug.log_
|
||||||
|
=======
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
>>>>>>> 6b9aa78 (init: Init React Application)
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
@ -11,6 +17,7 @@ lerna-debug.log*
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
<<<<<<< HEAD
|
||||||
\*.local
|
\*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
@ -24,3 +31,17 @@ _.ntvs_
|
|||||||
_.njsproj
|
_.njsproj
|
||||||
_.sln
|
_.sln
|
||||||
\*.sw?
|
\*.sw?
|
||||||
|
=======
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
>>>>>>> 6b9aa78 (init: Init React Application)
|
||||||
|
44
Dockerfile
@ -1,44 +0,0 @@
|
|||||||
# 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: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 install --frozen-lockfile; \
|
|
||||||
elif [ -f package-lock.json ]; then npm ci; \
|
|
||||||
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
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
|
|
||||||
# Обязательно создать рабочую директорию
|
|
||||||
WORKDIR /app/refine
|
|
||||||
|
|
||||||
COPY --from=deps /app/refine/node_modules ./node_modules
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Добавлена проверка и вывод логов
|
|
||||||
RUN echo "🚧 Starting build..." && npm run build || (echo "❌ Build failed" && exit 1)
|
|
||||||
|
|
||||||
FROM base AS runner
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
|
|
||||||
RUN npm install -g serve
|
|
||||||
|
|
||||||
WORKDIR /app/refine
|
|
||||||
|
|
||||||
COPY --from=builder /app/refine/dist ./
|
|
||||||
|
|
||||||
USER refine
|
|
||||||
|
|
||||||
CMD ["serve", "-s", ".", "-l", "3000"]
|
|
12
README.MD
@ -1,12 +0,0 @@
|
|||||||
# ⚡️white-nights
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git clone https://github.com/bozzhik/white-nights.git && cd white-nights && pnpm i && code .
|
|
||||||
```
|
|
||||||
|
|
||||||
This [Refine](https://github.com/refinedev/refine) project was generated with [create refine-app](https://github.com/refinedev/refine/tree/master/packages/create-refine-app).
|
|
||||||
|
|
||||||
- **REST Data Provider** [Docs](https://refine.dev/docs/core/providers/data-provider/#overview)
|
|
||||||
- **Material UI** [Docs](https://refine.dev/docs/ui-frameworks/mui/tutorial/)
|
|
||||||
- **React Router** [Docs](https://refine.dev/docs/core/providers/router-provider/)
|
|
||||||
- **Custom Auth Provider** [Docs](https://refine.dev/docs/core/providers/auth-provider/)
|
|
54
README.md
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default tseslint.config({
|
||||||
|
extends: [
|
||||||
|
// Remove ...tseslint.configs.recommended and replace with this
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
...tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
...tseslint.configs.stylisticTypeChecked,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
// other options...
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default tseslint.config({
|
||||||
|
plugins: {
|
||||||
|
// Add the react-x and react-dom plugins
|
||||||
|
'react-x': reactX,
|
||||||
|
'react-dom': reactDom,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// other rules...
|
||||||
|
// Enable its recommended typescript rules
|
||||||
|
...reactX.configs['recommended-typescript'].rules,
|
||||||
|
...reactDom.configs.recommended.rules,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
@ -1,5 +0,0 @@
|
|||||||
services:
|
|
||||||
refine:
|
|
||||||
image: white-nights:latest
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
28
eslint.config.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
32
index.html
@ -1,39 +1,13 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/png" href="/favicon_ship.png" />
|
<link rel="icon" type="image/png" href="/favicon_ship.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#000000" />
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="refine | Build your React-based CRUD applications, without constraints."
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
data-rh="true"
|
|
||||||
property="og:image"
|
|
||||||
content="https://refine.dev/img/refine_social.png"
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
data-rh="true"
|
|
||||||
name="twitter:image"
|
|
||||||
content="https://refine.dev/img/refine_social.png"
|
|
||||||
/>
|
|
||||||
<title>Белые ночи</title>
|
<title>Белые ночи</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/index.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
<!--
|
|
||||||
This HTML file is a template.
|
|
||||||
If you open it directly in the browser, you will see an empty page.
|
|
||||||
|
|
||||||
You can add webfonts, meta tags, or analytics to this file.
|
|
||||||
The build step will place the bundled scripts into the <body> tag.
|
|
||||||
|
|
||||||
To begin the development, run `npm dev` or `yarn start`.
|
|
||||||
To create a production bundle, use `npm run build` or `yarn build`.
|
|
||||||
-->
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
10605
package-lock.json
generated
111
package.json
@ -1,96 +1,57 @@
|
|||||||
{
|
{
|
||||||
"name": "white-nights",
|
"name": "white-nights",
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.8.2",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.8.1",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@mui/icons-material": "^6.1.6",
|
"@mui/material": "^7.1.0",
|
||||||
"@mui/lab": "^6.0.0-beta.14",
|
"@mui/x-data-grid": "^8.5.1",
|
||||||
"@mui/material": "^6.1.7",
|
|
||||||
"@mui/x-data-grid": "^7.22.2",
|
|
||||||
"@photo-sphere-viewer/core": "^5.13.2",
|
"@photo-sphere-viewer/core": "^5.13.2",
|
||||||
"@pixi/react": "^8.0.0-beta.25",
|
"@react-three/drei": "^10.1.2",
|
||||||
"@react-three/drei": "^10.0.6",
|
|
||||||
"@react-three/fiber": "^9.1.2",
|
"@react-three/fiber": "^9.1.2",
|
||||||
"@refinedev/cli": "^2.16.21",
|
"@tailwindcss/vite": "^4.1.8",
|
||||||
"@refinedev/core": "^4.57.9",
|
"axios": "^1.9.0",
|
||||||
"@refinedev/devtools": "^1.1.32",
|
"easymde": "^2.20.0",
|
||||||
"@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",
|
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"mobx": "^6.13.7",
|
"mobx": "^6.13.7",
|
||||||
"mobx-react-lite": "^4.1.0",
|
"mobx-react-lite": "^4.1.0",
|
||||||
"pixi.js": "^8.2.6",
|
"ol": "^10.5.0",
|
||||||
"react": "19.0.0",
|
"path": "^0.12.7",
|
||||||
"react-beautiful-dnd": "^13.1.1",
|
"react": "^19.1.0",
|
||||||
"react-dom": "19.0.0",
|
"react-colorful": "^5.6.1",
|
||||||
"react-draggable": "^4.4.6",
|
"react-dom": "^19.1.0",
|
||||||
"react-dropzone": "^14.3.8",
|
"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-markdown": "^10.1.0",
|
||||||
"react-photo-sphere-viewer": "^6.2.3",
|
"react-photo-sphere-viewer": "^6.2.3",
|
||||||
"react-router": "^7.0.2",
|
"react-router-dom": "^7.6.1",
|
||||||
"react-simple-maps": "^3.0.0",
|
|
||||||
"react-simplemde-editor": "^5.2.0",
|
"react-simplemde-editor": "^5.2.0",
|
||||||
"react-swipeable": "^7.0.2",
|
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.0.5",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"three": "^0.175.0",
|
"tailwindcss": "^4.1.8",
|
||||||
"vite-plugin-svgr": "^4.3.0"
|
"three": "^0.177.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/d3-geo": "^3.1.0",
|
"@eslint/js": "^9.25.0",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@types/node": "^18.16.2",
|
"@types/node": "^22.15.24",
|
||||||
"@types/react": "^18.0.0",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^18.0.0",
|
"@types/react-dom": "^19.1.2",
|
||||||
"@types/three": "^0.175.0",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.57.1",
|
"eslint": "^9.25.0",
|
||||||
"@typescript-eslint/parser": "^5.57.1",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"@vitejs/plugin-react": "^4.0.0",
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
"eslint": "^8.38.0",
|
"globals": "^16.0.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"typescript": "~5.8.3",
|
||||||
"eslint-plugin-react-refresh": "^0.3.4",
|
"typescript-eslint": "^8.30.1",
|
||||||
"typescript": "^5.4.2",
|
"vite": "^6.3.5"
|
||||||
"vite": "^4.3.1"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"dev": "refine dev",
|
|
||||||
"build": "tsc && refine build",
|
|
||||||
"start": "refine start",
|
|
||||||
"refine": "refine"
|
|
||||||
},
|
|
||||||
"browserslist": {
|
|
||||||
"production": [
|
|
||||||
">0.2%",
|
|
||||||
"not dead",
|
|
||||||
"not op_mini all"
|
|
||||||
],
|
|
||||||
"development": [
|
|
||||||
"last 1 chrome version",
|
|
||||||
"last 1 firefox version",
|
|
||||||
"last 1 safari version"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"refine": {
|
|
||||||
"projectId": "Wv044J-t53S3s-PcbJGe"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 176 KiB |
BIN
public/GET.png
Before Width: | Height: | Size: 6.4 KiB |
Before Width: | Height: | Size: 750 B |
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
473
src/App.tsx
@ -1,474 +1 @@
|
|||||||
import { Refine, Authenticated } from "@refinedev/core";
|
|
||||||
import { DevtoolsPanel, DevtoolsProvider } from "@refinedev/devtools";
|
|
||||||
|
|
||||||
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, 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 { 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 { 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>
|
|
||||||
<ColorModeContextProvider>
|
|
||||||
<CssBaseline />
|
|
||||||
<GlobalStyles styles={{ html: { WebkitFontSmoothing: "auto" } }} />
|
|
||||||
<RefineSnackbarProvider>
|
|
||||||
<DevtoolsProvider>
|
|
||||||
<Refine
|
|
||||||
dataProvider={customDataProvider}
|
|
||||||
notificationProvider={useNotificationProvider}
|
|
||||||
routerProvider={routerBindings}
|
|
||||||
authProvider={authProvider}
|
|
||||||
i18nProvider={i18nProvider}
|
|
||||||
resources={[
|
|
||||||
{
|
|
||||||
name: "country",
|
|
||||||
list: "/country",
|
|
||||||
create: "/country/create",
|
|
||||||
edit: "/country/edit/:id",
|
|
||||||
show: "/country/show/:id",
|
|
||||||
meta: {
|
|
||||||
canDelete: true,
|
|
||||||
label: "Страны",
|
|
||||||
icon: <CountryIcon />,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "city",
|
|
||||||
list: "/city",
|
|
||||||
create: "/city/create",
|
|
||||||
edit: "/city/edit/:id",
|
|
||||||
show: "/city/show/:id",
|
|
||||||
meta: {
|
|
||||||
canDelete: true,
|
|
||||||
label: "Города",
|
|
||||||
icon: <CityIcon />,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "carrier",
|
|
||||||
list: "/carrier",
|
|
||||||
create: "/carrier/create",
|
|
||||||
edit: "/carrier/edit/:id",
|
|
||||||
show: "/carrier/show/:id",
|
|
||||||
meta: {
|
|
||||||
canDelete: true,
|
|
||||||
label: "Перевозчики",
|
|
||||||
icon: <CarrierIcon />,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "media",
|
|
||||||
list: "/media",
|
|
||||||
create: "/media/create",
|
|
||||||
edit: "/media/edit/:id",
|
|
||||||
show: "/media/show/:id",
|
|
||||||
meta: {
|
|
||||||
canDelete: true,
|
|
||||||
label: "Медиа",
|
|
||||||
icon: <MediaIcon />,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "article",
|
|
||||||
list: "/article",
|
|
||||||
create: "/article/create",
|
|
||||||
edit: "/article/edit/:id",
|
|
||||||
show: "/article/show/:id",
|
|
||||||
meta: {
|
|
||||||
canDelete: true,
|
|
||||||
label: "Статьи",
|
|
||||||
icon: <ArticleIcon />,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "sight",
|
|
||||||
list: "/sight",
|
|
||||||
create: "/sight/create",
|
|
||||||
edit: "/sight/edit/:id",
|
|
||||||
show: "/sight/show/:id",
|
|
||||||
meta: {
|
|
||||||
canDelete: true,
|
|
||||||
label: "Достопримечательности",
|
|
||||||
icon: <SightIcon />,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "station",
|
|
||||||
list: "/station",
|
|
||||||
create: "/station/create",
|
|
||||||
edit: "/station/edit/:id",
|
|
||||||
show: "/station/show/:id",
|
|
||||||
meta: {
|
|
||||||
canDelete: true,
|
|
||||||
label: "Остановки",
|
|
||||||
icon: <StationIcon />,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "snapshots",
|
|
||||||
list: "/snapshot",
|
|
||||||
create: "/snapshot/create",
|
|
||||||
|
|
||||||
show: "/snapshot/show/:id",
|
|
||||||
meta: {
|
|
||||||
canDelete: true,
|
|
||||||
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",
|
|
||||||
meta: {
|
|
||||||
canDelete: true,
|
|
||||||
label: "Маршруты",
|
|
||||||
icon: <RouteIcon />,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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: "Пользователи",
|
|
||||||
icon: <UsersIcon />,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
options={{
|
|
||||||
syncWithLocation: true,
|
|
||||||
warnWhenUnsavedChanges: true, // Включаем глобально
|
|
||||||
useNewQueryKeys: true,
|
|
||||||
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" />}
|
|
||||||
>
|
|
||||||
<ThemedLayoutV2 Header={Header} Title={SidebarTitle}>
|
|
||||||
<Outlet />
|
|
||||||
</ThemedLayoutV2>
|
|
||||||
</Authenticated>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route
|
|
||||||
index
|
|
||||||
element={<NavigateToResource resource="country" />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route path="/country">
|
|
||||||
<Route index element={<CountryList />} />
|
|
||||||
<Route
|
|
||||||
path="create"
|
|
||||||
element={
|
|
||||||
<AdminOnly>
|
|
||||||
<CountryCreate />
|
|
||||||
</AdminOnly>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="edit/:id"
|
|
||||||
element={
|
|
||||||
<AdminOnly>
|
|
||||||
<CountryEdit />
|
|
||||||
</AdminOnly>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route path="show/:id" element={<CountryShow />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/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
|
|
||||||
path="create"
|
|
||||||
element={
|
|
||||||
<AdminOnly>
|
|
||||||
<CityCreate />
|
|
||||||
</AdminOnly>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="edit/:id"
|
|
||||||
element={
|
|
||||||
<AdminOnly>
|
|
||||||
<CityEdit />
|
|
||||||
</AdminOnly>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route path="show/:id" element={<CityShow />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/carrier">
|
|
||||||
<Route index element={<CarrierList />} />
|
|
||||||
<Route path="create" element={<CarrierCreate />} />
|
|
||||||
<Route path="edit/:id" element={<CarrierEdit />} />
|
|
||||||
<Route path="show/:id" element={<CarrierShow />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/media">
|
|
||||||
<Route index element={<MediaList />} />
|
|
||||||
<Route path="create" element={<MediaCreate />} />
|
|
||||||
<Route path="edit/:id" element={<MediaEdit />} />
|
|
||||||
<Route path="show/:id" element={<MediaShow />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/article">
|
|
||||||
<Route index element={<ArticleList />} />
|
|
||||||
<Route path="create" element={<ArticleCreate />} />
|
|
||||||
<Route path="edit/:id" element={<ArticleEdit />} />
|
|
||||||
<Route path="show/:id" element={<ArticleShow />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/sight">
|
|
||||||
<Route index element={<SightList />} />
|
|
||||||
<Route path="create" element={<SightCreate />} />
|
|
||||||
<Route path="edit/:id" element={<SightEdit />} />
|
|
||||||
<Route path="show/:id" element={<SightShow />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/station">
|
|
||||||
<Route index element={<StationList />} />
|
|
||||||
<Route
|
|
||||||
path="create"
|
|
||||||
element={
|
|
||||||
<AdminOnly>
|
|
||||||
<StationCreate />
|
|
||||||
</AdminOnly>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="edit/:id"
|
|
||||||
element={
|
|
||||||
<AdminOnly>
|
|
||||||
<StationEdit />
|
|
||||||
</AdminOnly>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route path="show/:id" element={<StationShow />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/vehicle">
|
|
||||||
<Route index element={<VehicleList />} />
|
|
||||||
<Route path="create" element={<VehicleCreate />} />
|
|
||||||
<Route path="edit/:id" element={<VehicleEdit />} />
|
|
||||||
<Route path="show/:id" element={<VehicleShow />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/route">
|
|
||||||
<Route index element={<RouteList />} />
|
|
||||||
<Route
|
|
||||||
path="create"
|
|
||||||
element={
|
|
||||||
<AdminOnly>
|
|
||||||
<RouteCreate />
|
|
||||||
</AdminOnly>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="edit/:id"
|
|
||||||
element={
|
|
||||||
<AdminOnly>
|
|
||||||
<RouteEdit />
|
|
||||||
</AdminOnly>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route path="show/:id" element={<RouteShow />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/user">
|
|
||||||
<Route
|
|
||||||
index
|
|
||||||
element={
|
|
||||||
<AdminOnly>
|
|
||||||
<UserList />
|
|
||||||
</AdminOnly>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="create"
|
|
||||||
element={
|
|
||||||
<AdminOnly>
|
|
||||||
<UserCreate />
|
|
||||||
</AdminOnly>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="edit/:id"
|
|
||||||
element={
|
|
||||||
<AdminOnly>
|
|
||||||
<UserEdit />
|
|
||||||
</AdminOnly>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="show/:id"
|
|
||||||
element={
|
|
||||||
<AdminOnly>
|
|
||||||
<UserShow />
|
|
||||||
</AdminOnly>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="*" element={<ErrorComponent />} />
|
|
||||||
</Route>
|
|
||||||
<Route
|
|
||||||
element={
|
|
||||||
<Authenticated
|
|
||||||
key="authenticated-outer"
|
|
||||||
fallback={<Outlet />}
|
|
||||||
>
|
|
||||||
<NavigateToResource />
|
|
||||||
</Authenticated>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route path="/login" element={<Login />} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
|
|
||||||
<UnsavedChangesNotifier />
|
|
||||||
<DocumentTitleHandler
|
|
||||||
handler={() => {
|
|
||||||
// const cleanedTitle = title.autoGeneratedTitle.split('|')[0].trim()
|
|
||||||
// return `${cleanedTitle} — Белые ночи`
|
|
||||||
return "Белые ночи";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<RefineKbar />
|
|
||||||
</KBarProvider>
|
|
||||||
</Refine>
|
|
||||||
<DevtoolsPanel />
|
|
||||||
</DevtoolsProvider>
|
|
||||||
</RefineSnackbarProvider>
|
|
||||||
</ColorModeContextProvider>
|
|
||||||
</BrowserRouter>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
|
13
src/app/index.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { Router } from "./router";
|
||||||
|
import { CustomTheme } from "@shared";
|
||||||
|
import { ThemeProvider } from "@mui/material/styles";
|
||||||
|
import { ToastContainer } from "react-toastify";
|
||||||
|
|
||||||
|
export const App: React.FC = () => (
|
||||||
|
<ThemeProvider theme={CustomTheme.Light}>
|
||||||
|
<ToastContainer />
|
||||||
|
<Router />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
161
src/app/router/index.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import {
|
||||||
|
CreateSightPage,
|
||||||
|
DevicesPage,
|
||||||
|
EditSightPage,
|
||||||
|
LoginPage,
|
||||||
|
MainPage,
|
||||||
|
SightListPage,
|
||||||
|
MapPage,
|
||||||
|
MediaListPage,
|
||||||
|
MediaPreviewPage,
|
||||||
|
MediaEditPage,
|
||||||
|
CountryListPage,
|
||||||
|
CityListPage,
|
||||||
|
RouteListPage,
|
||||||
|
UserListPage,
|
||||||
|
SnapshotListPage,
|
||||||
|
CarrierListPage,
|
||||||
|
StationListPage,
|
||||||
|
VehicleListPage,
|
||||||
|
ArticleListPage,
|
||||||
|
CityPreviewPage,
|
||||||
|
CountryPreviewPage,
|
||||||
|
VehiclePreviewPage,
|
||||||
|
CarrierPreviewPage,
|
||||||
|
SnapshotCreatePage,
|
||||||
|
CountryCreatePage,
|
||||||
|
CityCreatePage,
|
||||||
|
// CarrierCreatePage,
|
||||||
|
VehicleCreatePage,
|
||||||
|
} from "@pages";
|
||||||
|
import { authStore, createSightStore, editSightStore } from "@shared";
|
||||||
|
import { Layout } from "@widgets";
|
||||||
|
import { runInAction } from "mobx";
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createBrowserRouter,
|
||||||
|
RouterProvider,
|
||||||
|
Navigate,
|
||||||
|
Outlet,
|
||||||
|
useLocation,
|
||||||
|
} from "react-router-dom";
|
||||||
|
|
||||||
|
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const { isAuthenticated } = authStore;
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return <Navigate to="/sight" replace />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const { isAuthenticated } = authStore;
|
||||||
|
const location = useLocation();
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
if (location.pathname === "/") {
|
||||||
|
return <Navigate to="/sight" replace />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Чтобы очистка сторов происходила при смене локации
|
||||||
|
const ClearStoresWrapper: React.FC<{ children: React.ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
editSightStore.clearSightInfo();
|
||||||
|
createSightStore.clearCreateSight();
|
||||||
|
runInAction(() => {
|
||||||
|
editSightStore.hasLoadedCommon = false;
|
||||||
|
});
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: "/login",
|
||||||
|
element: (
|
||||||
|
<PublicRoute>
|
||||||
|
<LoginPage />
|
||||||
|
</PublicRoute>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
element: (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<ClearStoresWrapper>
|
||||||
|
<Outlet />
|
||||||
|
</ClearStoresWrapper>
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <MainPage /> },
|
||||||
|
|
||||||
|
// Sight
|
||||||
|
{ path: "sight", element: <SightListPage /> },
|
||||||
|
{ path: "sight/create", element: <CreateSightPage /> },
|
||||||
|
{ path: "sight/:id", element: <EditSightPage /> },
|
||||||
|
|
||||||
|
// Device
|
||||||
|
{ path: "devices", element: <DevicesPage /> },
|
||||||
|
|
||||||
|
// Map
|
||||||
|
{ path: "map", element: <MapPage /> },
|
||||||
|
|
||||||
|
// Media
|
||||||
|
{ path: "media", element: <MediaListPage /> },
|
||||||
|
{ path: "media/:id", element: <MediaPreviewPage /> },
|
||||||
|
{ path: "media/:id/edit", element: <MediaEditPage /> },
|
||||||
|
|
||||||
|
// Country
|
||||||
|
{ path: "country", element: <CountryListPage /> },
|
||||||
|
{ path: "country/create", element: <CountryCreatePage /> },
|
||||||
|
{ path: "country/:id", element: <CountryPreviewPage /> },
|
||||||
|
// City
|
||||||
|
{ path: "city", element: <CityListPage /> },
|
||||||
|
{ path: "city/create", element: <CityCreatePage /> },
|
||||||
|
{ path: "city/:id", element: <CityPreviewPage /> },
|
||||||
|
// Route
|
||||||
|
{ path: "route", element: <RouteListPage /> },
|
||||||
|
|
||||||
|
// User
|
||||||
|
{ path: "user", element: <UserListPage /> },
|
||||||
|
|
||||||
|
// Snapshot
|
||||||
|
{ path: "snapshot", element: <SnapshotListPage /> },
|
||||||
|
{ path: "snapshot/create", element: <SnapshotCreatePage /> },
|
||||||
|
|
||||||
|
// Carrier
|
||||||
|
{ path: "carrier", element: <CarrierListPage /> },
|
||||||
|
// { path: "carrier/create", element: <CarrierCreatePage /> },
|
||||||
|
{ path: "carrier/:id", element: <CarrierPreviewPage /> },
|
||||||
|
|
||||||
|
// Station
|
||||||
|
{ path: "station", element: <StationListPage /> },
|
||||||
|
|
||||||
|
// Vehicle
|
||||||
|
{ path: "vehicle", element: <VehicleListPage /> },
|
||||||
|
{ path: "vehicle/create", element: <VehicleCreatePage /> },
|
||||||
|
{ path: "vehicle/:id", element: <VehiclePreviewPage /> },
|
||||||
|
|
||||||
|
// Article
|
||||||
|
{ path: "article", element: <ArticleListPage /> },
|
||||||
|
|
||||||
|
// { path: "media/create", element: <CreateMediaPage /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const Router = () => {
|
||||||
|
return <RouterProvider router={router} />;
|
||||||
|
};
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
1
src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
After Width: | Height: | Size: 4.0 KiB |
@ -1,12 +0,0 @@
|
|||||||
import {usePermissions} from '@refinedev/core'
|
|
||||||
import {PropsWithChildren} from 'react'
|
|
||||||
|
|
||||||
export const AdminOnly: React.FC<PropsWithChildren> = ({children}) => {
|
|
||||||
const {data: permissions} = usePermissions<string[]>() // добавляем generic тип
|
|
||||||
|
|
||||||
if (!permissions?.includes('admin')) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>
|
|
||||||
}
|
|
@ -1,397 +0,0 @@
|
|||||||
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);
|
|
||||||
|
|
||||||
type MediaFile = {
|
|
||||||
file: File;
|
|
||||||
preview: string;
|
|
||||||
uploading: boolean;
|
|
||||||
mediaId?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
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,
|
|
||||||
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: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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...",
|
|
||||||
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 handleCreate = async (data: { heading: string; body: string }) => {
|
|
||||||
try {
|
|
||||||
// Создаем статью
|
|
||||||
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(
|
|
||||||
`${
|
|
||||||
import.meta.env.VITE_KRBL_API
|
|
||||||
}/${parentResource}/${parentId}/${childResource}`
|
|
||||||
);
|
|
||||||
const existingItems = existingItemsResponse.data ?? [];
|
|
||||||
const nextPageNum = existingItems.length + 1;
|
|
||||||
|
|
||||||
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);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Привязываем все медиа к статье
|
|
||||||
await Promise.all(
|
|
||||||
mediaIds.map((mediaId, index) =>
|
|
||||||
axiosInstance.post(
|
|
||||||
`${import.meta.env.VITE_KRBL_API}/article/${itemId}/media/`,
|
|
||||||
{
|
|
||||||
media_id: mediaId,
|
|
||||||
media_order: index + 1,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeMedia = (index: number) => {
|
|
||||||
setMediaFiles((prev) => {
|
|
||||||
const newFiles = [...prev];
|
|
||||||
URL.revokeObjectURL(newFiles[index].preview);
|
|
||||||
newFiles.splice(index, 1);
|
|
||||||
return newFiles;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<TextField
|
|
||||||
{...registerItem("heading", {
|
|
||||||
required: "Это поле является обязательным",
|
|
||||||
})}
|
|
||||||
error={!!(itemErrors as any)?.heading}
|
|
||||||
helperText={(itemErrors as any)?.heading?.message}
|
|
||||||
margin="normal"
|
|
||||||
fullWidth
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
|
|
||||||
<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([]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Очистить
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,187 +0,0 @@
|
|||||||
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 { languageStore } from "../store/LanguageStore";
|
|
||||||
import { LanguageSwitch } from "./LanguageSwitch";
|
|
||||||
|
|
||||||
interface CustomDataGridProps extends DataGridProps {
|
|
||||||
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;
|
|
||||||
|
|
||||||
export const CustomDataGrid = ({
|
|
||||||
languageEnabled = false,
|
|
||||||
hasCoordinates = false,
|
|
||||||
columns = [],
|
|
||||||
resource,
|
|
||||||
...props
|
|
||||||
}: CustomDataGridProps) => {
|
|
||||||
// const isDev = import.meta.env.DEV
|
|
||||||
const { triggerExport, isLoading: exportLoading } = useExport({
|
|
||||||
resource: resource ?? "",
|
|
||||||
// pageSize: 100, #*
|
|
||||||
// maxItemCount: 100, #*
|
|
||||||
});
|
|
||||||
|
|
||||||
const initialShowCoordinates = Cookies.get("showCoordinates") === "true";
|
|
||||||
const initialShowDevData = false; // Default to false in both prod and dev
|
|
||||||
const [showCoordinates, setShowCoordinates] = useState(
|
|
||||||
initialShowCoordinates
|
|
||||||
);
|
|
||||||
const [showDevData, setShowDevData] = useState(
|
|
||||||
Cookies.get("showDevData") === "true"
|
|
||||||
);
|
|
||||||
|
|
||||||
const availableDevFields = useMemo(
|
|
||||||
() =>
|
|
||||||
DEV_FIELDS.filter((field) =>
|
|
||||||
columns.some((column) => column.field === field)
|
|
||||||
),
|
|
||||||
[columns]
|
|
||||||
);
|
|
||||||
|
|
||||||
const initialVisibilityModel = useMemo(() => {
|
|
||||||
const model: GridColumnVisibilityModel = {};
|
|
||||||
|
|
||||||
availableDevFields.forEach((field) => {
|
|
||||||
model[field] = initialShowDevData;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasCoordinates) {
|
|
||||||
model.latitude = initialShowCoordinates;
|
|
||||||
model.longitude = initialShowCoordinates;
|
|
||||||
}
|
|
||||||
|
|
||||||
return model;
|
|
||||||
}, [
|
|
||||||
availableDevFields,
|
|
||||||
hasCoordinates,
|
|
||||||
initialShowCoordinates,
|
|
||||||
initialShowDevData,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [columnVisibilityModel, setColumnVisibilityModel] =
|
|
||||||
useState<GridColumnVisibilityModel>(initialVisibilityModel);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setColumnVisibilityModel((prevModel) => {
|
|
||||||
const newModel = { ...prevModel };
|
|
||||||
|
|
||||||
availableDevFields.forEach((field) => {
|
|
||||||
newModel[field] = showDevData;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasCoordinates) {
|
|
||||||
newModel.latitude = showCoordinates;
|
|
||||||
newModel.longitude = showCoordinates;
|
|
||||||
}
|
|
||||||
|
|
||||||
return newModel;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasCoordinates) {
|
|
||||||
Cookies.set("showCoordinates", String(showCoordinates));
|
|
||||||
}
|
|
||||||
Cookies.set("showDevData", String(showDevData));
|
|
||||||
}, [showCoordinates, showDevData, hasCoordinates, availableDevFields]);
|
|
||||||
|
|
||||||
const toggleCoordinates = () => {
|
|
||||||
setShowCoordinates((prev) => !prev);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleDevData = () => {
|
|
||||||
setShowDevData((prev) => !prev);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack spacing={2}>
|
|
||||||
<Box sx={{ visibility: languageEnabled ? "visible" : "hidden" }}>
|
|
||||||
<LanguageSwitch />
|
|
||||||
</Box>
|
|
||||||
<DataGrid
|
|
||||||
{...props}
|
|
||||||
columns={columns}
|
|
||||||
localeText={localeText}
|
|
||||||
columnVisibilityModel={columnVisibilityModel}
|
|
||||||
onColumnVisibilityModelChange={setColumnVisibilityModel}
|
|
||||||
// Добавляем базовые функции сортировки и фильтрации
|
|
||||||
sortingMode="client"
|
|
||||||
filterMode="client"
|
|
||||||
initialState={{
|
|
||||||
// pagination: {
|
|
||||||
// paginationModel: {pageSize: 25, page: 0},
|
|
||||||
// },
|
|
||||||
sorting: {
|
|
||||||
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 ? "Скрыть координаты" : "Показать координаты"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(import.meta.env.DEV || showDevData) &&
|
|
||||||
availableDevFields.length > 0 && (
|
|
||||||
<Button variant="contained" onClick={toggleDevData}>
|
|
||||||
{showDevData
|
|
||||||
? "Скрыть служебные данные"
|
|
||||||
: "Показать служебные данные"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<ExportButton
|
|
||||||
onClick={triggerExport}
|
|
||||||
loading={exportLoading}
|
|
||||||
hideText={false}
|
|
||||||
>
|
|
||||||
<Typography sx={{ marginLeft: "-2px" }}>Экспорт</Typography>
|
|
||||||
</ExportButton>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,68 +0,0 @@
|
|||||||
import { Box } from "@mui/material";
|
|
||||||
import { Languages, languageStore } from "../../store/LanguageStore";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
|
|
||||||
export const LanguageSwitch = observer(({ action }: any) => {
|
|
||||||
const { language, setLanguageAction } = languageStore;
|
|
||||||
|
|
||||||
const handleLanguageChange = (lang: Languages) => {
|
|
||||||
action?.();
|
|
||||||
setLanguageAction(lang);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
display: "flex",
|
|
||||||
gap: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
cursor: "pointer",
|
|
||||||
flex: 1,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
bgcolor: language === "ru" ? "primary.main" : "transparent",
|
|
||||||
color: language === "ru" ? "white" : "inherit",
|
|
||||||
borderRadius: 1,
|
|
||||||
p: 1,
|
|
||||||
}}
|
|
||||||
onClick={() => handleLanguageChange("ru")}
|
|
||||||
>
|
|
||||||
RU
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
cursor: "pointer",
|
|
||||||
flex: 1,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
bgcolor: language === "en" ? "primary.main" : "transparent",
|
|
||||||
color: language === "en" ? "white" : "inherit",
|
|
||||||
borderRadius: 1,
|
|
||||||
p: 1,
|
|
||||||
}}
|
|
||||||
onClick={() => handleLanguageChange("en")}
|
|
||||||
>
|
|
||||||
EN
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
cursor: "pointer",
|
|
||||||
flex: 1,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
bgcolor: language === "zh" ? "primary.main" : "transparent",
|
|
||||||
color: language === "zh" ? "white" : "inherit",
|
|
||||||
borderRadius: 1,
|
|
||||||
p: 1,
|
|
||||||
}}
|
|
||||||
onClick={() => handleLanguageChange("zh")}
|
|
||||||
>
|
|
||||||
ZH
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,558 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ExtraFieldConfig = {
|
|
||||||
type: "number";
|
|
||||||
label: string;
|
|
||||||
minValue: number;
|
|
||||||
maxValue: (linkedItems: any[]) => number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type LinkedItemsProps<T> = {
|
|
||||||
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;
|
|
||||||
cityId?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const reorder = (list: any[], startIndex: number, endIndex: number) => {
|
|
||||||
const result = Array.from(list);
|
|
||||||
const [removed] = result.splice(startIndex, 1);
|
|
||||||
result.splice(endIndex, 0, removed);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LinkedItems = <T extends { id: number; [key: string]: any }>(
|
|
||||||
props: LinkedItemsProps<T>
|
|
||||||
) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Accordion>
|
|
||||||
<AccordionSummary
|
|
||||||
expandIcon={<ExpandMoreIcon />}
|
|
||||||
sx={{
|
|
||||||
background: theme.palette.background.paper,
|
|
||||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="subtitle1" fontWeight="bold">
|
|
||||||
Привязанные {props.title}
|
|
||||||
</Typography>
|
|
||||||
</AccordionSummary>
|
|
||||||
|
|
||||||
<AccordionDetails sx={{ background: theme.palette.background.paper }}>
|
|
||||||
<Stack gap={2}>
|
|
||||||
<LinkedItemsContents {...props} />
|
|
||||||
</Stack>
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
{!props.dontRecurse && (
|
|
||||||
<>
|
|
||||||
<ArticleEditModal />
|
|
||||||
<StationEditModal />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LinkedItemsContents = <
|
|
||||||
T extends { id: number; [key: string]: any }
|
|
||||||
>({
|
|
||||||
parentId,
|
|
||||||
parentResource,
|
|
||||||
childResource,
|
|
||||||
setItemsParent,
|
|
||||||
fields,
|
|
||||||
title,
|
|
||||||
dragAllowed = false,
|
|
||||||
type,
|
|
||||||
onUpdate,
|
|
||||||
disableCreation = false,
|
|
||||||
updatedLinkedItems,
|
|
||||||
refresh,
|
|
||||||
cityId,
|
|
||||||
}: 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" && (
|
|
||||||
<TableCell width="120px">Действие</TableCell>
|
|
||||||
)}
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
|
|
||||||
<Droppable
|
|
||||||
droppableId="droppable"
|
|
||||||
isDropDisabled={type !== "edit" || !dragAllowed}
|
|
||||||
>
|
|
||||||
{(provided) => (
|
|
||||||
<TableBody
|
|
||||||
ref={provided.innerRef}
|
|
||||||
{...provided.droppableProps}
|
|
||||||
>
|
|
||||||
{linkedItems.map((item, index) => (
|
|
||||||
<Draggable
|
|
||||||
key={item.id}
|
|
||||||
draggableId={"q" + String(item.id)}
|
|
||||||
index={index}
|
|
||||||
isDragDisabled={type !== "edit" || !dragAllowed}
|
|
||||||
>
|
|
||||||
{(provided) => (
|
|
||||||
<TableRow
|
|
||||||
sx={{
|
|
||||||
cursor:
|
|
||||||
childResource === "article"
|
|
||||||
? "pointer"
|
|
||||||
: "default",
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (
|
|
||||||
childResource === "article" &&
|
|
||||||
type === "edit"
|
|
||||||
) {
|
|
||||||
setArticleModalOpenAction(true);
|
|
||||||
setArticleIdAction(item.id);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
childResource === "station" &&
|
|
||||||
type === "edit"
|
|
||||||
) {
|
|
||||||
setStationModalOpenAction(true);
|
|
||||||
setStationIdAction(item.id);
|
|
||||||
setRouteIdAction(Number(parentId));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
ref={provided.innerRef}
|
|
||||||
{...provided.draggableProps}
|
|
||||||
{...provided.dragHandleProps}
|
|
||||||
hover
|
|
||||||
>
|
|
||||||
{type === "edit" && dragAllowed && (
|
|
||||||
<TableCell {...provided.dragHandleProps}>
|
|
||||||
<IconButton size="small">
|
|
||||||
<DragIndicatorIcon />
|
|
||||||
</IconButton>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
<TableCell key={String(item.id)}>
|
|
||||||
{index + 1}
|
|
||||||
</TableCell>
|
|
||||||
{fields.map((field, index) => (
|
|
||||||
<TableCell
|
|
||||||
key={String(field.data) + String(index)}
|
|
||||||
>
|
|
||||||
{field.render
|
|
||||||
? field.render(item[field.data])
|
|
||||||
: item[field.data]}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{type === "edit" && (
|
|
||||||
<TableCell>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="error"
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
deleteItem(item.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Отвязать
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</Draggable>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{provided.placeholder}
|
|
||||||
</TableBody>
|
|
||||||
)}
|
|
||||||
</Droppable>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
</DragDropContext>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{linkedItems.length === 0 && !isLoading && (
|
|
||||||
<Typography color="textSecondary" textAlign="center" py={2}>
|
|
||||||
{title} не найдены
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{type === "edit" && !disableCreation && (
|
|
||||||
<Stack gap={2} mt={2}>
|
|
||||||
<Typography variant="subtitle1">Добавить {title}</Typography>
|
|
||||||
<Autocomplete
|
|
||||||
fullWidth
|
|
||||||
value={
|
|
||||||
availableItems?.find((item) => item.id === selectedItemId) || null
|
|
||||||
}
|
|
||||||
onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)}
|
|
||||||
options={availableItems.filter((item) => item.city_id == cityId)}
|
|
||||||
getOptionLabel={(item) => String(item[fields[0].data])}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField {...params} label={`Выберите ${title}`} fullWidth />
|
|
||||||
)}
|
|
||||||
isOptionEqualToValue={(option, value) => option.id === value?.id}
|
|
||||||
filterOptions={(options, { inputValue }) => {
|
|
||||||
const searchWords = inputValue
|
|
||||||
.toLowerCase()
|
|
||||||
.split(" ")
|
|
||||||
.filter((word) => word.length > 0);
|
|
||||||
|
|
||||||
return options.filter((option) => {
|
|
||||||
const optionWords = String(option[fields[0].data])
|
|
||||||
.toLowerCase()
|
|
||||||
.split(" ");
|
|
||||||
return searchWords.every((searchWord) =>
|
|
||||||
optionWords.some((word) => word.startsWith(searchWord))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
renderOption={(props, option) => (
|
|
||||||
<li {...props} key={option.id}>
|
|
||||||
{String(option[fields[0].data])}
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* {childResource === "article" && (
|
|
||||||
<FormControl fullWidth>
|
|
||||||
<TextField
|
|
||||||
type="number"
|
|
||||||
label="Позиция добавляемой статьи"
|
|
||||||
name="page_num"
|
|
||||||
value={pageNum}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newValue = Number(e.target.value);
|
|
||||||
const minValue = linkedItems.length + 1;
|
|
||||||
setPageNum(newValue < minValue ? minValue : newValue);
|
|
||||||
}}
|
|
||||||
fullWidth
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
)} */}
|
|
||||||
|
|
||||||
{childResource === "media" && (
|
|
||||||
<FormControl fullWidth>
|
|
||||||
<TextField
|
|
||||||
type="text"
|
|
||||||
label="Порядок отображения медиа"
|
|
||||||
value={mediaOrder}
|
|
||||||
onChange={(e) => {
|
|
||||||
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 }}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={linkItem}
|
|
||||||
disabled={
|
|
||||||
!selectedItemId || (childResource == "media" && mediaOrder == 0)
|
|
||||||
}
|
|
||||||
sx={{ alignSelf: "flex-start" }}
|
|
||||||
>
|
|
||||||
Добавить
|
|
||||||
</Button>
|
|
||||||
{childResource == "station" && (
|
|
||||||
<TextField
|
|
||||||
type="text"
|
|
||||||
label="Позиция добавляемой остановки к маршруту"
|
|
||||||
value={position}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newValue = Number(e.target.value);
|
|
||||||
setPosition(
|
|
||||||
newValue > linkedItems.length + 1
|
|
||||||
? linkedItems.length + 1
|
|
||||||
: newValue
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
></TextField>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,108 +0,0 @@
|
|||||||
import {styled} from '@mui/material/styles'
|
|
||||||
import zIndex from '@mui/material/styles/zIndex'
|
|
||||||
import SimpleMDE, {SimpleMDEReactProps, default as SimpleMDEDefault} from 'react-simplemde-editor'
|
|
||||||
|
|
||||||
const StyledMarkdownEditor = styled('div')(({theme}) => ({
|
|
||||||
'& .editor-toolbar': {
|
|
||||||
backgroundColor: theme.palette.background.paper,
|
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
},
|
|
||||||
'& .editor-toolbar button': {
|
|
||||||
color: theme.palette.text.primary,
|
|
||||||
},
|
|
||||||
'& .editor-toolbar button:hover': {
|
|
||||||
backgroundColor: theme.palette.action.hover,
|
|
||||||
},
|
|
||||||
'& .editor-toolbar button:active, & .editor-toolbar button.active': {
|
|
||||||
backgroundColor: theme.palette.action.selected,
|
|
||||||
},
|
|
||||||
'& .editor-statusbar': {
|
|
||||||
display: 'none',
|
|
||||||
},
|
|
||||||
// Стили для самого редактора
|
|
||||||
'& .CodeMirror': {
|
|
||||||
backgroundColor: theme.palette.background.paper,
|
|
||||||
color: theme.palette.text.primary,
|
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
},
|
|
||||||
// Стили для текста в редакторе
|
|
||||||
'& .CodeMirror-selected': {
|
|
||||||
backgroundColor: `${theme.palette.action.selected} !important`,
|
|
||||||
},
|
|
||||||
'& .CodeMirror-cursor': {
|
|
||||||
borderLeftColor: theme.palette.text.primary,
|
|
||||||
},
|
|
||||||
// Стили для markdown разметки
|
|
||||||
'& .cm-header': {
|
|
||||||
color: theme.palette.primary.main,
|
|
||||||
},
|
|
||||||
'& .cm-quote': {
|
|
||||||
color: theme.palette.text.secondary,
|
|
||||||
fontStyle: 'italic',
|
|
||||||
},
|
|
||||||
'& .cm-link': {
|
|
||||||
color: theme.palette.primary.main,
|
|
||||||
},
|
|
||||||
'& .cm-url': {
|
|
||||||
color: theme.palette.secondary.main,
|
|
||||||
},
|
|
||||||
'& .cm-formatting': {
|
|
||||||
color: theme.palette.text.secondary,
|
|
||||||
},
|
|
||||||
|
|
||||||
'& .CodeMirror .editor-preview-full': {
|
|
||||||
backgroundColor: theme.palette.background.paper,
|
|
||||||
color: theme.palette.text.primary,
|
|
||||||
borderColor: theme.palette.divider,
|
|
||||||
},
|
|
||||||
|
|
||||||
'& .EasyMDEContainer': {
|
|
||||||
position: 'relative',
|
|
||||||
zIndex: zIndex.modal,
|
|
||||||
},
|
|
||||||
'& .guide': {
|
|
||||||
display: 'none',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
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,235 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Header: React.FC<RefineThemedLayoutV2HeaderProps> = observer(
|
|
||||||
({ sticky }) => {
|
|
||||||
const { city_id, setCityIdAction } = cityStore;
|
|
||||||
const { language } = languageStore;
|
|
||||||
const { data: cities } = useList({
|
|
||||||
resource: "city",
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Сохраняем текущий URL перед любыми действиями
|
|
||||||
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 HTMLSelectElement) {
|
|
||||||
return (
|
|
||||||
input.value !==
|
|
||||||
input.options[input.selectedIndex].defaultSelected.toString()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasChanges || warnWhen) {
|
|
||||||
try {
|
|
||||||
// console.log('Attempting to save changes...')
|
|
||||||
setWarnWhen(false);
|
|
||||||
saveButton.click();
|
|
||||||
// console.log('Save button clicked')
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
// После сохранения меняем язык и возвращаемся на ту же страницу
|
|
||||||
Cookies.set("lang", lang);
|
|
||||||
|
|
||||||
i18n.changeLanguage(lang);
|
|
||||||
navigate(currentLocation);
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to save form:", error);
|
|
||||||
setWarnWhen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если нет формы или изменений, просто меняем язык
|
|
||||||
// console.log('Setting language cookie:', lang)
|
|
||||||
Cookies.set("lang", lang);
|
|
||||||
|
|
||||||
// console.log('Changing i18n language')
|
|
||||||
i18n.changeLanguage(lang);
|
|
||||||
|
|
||||||
// Используем текущий URL для навигации
|
|
||||||
navigate(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const savedLang = Cookies.get("lang") || "ru";
|
|
||||||
i18n.changeLanguage(savedLang);
|
|
||||||
}, [i18n]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppBar position={sticky ? "sticky" : "relative"}>
|
|
||||||
<Toolbar>
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
width="100%"
|
|
||||||
justifyContent="flex-end"
|
|
||||||
alignItems="center"
|
|
||||||
>
|
|
||||||
<HamburgerMenu />
|
|
||||||
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
width="100%"
|
|
||||||
justifyContent="flex-end"
|
|
||||||
alignItems="center"
|
|
||||||
spacing={2}
|
|
||||||
color="white"
|
|
||||||
sx={{
|
|
||||||
"& .MuiSelect-select": {
|
|
||||||
color: "white",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FormControl
|
|
||||||
variant="standard"
|
|
||||||
sx={{ width: "min-content", color: "white" }}
|
|
||||||
>
|
|
||||||
{city_id && cities && (
|
|
||||||
<Select
|
|
||||||
defaultValue={city_id}
|
|
||||||
value={city_id}
|
|
||||||
onChange={handleChange}
|
|
||||||
>
|
|
||||||
<MenuItem value={String(0)} key={0}>
|
|
||||||
Все города
|
|
||||||
</MenuItem>
|
|
||||||
{cities.data?.map((city) => (
|
|
||||||
<MenuItem value={String(city.id)} key={city.id}>
|
|
||||||
{city.name}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<IconButton
|
|
||||||
color="inherit"
|
|
||||||
onClick={() => {
|
|
||||||
setMode();
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
marginRight: "2px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{mode === "dark" ? <LightModeOutlined /> : <DarkModeOutlined />}
|
|
||||||
</IconButton>
|
|
||||||
|
|
||||||
{(user?.avatar || user?.name) && (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
gap="16px"
|
|
||||||
alignItems="center"
|
|
||||||
justifyContent="center"
|
|
||||||
>
|
|
||||||
{user?.name && (
|
|
||||||
<Stack direction="column" alignItems="start" gap="0px">
|
|
||||||
<Typography
|
|
||||||
sx={{
|
|
||||||
display: {
|
|
||||||
xs: "none",
|
|
||||||
sm: "inline-block",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
variant="subtitle2"
|
|
||||||
>
|
|
||||||
{user?.name}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Typography
|
|
||||||
sx={{
|
|
||||||
display: {
|
|
||||||
xs: "none",
|
|
||||||
sm: "inline-block",
|
|
||||||
},
|
|
||||||
backgroundColor: "primary.main",
|
|
||||||
color: "rgba(255, 255, 255, 0.7)",
|
|
||||||
padding: "1px 4px",
|
|
||||||
borderRadius: 1,
|
|
||||||
fontSize: "0.6rem",
|
|
||||||
}}
|
|
||||||
variant="subtitle2"
|
|
||||||
>
|
|
||||||
{isAdmin ? "Администратор" : "Пользователь"}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
<Avatar src={user?.avatar} alt={user?.name} />
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Toolbar>
|
|
||||||
</AppBar>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
@ -1,5 +0,0 @@
|
|||||||
export * from './AdminOnly'
|
|
||||||
export * from './CreateSightArticle'
|
|
||||||
export * from './CustomDataGrid'
|
|
||||||
export * from './LinkedItems'
|
|
||||||
export * from './MarkdownEditor'
|
|
@ -1,149 +0,0 @@
|
|||||||
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_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';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mediaType === 2 && !ALLOWED_VIDEO_TYPES.includes(file.type)) {
|
|
||||||
return 'Для типа "Видео" разрешены только форматы: MP4, WEBM, OGG';
|
|
||||||
}
|
|
||||||
|
|
||||||
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>;
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
if (selectedMediaType) {
|
|
||||||
const error = validateFileType(file, selectedMediaType);
|
|
||||||
if (error) {
|
|
||||||
setError("file", { type: "manual", message: error });
|
|
||||||
event.target.value = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearErrors("file");
|
|
||||||
setValue("file", file);
|
|
||||||
setSelectedFile(file);
|
|
||||||
|
|
||||||
if (file.type.startsWith("image/")) {
|
|
||||||
const url = URL.createObjectURL(file);
|
|
||||||
setPreviewUrl(url);
|
|
||||||
} else {
|
|
||||||
setPreviewUrl(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMediaTypeChange = (newMediaType: number | null) => {
|
|
||||||
setValue("media_type", newMediaType || null);
|
|
||||||
|
|
||||||
if (selectedFile && newMediaType) {
|
|
||||||
const error = validateFileType(selectedFile, newMediaType);
|
|
||||||
if (error) {
|
|
||||||
setError("file", { type: "manual", message: error });
|
|
||||||
setValue("file", null);
|
|
||||||
setSelectedFile(null);
|
|
||||||
setPreviewUrl(null);
|
|
||||||
} else {
|
|
||||||
clearErrors("file");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
selectedFile,
|
|
||||||
setSelectedFile,
|
|
||||||
previewUrl,
|
|
||||||
setPreviewUrl,
|
|
||||||
handleFileChange,
|
|
||||||
handleMediaTypeChange,
|
|
||||||
};
|
|
||||||
};
|
|
@ -1,410 +0,0 @@
|
|||||||
import { Modal, Box, Button, TextField, Typography } from "@mui/material";
|
|
||||||
import { articleStore } from "../../../store/ArticleStore";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
import { useForm } from "@refinedev/react-hook-form";
|
|
||||||
import { Controller } from "react-hook-form";
|
|
||||||
import "easymde/dist/easymde.min.css";
|
|
||||||
import { memo, useMemo, useEffect, useCallback, useState } from "react";
|
|
||||||
import { MarkdownEditor } from "../../MarkdownEditor";
|
|
||||||
import { Edit } from "@refinedev/mui";
|
|
||||||
import { EVERY_LANGUAGE, languageStore } from "../../../store/LanguageStore";
|
|
||||||
import { LanguageSwitch } from "../../LanguageSwitch/index";
|
|
||||||
import { useDropzone } from "react-dropzone";
|
|
||||||
import {
|
|
||||||
ALLOWED_IMAGE_TYPES,
|
|
||||||
ALLOWED_VIDEO_TYPES,
|
|
||||||
} from "../../media/MediaFormUtils";
|
|
||||||
import { TOKEN_KEY, axiosInstance } from "@providers";
|
|
||||||
import { LinkedItems } from "../../../components/LinkedItems";
|
|
||||||
import { mediaFields, MediaItem } from "../../../pages/article/types";
|
|
||||||
|
|
||||||
const MemoizedSimpleMDE = memo(MarkdownEditor);
|
|
||||||
|
|
||||||
type MediaFile = {
|
|
||||||
file: File;
|
|
||||||
preview: string;
|
|
||||||
uploading: boolean;
|
|
||||||
media_id?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
marginLeft: "auto",
|
|
||||||
marginRight: "auto",
|
|
||||||
//position: "absolute",
|
|
||||||
//top: "50%",
|
|
||||||
//left: "50%",
|
|
||||||
//transform: "translate(-50%, -50%)",
|
|
||||||
width: "60%",
|
|
||||||
bgcolor: "background.paper",
|
|
||||||
border: "2px solid #000",
|
|
||||||
boxShadow: 24,
|
|
||||||
p: 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ArticleEditModal = observer(() => {
|
|
||||||
const { language } = languageStore;
|
|
||||||
const [articleData, setArticleData] = useState({
|
|
||||||
heading: EVERY_LANGUAGE(language),
|
|
||||||
body: EVERY_LANGUAGE(language),
|
|
||||||
});
|
|
||||||
const { articleModalOpen, setArticleModalOpenAction, selectedArticleId } =
|
|
||||||
articleStore;
|
|
||||||
|
|
||||||
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]);
|
|
||||||
|
|
||||||
const [refresh, setRefresh] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
setArticleModalOpenAction(false);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load existing media files when editing an article
|
|
||||||
const loadExistingMedia = async () => {
|
|
||||||
if (selectedArticleId) {
|
|
||||||
try {
|
|
||||||
const response = await axiosInstance.get(
|
|
||||||
`${import.meta.env.VITE_KRBL_API}/article/${selectedArticleId}/media`
|
|
||||||
);
|
|
||||||
const existingMedia = response.data;
|
|
||||||
|
|
||||||
// Convert existing media to MediaFile format
|
|
||||||
const mediaFiles = await Promise.all(
|
|
||||||
existingMedia.map(async (media: any) => {
|
|
||||||
const response = await fetch(
|
|
||||||
`${import.meta.env.VITE_KRBL_MEDIA}${
|
|
||||||
media.id
|
|
||||||
}/download?token=${localStorage.getItem(TOKEN_KEY)}`
|
|
||||||
);
|
|
||||||
const blob = await response.blob();
|
|
||||||
const file = new File([blob], media.filename, {
|
|
||||||
type: media.media_type === 1 ? "image/jpeg" : "video/mp4",
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
file,
|
|
||||||
preview: URL.createObjectURL(blob),
|
|
||||||
uploading: false,
|
|
||||||
mediaId: media.id,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
setMediaFiles(mediaFiles);
|
|
||||||
setRefresh(refresh + 1);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error loading existing media:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadExistingMedia();
|
|
||||||
}, [selectedArticleId]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
control,
|
|
||||||
formState: { errors },
|
|
||||||
saveButtonProps,
|
|
||||||
reset,
|
|
||||||
setValue,
|
|
||||||
watch,
|
|
||||||
} = useForm({
|
|
||||||
refineCoreProps: {
|
|
||||||
resource: "article",
|
|
||||||
id: selectedArticleId ?? undefined,
|
|
||||||
action: "edit",
|
|
||||||
redirect: false,
|
|
||||||
|
|
||||||
onMutationSuccess: async () => {
|
|
||||||
try {
|
|
||||||
// Upload new media files
|
|
||||||
const newMediaFiles = mediaFiles.filter((file) => !file.media_id);
|
|
||||||
const existingMediaAmount = mediaFiles.filter(
|
|
||||||
(file) => file.media_id
|
|
||||||
).length;
|
|
||||||
const mediaIds = await Promise.all(
|
|
||||||
newMediaFiles.map(async (mediaFile) => {
|
|
||||||
return await uploadMedia(mediaFile);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Associate all media with the article
|
|
||||||
await Promise.all(
|
|
||||||
mediaIds.map((mediaId, index) =>
|
|
||||||
axiosInstance.post(
|
|
||||||
`${
|
|
||||||
import.meta.env.VITE_KRBL_API
|
|
||||||
}/article/${selectedArticleId}/media/`,
|
|
||||||
{
|
|
||||||
media_id: mediaId,
|
|
||||||
media_order: index + existingMediaAmount + 1,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
setArticleModalOpenAction(false);
|
|
||||||
reset();
|
|
||||||
window.location.reload();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error handling media:", error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
headers: {
|
|
||||||
"Accept-Language": language,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (articleData.heading[language]) {
|
|
||||||
setValue("heading", articleData.heading[language]);
|
|
||||||
}
|
|
||||||
if (articleData.body[language]) {
|
|
||||||
setValue("body", articleData.body[language]);
|
|
||||||
}
|
|
||||||
}, [language, articleData, setValue]);
|
|
||||||
|
|
||||||
const handleLanguageChange = () => {
|
|
||||||
setArticleData((prevData) => ({
|
|
||||||
...prevData,
|
|
||||||
heading: {
|
|
||||||
...prevData.heading,
|
|
||||||
[language]: watch("heading") ?? "",
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
...prevData.body,
|
|
||||||
[language]: watch("body") ?? "",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const simpleMDEOptions = useMemo(
|
|
||||||
() => ({
|
|
||||||
placeholder: "Введите контент в формате Markdown...",
|
|
||||||
spellChecker: false,
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
|
||||||
const newFiles = acceptedFiles.map((file) => ({
|
|
||||||
file,
|
|
||||||
preview: URL.createObjectURL(file),
|
|
||||||
uploading: false,
|
|
||||||
}));
|
|
||||||
setMediaFiles((prev) => [...prev, ...newFiles]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
||||||
onDrop,
|
|
||||||
accept: {
|
|
||||||
"image/jpeg": [".jpeg", ".jpg"],
|
|
||||||
"image/png": [".png"],
|
|
||||||
"image/webp": [".webp"],
|
|
||||||
"video/mp4": [".mp4"],
|
|
||||||
"video/webm": [".webm"],
|
|
||||||
"video/ogg": [".ogg"],
|
|
||||||
},
|
|
||||||
multiple: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploadMedia = async (mediaFile: MediaFile) => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("media_name", mediaFile.file.name);
|
|
||||||
formData.append("filename", mediaFile.file.name);
|
|
||||||
formData.append(
|
|
||||||
"type",
|
|
||||||
mediaFile.file.type.startsWith("image/") ? "1" : "2"
|
|
||||||
);
|
|
||||||
formData.append("file", mediaFile.file);
|
|
||||||
|
|
||||||
const response = await axiosInstance.post(
|
|
||||||
`${import.meta.env.VITE_KRBL_API}/media`,
|
|
||||||
formData
|
|
||||||
);
|
|
||||||
return response.data.id;
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeMedia = async (index: number) => {
|
|
||||||
const mediaFile = mediaFiles[index];
|
|
||||||
|
|
||||||
// If it's an existing media file (has mediaId), delete it from the server
|
|
||||||
if (mediaFile.media_id) {
|
|
||||||
try {
|
|
||||||
await axiosInstance.delete(
|
|
||||||
`${import.meta.env.VITE_KRBL_API}/media/${mediaFile.media_id}`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting media:", error);
|
|
||||||
return; // Don't remove from UI if server deletion failed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove from UI and cleanup
|
|
||||||
setMediaFiles((prev) => {
|
|
||||||
const newFiles = [...prev];
|
|
||||||
URL.revokeObjectURL(newFiles[index].preview);
|
|
||||||
newFiles.splice(index, 1);
|
|
||||||
return newFiles;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={articleModalOpen}
|
|
||||||
onClose={() => setArticleModalOpenAction(false)}
|
|
||||||
aria-labelledby="modal-modal-title"
|
|
||||||
aria-describedby="modal-modal-description"
|
|
||||||
sx={{ overflow: "auto" }}
|
|
||||||
>
|
|
||||||
<Box sx={style}>
|
|
||||||
<Edit
|
|
||||||
title={<Typography variant="h5">Редактирование статьи</Typography>}
|
|
||||||
headerProps={{
|
|
||||||
sx: {
|
|
||||||
fontSize: "50px",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
saveButtonProps={saveButtonProps}
|
|
||||||
>
|
|
||||||
<LanguageSwitch action={handleLanguageChange} />
|
|
||||||
<Box
|
|
||||||
component="form"
|
|
||||||
sx={{ display: "flex", flexDirection: "column" }}
|
|
||||||
autoComplete="off"
|
|
||||||
>
|
|
||||||
<TextField
|
|
||||||
{...register("heading", {
|
|
||||||
required: "Это поле является обязательным",
|
|
||||||
})}
|
|
||||||
error={!!errors.heading}
|
|
||||||
helperText={errors.heading?.message as string}
|
|
||||||
margin="normal"
|
|
||||||
fullWidth
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
type="text"
|
|
||||||
name="heading"
|
|
||||||
label="Заголовок *"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="body"
|
|
||||||
rules={{ required: "Это поле является обязательным" }}
|
|
||||||
defaultValue=""
|
|
||||||
render={({ field: { onChange, value } }) => (
|
|
||||||
<MemoizedSimpleMDE
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
options={simpleMDEOptions}
|
|
||||||
className="my-markdown-editor"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{selectedArticleId && (
|
|
||||||
<LinkedItems<MediaItem>
|
|
||||||
type="edit"
|
|
||||||
parentId={selectedArticleId}
|
|
||||||
parentResource="article"
|
|
||||||
childResource="media"
|
|
||||||
fields={mediaFields}
|
|
||||||
title="медиа"
|
|
||||||
dontRecurse
|
|
||||||
onUpdate={loadExistingMedia}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Dropzone для медиа файлов */}
|
|
||||||
<Box sx={{ mt: 2, mb: 2 }}>
|
|
||||||
<Box
|
|
||||||
{...getRootProps()}
|
|
||||||
sx={{
|
|
||||||
border: "2px dashed",
|
|
||||||
borderColor: isDragActive ? "primary.main" : "grey.300",
|
|
||||||
borderRadius: 1,
|
|
||||||
p: 2,
|
|
||||||
textAlign: "center",
|
|
||||||
cursor: "pointer",
|
|
||||||
"&:hover": {
|
|
||||||
borderColor: "primary.main",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input {...getInputProps()} />
|
|
||||||
<Typography>
|
|
||||||
{isDragActive
|
|
||||||
? "Перетащите файлы сюда..."
|
|
||||||
: "Перетащите файлы сюда или кликните для выбора"}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Превью загруженных файлов */}
|
|
||||||
<Box sx={{ mt: 2, display: "flex", flexWrap: "wrap", gap: 1 }}>
|
|
||||||
{mediaFiles.map((mediaFile, index) => (
|
|
||||||
<Box
|
|
||||||
key={mediaFile.preview}
|
|
||||||
sx={{
|
|
||||||
position: "relative",
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{mediaFile.file.type.startsWith("image/") ? (
|
|
||||||
<img
|
|
||||||
src={mediaFile.preview}
|
|
||||||
alt={mediaFile.file.name}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
objectFit: "cover",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
bgcolor: "grey.200",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="caption">
|
|
||||||
{mediaFile.file.name}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
color="error"
|
|
||||||
onClick={() => removeMedia(index)}
|
|
||||||
sx={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
minWidth: "auto",
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
p: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Edit>
|
|
||||||
</Box>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,191 +0,0 @@
|
|||||||
import {
|
|
||||||
Modal,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
Grid,
|
|
||||||
Paper,
|
|
||||||
} from "@mui/material";
|
|
||||||
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
import { useForm } from "@refinedev/react-hook-form";
|
|
||||||
import { Controller } from "react-hook-form";
|
|
||||||
import "easymde/dist/easymde.min.css";
|
|
||||||
import { memo, useMemo, useEffect } from "react";
|
|
||||||
import { MarkdownEditor } from "../../MarkdownEditor";
|
|
||||||
import { Edit } from "@refinedev/mui";
|
|
||||||
import { languageStore } from "../../../store/LanguageStore";
|
|
||||||
import { LanguageSwitch } from "../../LanguageSwitch/index";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { stationStore } from "../../../store/StationStore";
|
|
||||||
import { useCustom } from "@refinedev/core";
|
|
||||||
import { useApiUrl } from "@refinedev/core";
|
|
||||||
import { StationItem } from "src/pages/route/types";
|
|
||||||
const MemoizedSimpleMDE = memo(MarkdownEditor);
|
|
||||||
|
|
||||||
const TRANSFER_FIELDS = [
|
|
||||||
{ name: "bus", label: "Автобус" },
|
|
||||||
{ name: "metro_blue", label: "Метро (синяя)" },
|
|
||||||
{ name: "metro_green", label: "Метро (зеленая)" },
|
|
||||||
{ name: "metro_orange", label: "Метро (оранжевая)" },
|
|
||||||
{ name: "metro_purple", label: "Метро (фиолетовая)" },
|
|
||||||
{ name: "metro_red", label: "Метро (красная)" },
|
|
||||||
{ name: "train", label: "Электричка" },
|
|
||||||
{ name: "tram", label: "Трамвай" },
|
|
||||||
{ name: "trolleybus", label: "Троллейбус" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
position: "absolute",
|
|
||||||
top: "50%",
|
|
||||||
left: "50%",
|
|
||||||
transform: "translate(-50%, -50%)",
|
|
||||||
width: "60%",
|
|
||||||
bgcolor: "background.paper",
|
|
||||||
border: "2px solid #000",
|
|
||||||
boxShadow: 24,
|
|
||||||
p: 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const StationEditModal = observer(() => {
|
|
||||||
const {
|
|
||||||
stationModalOpen,
|
|
||||||
setStationModalOpenAction,
|
|
||||||
selectedStationId,
|
|
||||||
selectedRouteId,
|
|
||||||
} = stationStore;
|
|
||||||
const { language } = languageStore;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
setStationModalOpenAction(false);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const apiUrl = useApiUrl();
|
|
||||||
|
|
||||||
const { data: stationQuery, isLoading: isStationLoading } = useCustom({
|
|
||||||
url: `${apiUrl}/route/${selectedRouteId ?? 1}/station`,
|
|
||||||
method: "get",
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
control,
|
|
||||||
formState: { errors },
|
|
||||||
saveButtonProps,
|
|
||||||
reset,
|
|
||||||
setValue,
|
|
||||||
watch,
|
|
||||||
handleSubmit,
|
|
||||||
} = useForm({
|
|
||||||
refineCoreProps: {
|
|
||||||
resource: `route/${selectedRouteId ?? 1}/station`,
|
|
||||||
action: "edit",
|
|
||||||
id: "",
|
|
||||||
redirect: false,
|
|
||||||
onMutationSuccess: (data) => {
|
|
||||||
setStationModalOpenAction(false);
|
|
||||||
reset();
|
|
||||||
window.location.reload();
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
headers: {
|
|
||||||
"Accept-Language": language,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (stationModalOpen) {
|
|
||||||
const station = stationQuery?.data?.find(
|
|
||||||
(station: StationItem) => station.id === selectedStationId
|
|
||||||
);
|
|
||||||
if (!station) return;
|
|
||||||
for (const key in station) {
|
|
||||||
setValue(key, station[key]);
|
|
||||||
}
|
|
||||||
setValue("station_id", station.id);
|
|
||||||
}
|
|
||||||
}, [stationModalOpen, stationQuery]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={stationModalOpen}
|
|
||||||
onClose={() => setStationModalOpenAction(false)}
|
|
||||||
aria-labelledby="modal-modal-title"
|
|
||||||
aria-describedby="modal-modal-description"
|
|
||||||
>
|
|
||||||
<Box sx={style}>
|
|
||||||
<Edit
|
|
||||||
title={<Typography variant="h5">Редактирование остановки</Typography>}
|
|
||||||
saveButtonProps={saveButtonProps}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
component="form"
|
|
||||||
sx={{ display: "flex", flexDirection: "column" }}
|
|
||||||
autoComplete="off"
|
|
||||||
>
|
|
||||||
<TextField
|
|
||||||
{...register("offset_x", {
|
|
||||||
setValueAs: (value) => parseFloat(value),
|
|
||||||
})}
|
|
||||||
error={!!(errors as any)?.offset_x}
|
|
||||||
helperText={(errors as any)?.offset_x?.message}
|
|
||||||
margin="normal"
|
|
||||||
fullWidth
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
type="number"
|
|
||||||
label={"Смещение (X)"}
|
|
||||||
name="offset_x"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
{...register("offset_y", {
|
|
||||||
required: "Это поле является обязательным",
|
|
||||||
setValueAs: (value) => parseFloat(value),
|
|
||||||
})}
|
|
||||||
error={!!(errors as any)?.offset_y}
|
|
||||||
helperText={(errors as any)?.offset_y?.message}
|
|
||||||
margin="normal"
|
|
||||||
fullWidth
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
type="number"
|
|
||||||
label={"Смещение (Y)"}
|
|
||||||
name="offset_y"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Группа полей пересадок */}
|
|
||||||
<Paper sx={{ p: 2, mt: 2 }}>
|
|
||||||
<Typography variant="h6" gutterBottom>
|
|
||||||
Пересадки
|
|
||||||
</Typography>
|
|
||||||
<Grid container spacing={2}>
|
|
||||||
{TRANSFER_FIELDS.map((field) => (
|
|
||||||
<Grid item xs={12} sm={6} md={4} key={field.name}>
|
|
||||||
<TextField
|
|
||||||
{...register(`transfers.${field.name}`)}
|
|
||||||
error={!!(errors as any)?.transfers?.[field.name]}
|
|
||||||
helperText={
|
|
||||||
(errors as any)?.transfers?.[field.name]?.message
|
|
||||||
}
|
|
||||||
margin="normal"
|
|
||||||
fullWidth
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
type="text"
|
|
||||||
label={field.label}
|
|
||||||
name={`transfers.${field.name}`}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
</Edit>
|
|
||||||
</Box>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,25 +0,0 @@
|
|||||||
import BedtimeIcon from '@mui/icons-material/Bedtime'
|
|
||||||
import PublicIcon from '@mui/icons-material/Public'
|
|
||||||
import LocationCityIcon from '@mui/icons-material/LocationCity'
|
|
||||||
import TramIcon from '@mui/icons-material/Tram'
|
|
||||||
import PermMediaIcon from '@mui/icons-material/PermMedia'
|
|
||||||
import FeedIcon from '@mui/icons-material/Feed'
|
|
||||||
import CastleIcon from '@mui/icons-material/Castle'
|
|
||||||
import HailIcon from '@mui/icons-material/Hail'
|
|
||||||
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus'
|
|
||||||
import ForkLeftIcon from '@mui/icons-material/ForkLeft'
|
|
||||||
import PeopleAltIcon from '@mui/icons-material/PeopleAlt'
|
|
||||||
|
|
||||||
export {
|
|
||||||
BedtimeIcon as ProjectIcon,
|
|
||||||
PublicIcon as CountryIcon,
|
|
||||||
LocationCityIcon as CityIcon,
|
|
||||||
TramIcon as CarrierIcon,
|
|
||||||
PermMediaIcon as MediaIcon,
|
|
||||||
FeedIcon as ArticleIcon,
|
|
||||||
CastleIcon as SightIcon,
|
|
||||||
HailIcon as StationIcon,
|
|
||||||
DirectionsBusIcon as VehicleIcon,
|
|
||||||
ForkLeftIcon as RouteIcon,
|
|
||||||
PeopleAltIcon as UsersIcon, // users icon
|
|
||||||
}
|
|
@ -1,71 +0,0 @@
|
|||||||
import { Box } from "@mui/material";
|
|
||||||
import { Languages, languageStore } from "@stores";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
|
|
||||||
|
|
||||||
export const LanguageSelector = observer(({
|
|
||||||
action
|
|
||||||
}: {action?: (lang: Languages) => void}) => {
|
|
||||||
const { language, setLanguageAction } = languageStore;
|
|
||||||
|
|
||||||
function handleLanguageChange(language: Languages) {
|
|
||||||
if(action) action(language);
|
|
||||||
else setLanguageAction(language);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
gap: 2,
|
|
||||||
height: "min-content"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
cursor: "pointer",
|
|
||||||
flex: 1,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
bgcolor: language === "ru" ? "primary.main" : "transparent",
|
|
||||||
color: language === "ru" ? "white" : "inherit",
|
|
||||||
borderRadius: 1,
|
|
||||||
p: 1,
|
|
||||||
}}
|
|
||||||
onClick={() => handleLanguageChange("ru")}
|
|
||||||
>
|
|
||||||
RU
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
cursor: "pointer",
|
|
||||||
flex: 1,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
bgcolor: language === "en" ? "primary.main" : "transparent",
|
|
||||||
color: language === "en" ? "white" : "inherit",
|
|
||||||
borderRadius: 1,
|
|
||||||
p: 1,
|
|
||||||
}}
|
|
||||||
onClick={() => handleLanguageChange("en")}
|
|
||||||
>
|
|
||||||
EN
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
cursor: "pointer",
|
|
||||||
flex: 1,
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
bgcolor: language === "zh" ? "primary.main" : "transparent",
|
|
||||||
color: language === "zh" ? "white" : "inherit",
|
|
||||||
borderRadius: 1,
|
|
||||||
p: 1,
|
|
||||||
}}
|
|
||||||
onClick={() => handleLanguageChange("zh")}
|
|
||||||
>
|
|
||||||
ZH
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,15 +0,0 @@
|
|||||||
import { Logo } from "@/icons/Logo";
|
|
||||||
|
|
||||||
export default function SidebarTitle({ collapsed }: { collapsed: boolean }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{ display: "flex", alignItems: "center", whiteSpace: "nowrap" }}
|
|
||||||
>
|
|
||||||
<Logo width={40} height={40} />
|
|
||||||
|
|
||||||
{!collapsed && (
|
|
||||||
<span style={{ marginLeft: 8, fontWeight: "bold" }}>Белые ночи</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,5 +0,0 @@
|
|||||||
export * from './Icons';
|
|
||||||
export * from './LanguageSelector';
|
|
||||||
export * from './SidebarTitle';
|
|
||||||
export * from './MediaView';
|
|
||||||
export * from './ModelViewer';
|
|
@ -1,40 +0,0 @@
|
|||||||
import React, {PropsWithChildren, createContext, useEffect, useState} from 'react'
|
|
||||||
import {ThemeProvider, createTheme} from '@mui/material/styles'
|
|
||||||
import {ruRU} from '@mui/material/locale'
|
|
||||||
import {CustomTheme} from './theme'
|
|
||||||
|
|
||||||
type ColorModeContextType = {
|
|
||||||
mode: string
|
|
||||||
setMode: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ColorModeContext = createContext<ColorModeContextType>({} as ColorModeContextType)
|
|
||||||
|
|
||||||
export const ColorModeContextProvider: React.FC<PropsWithChildren> = ({children}) => {
|
|
||||||
const colorModeFromLocalStorage = localStorage.getItem('colorMode')
|
|
||||||
const isSystemPreferenceDark = window?.matchMedia('(prefers-color-scheme: dark)').matches
|
|
||||||
|
|
||||||
const systemPreference = isSystemPreferenceDark ? 'dark' : 'light'
|
|
||||||
const [mode, setMode] = useState(colorModeFromLocalStorage || systemPreference)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.localStorage.setItem('colorMode', mode)
|
|
||||||
}, [mode])
|
|
||||||
|
|
||||||
const setColorMode = () => {
|
|
||||||
setMode((prevMode) => (prevMode === 'light' ? 'dark' : 'light'))
|
|
||||||
}
|
|
||||||
|
|
||||||
const appliedTheme = createTheme(mode === 'light' ? CustomTheme.Light : CustomTheme.Dark, ruRU)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ColorModeContext.Provider
|
|
||||||
value={{
|
|
||||||
setMode: setColorMode,
|
|
||||||
mode,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ThemeProvider theme={appliedTheme}>{children}</ThemeProvider>
|
|
||||||
</ColorModeContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
1
src/entities/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./navigation";
|
2
src/entities/navigation/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./ui";
|
||||||
|
export * from "./model";
|
10
src/entities/navigation/model/index.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export interface NavigationItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NavigationSection = "primary" | "secondary";
|
82
src/entities/navigation/ui/index.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import ListItem from "@mui/material/ListItem";
|
||||||
|
import ListItemButton from "@mui/material/ListItemButton";
|
||||||
|
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||||
|
import ListItemText from "@mui/material/ListItemText";
|
||||||
|
import type { NavigationItem } from "../model";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
interface NavigationItemProps {
|
||||||
|
item: NavigationItem;
|
||||||
|
open: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
|
||||||
|
item,
|
||||||
|
open,
|
||||||
|
onClick,
|
||||||
|
}) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
onClick={() => {
|
||||||
|
if (onClick) {
|
||||||
|
onClick();
|
||||||
|
} else {
|
||||||
|
navigate(item.path);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disablePadding
|
||||||
|
sx={{ display: "block" }}
|
||||||
|
>
|
||||||
|
<ListItemButton
|
||||||
|
sx={[
|
||||||
|
{
|
||||||
|
minHeight: 48,
|
||||||
|
px: 2.5,
|
||||||
|
},
|
||||||
|
open
|
||||||
|
? {
|
||||||
|
justifyContent: "initial",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ListItemIcon
|
||||||
|
sx={[
|
||||||
|
{
|
||||||
|
minWidth: 0,
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
open
|
||||||
|
? {
|
||||||
|
mr: 3,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
mr: "auto",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={item.label}
|
||||||
|
sx={[
|
||||||
|
open
|
||||||
|
? {
|
||||||
|
opacity: 1,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
};
|
1
src/features/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./navigation";
|
1
src/features/navigation/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./ui";
|
34
src/features/navigation/ui/index.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import List from "@mui/material/List";
|
||||||
|
import Divider from "@mui/material/Divider";
|
||||||
|
import { NAVIGATION_ITEMS } from "@shared";
|
||||||
|
import { NavigationItem, NavigationItemComponent } from "@entities";
|
||||||
|
|
||||||
|
export const NavigationList = ({ open }: { open: boolean }) => {
|
||||||
|
const primaryItems = NAVIGATION_ITEMS.primary;
|
||||||
|
const secondaryItems = NAVIGATION_ITEMS.secondary;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<List>
|
||||||
|
{primaryItems.map((item) => (
|
||||||
|
<NavigationItemComponent
|
||||||
|
key={item.id}
|
||||||
|
item={item as NavigationItem}
|
||||||
|
open={open}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
<Divider />
|
||||||
|
<List>
|
||||||
|
{secondaryItems.map((item) => (
|
||||||
|
<NavigationItemComponent
|
||||||
|
key={item.id}
|
||||||
|
item={item as NavigationItem}
|
||||||
|
open={open}
|
||||||
|
onClick={item.onClick ? item.onClick : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -1,25 +0,0 @@
|
|||||||
@import "./stylesheets/hidden-functionality.css";
|
|
||||||
@import "./stylesheets/roles-functionality.css";
|
|
||||||
|
|
||||||
.limited-text {
|
|
||||||
overflow: hidden;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.backup-button {
|
|
||||||
background-color: transparent;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
color: rgba(79, 138, 95, 1);
|
|
||||||
border-radius: 10%;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
.backup-button:hover {
|
|
||||||
background-color: rgba(79, 138, 95, 0.05);
|
|
||||||
}
|
|
Before Width: | Height: | Size: 7.5 KiB |
9
src/index.css
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mde-preview {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
@ -1,9 +0,0 @@
|
|||||||
import { createRoot } from "react-dom/client";
|
|
||||||
|
|
||||||
import App from "./App";
|
|
||||||
import "./globals.css";
|
|
||||||
|
|
||||||
const container = document.getElementById("root") as HTMLElement;
|
|
||||||
const root = createRoot(container);
|
|
||||||
|
|
||||||
root.render(<App />);
|
|
@ -1,13 +0,0 @@
|
|||||||
export const MEDIA_TYPES = [
|
|
||||||
{ 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 },
|
|
||||||
];
|
|
@ -1 +0,0 @@
|
|||||||
export { MEDIA_TYPES, VEHICLE_TYPES } from './constants'
|
|
@ -1,78 +0,0 @@
|
|||||||
export const localeText = {
|
|
||||||
// Root
|
|
||||||
noRowsLabel: 'Нет данных',
|
|
||||||
noResultsOverlayLabel: 'Результаты не найдены.',
|
|
||||||
|
|
||||||
// Density selector toolbar button text
|
|
||||||
toolbarDensity: 'Плотность',
|
|
||||||
toolbarDensityLabel: 'Плотность',
|
|
||||||
toolbarDensityCompact: 'Компактная',
|
|
||||||
toolbarDensityStandard: 'Стандартная',
|
|
||||||
toolbarDensityComfortable: 'Комфортная',
|
|
||||||
|
|
||||||
// Columns selector toolbar button text
|
|
||||||
toolbarColumns: 'Столбцы',
|
|
||||||
toolbarColumnsLabel: 'Выбрать столбцы',
|
|
||||||
|
|
||||||
// Filters toolbar button text
|
|
||||||
toolbarFilters: 'Фильтры',
|
|
||||||
toolbarFiltersLabel: 'Показать фильтры',
|
|
||||||
toolbarFiltersTooltipHide: 'Скрыть фильтры',
|
|
||||||
toolbarFiltersTooltipShow: 'Показать фильтры',
|
|
||||||
toolbarFiltersTooltipActive: (count: number) => (count !== 1 ? `${count} активных фильтров` : `${count} активный фильтр`),
|
|
||||||
|
|
||||||
// Export selector toolbar button text
|
|
||||||
toolbarExport: 'Экспорт',
|
|
||||||
toolbarExportLabel: 'Экспорт',
|
|
||||||
toolbarExportCSV: 'Скачать CSV',
|
|
||||||
|
|
||||||
// Columns panel text
|
|
||||||
columnsPanelTextFieldLabel: 'Поиск столбца',
|
|
||||||
columnsPanelTextFieldPlaceholder: 'Название столбца',
|
|
||||||
columnsPanelDragIconLabel: 'Изменить порядок',
|
|
||||||
columnsPanelShowAllButton: 'Показать все',
|
|
||||||
columnsPanelHideAllButton: 'Скрыть все',
|
|
||||||
|
|
||||||
// Filter panel text
|
|
||||||
filterPanelOperator: 'Оператор',
|
|
||||||
filterPanelAddFilter: 'Добавить фильтр',
|
|
||||||
filterPanelDeleteIconLabel: 'Удалить',
|
|
||||||
filterPanelOperators: 'Операторы',
|
|
||||||
filterPanelOperatorAnd: 'И',
|
|
||||||
filterPanelOperatorOr: 'ИЛИ',
|
|
||||||
filterPanelColumns: 'Столбцы',
|
|
||||||
filterPanelInputLabel: 'Значение',
|
|
||||||
filterPanelInputPlaceholder: 'Фильтровать значение',
|
|
||||||
|
|
||||||
// Filter operators text
|
|
||||||
filterOperatorContains: 'содержит',
|
|
||||||
filterOperatorDoesNotContain: 'не содержит',
|
|
||||||
filterOperatorEquals: 'равно',
|
|
||||||
filterOperatorStartsWith: 'начинается с',
|
|
||||||
filterOperatorEndsWith: 'заканчивается на',
|
|
||||||
filterOperatorIs: 'является',
|
|
||||||
filterOperatorNot: 'не является',
|
|
||||||
filterOperatorAfter: 'после',
|
|
||||||
filterOperatorOnOrAfter: 'в или после',
|
|
||||||
filterOperatorBefore: 'до',
|
|
||||||
filterOperatorOnOrBefore: 'в или до',
|
|
||||||
filterOperatorIsEmpty: 'пусто',
|
|
||||||
filterOperatorIsNotEmpty: 'не пусто',
|
|
||||||
filterOperatorDoesNotEqual: 'не равно',
|
|
||||||
filterOperatorIsAnyOf: 'является одним из',
|
|
||||||
|
|
||||||
// Column menu text
|
|
||||||
columnMenuLabel: 'Меню',
|
|
||||||
columnMenuShowColumns: 'Показать столбцы',
|
|
||||||
columnMenuFilter: 'Фильтр',
|
|
||||||
columnMenuHideColumn: 'Скрыть столбец',
|
|
||||||
columnMenuUnsort: 'Сбросить сортировку',
|
|
||||||
columnMenuSortAsc: 'Сортировать по возрастанию',
|
|
||||||
columnMenuSortDesc: 'Сортировать по убыванию',
|
|
||||||
|
|
||||||
// Rows selected footer text
|
|
||||||
footerRowSelected: (count: number) => (count !== 1 ? `${count.toLocaleString()} строк выбрано` : `${count.toLocaleString()} строка выбрана`),
|
|
||||||
|
|
||||||
// Pagination footer text
|
|
||||||
footerPaginationRowsPerPage: 'Строк на странице:',
|
|
||||||
}
|
|
@ -1,130 +0,0 @@
|
|||||||
{
|
|
||||||
"actions": {
|
|
||||||
"create": "Создать",
|
|
||||||
"show": "Просмотр",
|
|
||||||
"edit": "Редактировать",
|
|
||||||
"delete": "Удалить",
|
|
||||||
"save": "Сохранить"
|
|
||||||
},
|
|
||||||
"buttons": {
|
|
||||||
"edit": "Редактировать",
|
|
||||||
"refresh": "Обновить",
|
|
||||||
"delete": "Удалить",
|
|
||||||
"save": "Сохранить",
|
|
||||||
"submit": "Отправить",
|
|
||||||
"create": "Создать",
|
|
||||||
"cancel": "Отмена",
|
|
||||||
"logout": "Выход"
|
|
||||||
},
|
|
||||||
"notifications": {
|
|
||||||
"createSuccess": "Успешно создано",
|
|
||||||
"createError": "Произошла ошибка при создании",
|
|
||||||
"editSuccess": "Успешно отредактировано",
|
|
||||||
"editError": "Произошла ошибка при редактировании",
|
|
||||||
"deleteSuccess": "Успешно удалено",
|
|
||||||
"deleteError": "Произошла ошибка при удалении",
|
|
||||||
"importSuccess": "Успешно импортировано",
|
|
||||||
"importError": "Произошла ошибка при импорте",
|
|
||||||
"exportSuccess": "Успешно экспортировано",
|
|
||||||
"exportError": "Произошла ошибка при экспорте",
|
|
||||||
"success": "Успешно",
|
|
||||||
"error": "Произошла ошибка"
|
|
||||||
},
|
|
||||||
"pages": {
|
|
||||||
"login": {
|
|
||||||
"title": "Вход в аккаунт",
|
|
||||||
"fields": {
|
|
||||||
"email": "Почта",
|
|
||||||
"password": "Пароль"
|
|
||||||
},
|
|
||||||
"buttons": {
|
|
||||||
"rememberMe": "Запомнить меня",
|
|
||||||
"forgotPassword": "Забыли пароль?",
|
|
||||||
"noAccount": "Нет аккаунта?"
|
|
||||||
},
|
|
||||||
"signin": "Войти",
|
|
||||||
"signup": "Зарегистрироваться",
|
|
||||||
"errors": {
|
|
||||||
"requiredEmail": "Электронная почта обязательна",
|
|
||||||
"requiredPassword": "Пароль обязателен"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"country": {
|
|
||||||
"titles": {
|
|
||||||
"create": "Создать страну",
|
|
||||||
"edit": "Редактировать страну",
|
|
||||||
"show": "Показать страну"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"city": {
|
|
||||||
"titles": {
|
|
||||||
"create": "Создать город",
|
|
||||||
"edit": "Редактировать город",
|
|
||||||
"show": "Показать город"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"carrier": {
|
|
||||||
"titles": {
|
|
||||||
"create": "Создать перевозчика",
|
|
||||||
"edit": "Редактировать перевозчика",
|
|
||||||
"show": "Показать перевозчика"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"media": {
|
|
||||||
"titles": {
|
|
||||||
"create": "Создать медиа",
|
|
||||||
"edit": "Редактировать медиа",
|
|
||||||
"show": "Показать медиа"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"article": {
|
|
||||||
"titles": {
|
|
||||||
"create": "Создать статью",
|
|
||||||
"edit": "Редактировать статью",
|
|
||||||
"show": "Показать статью"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"sight": {
|
|
||||||
"titles": {
|
|
||||||
"create": "Создать достопримечательность",
|
|
||||||
"edit": "Редактировать достопримечательность",
|
|
||||||
"show": "Показать достопримечательность"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"station": {
|
|
||||||
"titles": {
|
|
||||||
"create": "Создать остановку",
|
|
||||||
"edit": "Редактировать остановку",
|
|
||||||
"show": "Показать остановку"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"snapshots": {
|
|
||||||
"titles": {
|
|
||||||
"create": "Создать снапшот",
|
|
||||||
"show": "Показать снапшот"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
"vehicle": {
|
|
||||||
"titles": {
|
|
||||||
"create": "Создать транспорт",
|
|
||||||
"edit": "Редактировать транспорт",
|
|
||||||
"show": "Показать транспорт"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"route": {
|
|
||||||
"titles": {
|
|
||||||
"create": "Создать маршрут",
|
|
||||||
"edit": "Редактировать маршрут",
|
|
||||||
"show": "Показать маршрут"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"user": {
|
|
||||||
"titles": {
|
|
||||||
"create": "Создать пользователя",
|
|
||||||
"edit": "Редактировать пользователя",
|
|
||||||
"show": "Показать пользователя"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
8
src/main.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
|
import "./index.css";
|
||||||
|
import { App } from "./app/index";
|
||||||
|
|
||||||
|
const container = document.getElementById("root");
|
||||||
|
const root = createRoot(container!);
|
||||||
|
root.render(<App />);
|
86
src/pages/Article/ArticleListPage/index.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
|
import { articlesStore, languageStore } from "@shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Trash2, FileText } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { DeleteModal, LanguageSwitcher } from "@widgets";
|
||||||
|
|
||||||
|
export const ArticleListPage = observer(() => {
|
||||||
|
const { articleList, getArticleList } = articlesStore;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getArticleList();
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
|
const columns: GridColDef[] = [
|
||||||
|
{
|
||||||
|
field: "heading",
|
||||||
|
headerName: "Название",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
field: "actions",
|
||||||
|
headerName: "Действия",
|
||||||
|
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
|
<button onClick={() => navigate(`/media/${params.row.id}`)}>
|
||||||
|
<FileText size={20} className="text-green-500" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
setRowId(params.row.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={20} className="text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = articleList.map((article) => ({
|
||||||
|
id: article.id,
|
||||||
|
heading: article.heading,
|
||||||
|
body: article.body,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
hideFooterPagination
|
||||||
|
hideFooter
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeleteModal
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
onDelete={async () => {
|
||||||
|
if (rowId) {
|
||||||
|
getArticleList();
|
||||||
|
}
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setRowId(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setRowId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
1
src/pages/Article/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./ArticleListPage";
|
202
src/pages/Carrier/CarrierCreatePage/index.tsx
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Box,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { ArrowLeft, Save } from "lucide-react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { carrierStore, cityStore, mediaStore } from "@shared";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { LanguageSwitcher, MediaViewer } from "@widgets";
|
||||||
|
import { HexColorPicker } from "react-colorful";
|
||||||
|
|
||||||
|
export const CarrierCreatePage = observer(() => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [fullName, setFullName] = useState("");
|
||||||
|
const [shortName, setShortName] = useState("");
|
||||||
|
const [cityId, setCityId] = useState<number | null>(null);
|
||||||
|
const [primaryColor, setPrimaryColor] = useState("#000000");
|
||||||
|
const [secondaryColor, setSecondaryColor] = useState("#ffffff");
|
||||||
|
const [accentColor, setAccentColor] = useState("#ff0000");
|
||||||
|
const [slogan, setSlogan] = useState("");
|
||||||
|
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cityStore.getCities();
|
||||||
|
mediaStore.getMedia();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
await carrierStore.createCarrier(
|
||||||
|
fullName,
|
||||||
|
shortName,
|
||||||
|
cityStore.cities.find((c) => c.id === cityId)?.name!,
|
||||||
|
cityId!,
|
||||||
|
primaryColor,
|
||||||
|
secondaryColor,
|
||||||
|
accentColor,
|
||||||
|
slogan,
|
||||||
|
selectedMediaId!
|
||||||
|
);
|
||||||
|
toast.success("Перевозчик успешно создан");
|
||||||
|
navigate("/carrier");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Ошибка при создании перевозчика");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => navigate("/carrier")}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Город</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={cityId || ""}
|
||||||
|
label="Город"
|
||||||
|
required
|
||||||
|
onChange={(e) => setCityId(e.target.value as number)}
|
||||||
|
>
|
||||||
|
{cityStore.cities.map((city) => (
|
||||||
|
<MenuItem key={city.id} value={city.id}>
|
||||||
|
{city.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Полное название"
|
||||||
|
value={fullName}
|
||||||
|
required
|
||||||
|
onChange={(e) => setFullName(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Короткое название"
|
||||||
|
value={shortName}
|
||||||
|
required
|
||||||
|
onChange={(e) => setShortName(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="w-full flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="w-32">Основной цвет:</span>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
backgroundColor: primaryColor,
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<HexColorPicker color={primaryColor} onChange={setPrimaryColor} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="w-32">Вторичный цвет:</span>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
backgroundColor: secondaryColor,
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<HexColorPicker
|
||||||
|
color={secondaryColor}
|
||||||
|
onChange={setSecondaryColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="w-32">Акцентный цвет:</span>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
backgroundColor: accentColor,
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<HexColorPicker color={accentColor} onChange={setAccentColor} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Слоган"
|
||||||
|
value={slogan}
|
||||||
|
onChange={(e) => setSlogan(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="w-full flex flex-col gap-4">
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Логотип</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={selectedMediaId || ""}
|
||||||
|
label="Логотип"
|
||||||
|
required
|
||||||
|
onChange={(e) => setSelectedMediaId(e.target.value as string)}
|
||||||
|
>
|
||||||
|
{mediaStore.media.map((media) => (
|
||||||
|
<MenuItem key={media.id} value={media.id}>
|
||||||
|
{media.media_name || media.filename}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
{selectedMediaId && (
|
||||||
|
<div className="w-32 h-32">
|
||||||
|
<MediaViewer media={{ id: selectedMediaId, media_type: 1 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
className="w-min flex gap-2 items-center"
|
||||||
|
startIcon={<Save size={20} />}
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={
|
||||||
|
isLoading || !fullName || !shortName || !cityId || !selectedMediaId
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 size={20} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Создать"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
});
|
101
src/pages/Carrier/CarrierListPage/index.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
|
import { carrierStore, languageStore } from "@shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Eye, Trash2 } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { DeleteModal, LanguageSwitcher } from "@widgets";
|
||||||
|
|
||||||
|
export const CarrierListPage = observer(() => {
|
||||||
|
const { carriers, getCarriers, deleteCarrier } = carrierStore;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [rowId, setRowId] = useState<number | null>(null); // Lifted state
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getCarriers();
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
|
const columns: GridColDef[] = [
|
||||||
|
{
|
||||||
|
field: "full_name",
|
||||||
|
headerName: "Полное имя",
|
||||||
|
|
||||||
|
width: 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "short_name",
|
||||||
|
headerName: "Короткое имя",
|
||||||
|
width: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "city",
|
||||||
|
headerName: "Город",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "actions",
|
||||||
|
headerName: "Действия",
|
||||||
|
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
|
<button onClick={() => navigate(`/carrier/${params.row.id}`)}>
|
||||||
|
<Eye size={20} className="text-green-500" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
setRowId(params.row.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={20} className="text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = carriers.map((carrier) => ({
|
||||||
|
id: carrier.id,
|
||||||
|
full_name: carrier.full_name,
|
||||||
|
short_name: carrier.short_name,
|
||||||
|
city: carrier.city,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex justify-between items-center mb-10">
|
||||||
|
<h1 className="text-2xl">Перевозчики</h1>
|
||||||
|
{/* <CreateButton label="Создать перевозчика" path="/carrier/create" /> */}
|
||||||
|
</div>
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
hideFooterPagination
|
||||||
|
hideFooter
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeleteModal
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
onDelete={async () => {
|
||||||
|
if (rowId) {
|
||||||
|
deleteCarrier(rowId);
|
||||||
|
}
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setRowId(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setRowId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
120
src/pages/Carrier/CarrierPreviewPage/index.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { Paper } from "@mui/material";
|
||||||
|
import { carrierStore, mediaStore } from "@shared";
|
||||||
|
import { MediaViewer } from "@widgets";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
export const CarrierPreviewPage = observer(() => {
|
||||||
|
const { id } = useParams();
|
||||||
|
const { getCarrier, carrier } = carrierStore;
|
||||||
|
const { oneMedia, getOneMedia } = mediaStore;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const carrierResponse = await getCarrier(Number(id));
|
||||||
|
await getOneMedia(carrierResponse?.logo as string);
|
||||||
|
})();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
|
{carrier && (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
{/* <div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => navigate(`/carrier/${id}/edit`)}
|
||||||
|
startIcon={<Pencil size={20} />}
|
||||||
|
>
|
||||||
|
Редактировать
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
onClick={() => navigate(`/carrier/${id}/delete`)}
|
||||||
|
startIcon={<Trash2 size={20} />}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-10 w-full">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-lg font-bold">Полное имя</h1>
|
||||||
|
<p>{carrier?.full_name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-lg font-bold">Полное имя</h1>
|
||||||
|
<p>{carrier?.full_name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-lg font-bold">Город</h1>
|
||||||
|
<p>{carrier?.city}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 ">
|
||||||
|
<h1 className="text-lg font-bold">Основной цвет</h1>
|
||||||
|
<div
|
||||||
|
className="w-min"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${carrier?.main_color}90`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{carrier?.main_color}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-lg font-bold">Цвет левого виджета</h1>
|
||||||
|
<div
|
||||||
|
className="w-min"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${carrier?.left_color}90`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{carrier?.left_color}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-lg font-bold">Цвет правого виджета</h1>
|
||||||
|
<div
|
||||||
|
className="w-min"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${carrier?.right_color}90`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{carrier?.right_color}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-lg font-bold">Краткое имя</h1>
|
||||||
|
<p>{carrier?.short_name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-lg font-bold">Логотип</h1>
|
||||||
|
|
||||||
|
<MediaViewer
|
||||||
|
media={{
|
||||||
|
id: oneMedia?.id as string,
|
||||||
|
media_type: oneMedia?.media_type as number,
|
||||||
|
filename: oneMedia?.filename,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
});
|
3
src/pages/Carrier/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./CarrierListPage";
|
||||||
|
export * from "./CarrierPreviewPage";
|
||||||
|
export * from "./CarrierCreatePage";
|
166
src/pages/City/CityCreatePage/index.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Box,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { ArrowLeft, Save, ImagePlus } from "lucide-react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { cityStore, countryStore, mediaStore } from "@shared";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { LanguageSwitcher, MediaViewer } from "@widgets";
|
||||||
|
import { SelectMediaDialog } from "@shared";
|
||||||
|
|
||||||
|
export const CityCreatePage = observer(() => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [countryCode, setCountryCode] = useState("");
|
||||||
|
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
countryStore.getCountries();
|
||||||
|
mediaStore.getMedia();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
await cityStore.createCity(
|
||||||
|
name,
|
||||||
|
countryStore.countries.find((c) => c.code === countryCode)?.name!,
|
||||||
|
countryCode,
|
||||||
|
selectedMediaId!
|
||||||
|
);
|
||||||
|
toast.success("Город успешно создан");
|
||||||
|
navigate("/city");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Ошибка при создании города");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMediaSelect = (media: {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
media_name?: string;
|
||||||
|
media_type: number;
|
||||||
|
}) => {
|
||||||
|
setSelectedMediaId(media.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedMedia = selectedMediaId
|
||||||
|
? mediaStore.media.find((m) => m.id === selectedMediaId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => navigate("/city")}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Название города"
|
||||||
|
value={name}
|
||||||
|
required
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Страна</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={countryCode}
|
||||||
|
label="Страна"
|
||||||
|
required
|
||||||
|
onChange={(e) => setCountryCode(e.target.value)}
|
||||||
|
>
|
||||||
|
{countryStore.countries.map((country) => (
|
||||||
|
<MenuItem key={country.code} value={country.code}>
|
||||||
|
{country.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<div className="w-full flex flex-col gap-4">
|
||||||
|
<label className="text-sm text-gray-600">Герб города</label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => setIsSelectMediaOpen(true)}
|
||||||
|
startIcon={<ImagePlus size={20} />}
|
||||||
|
>
|
||||||
|
Выбрать герб
|
||||||
|
</Button>
|
||||||
|
{selectedMedia && (
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{selectedMedia.media_name || selectedMedia.filename}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedMedia && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "200px",
|
||||||
|
height: "200px",
|
||||||
|
border: "1px solid #e0e0e0",
|
||||||
|
borderRadius: "8px",
|
||||||
|
overflow: "hidden",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MediaViewer
|
||||||
|
media={{
|
||||||
|
id: selectedMedia.id,
|
||||||
|
media_type: selectedMedia.media_type,
|
||||||
|
filename: selectedMedia.filename,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
className="w-min flex gap-2 items-center"
|
||||||
|
startIcon={<Save size={20} />}
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={isLoading || !name || !countryCode}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 size={20} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Создать"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SelectMediaDialog
|
||||||
|
open={isSelectMediaOpen}
|
||||||
|
onClose={() => setIsSelectMediaOpen(false)}
|
||||||
|
onSelectMedia={handleMediaSelect}
|
||||||
|
mediaType={3} // Тип медиа для иконок
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
});
|
96
src/pages/City/CityListPage/index.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
|
import { cityStore, languageStore } from "@shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Eye, Trash2 } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||||
|
|
||||||
|
export const CityListPage = observer(() => {
|
||||||
|
const { cities, getCities, deleteCity } = cityStore;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [rowId, setRowId] = useState<number | null>(null);
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getCities();
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
|
const columns: GridColDef[] = [
|
||||||
|
{
|
||||||
|
field: "country",
|
||||||
|
headerName: "Страна",
|
||||||
|
width: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "name",
|
||||||
|
headerName: "Название",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "actions",
|
||||||
|
headerName: "Действия",
|
||||||
|
align: "center",
|
||||||
|
headerAlign: "center",
|
||||||
|
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
|
<button onClick={() => navigate(`/city/${params.row.id}`)}>
|
||||||
|
<Eye size={20} className="text-green-500" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
setRowId(params.row.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={20} className="text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = cities.map((city) => ({
|
||||||
|
id: city.id,
|
||||||
|
name: city.name,
|
||||||
|
country: city.country,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex justify-between items-center mb-10">
|
||||||
|
<h1 className="text-2xl">Города</h1>
|
||||||
|
<CreateButton label="Создать город" path="/city/create" />
|
||||||
|
</div>
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
hideFooterPagination
|
||||||
|
hideFooter
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeleteModal
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
onDelete={async () => {
|
||||||
|
if (rowId) {
|
||||||
|
deleteCity(rowId);
|
||||||
|
}
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setRowId(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setRowId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
76
src/pages/City/CityPreviewPage/index.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { Paper } from "@mui/material";
|
||||||
|
import { cityStore, mediaStore } from "@shared";
|
||||||
|
import { MediaViewer } from "@widgets";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
export const CityPreviewPage = observer(() => {
|
||||||
|
const { id } = useParams();
|
||||||
|
const { getCity, city } = cityStore;
|
||||||
|
const { oneMedia, getOneMedia } = mediaStore;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const cityResponse = await getCity(id as string);
|
||||||
|
await getOneMedia(cityResponse.arms as string);
|
||||||
|
})();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
{/* <div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => navigate(`/city/${id}/edit`)}
|
||||||
|
startIcon={<Pencil size={20} />}
|
||||||
|
>
|
||||||
|
Редактировать
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
onClick={() => navigate(`/city/${id}/edit`)}
|
||||||
|
startIcon={<Trash2 size={20} />}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-10 w-full">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-lg font-bold">Название</h1>
|
||||||
|
<p>{city?.name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-lg font-bold">Страна</h1>
|
||||||
|
<p>{city?.country}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-lg font-bold">Герб</h1>
|
||||||
|
<div className="w-[300px] h-[200px]">
|
||||||
|
<MediaViewer
|
||||||
|
media={{
|
||||||
|
id: oneMedia?.id as string,
|
||||||
|
media_type: oneMedia?.media_type as number,
|
||||||
|
filename: oneMedia?.filename,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
});
|
3
src/pages/City/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./CityListPage";
|
||||||
|
export * from "./CityPreviewPage";
|
||||||
|
export * from "./CityCreatePage";
|
75
src/pages/Country/CountryCreatePage/index.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { Button, Paper, TextField } from "@mui/material";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { ArrowLeft, Save } from "lucide-react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { countryStore } from "@shared";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { LanguageSwitcher } from "@widgets";
|
||||||
|
|
||||||
|
export const CountryCreatePage = observer(() => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
await countryStore.createCountry(code, name);
|
||||||
|
toast.success("Страна успешно создана");
|
||||||
|
navigate("/country");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Ошибка при создании страны");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => navigate("/country")}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Код страны"
|
||||||
|
value={code}
|
||||||
|
required
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Название"
|
||||||
|
value={name}
|
||||||
|
required
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
className="w-min flex gap-2 items-center"
|
||||||
|
startIcon={<Save size={20} />}
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={isLoading || !name || !code}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 size={20} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Создать"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
});
|
86
src/pages/Country/CountryListPage/index.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
|
import { countryStore, languageStore } from "@shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Eye, Trash2 } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||||
|
|
||||||
|
export const CountryListPage = observer(() => {
|
||||||
|
const { countries, getCountries } = countryStore;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getCountries();
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
|
const columns: GridColDef[] = [
|
||||||
|
{
|
||||||
|
field: "name",
|
||||||
|
headerName: "Название",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "actions",
|
||||||
|
headerName: "Действия",
|
||||||
|
align: "center",
|
||||||
|
headerAlign: "center",
|
||||||
|
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
|
<button onClick={() => navigate(`/country/${params.row.code}`)}>
|
||||||
|
<Eye size={20} className="text-green-500" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
setRowId(params.row.code);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={20} className="text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = countries.map((country) => ({
|
||||||
|
id: country.code,
|
||||||
|
code: country.code,
|
||||||
|
name: country.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex justify-between items-center mb-10">
|
||||||
|
<h1 className="text-2xl">Страны</h1>
|
||||||
|
<CreateButton label="Создать страну" path="/country/create" />
|
||||||
|
</div>
|
||||||
|
<DataGrid rows={rows} columns={columns} hideFooter />
|
||||||
|
</div>
|
||||||
|
<DeleteModal
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
onDelete={async () => {
|
||||||
|
if (rowId) {
|
||||||
|
await countryStore.deleteCountry(rowId);
|
||||||
|
getCountries(); // Refresh the list after deletion
|
||||||
|
}
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setRowId(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setRowId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
58
src/pages/Country/CountryPreviewPage/index.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { Paper } from "@mui/material";
|
||||||
|
import { countryStore } from "@shared";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
export const CountryPreviewPage = observer(() => {
|
||||||
|
const { id } = useParams();
|
||||||
|
const { getCountry, country } = countryStore;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
await getCountry(id as string);
|
||||||
|
})();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
{/* <div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => navigate(`/user/${id}/edit`)}
|
||||||
|
startIcon={<Pencil size={20} />}
|
||||||
|
>
|
||||||
|
Редактировать
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
onClick={() => navigate(`/user/${id}/delete`)}
|
||||||
|
startIcon={<Trash2 size={20} />}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
{country && (
|
||||||
|
<div className="flex flex-col gap-10 w-full">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-lg font-bold">Название</h1>
|
||||||
|
<p>{country?.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
});
|
3
src/pages/Country/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./CountryListPage";
|
||||||
|
export * from "./CountryPreviewPage";
|
||||||
|
export * from "./CountryCreatePage";
|
93
src/pages/CreateSightPage/index.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { Box, Tab, Tabs } from "@mui/material";
|
||||||
|
import {
|
||||||
|
articlesStore,
|
||||||
|
cityStore,
|
||||||
|
createSightStore,
|
||||||
|
languageStore,
|
||||||
|
} from "@shared";
|
||||||
|
import {
|
||||||
|
CreateInformationTab,
|
||||||
|
CreateLeftTab,
|
||||||
|
CreateRightTab,
|
||||||
|
LeaveAgree,
|
||||||
|
} from "@widgets";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
|
function a11yProps(index: number) {
|
||||||
|
return {
|
||||||
|
id: `sight-tab-${index}`,
|
||||||
|
"aria-controls": `sight-tabpanel-${index}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
import { useBlocker } from "react-router";
|
||||||
|
|
||||||
|
export const CreateSightPage = observer(() => {
|
||||||
|
const [value, setValue] = useState(0);
|
||||||
|
const { getCities } = cityStore;
|
||||||
|
const { getArticles } = articlesStore;
|
||||||
|
const { needLeaveAgree } = createSightStore;
|
||||||
|
|
||||||
|
const handleChange = (_: React.SyntheticEvent, newValue: number) => {
|
||||||
|
setValue(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
let blocker = useBlocker(
|
||||||
|
({ currentLocation, nextLocation }) =>
|
||||||
|
needLeaveAgree && currentLocation.pathname !== nextLocation.pathname
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
await getCities();
|
||||||
|
await getArticles(languageStore.language);
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
minHeight: "100vh",
|
||||||
|
z: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
aria-label="sight tabs"
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
"& .MuiTabs-flexContainer": {
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab sx={{ flex: 1 }} label="Общая информация" {...a11yProps(0)} />
|
||||||
|
<Tab sx={{ flex: 1 }} label="Левый виджет" {...a11yProps(1)} />
|
||||||
|
<Tab sx={{ flex: 1 }} label="Правый виджет" {...a11yProps(2)} />
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<CreateInformationTab value={value} index={0} />
|
||||||
|
<CreateLeftTab value={value} index={1} />
|
||||||
|
<CreateRightTab value={value} index={2} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{blocker.state === "blocked" ? <LeaveAgree blocker={blocker} /> : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
9
src/pages/DevicesPage/index.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { DevicesTable } from "@widgets";
|
||||||
|
|
||||||
|
export const DevicesPage = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DevicesTable />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
94
src/pages/EditSightPage/index.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { Box, Tab, Tabs } from "@mui/material";
|
||||||
|
import { InformationTab, LeaveAgree, RightWidgetTab } from "@widgets";
|
||||||
|
import { LeftWidgetTab } from "@widgets";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import {
|
||||||
|
articlesStore,
|
||||||
|
cityStore,
|
||||||
|
editSightStore,
|
||||||
|
languageStore,
|
||||||
|
} from "@shared";
|
||||||
|
import { useBlocker, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
function a11yProps(index: number) {
|
||||||
|
return {
|
||||||
|
id: `sight-tab-${index}`,
|
||||||
|
"aria-controls": `sight-tabpanel-${index}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditSightPage = observer(() => {
|
||||||
|
const [value, setValue] = useState(0);
|
||||||
|
const { sight, getSightInfo, needLeaveAgree } = editSightStore;
|
||||||
|
const { getArticles } = articlesStore;
|
||||||
|
const { language } = languageStore;
|
||||||
|
const { id } = useParams();
|
||||||
|
const { getCities } = cityStore;
|
||||||
|
|
||||||
|
let blocker = useBlocker(
|
||||||
|
({ currentLocation, nextLocation }) =>
|
||||||
|
needLeaveAgree && currentLocation.pathname !== nextLocation.pathname
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = (_: React.SyntheticEvent, newValue: number) => {
|
||||||
|
setValue(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
if (id) {
|
||||||
|
await getSightInfo(+id, language);
|
||||||
|
await getArticles(language);
|
||||||
|
await getCities();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, [id, language]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
minHeight: "100vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
aria-label="sight tabs"
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
"& .MuiTabs-flexContainer": {
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab sx={{ flex: 1 }} label="Общая информация" {...a11yProps(0)} />
|
||||||
|
<Tab sx={{ flex: 1 }} label="Левый виджет" {...a11yProps(1)} />
|
||||||
|
<Tab sx={{ flex: 1 }} label="Правый виджет" {...a11yProps(2)} />
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{sight.common.id !== 0 && (
|
||||||
|
<div className="flex-1">
|
||||||
|
<InformationTab value={value} index={0} />
|
||||||
|
<LeftWidgetTab value={value} index={1} />
|
||||||
|
<RightWidgetTab value={value} index={2} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{blocker.state === "blocked" ? <LeaveAgree blocker={blocker} /> : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
119
src/pages/LoginPage/index.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import {
|
||||||
|
TextField,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { authStore } from "@shared";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const LoginPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const { login } = authStore;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
navigate("/sight");
|
||||||
|
toast.success("Вход в систему выполнен успешно");
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Ошибка при входе в систему"
|
||||||
|
);
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error ? err.message : "Ошибка при входе в систему"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minHeight: "100vh",
|
||||||
|
gap: 3,
|
||||||
|
p: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
|
Вход в систему
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 2,
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "400px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<TextField
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
error={!!error}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Пароль"
|
||||||
|
type="password"
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
error={!!error}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: "50px",
|
||||||
|
position: "relative",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? <CircularProgress size={24} sx={{}} /> : "Войти"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
37
src/pages/MainPage/index.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
|
||||||
|
export const MainPage: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Typography sx={{ marginBottom: 2 }}>
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
|
||||||
|
tempor incididunt ut labore et dolore magna aliqua. Rhoncus dolor purus
|
||||||
|
non enim praesent elementum facilisis leo vel. Risus at ultrices mi
|
||||||
|
tempus imperdiet. Semper risus in hendrerit gravida rutrum quisque non
|
||||||
|
tellus. Convallis convallis tellus id interdum velit laoreet id donec
|
||||||
|
ultrices. Odio morbi quis commodo odio aenean sed adipiscing. Amet nisl
|
||||||
|
suscipit adipiscing bibendum est ultricies integer quis. Cursus euismod
|
||||||
|
quis viverra nibh cras. Metus vulputate eu scelerisque felis imperdiet
|
||||||
|
proin fermentum leo. Mauris commodo quis imperdiet massa tincidunt. Cras
|
||||||
|
tincidunt lobortis feugiat vivamus at augue. At augue eget arcu dictum
|
||||||
|
varius duis at consectetur lorem. Velit sed ullamcorper morbi tincidunt.
|
||||||
|
Lorem donec massa sapien faucibus et molestie ac.
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ marginBottom: 2 }}>
|
||||||
|
Consequat mauris nunc congue nisi vitae suscipit. Fringilla est
|
||||||
|
ullamcorper eget nulla facilisi etiam dignissim diam. Pulvinar elementum
|
||||||
|
integer enim neque volutpat ac tincidunt. Ornare suspendisse sed nisi
|
||||||
|
lacus sed viverra tellus. Purus sit amet volutpat consequat mauris.
|
||||||
|
Elementum eu facilisis sed odio morbi. Euismod lacinia at quis risus sed
|
||||||
|
vulputate odio. Morbi tincidunt ornare massa eget egestas purus viverra
|
||||||
|
accumsan in. In hendrerit gravida rutrum quisque non tellus orci ac.
|
||||||
|
Pellentesque nec nam aliquam sem et tortor. Habitant morbi tristique
|
||||||
|
senectus et. Adipiscing elit duis tristique sollicitudin nibh sit.
|
||||||
|
Ornare aenean euismod elementum nisi quis eleifend. Commodo viverra
|
||||||
|
maecenas accumsan lacus vel facilisis. Nulla posuere sollicitudin
|
||||||
|
aliquam ultrices sagittis orci a.
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
1594
src/pages/MapPage/index.tsx
Normal file
89
src/pages/Media/MediaCreatePage/index.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { mediaStore, MEDIA_TYPE_LABELS } from "@shared";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const MediaCreatePage = observer(() => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [type, setType] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
await mediaStore.createMedia(name, type);
|
||||||
|
toast.success("Медиа успешно создано");
|
||||||
|
navigate("/media");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Ошибка при создании медиа");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold">Создание медиа</h1>
|
||||||
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
|
<TextField
|
||||||
|
className="w-full"
|
||||||
|
label="Название"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Тип</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={type}
|
||||||
|
label="Тип"
|
||||||
|
onChange={(e) => setType(e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
{Object.entries(MEDIA_TYPE_LABELS).map(([value, label]) => (
|
||||||
|
<MenuItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
className="w-min flex gap-2 items-center"
|
||||||
|
startIcon={<Save size={20} />}
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={isLoading || !name || !type}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 size={20} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Создать"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
});
|
295
src/pages/Media/MediaEditPage/index.tsx
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
Alert,
|
||||||
|
Snackbar,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { Save, ArrowLeft } from "lucide-react";
|
||||||
|
import { authInstance, mediaStore, MEDIA_TYPE_LABELS } from "@shared";
|
||||||
|
import { MediaViewer } from "@widgets";
|
||||||
|
|
||||||
|
export const MediaEditPage = observer(() => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [newFile, setNewFile] = useState<File | null>(null);
|
||||||
|
const [uploadDialogOpen, setUploadDialogOpen] = useState(false); // State for the upload dialog
|
||||||
|
|
||||||
|
const media = id ? mediaStore.media.find((m) => m.id === id) : null;
|
||||||
|
const [mediaName, setMediaName] = useState(media?.media_name ?? "");
|
||||||
|
const [mediaFilename, setMediaFilename] = useState(media?.filename ?? "");
|
||||||
|
const [mediaType, setMediaType] = useState(media?.media_type ?? 1);
|
||||||
|
const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
mediaStore.getOneMedia(id);
|
||||||
|
}
|
||||||
|
console.log(newFile);
|
||||||
|
console.log(uploadDialogOpen);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (media) {
|
||||||
|
setMediaName(media.media_name);
|
||||||
|
setMediaFilename(media.filename);
|
||||||
|
setMediaType(media.media_type);
|
||||||
|
|
||||||
|
// Set available media types based on current file extension
|
||||||
|
const extension = media.filename.split(".").pop()?.toLowerCase();
|
||||||
|
if (extension) {
|
||||||
|
if (["glb", "gltf"].includes(extension)) {
|
||||||
|
setAvailableMediaTypes([6]); // 3D model
|
||||||
|
} else if (["jpg", "jpeg", "png", "gif"].includes(extension)) {
|
||||||
|
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
|
||||||
|
} else if (["mp4", "webm", "mov"].includes(extension)) {
|
||||||
|
setAvailableMediaTypes([2]); // Video
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [media]);
|
||||||
|
|
||||||
|
// const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
// e.preventDefault();
|
||||||
|
// e.stopPropagation();
|
||||||
|
// setIsDragging(false);
|
||||||
|
|
||||||
|
// const files = Array.from(e.dataTransfer.files);
|
||||||
|
// if (files.length > 0) {
|
||||||
|
// setNewFile(files[0]);
|
||||||
|
// setMediaFilename(files[0].name);
|
||||||
|
// setUploadDialogOpen(true); // Open dialog on file drop
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
// e.preventDefault();
|
||||||
|
// setIsDragging(true);
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const handleDragLeave = () => {
|
||||||
|
// setIsDragging(false);
|
||||||
|
// };
|
||||||
|
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
const file = files[0];
|
||||||
|
setNewFile(file);
|
||||||
|
setMediaFilename(file.name);
|
||||||
|
|
||||||
|
// Determine media type based on file extension
|
||||||
|
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||||
|
if (extension) {
|
||||||
|
if (["glb", "gltf"].includes(extension)) {
|
||||||
|
setAvailableMediaTypes([6]); // 3D model
|
||||||
|
setMediaType(6);
|
||||||
|
} else if (["jpg", "jpeg", "png", "gif"].includes(extension)) {
|
||||||
|
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
|
||||||
|
setMediaType(1); // Default to Photo
|
||||||
|
} else if (["mp4", "webm", "mov"].includes(extension)) {
|
||||||
|
setAvailableMediaTypes([2]); // Video
|
||||||
|
setMediaType(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadDialogOpen(true); // Open dialog on file selection
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authInstance.patch(`/media/${id}`, {
|
||||||
|
media_name: mediaName,
|
||||||
|
filename: mediaFilename,
|
||||||
|
type: mediaType,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If a new file was selected, the actual file upload will happen
|
||||||
|
// via the UploadMediaDialog. We just need to make sure the metadata
|
||||||
|
// is updated correctly before or after.
|
||||||
|
// Since the dialog handles the actual upload, we don't call updateMediaFile here.
|
||||||
|
|
||||||
|
setSuccess(true);
|
||||||
|
handleUploadSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to save media");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadSuccess = () => {
|
||||||
|
// After successful upload in the dialog, refresh media data if needed
|
||||||
|
if (id) {
|
||||||
|
mediaStore.getOneMedia(id);
|
||||||
|
}
|
||||||
|
setNewFile(null); // Clear the new file state after successful upload
|
||||||
|
setUploadDialogOpen(false);
|
||||||
|
setSuccess(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!media && id) {
|
||||||
|
// Only show loading if an ID is present and media is not yet loaded
|
||||||
|
return (
|
||||||
|
<Box className="flex justify-center items-center h-screen">
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="p-6 max-w-4xl mx-auto">
|
||||||
|
<Box className="flex items-center gap-4 mb-6">
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<ArrowLeft size={20} />}
|
||||||
|
onClick={() => navigate("/media")}
|
||||||
|
>
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
<Typography variant="h5">Редактирование медиа</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Paper className="p-6">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
accept="image/*,video/*,.glb,.gltf"
|
||||||
|
/>
|
||||||
|
<Box className="flex flex-col gap-6">
|
||||||
|
<Box className="flex gap-4">
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
value={mediaName}
|
||||||
|
onChange={(e) => setMediaName(e.target.value)}
|
||||||
|
label="Название медиа"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
value={mediaFilename}
|
||||||
|
onChange={(e) => setMediaFilename(e.target.value)}
|
||||||
|
label="Название файла"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<FormControl fullWidth sx={{ width: "50%" }}>
|
||||||
|
<InputLabel>Тип медиа</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={mediaType}
|
||||||
|
label="Тип медиа"
|
||||||
|
onChange={(e) => setMediaType(Number(e.target.value))}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{availableMediaTypes.length > 0
|
||||||
|
? availableMediaTypes.map((type) => (
|
||||||
|
<MenuItem key={type} value={type}>
|
||||||
|
{
|
||||||
|
MEDIA_TYPE_LABELS[
|
||||||
|
type as keyof typeof MEDIA_TYPE_LABELS
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</MenuItem>
|
||||||
|
))
|
||||||
|
: Object.entries(MEDIA_TYPE_LABELS).map(([type, label]) => (
|
||||||
|
<MenuItem key={type} value={Number(type)}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Box className="flex gap-6">
|
||||||
|
<Paper
|
||||||
|
elevation={2}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
p: 2,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minHeight: 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MediaViewer
|
||||||
|
media={{
|
||||||
|
id: id || "",
|
||||||
|
media_type: mediaType,
|
||||||
|
filename: mediaFilename,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Box className="flex flex-col gap-4 self-end">
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
startIcon={<Save size={20} />}
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isLoading || (!mediaName && !mediaFilename)}
|
||||||
|
>
|
||||||
|
{isLoading ? <CircularProgress size={20} /> : "Сохранить"}
|
||||||
|
</Button>
|
||||||
|
{/* Only show "Replace file" button if no new file is currently selected */}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Typography color="error" className="mt-2">
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<Typography color="success.main" className="mt-2">
|
||||||
|
Медиа успешно сохранено
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
open={!!error}
|
||||||
|
autoHideDuration={6000}
|
||||||
|
onClose={() => setError(null)}
|
||||||
|
>
|
||||||
|
<Alert severity="error" onClose={() => setError(null)}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
open={success}
|
||||||
|
autoHideDuration={2000}
|
||||||
|
onClose={() => setSuccess(false)}
|
||||||
|
>
|
||||||
|
<Alert severity="success" onClose={() => setSuccess(false)}>
|
||||||
|
Медиа успешно сохранено
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
110
src/pages/Media/MediaListPage/index.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
|
import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Eye, Trash2 } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||||
|
|
||||||
|
export const MediaListPage = observer(() => {
|
||||||
|
const { media, getMedia, deleteMedia } = mediaStore;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getMedia();
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
|
const columns: GridColDef[] = [
|
||||||
|
{
|
||||||
|
field: "media_name",
|
||||||
|
headerName: "Название",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "media_type",
|
||||||
|
headerName: "Тип",
|
||||||
|
width: 200,
|
||||||
|
flex: 1,
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
{
|
||||||
|
MEDIA_TYPE_LABELS[
|
||||||
|
params.row.media_type as keyof typeof MEDIA_TYPE_LABELS
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "actions",
|
||||||
|
headerName: "Действия",
|
||||||
|
flex: 1,
|
||||||
|
align: "center",
|
||||||
|
headerAlign: "center",
|
||||||
|
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
|
<button onClick={() => navigate(`/media/${params.row.id}`)}>
|
||||||
|
<Eye size={20} className="text-green-500" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
setRowId(params.row.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={20} className="text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = media.map((media) => ({
|
||||||
|
id: media.id,
|
||||||
|
media_name: media.media_name,
|
||||||
|
media_type: media.media_type,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex justify-between items-center mb-10">
|
||||||
|
<h1 className="text-2xl">Медиа</h1>
|
||||||
|
<CreateButton label="Создать медиа" path="/media/create" />
|
||||||
|
</div>
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
hideFooterPagination
|
||||||
|
hideFooter
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeleteModal
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
onDelete={async () => {
|
||||||
|
if (rowId) {
|
||||||
|
deleteMedia(rowId.toString());
|
||||||
|
getMedia();
|
||||||
|
}
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setRowId(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setRowId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
44
src/pages/Media/MediaPreviewPage/index.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { mediaStore } from "@shared";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { MediaViewer } from "@widgets";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Download } from "lucide-react";
|
||||||
|
import { Button } from "@mui/material";
|
||||||
|
|
||||||
|
export const MediaPreviewPage = observer(() => {
|
||||||
|
const { id } = useParams();
|
||||||
|
const { oneMedia, getOneMedia } = mediaStore;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getOneMedia(id!);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-[80vh] flex flex-col justify-center items-center gap-4">
|
||||||
|
<div className="w-full h-full flex justify-center items-center">
|
||||||
|
<MediaViewer className="w-full h-full" media={oneMedia!} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{oneMedia && (
|
||||||
|
<div className="flex flex-col items-center gap-4 bg-[#998879] p-4 rounded-md">
|
||||||
|
<p className="text-white text-center">
|
||||||
|
Чтобы скачать файл, нажмите на кнопку ниже
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
startIcon={<Download size={16} />}
|
||||||
|
component="a"
|
||||||
|
href={`${
|
||||||
|
import.meta.env.VITE_KRBL_MEDIA
|
||||||
|
}${id}/download?token=${localStorage.getItem("token")}`}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Скачать
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
3
src/pages/Media/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./MediaEditPage";
|
||||||
|
export * from "./MediaListPage";
|
||||||
|
export * from "./MediaPreviewPage";
|
86
src/pages/Route/RouteCreatePage/index.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
} from "@mui/material";
|
||||||
|
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const RouteCreatePage = observer(() => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [routeNumber, setRouteNumber] = useState("");
|
||||||
|
const [direction, setDirection] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold">Создание маршрута</h1>
|
||||||
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
|
<TextField
|
||||||
|
className="w-full"
|
||||||
|
label="Номер маршрута"
|
||||||
|
required
|
||||||
|
value={routeNumber}
|
||||||
|
onChange={(e) => setRouteNumber(e.target.value)}
|
||||||
|
/>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Направление</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={direction}
|
||||||
|
label="Направление"
|
||||||
|
onChange={(e) => setDirection(e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<MenuItem value="forward">Прямое</MenuItem>
|
||||||
|
<MenuItem value="backward">Обратное</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
className="w-min flex gap-2 items-center"
|
||||||
|
startIcon={<Save size={20} />}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
// await createRoute(routeNumber, direction === "forward");
|
||||||
|
setIsLoading(false);
|
||||||
|
toast.success("Маршрут успешно создан");
|
||||||
|
navigate(-1);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Произошла ошибка");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 size={20} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Сохранить"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
});
|
115
src/pages/Route/RouteListPage/index.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
|
import { languageStore, routeStore } from "@shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Eye, Trash2 } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||||
|
|
||||||
|
export const RouteListPage = observer(() => {
|
||||||
|
const { routes, getRoutes, deleteRoute } = routeStore;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [rowId, setRowId] = useState<number | null>(null); // Lifted state
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getRoutes();
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
|
const columns: GridColDef[] = [
|
||||||
|
{
|
||||||
|
field: "carrier",
|
||||||
|
headerName: "Перевозчик",
|
||||||
|
width: 250,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "route_number",
|
||||||
|
headerName: "Номер маршрута",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "route_direction",
|
||||||
|
headerName: "Направление",
|
||||||
|
flex: 1,
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={
|
||||||
|
params.row.route_direction === "Прямой"
|
||||||
|
? "text-green-500"
|
||||||
|
: "text-red-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{params.row.route_direction}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "actions",
|
||||||
|
headerName: "Действия",
|
||||||
|
width: 140,
|
||||||
|
align: "center",
|
||||||
|
headerAlign: "center",
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
|
<button onClick={() => navigate(`/route/${params.row.id}`)}>
|
||||||
|
<Eye size={20} className="text-green-500" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
setRowId(params.row.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={20} className="text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = routes.map((route) => ({
|
||||||
|
id: route.id,
|
||||||
|
carrier: route.carrier,
|
||||||
|
route_number: route.route_number,
|
||||||
|
route_direction: route.route_direction ? "Прямой" : "Обратный",
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
|
||||||
|
<div style={{ width: "100%" }}>
|
||||||
|
<div className="flex justify-between items-center mb-10">
|
||||||
|
<h1 className="text-2xl">Маршруты</h1>
|
||||||
|
<CreateButton label="Создать маршрут" path="/route/create" />
|
||||||
|
</div>
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
hideFooterPagination
|
||||||
|
hideFooter
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeleteModal
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
onDelete={async () => {
|
||||||
|
if (rowId) {
|
||||||
|
deleteRoute(rowId);
|
||||||
|
}
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setRowId(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setRowId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
1
src/pages/Route/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./RouteListPage";
|
103
src/pages/Sight/SightListPage/index.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
|
import { languageStore, sightsStore } from "@shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||||
|
|
||||||
|
export const SightListPage = observer(() => {
|
||||||
|
const { sights, getSights, deleteListSight } = sightsStore;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getSights();
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
|
const columns: GridColDef[] = [
|
||||||
|
{
|
||||||
|
field: "name",
|
||||||
|
headerName: "Имя",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "city",
|
||||||
|
headerName: "Город",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
field: "actions",
|
||||||
|
headerName: "Действия",
|
||||||
|
align: "center",
|
||||||
|
headerAlign: "center",
|
||||||
|
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
|
<button onClick={() => navigate(`/sight/${params.row.id}`)}>
|
||||||
|
<Pencil size={20} className="text-blue-500" />
|
||||||
|
</button>
|
||||||
|
{/* <button onClick={() => navigate(`/sight/${params.row.id}`)}>
|
||||||
|
<Eye size={20} className="text-green-500" />
|
||||||
|
</button> */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
setRowId(params.row.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={20} className="text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = sights.map((sight) => ({
|
||||||
|
id: sight.id,
|
||||||
|
name: sight.name,
|
||||||
|
city: sight.city,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex justify-between items-center mb-10">
|
||||||
|
<h1 className="text-2xl">Достопримечательности</h1>
|
||||||
|
<CreateButton
|
||||||
|
label="Создать достопримечательность"
|
||||||
|
path="/sight/create"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
hideFooterPagination
|
||||||
|
hideFooter
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeleteModal
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
onDelete={async () => {
|
||||||
|
if (rowId) {
|
||||||
|
await deleteListSight(Number(rowId));
|
||||||
|
}
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setRowId(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setRowId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
1
src/pages/Sight/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./SightListPage";
|
9
src/pages/SightPage/index.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { SightsTable } from "@widgets";
|
||||||
|
|
||||||
|
export const SightPage = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SightsTable />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
69
src/pages/Snapshot/SnapshotCreatePage/index.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { Button, Paper, TextField } from "@mui/material";
|
||||||
|
import { snapshotStore } from "@shared";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
export const SnapshotCreatePage = observer(() => {
|
||||||
|
const { id } = useParams();
|
||||||
|
const { getSnapshot, createSnapshot } = snapshotStore;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
await getSnapshot(id as string);
|
||||||
|
})();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold">Создание снапшота</h1>
|
||||||
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
|
<TextField
|
||||||
|
className="w-full"
|
||||||
|
label="Название"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
className="w-min flex gap-2 items-center"
|
||||||
|
startIcon={<Save size={20} />}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
await createSnapshot(name);
|
||||||
|
setIsLoading(false);
|
||||||
|
toast.success("Снапшот успешно создан");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 size={20} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Сохранить"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
});
|
125
src/pages/Snapshot/SnapshotListPage/index.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
|
import { languageStore, snapshotStore } from "@shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { DatabaseBackup, Eye, Trash2 } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
CreateButton,
|
||||||
|
DeleteModal,
|
||||||
|
LanguageSwitcher,
|
||||||
|
SnapshotRestore,
|
||||||
|
} from "@widgets";
|
||||||
|
|
||||||
|
export const SnapshotListPage = observer(() => {
|
||||||
|
const { snapshots, getSnapshots, deleteSnapshot, restoreSnapshot } =
|
||||||
|
snapshotStore;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
|
||||||
|
const { language } = languageStore;
|
||||||
|
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getSnapshots();
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
|
const columns: GridColDef[] = [
|
||||||
|
{
|
||||||
|
field: "name",
|
||||||
|
headerName: "Название",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "parent",
|
||||||
|
headerName: "Родитель",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
field: "actions",
|
||||||
|
headerName: "Действия",
|
||||||
|
width: 300,
|
||||||
|
headerAlign: "center",
|
||||||
|
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsRestoreModalOpen(true);
|
||||||
|
setRowId(params.row.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DatabaseBackup size={20} className="text-blue-500" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
setRowId(params.row.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={20} className="text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = snapshots.map((snapshot) => ({
|
||||||
|
id: snapshot.ID,
|
||||||
|
name: snapshot.Name,
|
||||||
|
parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
|
||||||
|
<div style={{ width: "100%" }}>
|
||||||
|
<div className="flex justify-between items-center mb-10">
|
||||||
|
<h1 className="text-2xl ">Снапшоты</h1>
|
||||||
|
<CreateButton label="Создать снапшот" path="/snapshot/create" />
|
||||||
|
</div>
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
hideFooterPagination
|
||||||
|
hideFooter
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeleteModal
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
onDelete={async () => {
|
||||||
|
if (rowId) {
|
||||||
|
await deleteSnapshot(rowId);
|
||||||
|
}
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setRowId(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setRowId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SnapshotRestore
|
||||||
|
open={isRestoreModalOpen}
|
||||||
|
onDelete={async () => {
|
||||||
|
if (rowId) {
|
||||||
|
await restoreSnapshot(rowId);
|
||||||
|
}
|
||||||
|
setIsRestoreModalOpen(false);
|
||||||
|
setRowId(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsRestoreModalOpen(false);
|
||||||
|
setRowId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
3
src/pages/Snapshot/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./SnapshotListPage";
|
||||||
|
|
||||||
|
export * from "./SnapshotCreatePage";
|
94
src/pages/Station/StationCreatePage/index.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { stationsStore } from "@shared";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export const StationCreatePage = observer(() => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [systemName, setSystemName] = useState("");
|
||||||
|
const [direction, setDirection] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
await stationsStore.createStation(name, systemName, direction);
|
||||||
|
toast.success("Станция успешно создана");
|
||||||
|
navigate("/station");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Ошибка при создании станции");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold">Создание станции</h1>
|
||||||
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
|
<TextField
|
||||||
|
className="w-full"
|
||||||
|
label="Название"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
className="w-full"
|
||||||
|
label="Системное название"
|
||||||
|
required
|
||||||
|
value={systemName}
|
||||||
|
onChange={(e) => setSystemName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Направление</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={direction}
|
||||||
|
label="Направление"
|
||||||
|
onChange={(e) => setDirection(e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<MenuItem value="forward">Прямое</MenuItem>
|
||||||
|
<MenuItem value="backward">Обратное</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
className="w-min flex gap-2 items-center"
|
||||||
|
startIcon={<Save size={20} />}
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={isLoading || !name || !systemName || !direction}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 size={20} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Создать"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
});
|
117
src/pages/Station/StationListPage/index.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
|
import { languageStore, stationsStore } from "@shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Eye, Trash2 } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||||
|
|
||||||
|
export const StationListPage = observer(() => {
|
||||||
|
const { stations, getStations, deleteStation } = stationsStore;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [rowId, setRowId] = useState<number | null>(null); // Lifted state
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getStations();
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
|
const columns: GridColDef[] = [
|
||||||
|
{
|
||||||
|
field: "name",
|
||||||
|
headerName: "Название",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "system_name",
|
||||||
|
headerName: "Системное название",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "direction",
|
||||||
|
headerName: "Направление",
|
||||||
|
width: 200,
|
||||||
|
align: "center",
|
||||||
|
headerAlign: "center",
|
||||||
|
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={
|
||||||
|
params.row.direction === true ? "text-green-500" : "text-red-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{params.row.direction ? "Прямой" : "Обратный"}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "actions",
|
||||||
|
headerName: "Действия",
|
||||||
|
width: 140,
|
||||||
|
align: "center",
|
||||||
|
headerAlign: "center",
|
||||||
|
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
|
<button onClick={() => navigate(`/station/${params.row.id}`)}>
|
||||||
|
<Eye size={20} className="text-green-500" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
setRowId(params.row.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={20} className="text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = stations.map((station) => ({
|
||||||
|
id: station.id,
|
||||||
|
name: station.name,
|
||||||
|
system_name: station.system_name,
|
||||||
|
direction: station.direction,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
|
||||||
|
<div style={{ width: "100%" }}>
|
||||||
|
<div className="flex justify-between items-center mb-10">
|
||||||
|
<h1 className="text-2xl">Станции</h1>
|
||||||
|
<CreateButton label="Создать станцию" path="/station/create" />
|
||||||
|
</div>
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
hideFooterPagination
|
||||||
|
hideFooter
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeleteModal
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
onDelete={async () => {
|
||||||
|
if (rowId) {
|
||||||
|
deleteStation(rowId);
|
||||||
|
}
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setRowId(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setRowId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
1
src/pages/Station/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./StationListPage";
|
109
src/pages/User/UserListPage/index.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
|
import { languageStore, userStore } from "@shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Eye, Trash2 } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { DeleteModal, LanguageSwitcher } from "@widgets";
|
||||||
|
|
||||||
|
export const UserListPage = observer(() => {
|
||||||
|
const { users, getUsers, deleteUser } = userStore;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [rowId, setRowId] = useState<number | null>(null); // Lifted state
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getUsers();
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
|
const columns: GridColDef[] = [
|
||||||
|
{
|
||||||
|
field: "name",
|
||||||
|
headerName: "Имя",
|
||||||
|
width: 400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "email",
|
||||||
|
headerName: "Email",
|
||||||
|
width: 400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "is_admin",
|
||||||
|
headerName: "Роль",
|
||||||
|
flex: 1,
|
||||||
|
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={
|
||||||
|
params.row.is_admin === true ? "text-green-500" : "text-red-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{params.row.is_admin ? "Администратор" : "Пользователь"}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
field: "actions",
|
||||||
|
headerName: "Действия",
|
||||||
|
flex: 1,
|
||||||
|
align: "center",
|
||||||
|
headerAlign: "center",
|
||||||
|
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
setRowId(params.row.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={20} className="text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = users.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
is_admin: user.is_admin,
|
||||||
|
name: user.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
|
||||||
|
<div style={{ width: "100%" }}>
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
hideFooterPagination
|
||||||
|
hideFooter
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeleteModal
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
onDelete={async () => {
|
||||||
|
if (rowId) {
|
||||||
|
await deleteUser(rowId);
|
||||||
|
}
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setRowId(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setRowId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
1
src/pages/User/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./UserListPage";
|
117
src/pages/Vehicle/VehicleCreatePage/index.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { vehicleStore, VEHICLE_TYPES, carrierStore } from "@shared";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { ArrowLeft, Save } from "lucide-react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { LanguageSwitcher } from "@widgets";
|
||||||
|
|
||||||
|
export const VehicleCreatePage = observer(() => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [tailNumber, setTailNumber] = useState("");
|
||||||
|
const [type, setType] = useState("");
|
||||||
|
const [carrierId, setCarrierId] = useState<number | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
carrierStore.getCarriers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
await vehicleStore.createVehicle(
|
||||||
|
Number(tailNumber),
|
||||||
|
type,
|
||||||
|
carrierStore.carriers.find((c) => c.id === carrierId)?.full_name!,
|
||||||
|
carrierId!
|
||||||
|
);
|
||||||
|
toast.success("Транспорт успешно создан");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Ошибка при создании транспорта");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => navigate("/vehicle")}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-10 w-full items-end">
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Бортовой номер"
|
||||||
|
value={tailNumber}
|
||||||
|
required
|
||||||
|
onChange={(e) => setTailNumber(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Тип</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={type}
|
||||||
|
label="Тип"
|
||||||
|
required
|
||||||
|
onChange={(e) => setType(e.target.value)}
|
||||||
|
>
|
||||||
|
{VEHICLE_TYPES.map((type) => (
|
||||||
|
<MenuItem key={type.value} value={type.value}>
|
||||||
|
{type.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Перевозчик</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={carrierId || ""}
|
||||||
|
label="Перевозчик"
|
||||||
|
required
|
||||||
|
onChange={(e) => setCarrierId(e.target.value as number)}
|
||||||
|
>
|
||||||
|
{carrierStore.carriers.map((carrier) => (
|
||||||
|
<MenuItem key={carrier.id} value={carrier.id}>
|
||||||
|
{carrier.full_name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
className="w-min flex gap-2 items-center"
|
||||||
|
startIcon={<Save size={20} />}
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={isLoading || !tailNumber || !type || !carrierId}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 size={20} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Создать"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
});
|
133
src/pages/Vehicle/VehicleListPage/index.tsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||||
|
import { carrierStore, languageStore, vehicleStore } from "@shared";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Eye, Trash2 } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
|
||||||
|
import { VEHICLE_TYPES } from "@shared";
|
||||||
|
|
||||||
|
export const VehicleListPage = observer(() => {
|
||||||
|
const { vehicles, getVehicles, deleteVehicle } = vehicleStore;
|
||||||
|
const { carriers, getCarriers } = carrierStore;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [rowId, setRowId] = useState<number | null>(null);
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getVehicles();
|
||||||
|
getCarriers();
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
|
const columns: GridColDef[] = [
|
||||||
|
{
|
||||||
|
field: "tail_number",
|
||||||
|
headerName: "Бортовой номер",
|
||||||
|
flex: 1,
|
||||||
|
align: "center",
|
||||||
|
headerAlign: "center",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "type",
|
||||||
|
headerName: "Тип",
|
||||||
|
flex: 1,
|
||||||
|
align: "center",
|
||||||
|
headerAlign: "center",
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
|
{VEHICLE_TYPES.find((type) => type.value === params.row.type)
|
||||||
|
?.label || params.row.type}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "carrier",
|
||||||
|
headerName: "Перевозчик",
|
||||||
|
flex: 1,
|
||||||
|
align: "center",
|
||||||
|
headerAlign: "center",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "city",
|
||||||
|
headerName: "Город",
|
||||||
|
flex: 1,
|
||||||
|
align: "center",
|
||||||
|
headerAlign: "center",
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
field: "actions",
|
||||||
|
headerName: "Действия",
|
||||||
|
flex: 1,
|
||||||
|
align: "center",
|
||||||
|
headerAlign: "center",
|
||||||
|
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full gap-7 justify-center items-center">
|
||||||
|
<button onClick={() => navigate(`/vehicle/${params.row.id}`)}>
|
||||||
|
<Eye size={20} className="text-green-500" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
setRowId(params.row.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={20} className="text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = vehicles.map((vehicle) => ({
|
||||||
|
id: vehicle.vehicle.id,
|
||||||
|
tail_number: vehicle.vehicle.tail_number,
|
||||||
|
type: vehicle.vehicle.type,
|
||||||
|
carrier: vehicle.vehicle.carrier,
|
||||||
|
city: carriers.find((carrier) => carrier.id === vehicle.vehicle.carrier_id)
|
||||||
|
?.city,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
|
||||||
|
<div style={{ width: "100%" }}>
|
||||||
|
<div className="flex justify-between items-center mb-10">
|
||||||
|
<h1 className="text-2xl">Транспортные средства</h1>
|
||||||
|
<CreateButton
|
||||||
|
label="Создать транспортное средство"
|
||||||
|
path="/vehicle/create"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
hideFooterPagination
|
||||||
|
hideFooter
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeleteModal
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
onDelete={async () => {
|
||||||
|
if (rowId) {
|
||||||
|
await deleteVehicle(rowId);
|
||||||
|
}
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setRowId(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setRowId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
70
src/pages/Vehicle/VehiclePreviewPage/index.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { Paper } from "@mui/material";
|
||||||
|
import { vehicleStore, VEHICLE_TYPES } from "@shared";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
export const VehiclePreviewPage = observer(() => {
|
||||||
|
const { id } = useParams();
|
||||||
|
const { getVehicle, vehicle } = vehicleStore;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
await getVehicle(Number(id));
|
||||||
|
})();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper className="w-full h-full p-3 flex flex-col gap-10">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
{/* <div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => navigate(`/vehicle/${id}/edit`)}
|
||||||
|
startIcon={<Pencil size={20} />}
|
||||||
|
>
|
||||||
|
Редактировать
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
onClick={() => navigate(`/vehicle/${id}/delete`)}
|
||||||
|
startIcon={<Trash2 size={20} />}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
{vehicle && (
|
||||||
|
<div className="flex flex-col gap-10 w-full">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-lg font-bold">Системный номер</h1>
|
||||||
|
<p>{vehicle?.vehicle.tail_number}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-lg font-bold">Тип транспортного средства</h1>
|
||||||
|
<p>
|
||||||
|
{VEHICLE_TYPES.find(
|
||||||
|
(type) => type.value === vehicle?.vehicle.type
|
||||||
|
)?.label || vehicle?.vehicle.type}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-lg font-bold">Перевозчик</h1>
|
||||||
|
<p>{vehicle?.vehicle.carrier}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
});
|
3
src/pages/Vehicle/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./VehicleListPage";
|
||||||
|
export * from "./VehiclePreviewPage";
|
||||||
|
export * from "./VehicleCreatePage";
|
@ -1,218 +0,0 @@
|
|||||||
import { Box, TextField, Typography, Paper } from "@mui/material";
|
|
||||||
import { Create } from "@refinedev/mui";
|
|
||||||
import { useForm } from "@refinedev/react-hook-form";
|
|
||||||
import { Controller, FieldValues } from "react-hook-form";
|
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import { MarkdownEditor } from "../../components/MarkdownEditor";
|
|
||||||
import "easymde/dist/easymde.min.css";
|
|
||||||
import { LanguageSelector } from "@ui";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
import { EVERY_LANGUAGE, Languages, languageStore, META_LANGUAGE } from "@stores";
|
|
||||||
import rehypeRaw from "rehype-raw";
|
|
||||||
|
|
||||||
const MemoizedSimpleMDE = React.memo(MarkdownEditor);
|
|
||||||
|
|
||||||
export const ArticleCreate = observer(() => {
|
|
||||||
const { language, setLanguageAction } = languageStore;
|
|
||||||
const [articleData, setArticleData] = useState({
|
|
||||||
heading: EVERY_LANGUAGE(""),
|
|
||||||
body: EVERY_LANGUAGE("")
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
saveButtonProps,
|
|
||||||
refineCore: { formLoading, onFinish },
|
|
||||||
register,
|
|
||||||
control,
|
|
||||||
watch,
|
|
||||||
formState: { errors },
|
|
||||||
setValue,
|
|
||||||
handleSubmit,
|
|
||||||
} = useForm({
|
|
||||||
refineCoreProps: {
|
|
||||||
resource: "article",
|
|
||||||
...META_LANGUAGE(language)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Следим за изменениями в полях body и 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(() => {
|
|
||||||
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(() => {
|
|
||||||
setPreview(bodyContent ?? "");
|
|
||||||
}, [bodyContent]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setHeadingPreview(headingContent ?? "");
|
|
||||||
}, [headingContent]);
|
|
||||||
|
|
||||||
const simpleMDEOptions = React.useMemo(() => ({
|
|
||||||
placeholder: "Введите контент в формате Markdown...",
|
|
||||||
spellChecker: false,
|
|
||||||
}), []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Create isLoading={formLoading} saveButtonProps={{
|
|
||||||
onClick: handleFormSubmit
|
|
||||||
}}>
|
|
||||||
<Box sx={{ display: "flex", flex: 1, gap: 2 }}>
|
|
||||||
{/* Форма создания */}
|
|
||||||
<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: "Это поле является обязательным",
|
|
||||||
})}
|
|
||||||
error={!!(errors as any)?.heading}
|
|
||||||
helperText={(errors as any)?.heading?.message}
|
|
||||||
margin="normal"
|
|
||||||
fullWidth
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Блок предпросмотра */}
|
|
||||||
<Paper
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
p: 2,
|
|
||||||
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",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="h6" gutterBottom color="primary">
|
|
||||||
Предпросмотр
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{/* Заголовок статьи */}
|
|
||||||
<Typography
|
|
||||||
variant="h4"
|
|
||||||
gutterBottom
|
|
||||||
sx={{
|
|
||||||
color: (theme) =>
|
|
||||||
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
|
|
||||||
mb: 3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{headingPreview}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{/* Markdown контент */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
"& img": {
|
|
||||||
maxWidth: "100%",
|
|
||||||
height: "auto",
|
|
||||||
borderRadius: 1,
|
|
||||||
},
|
|
||||||
"& h1, & h2, & h3, & h4, & h5, & h6": {
|
|
||||||
color: "primary.main",
|
|
||||||
mt: 2,
|
|
||||||
mb: 1,
|
|
||||||
},
|
|
||||||
"& p": {
|
|
||||||
mb: 2,
|
|
||||||
color: (theme) =>
|
|
||||||
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
|
|
||||||
},
|
|
||||||
"& a": {
|
|
||||||
color: "primary.main",
|
|
||||||
textDecoration: "none",
|
|
||||||
"&:hover": {
|
|
||||||
textDecoration: "underline",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"& blockquote": {
|
|
||||||
borderLeft: "4px solid",
|
|
||||||
borderColor: "primary.main",
|
|
||||||
pl: 2,
|
|
||||||
my: 2,
|
|
||||||
color: "text.secondary",
|
|
||||||
},
|
|
||||||
"& code": {
|
|
||||||
bgcolor: (theme) =>
|
|
||||||
theme.palette.mode === "dark" ? "grey.900" : "grey.100",
|
|
||||||
p: 0.5,
|
|
||||||
borderRadius: 0.5,
|
|
||||||
color: "primary.main",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ReactMarkdown rehypePlugins={[rehypeRaw]}>{preview}</ReactMarkdown>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
</Create>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,293 +0,0 @@
|
|||||||
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, 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);
|
|
||||||
|
|
||||||
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,
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
saveButtonProps,
|
|
||||||
refineCore: { onFinish },
|
|
||||||
register,
|
|
||||||
control,
|
|
||||||
handleSubmit,
|
|
||||||
watch,
|
|
||||||
formState: { errors },
|
|
||||||
setValue,
|
|
||||||
getValues,
|
|
||||||
} = useForm<{ heading: string; body: string }>({
|
|
||||||
refineCoreProps: META_LANGUAGE(language)
|
|
||||||
});
|
|
||||||
|
|
||||||
const bodyContent = watch("body");
|
|
||||||
const headingContent = watch("heading");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log(bodyContent)
|
|
||||||
}, [bodyContent])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log(articleData)
|
|
||||||
}, [articleData])
|
|
||||||
|
|
||||||
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,
|
|
||||||
onClick: handleFormSubmit
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: "flex", gap: 2 }}>
|
|
||||||
{/* Форма редактирования */}
|
|
||||||
<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: "Это поле является обязательным",
|
|
||||||
})}
|
|
||||||
error={!!errors?.heading}
|
|
||||||
helperText={errors?.heading?.message as string}
|
|
||||||
margin="normal"
|
|
||||||
fullWidth
|
|
||||||
slotProps={{inputLabel: {shrink: true}}}
|
|
||||||
type="text"
|
|
||||||
label="Заголовок *"
|
|
||||||
name="heading"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="body"
|
|
||||||
//rules={{ required: "Это поле является обязательным" }}
|
|
||||||
defaultValue=""
|
|
||||||
render={({ field: { onChange, value } }) => (
|
|
||||||
<MemoizedSimpleMDE
|
|
||||||
value={value} // markdown
|
|
||||||
onChange={onChange}
|
|
||||||
options={simpleMDEOptions}
|
|
||||||
className="my-markdown-editor"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{articleId && (
|
|
||||||
<LinkedItems<MediaItem>
|
|
||||||
type="edit"
|
|
||||||
parentId={articleId}
|
|
||||||
parentResource="article"
|
|
||||||
childResource="media"
|
|
||||||
fields={mediaFields}
|
|
||||||
title="медиа"
|
|
||||||
onUpdate={refetch}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Блок предпросмотра */}
|
|
||||||
<Paper
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
p: 2,
|
|
||||||
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",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="h6" gutterBottom color="primary">
|
|
||||||
Предпросмотр
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{/* Заголовок статьи */}
|
|
||||||
<Typography
|
|
||||||
variant="h4"
|
|
||||||
gutterBottom
|
|
||||||
sx={{
|
|
||||||
color: (theme) =>
|
|
||||||
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
|
|
||||||
mb: 3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{headingContent}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{/* Markdown контент */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
"& img": {
|
|
||||||
maxWidth: "100%",
|
|
||||||
height: "auto",
|
|
||||||
borderRadius: 1,
|
|
||||||
},
|
|
||||||
"& h1, & h2, & h3, & h4, & h5, & h6": {
|
|
||||||
color: "primary.main",
|
|
||||||
mt: 2,
|
|
||||||
mb: 1,
|
|
||||||
},
|
|
||||||
"& p": {
|
|
||||||
mb: 2,
|
|
||||||
color: (theme) =>
|
|
||||||
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
|
|
||||||
},
|
|
||||||
"& a": {
|
|
||||||
color: "primary.main",
|
|
||||||
textDecoration: "none",
|
|
||||||
"&:hover": {
|
|
||||||
textDecoration: "underline",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"& blockquote": {
|
|
||||||
borderLeft: "4px solid",
|
|
||||||
borderColor: "primary.main",
|
|
||||||
pl: 2,
|
|
||||||
my: 2,
|
|
||||||
color: "text.secondary",
|
|
||||||
},
|
|
||||||
"& code": {
|
|
||||||
bgcolor: (theme) =>
|
|
||||||
theme.palette.mode === "dark" ? "grey.900" : "grey.100",
|
|
||||||
p: 0.5,
|
|
||||||
borderRadius: 0.5,
|
|
||||||
color: "primary.main",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ReactMarkdown rehypePlugins={[rehypeRaw]}>{bodyContent}</ReactMarkdown>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Привязанные медиа */}
|
|
||||||
{mediaData?.data && mediaData.data.length > 0 && (
|
|
||||||
<Box sx={{ mb: 3 }}>
|
|
||||||
<Typography variant="subtitle1" gutterBottom color="primary">
|
|
||||||
Привязанные медиа:
|
|
||||||
</Typography>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
gap: 1,
|
|
||||||
flexWrap: "wrap",
|
|
||||||
mb: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{mediaData.data.map((media) => (
|
|
||||||
<Box
|
|
||||||
key={media.id}
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
width: "45%",
|
|
||||||
height: "45%",
|
|
||||||
aspectRatio: "1/1",
|
|
||||||
borderRadius: 1,
|
|
||||||
overflow: "hidden",
|
|
||||||
border: "1px solid",
|
|
||||||
borderColor: "primary.main",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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",
|
|
||||||
}}
|
|
||||||
/> */}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
</Edit>
|
|
||||||
);
|
|
||||||
});
|
|