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
|
||||
<<<<<<< HEAD
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
=======
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
>>>>>>> 6b9aa78 (init: Init React Application)
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
@ -17,7 +11,6 @@ lerna-debug.log*
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
<<<<<<< HEAD
|
||||
\*.local
|
||||
|
||||
# Editor directories and files
|
||||
@ -31,17 +24,3 @@ _.ntvs_
|
||||
_.njsproj
|
||||
_.sln
|
||||
\*.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>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta charset="utf-8" />
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<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>
|
||||
</html>
|
||||
|
10641
package-lock.json
generated
111
package.json
@ -1,57 +1,96 @@
|
||||
{
|
||||
"name": "white-nights",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@emotion/react": "^11.8.2",
|
||||
"@emotion/styled": "^11.8.1",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@mui/material": "^7.1.0",
|
||||
"@mui/x-data-grid": "^8.5.1",
|
||||
"@mui/icons-material": "^6.1.6",
|
||||
"@mui/lab": "^6.0.0-beta.14",
|
||||
"@mui/material": "^6.1.7",
|
||||
"@mui/x-data-grid": "^7.22.2",
|
||||
"@photo-sphere-viewer/core": "^5.13.2",
|
||||
"@react-three/drei": "^10.1.2",
|
||||
"@pixi/react": "^8.0.0-beta.25",
|
||||
"@react-three/drei": "^10.0.6",
|
||||
"@react-three/fiber": "^9.1.2",
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
"axios": "^1.9.0",
|
||||
"easymde": "^2.20.0",
|
||||
"@refinedev/cli": "^2.16.21",
|
||||
"@refinedev/core": "^4.57.9",
|
||||
"@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",
|
||||
"mobx": "^6.13.7",
|
||||
"mobx-react-lite": "^4.1.0",
|
||||
"ol": "^10.5.0",
|
||||
"path": "^0.12.7",
|
||||
"react": "^19.1.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^19.1.0",
|
||||
"pixi.js": "^8.2.6",
|
||||
"react": "19.0.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-dom": "19.0.0",
|
||||
"react-draggable": "^4.4.6",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hook-form": "^7.30.0",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-intl": "^7.1.10",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-photo-sphere-viewer": "^6.2.3",
|
||||
"react-router-dom": "^7.6.1",
|
||||
"react-router": "^7.0.2",
|
||||
"react-simple-maps": "^3.0.0",
|
||||
"react-simplemde-editor": "^5.2.0",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"react-toastify": "^11.0.5",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"three": "^0.177.0"
|
||||
"three": "^0.175.0",
|
||||
"vite-plugin-svgr": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/node": "^22.15.24",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vite": "^6.3.5"
|
||||
"@types/d3-geo": "^3.1.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^18.16.2",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"@types/three": "^0.175.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.57.1",
|
||||
"@typescript-eslint/parser": "^5.57.1",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"eslint": "^8.38.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.3.4",
|
||||
"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 { TOKEN_KEY } from "@providers";
|
||||
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
|
||||
import { ThreeView } from "./ThreeView";
|
||||
import { ModelViewer } from "./ModelViewer";
|
||||
|
||||
export interface MediaData {
|
||||
id: string | number;
|
||||
@ -9,20 +9,33 @@ export interface MediaData {
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export function MediaViewer({
|
||||
media,
|
||||
className,
|
||||
fullWidth,
|
||||
}: Readonly<{ media?: MediaData; className?: string; fullWidth?: boolean }>) {
|
||||
const token = localStorage.getItem("token");
|
||||
export function MediaView({ media }: Readonly<{ media?: MediaData }>) {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
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 && (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
media?.id
|
||||
}/download?token=${token}`}
|
||||
alt={media?.filename}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
height: "auto",
|
||||
objectFit: "contain",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -32,10 +45,10 @@ export function MediaViewer({
|
||||
media?.id
|
||||
}/download?token=${token}`}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
borderRadius: 8,
|
||||
objectFit: "contain",
|
||||
borderRadius: 30,
|
||||
}}
|
||||
controls
|
||||
autoPlay
|
||||
@ -48,6 +61,12 @@ export function MediaViewer({
|
||||
media?.id
|
||||
}/download?token=${token}`}
|
||||
alt={media?.filename}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{media?.media_type === 4 && (
|
||||
@ -57,7 +76,10 @@ export function MediaViewer({
|
||||
}/download?token=${token}`}
|
||||
alt={media?.filename}
|
||||
style={{
|
||||
objectFit: "cover",
|
||||
maxWidth: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@ -73,11 +95,10 @@ export function MediaViewer({
|
||||
)}
|
||||
|
||||
{media?.media_type === 6 && (
|
||||
<ThreeView
|
||||
<ModelViewer
|
||||
fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||
media?.id
|
||||
}/download?token=${token}`}
|
||||
height="100%"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
@ -6,7 +6,7 @@ type ModelViewerProps = {
|
||||
height?: string;
|
||||
};
|
||||
|
||||
export const ThreeView = ({ fileUrl, height = "100%" }: ModelViewerProps) => {
|
||||
export const ModelViewer = ({ fileUrl, height = "100%" }: ModelViewerProps) => {
|
||||
const { scene } = useGLTF(fileUrl);
|
||||
|
||||
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 = {
|
||||
primary: "#7f6b58",
|
||||
secondary: "#48989f",
|
||||
};
|
||||
primary: '#7f6b58',
|
||||
secondary: '#48989f',
|
||||
}
|
||||
|
||||
const theme = {
|
||||
palette: {
|
||||
@ -23,11 +24,13 @@ const theme = {
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const CustomTheme = {
|
||||
Light: createTheme({
|
||||
...RefineThemes.Blue,
|
||||
palette: {
|
||||
...RefineThemes.Blue.palette,
|
||||
...theme.palette,
|
||||
},
|
||||
components: {
|
||||
@ -35,11 +38,13 @@ export const CustomTheme = {
|
||||
},
|
||||
}),
|
||||
Dark: createTheme({
|
||||
...RefineThemes.BlueDark,
|
||||
palette: {
|
||||
...RefineThemes.BlueDark.palette,
|
||||
...theme.palette,
|
||||
},
|
||||
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>
|
||||
);
|
||||
});
|