Compare commits
No commits in common. "react" and "master" have entirely different histories.
4
.env
@ -1,2 +1,2 @@
|
|||||||
VITE_REACT_APP ='https://wn.krbl.ru/'
|
VITE_KRBL_MEDIA = "https://wn.krbl.ru/media/"
|
||||||
VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
|
VITE_KRBL_API = "https://wn.krbl.ru"
|
||||||
|
17
.eslintrc.cjs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
/* 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"
|
||||||
|
}
|
||||||
|
};
|
51
.gitea/workflows/publish.yaml
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
name: release-tag
|
||||||
|
|
||||||
|
on:
|
||||||
|
push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
env:
|
||||||
|
DOCKER_ORG: krbl
|
||||||
|
DOCKER_LATEST: nightly
|
||||||
|
RUNNER_TOOL_CACHE: /toolcache
|
||||||
|
IMAGE_NAME: white-nights-admin-panel
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
|
- name: Set up Docker BuildX
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
with:
|
||||||
|
config-inline: |
|
||||||
|
[registry."gitea.unprism.ru"]
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: gitea.unprism.ru
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Get Meta
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
|
||||||
|
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: |
|
||||||
|
linux/amd64
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
gitea.unprism.ru/${{ env.DOCKER_ORG }}/${{ env.IMAGE_NAME }}:${{ env.DOCKER_LATEST }}
|
21
.gitignore
vendored
@ -1,14 +1,8 @@
|
|||||||
# 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*
|
||||||
@ -17,7 +11,6 @@ lerna-debug.log*
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
<<<<<<< HEAD
|
|
||||||
\*.local
|
\*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
@ -31,17 +24,3 @@ _.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
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# This Dockerfile uses `serve` npm package to serve the static files with node process.
|
||||||
|
# You can find the Dockerfile for nginx in the following link:
|
||||||
|
# https://github.com/refinedev/dockerfiles/blob/main/vite/Dockerfile.nginx
|
||||||
|
|
||||||
|
FROM refinedev/node: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
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# ⚡️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
@ -1,54 +0,0 @@
|
|||||||
# 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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
5
compose.yaml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
services:
|
||||||
|
refine:
|
||||||
|
image: white-nights:latest
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
@ -1,28 +0,0 @@
|
|||||||
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,13 +1,39 @@
|
|||||||
<!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.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<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/main.tsx"></script>
|
<script type="module" src="/src/index.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>
|
||||||
|
10623
package-lock.json
generated
111
package.json
@ -1,57 +1,96 @@
|
|||||||
{
|
{
|
||||||
"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.14.0",
|
"@emotion/react": "^11.8.2",
|
||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.8.1",
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@mui/material": "^7.1.0",
|
"@mui/icons-material": "^6.1.6",
|
||||||
"@mui/x-data-grid": "^8.5.1",
|
"@mui/lab": "^6.0.0-beta.14",
|
||||||
|
"@mui/material": "^6.1.7",
|
||||||
|
"@mui/x-data-grid": "^7.22.2",
|
||||||
"@photo-sphere-viewer/core": "^5.13.2",
|
"@photo-sphere-viewer/core": "^5.13.2",
|
||||||
"@react-three/drei": "^10.1.2",
|
"@pixi/react": "^8.0.0-beta.25",
|
||||||
|
"@react-three/drei": "^10.0.6",
|
||||||
"@react-three/fiber": "^9.1.2",
|
"@react-three/fiber": "^9.1.2",
|
||||||
"@tailwindcss/vite": "^4.1.8",
|
"@refinedev/cli": "^2.16.21",
|
||||||
"axios": "^1.9.0",
|
"@refinedev/core": "^4.57.9",
|
||||||
"easymde": "^2.20.0",
|
"@refinedev/devtools": "^1.1.32",
|
||||||
|
"@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",
|
||||||
"ol": "^10.5.0",
|
"pixi.js": "^8.2.6",
|
||||||
"path": "^0.12.7",
|
"react": "19.0.0",
|
||||||
"react": "^19.1.0",
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
"react-colorful": "^5.6.1",
|
"react-dom": "19.0.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-draggable": "^4.4.6",
|
||||||
"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-dom": "^7.6.1",
|
"react-router": "^7.0.2",
|
||||||
|
"react-simple-maps": "^3.0.0",
|
||||||
"react-simplemde-editor": "^5.2.0",
|
"react-simplemde-editor": "^5.2.0",
|
||||||
|
"react-swipeable": "^7.0.2",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.0.5",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"tailwindcss": "^4.1.8",
|
"three": "^0.175.0",
|
||||||
"three": "^0.177.0"
|
"vite-plugin-svgr": "^4.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@types/d3-geo": "^3.1.0",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/node": "^22.15.24",
|
"@types/node": "^18.16.2",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^18.0.0",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^18.0.0",
|
||||||
"@vitejs/plugin-react": "^4.4.1",
|
"@types/three": "^0.175.0",
|
||||||
"eslint": "^9.25.0",
|
"@typescript-eslint/eslint-plugin": "^5.57.1",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"@typescript-eslint/parser": "^5.57.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.19",
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
"globals": "^16.0.0",
|
"eslint": "^8.38.0",
|
||||||
"typescript": "~5.8.3",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"typescript-eslint": "^8.30.1",
|
"eslint-plugin-react-refresh": "^0.3.4",
|
||||||
"vite": "^6.3.5"
|
"typescript": "^5.4.2",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
373
public/Emblem.svg
Normal file
After Width: | Height: | Size: 176 KiB |
BIN
public/GET.png
Normal file
After Width: | Height: | Size: 6.4 KiB |
BIN
public/SightIcon.png
Normal file
After Width: | Height: | Size: 750 B |
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
@ -1 +0,0 @@
|
|||||||
<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>
|
|
Before Width: | Height: | Size: 1.5 KiB |
473
src/App.tsx
@ -1 +1,474 @@
|
|||||||
|
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;
|
||||||
|
@ -1,13 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
@ -1,161 +0,0 @@
|
|||||||
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} />;
|
|
||||||
};
|
|
@ -1 +0,0 @@
|
|||||||
<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>
|
|
Before Width: | Height: | Size: 4.0 KiB |
12
src/components/AdminOnly.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
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}</>
|
||||||
|
}
|
397
src/components/CreateSightArticle.tsx
Normal file
@ -0,0 +1,397 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
187
src/components/CustomDataGrid.tsx
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
68
src/components/LanguageSwitch/index.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { Box } from "@mui/material";
|
||||||
|
import { Languages, languageStore } from "../../store/LanguageStore";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
|
export const LanguageSwitch = observer(({ action }: any) => {
|
||||||
|
const { language, setLanguageAction } = languageStore;
|
||||||
|
|
||||||
|
const handleLanguageChange = (lang: Languages) => {
|
||||||
|
action?.();
|
||||||
|
setLanguageAction(lang);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
cursor: "pointer",
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: language === "ru" ? "primary.main" : "transparent",
|
||||||
|
color: language === "ru" ? "white" : "inherit",
|
||||||
|
borderRadius: 1,
|
||||||
|
p: 1,
|
||||||
|
}}
|
||||||
|
onClick={() => handleLanguageChange("ru")}
|
||||||
|
>
|
||||||
|
RU
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
cursor: "pointer",
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: language === "en" ? "primary.main" : "transparent",
|
||||||
|
color: language === "en" ? "white" : "inherit",
|
||||||
|
borderRadius: 1,
|
||||||
|
p: 1,
|
||||||
|
}}
|
||||||
|
onClick={() => handleLanguageChange("en")}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
cursor: "pointer",
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: language === "zh" ? "primary.main" : "transparent",
|
||||||
|
color: language === "zh" ? "white" : "inherit",
|
||||||
|
borderRadius: 1,
|
||||||
|
p: 1,
|
||||||
|
}}
|
||||||
|
onClick={() => handleLanguageChange("zh")}
|
||||||
|
>
|
||||||
|
ZH
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
558
src/components/LinkedItems.tsx
Normal file
@ -0,0 +1,558 @@
|
|||||||
|
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
108
src/components/MarkdownEditor.tsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
235
src/components/header/index.tsx
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
5
src/components/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export * from './AdminOnly'
|
||||||
|
export * from './CreateSightArticle'
|
||||||
|
export * from './CustomDataGrid'
|
||||||
|
export * from './LinkedItems'
|
||||||
|
export * from './MarkdownEditor'
|
149
src/components/media/MediaFormUtils.tsx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
410
src/components/modals/ArticleEditModal/index.tsx
Normal file
@ -0,0 +1,410 @@
|
|||||||
|
import { Modal, Box, Button, TextField, Typography } from "@mui/material";
|
||||||
|
import { articleStore } from "../../../store/ArticleStore";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useForm } from "@refinedev/react-hook-form";
|
||||||
|
import { Controller } from "react-hook-form";
|
||||||
|
import "easymde/dist/easymde.min.css";
|
||||||
|
import { memo, useMemo, useEffect, useCallback, useState } from "react";
|
||||||
|
import { MarkdownEditor } from "../../MarkdownEditor";
|
||||||
|
import { Edit } from "@refinedev/mui";
|
||||||
|
import { EVERY_LANGUAGE, languageStore } from "../../../store/LanguageStore";
|
||||||
|
import { LanguageSwitch } from "../../LanguageSwitch/index";
|
||||||
|
import { useDropzone } from "react-dropzone";
|
||||||
|
import {
|
||||||
|
ALLOWED_IMAGE_TYPES,
|
||||||
|
ALLOWED_VIDEO_TYPES,
|
||||||
|
} from "../../media/MediaFormUtils";
|
||||||
|
import { TOKEN_KEY, axiosInstance } from "@providers";
|
||||||
|
import { LinkedItems } from "../../../components/LinkedItems";
|
||||||
|
import { mediaFields, MediaItem } from "../../../pages/article/types";
|
||||||
|
|
||||||
|
const MemoizedSimpleMDE = memo(MarkdownEditor);
|
||||||
|
|
||||||
|
type MediaFile = {
|
||||||
|
file: File;
|
||||||
|
preview: string;
|
||||||
|
uploading: boolean;
|
||||||
|
media_id?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
marginLeft: "auto",
|
||||||
|
marginRight: "auto",
|
||||||
|
//position: "absolute",
|
||||||
|
//top: "50%",
|
||||||
|
//left: "50%",
|
||||||
|
//transform: "translate(-50%, -50%)",
|
||||||
|
width: "60%",
|
||||||
|
bgcolor: "background.paper",
|
||||||
|
border: "2px solid #000",
|
||||||
|
boxShadow: 24,
|
||||||
|
p: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ArticleEditModal = observer(() => {
|
||||||
|
const { language } = languageStore;
|
||||||
|
const [articleData, setArticleData] = useState({
|
||||||
|
heading: EVERY_LANGUAGE(language),
|
||||||
|
body: EVERY_LANGUAGE(language),
|
||||||
|
});
|
||||||
|
const { articleModalOpen, setArticleModalOpenAction, selectedArticleId } =
|
||||||
|
articleStore;
|
||||||
|
|
||||||
|
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]);
|
||||||
|
|
||||||
|
const [refresh, setRefresh] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setArticleModalOpenAction(false);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load existing media files when editing an article
|
||||||
|
const loadExistingMedia = async () => {
|
||||||
|
if (selectedArticleId) {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get(
|
||||||
|
`${import.meta.env.VITE_KRBL_API}/article/${selectedArticleId}/media`
|
||||||
|
);
|
||||||
|
const existingMedia = response.data;
|
||||||
|
|
||||||
|
// Convert existing media to MediaFile format
|
||||||
|
const mediaFiles = await Promise.all(
|
||||||
|
existingMedia.map(async (media: any) => {
|
||||||
|
const response = await fetch(
|
||||||
|
`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
|
media.id
|
||||||
|
}/download?token=${localStorage.getItem(TOKEN_KEY)}`
|
||||||
|
);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const file = new File([blob], media.filename, {
|
||||||
|
type: media.media_type === 1 ? "image/jpeg" : "video/mp4",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
preview: URL.createObjectURL(blob),
|
||||||
|
uploading: false,
|
||||||
|
mediaId: media.id,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
setMediaFiles(mediaFiles);
|
||||||
|
setRefresh(refresh + 1);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading existing media:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadExistingMedia();
|
||||||
|
}, [selectedArticleId]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
saveButtonProps,
|
||||||
|
reset,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
} = useForm({
|
||||||
|
refineCoreProps: {
|
||||||
|
resource: "article",
|
||||||
|
id: selectedArticleId ?? undefined,
|
||||||
|
action: "edit",
|
||||||
|
redirect: false,
|
||||||
|
|
||||||
|
onMutationSuccess: async () => {
|
||||||
|
try {
|
||||||
|
// Upload new media files
|
||||||
|
const newMediaFiles = mediaFiles.filter((file) => !file.media_id);
|
||||||
|
const existingMediaAmount = mediaFiles.filter(
|
||||||
|
(file) => file.media_id
|
||||||
|
).length;
|
||||||
|
const mediaIds = await Promise.all(
|
||||||
|
newMediaFiles.map(async (mediaFile) => {
|
||||||
|
return await uploadMedia(mediaFile);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Associate all media with the article
|
||||||
|
await Promise.all(
|
||||||
|
mediaIds.map((mediaId, index) =>
|
||||||
|
axiosInstance.post(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_KRBL_API
|
||||||
|
}/article/${selectedArticleId}/media/`,
|
||||||
|
{
|
||||||
|
media_id: mediaId,
|
||||||
|
media_order: index + existingMediaAmount + 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
setArticleModalOpenAction(false);
|
||||||
|
reset();
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error handling media:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headers: {
|
||||||
|
"Accept-Language": language,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (articleData.heading[language]) {
|
||||||
|
setValue("heading", articleData.heading[language]);
|
||||||
|
}
|
||||||
|
if (articleData.body[language]) {
|
||||||
|
setValue("body", articleData.body[language]);
|
||||||
|
}
|
||||||
|
}, [language, articleData, setValue]);
|
||||||
|
|
||||||
|
const handleLanguageChange = () => {
|
||||||
|
setArticleData((prevData) => ({
|
||||||
|
...prevData,
|
||||||
|
heading: {
|
||||||
|
...prevData.heading,
|
||||||
|
[language]: watch("heading") ?? "",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
...prevData.body,
|
||||||
|
[language]: watch("body") ?? "",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const simpleMDEOptions = useMemo(
|
||||||
|
() => ({
|
||||||
|
placeholder: "Введите контент в формате Markdown...",
|
||||||
|
spellChecker: false,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||||
|
const newFiles = acceptedFiles.map((file) => ({
|
||||||
|
file,
|
||||||
|
preview: URL.createObjectURL(file),
|
||||||
|
uploading: false,
|
||||||
|
}));
|
||||||
|
setMediaFiles((prev) => [...prev, ...newFiles]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
|
onDrop,
|
||||||
|
accept: {
|
||||||
|
"image/jpeg": [".jpeg", ".jpg"],
|
||||||
|
"image/png": [".png"],
|
||||||
|
"image/webp": [".webp"],
|
||||||
|
"video/mp4": [".mp4"],
|
||||||
|
"video/webm": [".webm"],
|
||||||
|
"video/ogg": [".ogg"],
|
||||||
|
},
|
||||||
|
multiple: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadMedia = async (mediaFile: MediaFile) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("media_name", mediaFile.file.name);
|
||||||
|
formData.append("filename", mediaFile.file.name);
|
||||||
|
formData.append(
|
||||||
|
"type",
|
||||||
|
mediaFile.file.type.startsWith("image/") ? "1" : "2"
|
||||||
|
);
|
||||||
|
formData.append("file", mediaFile.file);
|
||||||
|
|
||||||
|
const response = await axiosInstance.post(
|
||||||
|
`${import.meta.env.VITE_KRBL_API}/media`,
|
||||||
|
formData
|
||||||
|
);
|
||||||
|
return response.data.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMedia = async (index: number) => {
|
||||||
|
const mediaFile = mediaFiles[index];
|
||||||
|
|
||||||
|
// If it's an existing media file (has mediaId), delete it from the server
|
||||||
|
if (mediaFile.media_id) {
|
||||||
|
try {
|
||||||
|
await axiosInstance.delete(
|
||||||
|
`${import.meta.env.VITE_KRBL_API}/media/${mediaFile.media_id}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting media:", error);
|
||||||
|
return; // Don't remove from UI if server deletion failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from UI and cleanup
|
||||||
|
setMediaFiles((prev) => {
|
||||||
|
const newFiles = [...prev];
|
||||||
|
URL.revokeObjectURL(newFiles[index].preview);
|
||||||
|
newFiles.splice(index, 1);
|
||||||
|
return newFiles;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={articleModalOpen}
|
||||||
|
onClose={() => setArticleModalOpenAction(false)}
|
||||||
|
aria-labelledby="modal-modal-title"
|
||||||
|
aria-describedby="modal-modal-description"
|
||||||
|
sx={{ overflow: "auto" }}
|
||||||
|
>
|
||||||
|
<Box sx={style}>
|
||||||
|
<Edit
|
||||||
|
title={<Typography variant="h5">Редактирование статьи</Typography>}
|
||||||
|
headerProps={{
|
||||||
|
sx: {
|
||||||
|
fontSize: "50px",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
saveButtonProps={saveButtonProps}
|
||||||
|
>
|
||||||
|
<LanguageSwitch action={handleLanguageChange} />
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
sx={{ display: "flex", flexDirection: "column" }}
|
||||||
|
autoComplete="off"
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
{...register("heading", {
|
||||||
|
required: "Это поле является обязательным",
|
||||||
|
})}
|
||||||
|
error={!!errors.heading}
|
||||||
|
helperText={errors.heading?.message as string}
|
||||||
|
margin="normal"
|
||||||
|
fullWidth
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
type="text"
|
||||||
|
name="heading"
|
||||||
|
label="Заголовок *"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="body"
|
||||||
|
rules={{ required: "Это поле является обязательным" }}
|
||||||
|
defaultValue=""
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<MemoizedSimpleMDE
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
options={simpleMDEOptions}
|
||||||
|
className="my-markdown-editor"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedArticleId && (
|
||||||
|
<LinkedItems<MediaItem>
|
||||||
|
type="edit"
|
||||||
|
parentId={selectedArticleId}
|
||||||
|
parentResource="article"
|
||||||
|
childResource="media"
|
||||||
|
fields={mediaFields}
|
||||||
|
title="медиа"
|
||||||
|
dontRecurse
|
||||||
|
onUpdate={loadExistingMedia}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Dropzone для медиа файлов */}
|
||||||
|
<Box sx={{ mt: 2, mb: 2 }}>
|
||||||
|
<Box
|
||||||
|
{...getRootProps()}
|
||||||
|
sx={{
|
||||||
|
border: "2px dashed",
|
||||||
|
borderColor: isDragActive ? "primary.main" : "grey.300",
|
||||||
|
borderRadius: 1,
|
||||||
|
p: 2,
|
||||||
|
textAlign: "center",
|
||||||
|
cursor: "pointer",
|
||||||
|
"&:hover": {
|
||||||
|
borderColor: "primary.main",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
<Typography>
|
||||||
|
{isDragActive
|
||||||
|
? "Перетащите файлы сюда..."
|
||||||
|
: "Перетащите файлы сюда или кликните для выбора"}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Превью загруженных файлов */}
|
||||||
|
<Box sx={{ mt: 2, display: "flex", flexWrap: "wrap", gap: 1 }}>
|
||||||
|
{mediaFiles.map((mediaFile, index) => (
|
||||||
|
<Box
|
||||||
|
key={mediaFile.preview}
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mediaFile.file.type.startsWith("image/") ? (
|
||||||
|
<img
|
||||||
|
src={mediaFile.preview}
|
||||||
|
alt={mediaFile.file.name}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: "grey.200",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption">
|
||||||
|
{mediaFile.file.name}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => removeMedia(index)}
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
minWidth: "auto",
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
p: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Edit>
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
});
|
191
src/components/modals/StationEditModal/index.tsx
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
Grid,
|
||||||
|
Paper,
|
||||||
|
} from "@mui/material";
|
||||||
|
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useForm } from "@refinedev/react-hook-form";
|
||||||
|
import { Controller } from "react-hook-form";
|
||||||
|
import "easymde/dist/easymde.min.css";
|
||||||
|
import { memo, useMemo, useEffect } from "react";
|
||||||
|
import { MarkdownEditor } from "../../MarkdownEditor";
|
||||||
|
import { Edit } from "@refinedev/mui";
|
||||||
|
import { languageStore } from "../../../store/LanguageStore";
|
||||||
|
import { LanguageSwitch } from "../../LanguageSwitch/index";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { stationStore } from "../../../store/StationStore";
|
||||||
|
import { useCustom } from "@refinedev/core";
|
||||||
|
import { useApiUrl } from "@refinedev/core";
|
||||||
|
import { StationItem } from "src/pages/route/types";
|
||||||
|
const MemoizedSimpleMDE = memo(MarkdownEditor);
|
||||||
|
|
||||||
|
const TRANSFER_FIELDS = [
|
||||||
|
{ name: "bus", label: "Автобус" },
|
||||||
|
{ name: "metro_blue", label: "Метро (синяя)" },
|
||||||
|
{ name: "metro_green", label: "Метро (зеленая)" },
|
||||||
|
{ name: "metro_orange", label: "Метро (оранжевая)" },
|
||||||
|
{ name: "metro_purple", label: "Метро (фиолетовая)" },
|
||||||
|
{ name: "metro_red", label: "Метро (красная)" },
|
||||||
|
{ name: "train", label: "Электричка" },
|
||||||
|
{ name: "tram", label: "Трамвай" },
|
||||||
|
{ name: "trolleybus", label: "Троллейбус" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
width: "60%",
|
||||||
|
bgcolor: "background.paper",
|
||||||
|
border: "2px solid #000",
|
||||||
|
boxShadow: 24,
|
||||||
|
p: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StationEditModal = observer(() => {
|
||||||
|
const {
|
||||||
|
stationModalOpen,
|
||||||
|
setStationModalOpenAction,
|
||||||
|
selectedStationId,
|
||||||
|
selectedRouteId,
|
||||||
|
} = stationStore;
|
||||||
|
const { language } = languageStore;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setStationModalOpenAction(false);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const apiUrl = useApiUrl();
|
||||||
|
|
||||||
|
const { data: stationQuery, isLoading: isStationLoading } = useCustom({
|
||||||
|
url: `${apiUrl}/route/${selectedRouteId ?? 1}/station`,
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
saveButtonProps,
|
||||||
|
reset,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
handleSubmit,
|
||||||
|
} = useForm({
|
||||||
|
refineCoreProps: {
|
||||||
|
resource: `route/${selectedRouteId ?? 1}/station`,
|
||||||
|
action: "edit",
|
||||||
|
id: "",
|
||||||
|
redirect: false,
|
||||||
|
onMutationSuccess: (data) => {
|
||||||
|
setStationModalOpenAction(false);
|
||||||
|
reset();
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headers: {
|
||||||
|
"Accept-Language": language,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (stationModalOpen) {
|
||||||
|
const station = stationQuery?.data?.find(
|
||||||
|
(station: StationItem) => station.id === selectedStationId
|
||||||
|
);
|
||||||
|
if (!station) return;
|
||||||
|
for (const key in station) {
|
||||||
|
setValue(key, station[key]);
|
||||||
|
}
|
||||||
|
setValue("station_id", station.id);
|
||||||
|
}
|
||||||
|
}, [stationModalOpen, stationQuery]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={stationModalOpen}
|
||||||
|
onClose={() => setStationModalOpenAction(false)}
|
||||||
|
aria-labelledby="modal-modal-title"
|
||||||
|
aria-describedby="modal-modal-description"
|
||||||
|
>
|
||||||
|
<Box sx={style}>
|
||||||
|
<Edit
|
||||||
|
title={<Typography variant="h5">Редактирование остановки</Typography>}
|
||||||
|
saveButtonProps={saveButtonProps}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
sx={{ display: "flex", flexDirection: "column" }}
|
||||||
|
autoComplete="off"
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
{...register("offset_x", {
|
||||||
|
setValueAs: (value) => parseFloat(value),
|
||||||
|
})}
|
||||||
|
error={!!(errors as any)?.offset_x}
|
||||||
|
helperText={(errors as any)?.offset_x?.message}
|
||||||
|
margin="normal"
|
||||||
|
fullWidth
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
type="number"
|
||||||
|
label={"Смещение (X)"}
|
||||||
|
name="offset_x"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
{...register("offset_y", {
|
||||||
|
required: "Это поле является обязательным",
|
||||||
|
setValueAs: (value) => parseFloat(value),
|
||||||
|
})}
|
||||||
|
error={!!(errors as any)?.offset_y}
|
||||||
|
helperText={(errors as any)?.offset_y?.message}
|
||||||
|
margin="normal"
|
||||||
|
fullWidth
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
type="number"
|
||||||
|
label={"Смещение (Y)"}
|
||||||
|
name="offset_y"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Группа полей пересадок */}
|
||||||
|
<Paper sx={{ p: 2, mt: 2 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Пересадки
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{TRANSFER_FIELDS.map((field) => (
|
||||||
|
<Grid item xs={12} sm={6} md={4} key={field.name}>
|
||||||
|
<TextField
|
||||||
|
{...register(`transfers.${field.name}`)}
|
||||||
|
error={!!(errors as any)?.transfers?.[field.name]}
|
||||||
|
helperText={
|
||||||
|
(errors as any)?.transfers?.[field.name]?.message
|
||||||
|
}
|
||||||
|
margin="normal"
|
||||||
|
fullWidth
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
type="text"
|
||||||
|
label={field.label}
|
||||||
|
name={`transfers.${field.name}`}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
</Edit>
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
});
|
25
src/components/ui/Icons.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
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
|
||||||
|
}
|
71
src/components/ui/LanguageSelector.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { Box } from "@mui/material";
|
||||||
|
import { Languages, languageStore } from "@stores";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
|
|
||||||
|
export const LanguageSelector = observer(({
|
||||||
|
action
|
||||||
|
}: {action?: (lang: Languages) => void}) => {
|
||||||
|
const { language, setLanguageAction } = languageStore;
|
||||||
|
|
||||||
|
function handleLanguageChange(language: Languages) {
|
||||||
|
if(action) action(language);
|
||||||
|
else setLanguageAction(language);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 2,
|
||||||
|
height: "min-content"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
cursor: "pointer",
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: language === "ru" ? "primary.main" : "transparent",
|
||||||
|
color: language === "ru" ? "white" : "inherit",
|
||||||
|
borderRadius: 1,
|
||||||
|
p: 1,
|
||||||
|
}}
|
||||||
|
onClick={() => handleLanguageChange("ru")}
|
||||||
|
>
|
||||||
|
RU
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
cursor: "pointer",
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: language === "en" ? "primary.main" : "transparent",
|
||||||
|
color: language === "en" ? "white" : "inherit",
|
||||||
|
borderRadius: 1,
|
||||||
|
p: 1,
|
||||||
|
}}
|
||||||
|
onClick={() => handleLanguageChange("en")}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
cursor: "pointer",
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: language === "zh" ? "primary.main" : "transparent",
|
||||||
|
color: language === "zh" ? "white" : "inherit",
|
||||||
|
borderRadius: 1,
|
||||||
|
p: 1,
|
||||||
|
}}
|
||||||
|
onClick={() => handleLanguageChange("zh")}
|
||||||
|
>
|
||||||
|
ZH
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
@ -1,7 +1,7 @@
|
|||||||
import { Box } from "@mui/material";
|
import { Box } from "@mui/material";
|
||||||
|
import { TOKEN_KEY } from "@providers";
|
||||||
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
|
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
|
||||||
import { ThreeView } from "./ThreeView";
|
import { ModelViewer } from "./ModelViewer";
|
||||||
|
|
||||||
export interface MediaData {
|
export interface MediaData {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
@ -9,20 +9,33 @@ export interface MediaData {
|
|||||||
filename?: string;
|
filename?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MediaViewer({
|
export function MediaView({ media }: Readonly<{ media?: MediaData }>) {
|
||||||
media,
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
className,
|
|
||||||
fullWidth,
|
|
||||||
}: Readonly<{ media?: MediaData; className?: string; fullWidth?: boolean }>) {
|
|
||||||
const token = localStorage.getItem("token");
|
|
||||||
return (
|
return (
|
||||||
<Box className={className} width={fullWidth ? "100%" : "auto"}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
maxHeight: "300px",
|
||||||
|
width: "80%",
|
||||||
|
height: "100%",
|
||||||
|
maxWidth: "600px",
|
||||||
|
display: "flex",
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
margin: "0 auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{media?.media_type === 1 && (
|
{media?.media_type === 1 && (
|
||||||
<img
|
<img
|
||||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
media?.id
|
media?.id
|
||||||
}/download?token=${token}`}
|
}/download?token=${token}`}
|
||||||
alt={media?.filename}
|
alt={media?.filename}
|
||||||
|
style={{
|
||||||
|
maxWidth: "100%",
|
||||||
|
height: "auto",
|
||||||
|
objectFit: "contain",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -32,10 +45,10 @@ export function MediaViewer({
|
|||||||
media?.id
|
media?.id
|
||||||
}/download?token=${token}`}
|
}/download?token=${token}`}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
maxWidth: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
objectFit: "cover",
|
objectFit: "contain",
|
||||||
borderRadius: 8,
|
borderRadius: 30,
|
||||||
}}
|
}}
|
||||||
controls
|
controls
|
||||||
autoPlay
|
autoPlay
|
||||||
@ -48,6 +61,12 @@ export function MediaViewer({
|
|||||||
media?.id
|
media?.id
|
||||||
}/download?token=${token}`}
|
}/download?token=${token}`}
|
||||||
alt={media?.filename}
|
alt={media?.filename}
|
||||||
|
style={{
|
||||||
|
maxWidth: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "contain",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{media?.media_type === 4 && (
|
{media?.media_type === 4 && (
|
||||||
@ -57,7 +76,10 @@ export function MediaViewer({
|
|||||||
}/download?token=${token}`}
|
}/download?token=${token}`}
|
||||||
alt={media?.filename}
|
alt={media?.filename}
|
||||||
style={{
|
style={{
|
||||||
objectFit: "cover",
|
maxWidth: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "contain",
|
||||||
|
borderRadius: 8,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -73,11 +95,10 @@ export function MediaViewer({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{media?.media_type === 6 && (
|
{media?.media_type === 6 && (
|
||||||
<ThreeView
|
<ModelViewer
|
||||||
fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${
|
fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
media?.id
|
media?.id
|
||||||
}/download?token=${token}`}
|
}/download?token=${token}`}
|
||||||
height="100%"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
@ -6,7 +6,7 @@ type ModelViewerProps = {
|
|||||||
height?: string;
|
height?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ThreeView = ({ fileUrl, height = "100%" }: ModelViewerProps) => {
|
export const ModelViewer = ({ fileUrl, height = "100%" }: ModelViewerProps) => {
|
||||||
const { scene } = useGLTF(fileUrl);
|
const { scene } = useGLTF(fileUrl);
|
||||||
|
|
||||||
return (
|
return (
|
15
src/components/ui/SidebarTitle.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
5
src/components/ui/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export * from './Icons';
|
||||||
|
export * from './LanguageSelector';
|
||||||
|
export * from './SidebarTitle';
|
||||||
|
export * from './MediaView';
|
||||||
|
export * from './ModelViewer';
|
40
src/contexts/color-mode/index.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
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,9 +1,10 @@
|
|||||||
import { createTheme } from "@mui/material/styles";
|
import {createTheme} from '@mui/material/styles'
|
||||||
|
import {RefineThemes} from '@refinedev/mui'
|
||||||
|
|
||||||
export const COLORS = {
|
export const COLORS = {
|
||||||
primary: "#7f6b58",
|
primary: '#7f6b58',
|
||||||
secondary: "#48989f",
|
secondary: '#48989f',
|
||||||
};
|
}
|
||||||
|
|
||||||
const theme = {
|
const theme = {
|
||||||
palette: {
|
palette: {
|
||||||
@ -23,11 +24,13 @@ const theme = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
export const CustomTheme = {
|
export const CustomTheme = {
|
||||||
Light: createTheme({
|
Light: createTheme({
|
||||||
|
...RefineThemes.Blue,
|
||||||
palette: {
|
palette: {
|
||||||
|
...RefineThemes.Blue.palette,
|
||||||
...theme.palette,
|
...theme.palette,
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
@ -35,11 +38,13 @@ export const CustomTheme = {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
Dark: createTheme({
|
Dark: createTheme({
|
||||||
|
...RefineThemes.BlueDark,
|
||||||
palette: {
|
palette: {
|
||||||
|
...RefineThemes.BlueDark.palette,
|
||||||
...theme.palette,
|
...theme.palette,
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
...theme.components,
|
...theme.components,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
}
|
@ -1 +0,0 @@
|
|||||||
export * from "./navigation";
|
|
@ -1,2 +0,0 @@
|
|||||||
export * from "./ui";
|
|
||||||
export * from "./model";
|
|
@ -1,10 +0,0 @@
|
|||||||
import { LucideIcon } from "lucide-react";
|
|
||||||
|
|
||||||
export interface NavigationItem {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
icon: LucideIcon;
|
|
||||||
path: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type NavigationSection = "primary" | "secondary";
|
|
@ -1,82 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
export * from "./navigation";
|
|
@ -1 +0,0 @@
|
|||||||
export * from "./ui";
|
|
@ -1,34 +0,0 @@
|
|||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
25
src/globals.css
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
@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);
|
||||||
|
}
|
3
src/icons/124.svg
Normal file
After Width: | Height: | Size: 7.5 KiB |
22
src/icons/Logo.tsx
Normal file
@ -1,9 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
@plugin "@tailwindcss/typography";
|
|
||||||
button {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mde-preview {
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
9
src/index.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
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 />);
|
13
src/lib/constants.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
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
src/lib/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { MEDIA_TYPES, VEHICLE_TYPES } from './constants'
|
78
src/locales/ru/localeText.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
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: 'Строк на странице:',
|
||||||
|
}
|
130
src/locales/ru/translation.json
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
{
|
||||||
|
"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": "Показать пользователя"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,8 +0,0 @@
|
|||||||
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 />);
|
|
@ -1,86 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
export * from "./ArticleListPage";
|
|
@ -1,202 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,101 +0,0 @@
|
|||||||
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);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,120 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,3 +0,0 @@
|
|||||||
export * from "./CarrierListPage";
|
|
||||||
export * from "./CarrierPreviewPage";
|
|
||||||
export * from "./CarrierCreatePage";
|
|
@ -1,166 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,96 +0,0 @@
|
|||||||
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);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,76 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,3 +0,0 @@
|
|||||||
export * from "./CityListPage";
|
|
||||||
export * from "./CityPreviewPage";
|
|
||||||
export * from "./CityCreatePage";
|
|
@ -1,75 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,86 +0,0 @@
|
|||||||
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);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,58 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,3 +0,0 @@
|
|||||||
export * from "./CountryListPage";
|
|
||||||
export * from "./CountryPreviewPage";
|
|
||||||
export * from "./CountryCreatePage";
|
|
@ -1,93 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,9 +0,0 @@
|
|||||||
import { DevicesTable } from "@widgets";
|
|
||||||
|
|
||||||
export const DevicesPage = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DevicesTable />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,94 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,119 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,37 +0,0 @@
|
|||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,89 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,295 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,110 +0,0 @@
|
|||||||
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);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,44 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,3 +0,0 @@
|
|||||||
export * from "./MediaEditPage";
|
|
||||||
export * from "./MediaListPage";
|
|
||||||
export * from "./MediaPreviewPage";
|
|
@ -1,86 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,115 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
export * from "./RouteListPage";
|
|
@ -1,103 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
export * from "./SightListPage";
|
|
@ -1,9 +0,0 @@
|
|||||||
import { SightsTable } from "@widgets";
|
|
||||||
|
|
||||||
export const SightPage = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SightsTable />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,69 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,125 +0,0 @@
|
|||||||
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);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,3 +0,0 @@
|
|||||||
export * from "./SnapshotListPage";
|
|
||||||
|
|
||||||
export * from "./SnapshotCreatePage";
|
|
@ -1,94 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,117 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
export * from "./StationListPage";
|
|
@ -1,109 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
export * from "./UserListPage";
|
|
@ -1,117 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,133 +0,0 @@
|
|||||||
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);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,70 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
});
|
|