Compare commits

...

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

259 changed files with 17394 additions and 27886 deletions

4
.env
View File

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

View File

@ -1,17 +0,0 @@
/* eslint-env node */
module.exports = {
env: { browser: true, es2020: true },
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended",
],
parser: "@typescript-eslint/parser",
parserOptions: { ecmaVersion: "latest", sourceType: "module" },
plugins: ["react-refresh"],
rules: {
"react-refresh/only-export-components": "warn",
"@typescript-eslint/no-explicit-any": "off"
}
};

View File

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

21
.gitignore vendored
View File

@ -1,8 +1,14 @@
# 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*
@ -11,6 +17,7 @@ lerna-debug.log*
node_modules
dist
dist-ssr
<<<<<<< HEAD
\*.local
# Editor directories and files
@ -24,3 +31,17 @@ _.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)

2
.npmrc
View File

@ -1,2 +0,0 @@
legacy-peer-deps=true
strict-peer-dependencies=false

View File

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

View File

@ -1,12 +0,0 @@
# ⚡white-nights
```sh
git clone https://github.com/bozzhik/white-nights.git && cd white-nights && pnpm i && code .
```
This [Refine](https://github.com/refinedev/refine) project was generated with [create refine-app](https://github.com/refinedev/refine/tree/master/packages/create-refine-app).
- **REST Data Provider** [Docs](https://refine.dev/docs/core/providers/data-provider/#overview)
- **Material UI** [Docs](https://refine.dev/docs/ui-frameworks/mui/tutorial/)
- **React Router** [Docs](https://refine.dev/docs/core/providers/router-provider/)
- **Custom Auth Provider** [Docs](https://refine.dev/docs/core/providers/auth-provider/)

54
README.md Normal file
View File

@ -0,0 +1,54 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
})
```

View File

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

28
eslint.config.js Normal file
View File

@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

View File

@ -1,39 +1,13 @@
<!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" />
<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"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Белые ночи</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<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`.
-->
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

10599
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,96 +1,57 @@
{
"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.8.2",
"@emotion/styled": "^11.8.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@hello-pangea/dnd": "^18.0.1",
"@mui/icons-material": "^6.1.6",
"@mui/lab": "^6.0.0-beta.14",
"@mui/material": "^6.1.7",
"@mui/x-data-grid": "^7.22.2",
"@mui/material": "^7.1.0",
"@mui/x-data-grid": "^8.5.1",
"@photo-sphere-viewer/core": "^5.13.2",
"@pixi/react": "^8.0.0-beta.25",
"@react-three/drei": "^10.0.6",
"@react-three/drei": "^10.1.2",
"@react-three/fiber": "^9.1.2",
"@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",
"@tailwindcss/vite": "^4.1.8",
"axios": "^1.9.0",
"easymde": "^2.20.0",
"lucide-react": "^0.511.0",
"mobx": "^6.13.7",
"mobx-react-lite": "^4.1.0",
"pixi.js": "^8.2.6",
"react": "19.0.0",
"react-beautiful-dnd": "^13.1.1",
"react-dom": "19.0.0",
"react-draggable": "^4.4.6",
"ol": "^10.5.0",
"path": "^0.12.7",
"react": "^19.1.0",
"react-colorful": "^5.6.1",
"react-dom": "^19.1.0",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.30.0",
"react-i18next": "^15.4.1",
"react-intl": "^7.1.10",
"react-markdown": "^10.1.0",
"react-photo-sphere-viewer": "^6.2.3",
"react-router": "^7.0.2",
"react-simple-maps": "^3.0.0",
"react-router-dom": "^7.6.1",
"react-simplemde-editor": "^5.2.0",
"react-swipeable": "^7.0.2",
"react-toastify": "^11.0.5",
"rehype-raw": "^7.0.0",
"three": "^0.175.0",
"vite-plugin-svgr": "^4.3.0"
"tailwindcss": "^4.1.8",
"three": "^0.177.0"
},
"devDependencies": {
"@types/d3-geo": "^3.1.0",
"@types/js-cookie": "^3.0.6",
"@types/node": "^18.16.2",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@types/three": "^0.175.0",
"@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.1",
"@vitejs/plugin-react": "^4.0.0",
"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"
"@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"
}
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 750 B

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

13
src/app/index.tsx Normal file
View File

@ -0,0 +1,13 @@
import * as React from "react";
import { Router } from "./router";
import { CustomTheme } from "@shared";
import { ThemeProvider } from "@mui/material/styles";
import { ToastContainer } from "react-toastify";
export const App: React.FC = () => (
<ThemeProvider theme={CustomTheme.Light}>
<ToastContainer />
<Router />
</ThemeProvider>
);

161
src/app/router/index.tsx Normal file
View File

@ -0,0 +1,161 @@
import {
CreateSightPage,
DevicesPage,
EditSightPage,
LoginPage,
MainPage,
SightListPage,
MapPage,
MediaListPage,
MediaPreviewPage,
MediaEditPage,
CountryListPage,
CityListPage,
RouteListPage,
UserListPage,
SnapshotListPage,
CarrierListPage,
StationListPage,
VehicleListPage,
ArticleListPage,
CityPreviewPage,
CountryPreviewPage,
VehiclePreviewPage,
CarrierPreviewPage,
SnapshotCreatePage,
CountryCreatePage,
CityCreatePage,
// CarrierCreatePage,
VehicleCreatePage,
} from "@pages";
import { authStore, createSightStore, editSightStore } from "@shared";
import { Layout } from "@widgets";
import { runInAction } from "mobx";
import React, { useEffect } from "react";
import {
createBrowserRouter,
RouterProvider,
Navigate,
Outlet,
useLocation,
} from "react-router-dom";
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated } = authStore;
if (isAuthenticated) {
return <Navigate to="/sight" replace />;
}
return <>{children}</>;
};
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated } = authStore;
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
if (location.pathname === "/") {
return <Navigate to="/sight" replace />;
}
return <>{children}</>;
};
// Чтобы очистка сторов происходила при смене локации
const ClearStoresWrapper: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const location = useLocation();
useEffect(() => {
editSightStore.clearSightInfo();
createSightStore.clearCreateSight();
runInAction(() => {
editSightStore.hasLoadedCommon = false;
});
}, [location]);
return <>{children}</>;
};
const router = createBrowserRouter([
{
path: "/login",
element: (
<PublicRoute>
<LoginPage />
</PublicRoute>
),
},
{
path: "/",
element: (
<ProtectedRoute>
<Layout>
<ClearStoresWrapper>
<Outlet />
</ClearStoresWrapper>
</Layout>
</ProtectedRoute>
),
children: [
{ index: true, element: <MainPage /> },
// Sight
{ path: "sight", element: <SightListPage /> },
{ path: "sight/create", element: <CreateSightPage /> },
{ path: "sight/:id", element: <EditSightPage /> },
// Device
{ path: "devices", element: <DevicesPage /> },
// Map
{ path: "map", element: <MapPage /> },
// Media
{ path: "media", element: <MediaListPage /> },
{ path: "media/:id", element: <MediaPreviewPage /> },
{ path: "media/:id/edit", element: <MediaEditPage /> },
// Country
{ path: "country", element: <CountryListPage /> },
{ path: "country/create", element: <CountryCreatePage /> },
{ path: "country/:id", element: <CountryPreviewPage /> },
// City
{ path: "city", element: <CityListPage /> },
{ path: "city/create", element: <CityCreatePage /> },
{ path: "city/:id", element: <CityPreviewPage /> },
// Route
{ path: "route", element: <RouteListPage /> },
// User
{ path: "user", element: <UserListPage /> },
// Snapshot
{ path: "snapshot", element: <SnapshotListPage /> },
{ path: "snapshot/create", element: <SnapshotCreatePage /> },
// Carrier
{ path: "carrier", element: <CarrierListPage /> },
// { path: "carrier/create", element: <CarrierCreatePage /> },
{ path: "carrier/:id", element: <CarrierPreviewPage /> },
// Station
{ path: "station", element: <StationListPage /> },
// Vehicle
{ path: "vehicle", element: <VehicleListPage /> },
{ path: "vehicle/create", element: <VehicleCreatePage /> },
{ path: "vehicle/:id", element: <VehiclePreviewPage /> },
// Article
{ path: "article", element: <ArticleListPage /> },
// { path: "media/create", element: <CreateMediaPage /> },
],
},
]);
export const Router = () => {
return <RouterProvider router={router} />;
};

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

1
src/assets/react.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -1,12 +0,0 @@
import {usePermissions} from '@refinedev/core'
import {PropsWithChildren} from 'react'
export const AdminOnly: React.FC<PropsWithChildren> = ({children}) => {
const {data: permissions} = usePermissions<string[]>() // добавляем generic тип
if (!permissions?.includes('admin')) {
return null
}
return <>{children}</>
}

View File

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

View File

@ -1,187 +0,0 @@
import {
DataGrid,
type DataGridProps,
type GridColumnVisibilityModel,
} from "@mui/x-data-grid";
import { Stack, Button, Typography, Box } from "@mui/material";
import { ExportButton } from "@refinedev/mui";
import { useExport } from "@refinedev/core";
import React, { useState, useEffect, useMemo } from "react";
import Cookies from "js-cookie";
import { localeText } from "../locales/ru/localeText";
import { languageStore } from "../store/LanguageStore";
import { LanguageSwitch } from "./LanguageSwitch";
interface CustomDataGridProps extends DataGridProps {
hasCoordinates?: boolean;
resource?: string; // Add this prop
languageEnabled?: boolean;
}
const DEV_FIELDS = [
"id",
"code",
"country_code",
"city_id",
"carrier_id",
"main_color",
"left_color",
"right_color",
"logo",
"slogan",
"filename",
"arms",
"thumbnail",
"route_sys_number",
"governor_appeal",
"scale_min",
"scale_max",
"rotate",
"center_latitude",
"center_longitude",
"watermark_lu",
"watermark_rd",
"left_article",
"preview_article",
"offset_x",
"offset_y",
] as const;
export const CustomDataGrid = ({
languageEnabled = false,
hasCoordinates = false,
columns = [],
resource,
...props
}: CustomDataGridProps) => {
// const isDev = import.meta.env.DEV
const { triggerExport, isLoading: exportLoading } = useExport({
resource: resource ?? "",
// pageSize: 100, #*
// maxItemCount: 100, #*
});
const initialShowCoordinates = Cookies.get("showCoordinates") === "true";
const initialShowDevData = false; // Default to false in both prod and dev
const [showCoordinates, setShowCoordinates] = useState(
initialShowCoordinates
);
const [showDevData, setShowDevData] = useState(
Cookies.get("showDevData") === "true"
);
const availableDevFields = useMemo(
() =>
DEV_FIELDS.filter((field) =>
columns.some((column) => column.field === field)
),
[columns]
);
const initialVisibilityModel = useMemo(() => {
const model: GridColumnVisibilityModel = {};
availableDevFields.forEach((field) => {
model[field] = initialShowDevData;
});
if (hasCoordinates) {
model.latitude = initialShowCoordinates;
model.longitude = initialShowCoordinates;
}
return model;
}, [
availableDevFields,
hasCoordinates,
initialShowCoordinates,
initialShowDevData,
]);
const [columnVisibilityModel, setColumnVisibilityModel] =
useState<GridColumnVisibilityModel>(initialVisibilityModel);
useEffect(() => {
setColumnVisibilityModel((prevModel) => {
const newModel = { ...prevModel };
availableDevFields.forEach((field) => {
newModel[field] = showDevData;
});
if (hasCoordinates) {
newModel.latitude = showCoordinates;
newModel.longitude = showCoordinates;
}
return newModel;
});
if (hasCoordinates) {
Cookies.set("showCoordinates", String(showCoordinates));
}
Cookies.set("showDevData", String(showDevData));
}, [showCoordinates, showDevData, hasCoordinates, availableDevFields]);
const toggleCoordinates = () => {
setShowCoordinates((prev) => !prev);
};
const toggleDevData = () => {
setShowDevData((prev) => !prev);
};
return (
<Stack spacing={2}>
<Box sx={{ visibility: languageEnabled ? "visible" : "hidden" }}>
<LanguageSwitch />
</Box>
<DataGrid
{...props}
columns={columns}
localeText={localeText}
columnVisibilityModel={columnVisibilityModel}
onColumnVisibilityModelChange={setColumnVisibilityModel}
// Добавляем базовые функции сортировки и фильтрации
sortingMode="client"
filterMode="client"
initialState={{
// pagination: {
// paginationModel: {pageSize: 25, page: 0},
// },
sorting: {
sortModel: [{ field: "id", sort: "asc" }],
},
}}
pageSizeOptions={[10, 25, 50, 100]}
/>
<Stack direction="row" spacing={2} justifyContent="space-between" mb={2}>
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
{hasCoordinates && (
<Button variant="contained" onClick={toggleCoordinates}>
{showCoordinates ? "Скрыть координаты" : "Показать координаты"}
</Button>
)}
{(import.meta.env.DEV || showDevData) &&
availableDevFields.length > 0 && (
<Button variant="contained" onClick={toggleDevData}>
{showDevData
? "Скрыть служебные данные"
: "Показать служебные данные"}
</Button>
)}
</Stack>
<ExportButton
onClick={triggerExport}
loading={exportLoading}
hideText={false}
>
<Typography sx={{ marginLeft: "-2px" }}>Экспорт</Typography>
</ExportButton>
</Stack>
</Stack>
);
};

View File

@ -1,68 +0,0 @@
import { Box } from "@mui/material";
import { Languages, languageStore } from "../../store/LanguageStore";
import { observer } from "mobx-react-lite";
export const LanguageSwitch = observer(({ action }: any) => {
const { language, setLanguageAction } = languageStore;
const handleLanguageChange = (lang: Languages) => {
action?.();
setLanguageAction(lang);
};
return (
<Box
sx={{
flex: 1,
display: "flex",
gap: 2,
}}
>
<Box
sx={{
cursor: "pointer",
flex: 1,
display: "flex",
justifyContent: "center",
bgcolor: language === "ru" ? "primary.main" : "transparent",
color: language === "ru" ? "white" : "inherit",
borderRadius: 1,
p: 1,
}}
onClick={() => handleLanguageChange("ru")}
>
RU
</Box>
<Box
sx={{
cursor: "pointer",
flex: 1,
display: "flex",
justifyContent: "center",
bgcolor: language === "en" ? "primary.main" : "transparent",
color: language === "en" ? "white" : "inherit",
borderRadius: 1,
p: 1,
}}
onClick={() => handleLanguageChange("en")}
>
EN
</Box>
<Box
sx={{
cursor: "pointer",
flex: 1,
display: "flex",
justifyContent: "center",
bgcolor: language === "zh" ? "primary.main" : "transparent",
color: language === "zh" ? "white" : "inherit",
borderRadius: 1,
p: 1,
}}
onClick={() => handleLanguageChange("zh")}
>
ZH
</Box>
</Box>
);
});

View File

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

View File

@ -1,108 +0,0 @@
import {styled} from '@mui/material/styles'
import zIndex from '@mui/material/styles/zIndex'
import SimpleMDE, {SimpleMDEReactProps, default as SimpleMDEDefault} from 'react-simplemde-editor'
const StyledMarkdownEditor = styled('div')(({theme}) => ({
'& .editor-toolbar': {
backgroundColor: theme.palette.background.paper,
borderColor: theme.palette.divider,
},
'& .editor-toolbar button': {
color: theme.palette.text.primary,
},
'& .editor-toolbar button:hover': {
backgroundColor: theme.palette.action.hover,
},
'& .editor-toolbar button:active, & .editor-toolbar button.active': {
backgroundColor: theme.palette.action.selected,
},
'& .editor-statusbar': {
display: 'none',
},
// Стили для самого редактора
'& .CodeMirror': {
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.primary,
borderColor: theme.palette.divider,
},
// Стили для текста в редакторе
'& .CodeMirror-selected': {
backgroundColor: `${theme.palette.action.selected} !important`,
},
'& .CodeMirror-cursor': {
borderLeftColor: theme.palette.text.primary,
},
// Стили для markdown разметки
'& .cm-header': {
color: theme.palette.primary.main,
},
'& .cm-quote': {
color: theme.palette.text.secondary,
fontStyle: 'italic',
},
'& .cm-link': {
color: theme.palette.primary.main,
},
'& .cm-url': {
color: theme.palette.secondary.main,
},
'& .cm-formatting': {
color: theme.palette.text.secondary,
},
'& .CodeMirror .editor-preview-full': {
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.primary,
borderColor: theme.palette.divider,
},
'& .EasyMDEContainer': {
position: 'relative',
zIndex: zIndex.modal,
},
'& .guide': {
display: 'none',
},
}));
export const MarkdownEditor = (props: SimpleMDEReactProps) => {
if(props.options)
props.options.toolbar = [
"bold",
"italic",
"strikethrough",
{
name: "Underline",
action: (editor: any) => {
const cm = editor.codemirror;
let output = '';
const selectedText = cm.getSelection();
const text = selectedText ?? 'placeholder';
output = '<u>' + text + '</u>';
cm.replaceSelection(output);
},
className: "fa fa-underline", // Look for a suitable icon
title: "Underline (Ctrl/Cmd-Alt-U)",
},
"heading",
"quote",
"unordered-list",
"ordered-list",
"link",
"image",
"code",
"table",
"horizontal-rule",
"preview",
"fullscreen",
"guide"
]
return (
<StyledMarkdownEditor className="my-markdown-editor" sx={{marginTop: 1.5, marginBottom: 3}}>
<SimpleMDE {...props}/>
</StyledMarkdownEditor>
)
}

View File

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

View File

@ -1,5 +0,0 @@
export * from './AdminOnly'
export * from './CreateSightArticle'
export * from './CustomDataGrid'
export * from './LinkedItems'
export * from './MarkdownEditor'

View File

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

View File

@ -1,410 +0,0 @@
import { Modal, Box, Button, TextField, Typography } from "@mui/material";
import { articleStore } from "../../../store/ArticleStore";
import { observer } from "mobx-react-lite";
import { useForm } from "@refinedev/react-hook-form";
import { Controller } from "react-hook-form";
import "easymde/dist/easymde.min.css";
import { memo, useMemo, useEffect, useCallback, useState } from "react";
import { MarkdownEditor } from "../../MarkdownEditor";
import { Edit } from "@refinedev/mui";
import { EVERY_LANGUAGE, languageStore } from "../../../store/LanguageStore";
import { LanguageSwitch } from "../../LanguageSwitch/index";
import { useDropzone } from "react-dropzone";
import {
ALLOWED_IMAGE_TYPES,
ALLOWED_VIDEO_TYPES,
} from "../../media/MediaFormUtils";
import { TOKEN_KEY, axiosInstance } from "@providers";
import { LinkedItems } from "../../../components/LinkedItems";
import { mediaFields, MediaItem } from "../../../pages/article/types";
const MemoizedSimpleMDE = memo(MarkdownEditor);
type MediaFile = {
file: File;
preview: string;
uploading: boolean;
media_id?: number;
};
const style = {
marginLeft: "auto",
marginRight: "auto",
//position: "absolute",
//top: "50%",
//left: "50%",
//transform: "translate(-50%, -50%)",
width: "60%",
bgcolor: "background.paper",
border: "2px solid #000",
boxShadow: 24,
p: 4,
};
export const ArticleEditModal = observer(() => {
const { language } = languageStore;
const [articleData, setArticleData] = useState({
heading: EVERY_LANGUAGE(language),
body: EVERY_LANGUAGE(language),
});
const { articleModalOpen, setArticleModalOpenAction, selectedArticleId } =
articleStore;
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]);
const [refresh, setRefresh] = useState(0);
useEffect(() => {
return () => {
setArticleModalOpenAction(false);
};
}, []);
// Load existing media files when editing an article
const loadExistingMedia = async () => {
if (selectedArticleId) {
try {
const response = await axiosInstance.get(
`${import.meta.env.VITE_KRBL_API}/article/${selectedArticleId}/media`
);
const existingMedia = response.data;
// Convert existing media to MediaFile format
const mediaFiles = await Promise.all(
existingMedia.map(async (media: any) => {
const response = await fetch(
`${import.meta.env.VITE_KRBL_MEDIA}${
media.id
}/download?token=${localStorage.getItem(TOKEN_KEY)}`
);
const blob = await response.blob();
const file = new File([blob], media.filename, {
type: media.media_type === 1 ? "image/jpeg" : "video/mp4",
});
return {
file,
preview: URL.createObjectURL(blob),
uploading: false,
mediaId: media.id,
};
})
);
setMediaFiles(mediaFiles);
setRefresh(refresh + 1);
} catch (error) {
console.error("Error loading existing media:", error);
}
}
};
useEffect(() => {
loadExistingMedia();
}, [selectedArticleId]);
const {
register,
control,
formState: { errors },
saveButtonProps,
reset,
setValue,
watch,
} = useForm({
refineCoreProps: {
resource: "article",
id: selectedArticleId ?? undefined,
action: "edit",
redirect: false,
onMutationSuccess: async () => {
try {
// Upload new media files
const newMediaFiles = mediaFiles.filter((file) => !file.media_id);
const existingMediaAmount = mediaFiles.filter(
(file) => file.media_id
).length;
const mediaIds = await Promise.all(
newMediaFiles.map(async (mediaFile) => {
return await uploadMedia(mediaFile);
})
);
// Associate all media with the article
await Promise.all(
mediaIds.map((mediaId, index) =>
axiosInstance.post(
`${
import.meta.env.VITE_KRBL_API
}/article/${selectedArticleId}/media/`,
{
media_id: mediaId,
media_order: index + existingMediaAmount + 1,
}
)
)
);
setArticleModalOpenAction(false);
reset();
window.location.reload();
} catch (error) {
console.error("Error handling media:", error);
}
},
meta: {
headers: {
"Accept-Language": language,
},
},
},
});
useEffect(() => {
if (articleData.heading[language]) {
setValue("heading", articleData.heading[language]);
}
if (articleData.body[language]) {
setValue("body", articleData.body[language]);
}
}, [language, articleData, setValue]);
const handleLanguageChange = () => {
setArticleData((prevData) => ({
...prevData,
heading: {
...prevData.heading,
[language]: watch("heading") ?? "",
},
body: {
...prevData.body,
[language]: watch("body") ?? "",
},
}));
};
const simpleMDEOptions = useMemo(
() => ({
placeholder: "Введите контент в формате Markdown...",
spellChecker: false,
}),
[]
);
const onDrop = useCallback((acceptedFiles: File[]) => {
const newFiles = acceptedFiles.map((file) => ({
file,
preview: URL.createObjectURL(file),
uploading: false,
}));
setMediaFiles((prev) => [...prev, ...newFiles]);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
"image/jpeg": [".jpeg", ".jpg"],
"image/png": [".png"],
"image/webp": [".webp"],
"video/mp4": [".mp4"],
"video/webm": [".webm"],
"video/ogg": [".ogg"],
},
multiple: true,
});
const uploadMedia = async (mediaFile: MediaFile) => {
const formData = new FormData();
formData.append("media_name", mediaFile.file.name);
formData.append("filename", mediaFile.file.name);
formData.append(
"type",
mediaFile.file.type.startsWith("image/") ? "1" : "2"
);
formData.append("file", mediaFile.file);
const response = await axiosInstance.post(
`${import.meta.env.VITE_KRBL_API}/media`,
formData
);
return response.data.id;
};
const removeMedia = async (index: number) => {
const mediaFile = mediaFiles[index];
// If it's an existing media file (has mediaId), delete it from the server
if (mediaFile.media_id) {
try {
await axiosInstance.delete(
`${import.meta.env.VITE_KRBL_API}/media/${mediaFile.media_id}`
);
} catch (error) {
console.error("Error deleting media:", error);
return; // Don't remove from UI if server deletion failed
}
}
// Remove from UI and cleanup
setMediaFiles((prev) => {
const newFiles = [...prev];
URL.revokeObjectURL(newFiles[index].preview);
newFiles.splice(index, 1);
return newFiles;
});
};
return (
<Modal
open={articleModalOpen}
onClose={() => setArticleModalOpenAction(false)}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
sx={{ overflow: "auto" }}
>
<Box sx={style}>
<Edit
title={<Typography variant="h5">Редактирование статьи</Typography>}
headerProps={{
sx: {
fontSize: "50px",
},
}}
saveButtonProps={saveButtonProps}
>
<LanguageSwitch action={handleLanguageChange} />
<Box
component="form"
sx={{ display: "flex", flexDirection: "column" }}
autoComplete="off"
>
<TextField
{...register("heading", {
required: "Это поле является обязательным",
})}
error={!!errors.heading}
helperText={errors.heading?.message as string}
margin="normal"
fullWidth
InputLabelProps={{ shrink: true }}
type="text"
name="heading"
label="Заголовок *"
/>
<Controller
control={control}
name="body"
rules={{ required: "Это поле является обязательным" }}
defaultValue=""
render={({ field: { onChange, value } }) => (
<MemoizedSimpleMDE
value={value}
onChange={onChange}
options={simpleMDEOptions}
className="my-markdown-editor"
/>
)}
/>
{selectedArticleId && (
<LinkedItems<MediaItem>
type="edit"
parentId={selectedArticleId}
parentResource="article"
childResource="media"
fields={mediaFields}
title="медиа"
dontRecurse
onUpdate={loadExistingMedia}
/>
)}
</Box>
{/* Dropzone для медиа файлов */}
<Box sx={{ mt: 2, mb: 2 }}>
<Box
{...getRootProps()}
sx={{
border: "2px dashed",
borderColor: isDragActive ? "primary.main" : "grey.300",
borderRadius: 1,
p: 2,
textAlign: "center",
cursor: "pointer",
"&:hover": {
borderColor: "primary.main",
},
}}
>
<input {...getInputProps()} />
<Typography>
{isDragActive
? "Перетащите файлы сюда..."
: "Перетащите файлы сюда или кликните для выбора"}
</Typography>
</Box>
{/* Превью загруженных файлов */}
<Box sx={{ mt: 2, display: "flex", flexWrap: "wrap", gap: 1 }}>
{mediaFiles.map((mediaFile, index) => (
<Box
key={mediaFile.preview}
sx={{
position: "relative",
width: 100,
height: 100,
}}
>
{mediaFile.file.type.startsWith("image/") ? (
<img
src={mediaFile.preview}
alt={mediaFile.file.name}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
) : (
<Box
sx={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "grey.200",
}}
>
<Typography variant="caption">
{mediaFile.file.name}
</Typography>
</Box>
)}
<Button
size="small"
color="error"
onClick={() => removeMedia(index)}
sx={{
position: "absolute",
top: 0,
right: 0,
minWidth: "auto",
width: 20,
height: 20,
p: 0,
}}
>
×
</Button>
</Box>
))}
</Box>
</Box>
</Edit>
</Box>
</Modal>
);
});

View File

@ -1,191 +0,0 @@
import {
Modal,
Box,
Button,
TextField,
Typography,
Grid,
Paper,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { useForm } from "@refinedev/react-hook-form";
import { Controller } from "react-hook-form";
import "easymde/dist/easymde.min.css";
import { memo, useMemo, useEffect } from "react";
import { MarkdownEditor } from "../../MarkdownEditor";
import { Edit } from "@refinedev/mui";
import { languageStore } from "../../../store/LanguageStore";
import { LanguageSwitch } from "../../LanguageSwitch/index";
import { useState } from "react";
import { stationStore } from "../../../store/StationStore";
import { useCustom } from "@refinedev/core";
import { useApiUrl } from "@refinedev/core";
import { StationItem } from "src/pages/route/types";
const MemoizedSimpleMDE = memo(MarkdownEditor);
const TRANSFER_FIELDS = [
{ name: "bus", label: "Автобус" },
{ name: "metro_blue", label: "Метро (синяя)" },
{ name: "metro_green", label: "Метро (зеленая)" },
{ name: "metro_orange", label: "Метро (оранжевая)" },
{ name: "metro_purple", label: "Метро (фиолетовая)" },
{ name: "metro_red", label: "Метро (красная)" },
{ name: "train", label: "Электричка" },
{ name: "tram", label: "Трамвай" },
{ name: "trolleybus", label: "Троллейбус" },
];
const style = {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "60%",
bgcolor: "background.paper",
border: "2px solid #000",
boxShadow: 24,
p: 4,
};
export const StationEditModal = observer(() => {
const {
stationModalOpen,
setStationModalOpenAction,
selectedStationId,
selectedRouteId,
} = stationStore;
const { language } = languageStore;
useEffect(() => {
return () => {
setStationModalOpenAction(false);
};
}, []);
const apiUrl = useApiUrl();
const { data: stationQuery, isLoading: isStationLoading } = useCustom({
url: `${apiUrl}/route/${selectedRouteId ?? 1}/station`,
method: "get",
});
const {
register,
control,
formState: { errors },
saveButtonProps,
reset,
setValue,
watch,
handleSubmit,
} = useForm({
refineCoreProps: {
resource: `route/${selectedRouteId ?? 1}/station`,
action: "edit",
id: "",
redirect: false,
onMutationSuccess: (data) => {
setStationModalOpenAction(false);
reset();
window.location.reload();
},
meta: {
headers: {
"Accept-Language": language,
},
},
},
});
useEffect(() => {
if (stationModalOpen) {
const station = stationQuery?.data?.find(
(station: StationItem) => station.id === selectedStationId
);
if (!station) return;
for (const key in station) {
setValue(key, station[key]);
}
setValue("station_id", station.id);
}
}, [stationModalOpen, stationQuery]);
return (
<Modal
open={stationModalOpen}
onClose={() => setStationModalOpenAction(false)}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<Box sx={style}>
<Edit
title={<Typography variant="h5">Редактирование остановки</Typography>}
saveButtonProps={saveButtonProps}
>
<Box
component="form"
sx={{ display: "flex", flexDirection: "column" }}
autoComplete="off"
>
<TextField
{...register("offset_x", {
setValueAs: (value) => parseFloat(value),
})}
error={!!(errors as any)?.offset_x}
helperText={(errors as any)?.offset_x?.message}
margin="normal"
fullWidth
InputLabelProps={{ shrink: true }}
type="number"
label={"Смещение (X)"}
name="offset_x"
/>
<TextField
{...register("offset_y", {
required: "Это поле является обязательным",
setValueAs: (value) => parseFloat(value),
})}
error={!!(errors as any)?.offset_y}
helperText={(errors as any)?.offset_y?.message}
margin="normal"
fullWidth
InputLabelProps={{ shrink: true }}
type="number"
label={"Смещение (Y)"}
name="offset_y"
/>
{/* Группа полей пересадок */}
<Paper sx={{ p: 2, mt: 2 }}>
<Typography variant="h6" gutterBottom>
Пересадки
</Typography>
<Grid container spacing={2}>
{TRANSFER_FIELDS.map((field) => (
<Grid item xs={12} sm={6} md={4} key={field.name}>
<TextField
{...register(`transfers.${field.name}`)}
error={!!(errors as any)?.transfers?.[field.name]}
helperText={
(errors as any)?.transfers?.[field.name]?.message
}
margin="normal"
fullWidth
InputLabelProps={{ shrink: true }}
type="text"
label={field.label}
name={`transfers.${field.name}`}
/>
</Grid>
))}
</Grid>
</Paper>
</Box>
</Edit>
</Box>
</Modal>
);
});

View File

@ -1,25 +0,0 @@
import BedtimeIcon from '@mui/icons-material/Bedtime'
import PublicIcon from '@mui/icons-material/Public'
import LocationCityIcon from '@mui/icons-material/LocationCity'
import TramIcon from '@mui/icons-material/Tram'
import PermMediaIcon from '@mui/icons-material/PermMedia'
import FeedIcon from '@mui/icons-material/Feed'
import CastleIcon from '@mui/icons-material/Castle'
import HailIcon from '@mui/icons-material/Hail'
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus'
import ForkLeftIcon from '@mui/icons-material/ForkLeft'
import PeopleAltIcon from '@mui/icons-material/PeopleAlt'
export {
BedtimeIcon as ProjectIcon,
PublicIcon as CountryIcon,
LocationCityIcon as CityIcon,
TramIcon as CarrierIcon,
PermMediaIcon as MediaIcon,
FeedIcon as ArticleIcon,
CastleIcon as SightIcon,
HailIcon as StationIcon,
DirectionsBusIcon as VehicleIcon,
ForkLeftIcon as RouteIcon,
PeopleAltIcon as UsersIcon, // users icon
}

View File

@ -1,71 +0,0 @@
import { Box } from "@mui/material";
import { Languages, languageStore } from "@stores";
import { observer } from "mobx-react-lite";
export const LanguageSelector = observer(({
action
}: {action?: (lang: Languages) => void}) => {
const { language, setLanguageAction } = languageStore;
function handleLanguageChange(language: Languages) {
if(action) action(language);
else setLanguageAction(language);
}
return (
<Box
sx={{
display: "flex",
gap: 2,
height: "min-content"
}}
>
<Box
sx={{
cursor: "pointer",
flex: 1,
display: "flex",
justifyContent: "center",
bgcolor: language === "ru" ? "primary.main" : "transparent",
color: language === "ru" ? "white" : "inherit",
borderRadius: 1,
p: 1,
}}
onClick={() => handleLanguageChange("ru")}
>
RU
</Box>
<Box
sx={{
cursor: "pointer",
flex: 1,
display: "flex",
justifyContent: "center",
bgcolor: language === "en" ? "primary.main" : "transparent",
color: language === "en" ? "white" : "inherit",
borderRadius: 1,
p: 1,
}}
onClick={() => handleLanguageChange("en")}
>
EN
</Box>
<Box
sx={{
cursor: "pointer",
flex: 1,
display: "flex",
justifyContent: "center",
bgcolor: language === "zh" ? "primary.main" : "transparent",
color: language === "zh" ? "white" : "inherit",
borderRadius: 1,
p: 1,
}}
onClick={() => handleLanguageChange("zh")}
>
ZH
</Box>
</Box>
);
});

View File

@ -1,15 +0,0 @@
import { Logo } from "@/icons/Logo";
export default function SidebarTitle({ collapsed }: { collapsed: boolean }) {
return (
<div
style={{ display: "flex", alignItems: "center", whiteSpace: "nowrap" }}
>
<Logo width={40} height={40} />
{!collapsed && (
<span style={{ marginLeft: 8, fontWeight: "bold" }}>Белые ночи</span>
)}
</div>
);
}

View File

@ -1,5 +0,0 @@
export * from './Icons';
export * from './LanguageSelector';
export * from './SidebarTitle';
export * from './MediaView';
export * from './ModelViewer';

View File

@ -1,40 +0,0 @@
import React, {PropsWithChildren, createContext, useEffect, useState} from 'react'
import {ThemeProvider, createTheme} from '@mui/material/styles'
import {ruRU} from '@mui/material/locale'
import {CustomTheme} from './theme'
type ColorModeContextType = {
mode: string
setMode: () => void
}
export const ColorModeContext = createContext<ColorModeContextType>({} as ColorModeContextType)
export const ColorModeContextProvider: React.FC<PropsWithChildren> = ({children}) => {
const colorModeFromLocalStorage = localStorage.getItem('colorMode')
const isSystemPreferenceDark = window?.matchMedia('(prefers-color-scheme: dark)').matches
const systemPreference = isSystemPreferenceDark ? 'dark' : 'light'
const [mode, setMode] = useState(colorModeFromLocalStorage || systemPreference)
useEffect(() => {
window.localStorage.setItem('colorMode', mode)
}, [mode])
const setColorMode = () => {
setMode((prevMode) => (prevMode === 'light' ? 'dark' : 'light'))
}
const appliedTheme = createTheme(mode === 'light' ? CustomTheme.Light : CustomTheme.Dark, ruRU)
return (
<ColorModeContext.Provider
value={{
setMode: setColorMode,
mode,
}}
>
<ThemeProvider theme={appliedTheme}>{children}</ThemeProvider>
</ColorModeContext.Provider>
)
}

1
src/entities/index.ts Normal file
View File

@ -0,0 +1 @@
export * from "./navigation";

View File

@ -0,0 +1,2 @@
export * from "./ui";
export * from "./model";

View File

@ -0,0 +1,10 @@
import { LucideIcon } from "lucide-react";
export interface NavigationItem {
id: string;
label: string;
icon: LucideIcon;
path: string;
}
export type NavigationSection = "primary" | "secondary";

View File

@ -0,0 +1,82 @@
import * as React from "react";
import ListItem from "@mui/material/ListItem";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import type { NavigationItem } from "../model";
import { useNavigate } from "react-router-dom";
interface NavigationItemProps {
item: NavigationItem;
open: boolean;
onClick?: () => void;
}
export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
item,
open,
onClick,
}) => {
const Icon = item.icon;
const navigate = useNavigate();
return (
<ListItem
onClick={() => {
if (onClick) {
onClick();
} else {
navigate(item.path);
}
}}
disablePadding
sx={{ display: "block" }}
>
<ListItemButton
sx={[
{
minHeight: 48,
px: 2.5,
},
open
? {
justifyContent: "initial",
}
: {
justifyContent: "center",
},
]}
>
<ListItemIcon
sx={[
{
minWidth: 0,
justifyContent: "center",
},
open
? {
mr: 3,
}
: {
mr: "auto",
},
]}
>
<Icon />
</ListItemIcon>
<ListItemText
primary={item.label}
sx={[
open
? {
opacity: 1,
}
: {
opacity: 0,
},
]}
/>
</ListItemButton>
</ListItem>
);
};

1
src/features/index.ts Normal file
View File

@ -0,0 +1 @@
export * from "./navigation";

View File

@ -0,0 +1 @@
export * from "./ui";

View File

@ -0,0 +1,34 @@
import List from "@mui/material/List";
import Divider from "@mui/material/Divider";
import { NAVIGATION_ITEMS } from "@shared";
import { NavigationItem, NavigationItemComponent } from "@entities";
export const NavigationList = ({ open }: { open: boolean }) => {
const primaryItems = NAVIGATION_ITEMS.primary;
const secondaryItems = NAVIGATION_ITEMS.secondary;
return (
<>
<List>
{primaryItems.map((item) => (
<NavigationItemComponent
key={item.id}
item={item as NavigationItem}
open={open}
/>
))}
</List>
<Divider />
<List>
{secondaryItems.map((item) => (
<NavigationItemComponent
key={item.id}
item={item as NavigationItem}
open={open}
onClick={item.onClick ? item.onClick : undefined}
/>
))}
</List>
</>
);
};

View File

@ -1,25 +0,0 @@
@import "./stylesheets/hidden-functionality.css";
@import "./stylesheets/roles-functionality.css";
.limited-text {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.backup-button {
background-color: transparent;
border: none;
cursor: pointer;
padding: 0;
margin: 0;
width: 32px;
height: 32px;
color: rgba(79, 138, 95, 1);
border-radius: 10%;
transition: all 0.3s ease;
}
.backup-button:hover {
background-color: rgba(79, 138, 95, 0.05);
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.5 KiB

File diff suppressed because one or more lines are too long

9
src/index.css Normal file
View File

@ -0,0 +1,9 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
button {
cursor: pointer;
}
.mde-preview {
background-color: #f5f5f5;
}

View File

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

View File

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

View File

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

View File

@ -1,78 +0,0 @@
export const localeText = {
// Root
noRowsLabel: 'Нет данных',
noResultsOverlayLabel: 'Результаты не найдены.',
// Density selector toolbar button text
toolbarDensity: 'Плотность',
toolbarDensityLabel: 'Плотность',
toolbarDensityCompact: 'Компактная',
toolbarDensityStandard: 'Стандартная',
toolbarDensityComfortable: 'Комфортная',
// Columns selector toolbar button text
toolbarColumns: 'Столбцы',
toolbarColumnsLabel: 'Выбрать столбцы',
// Filters toolbar button text
toolbarFilters: 'Фильтры',
toolbarFiltersLabel: 'Показать фильтры',
toolbarFiltersTooltipHide: 'Скрыть фильтры',
toolbarFiltersTooltipShow: 'Показать фильтры',
toolbarFiltersTooltipActive: (count: number) => (count !== 1 ? `${count} активных фильтров` : `${count} активный фильтр`),
// Export selector toolbar button text
toolbarExport: 'Экспорт',
toolbarExportLabel: 'Экспорт',
toolbarExportCSV: 'Скачать CSV',
// Columns panel text
columnsPanelTextFieldLabel: 'Поиск столбца',
columnsPanelTextFieldPlaceholder: 'Название столбца',
columnsPanelDragIconLabel: 'Изменить порядок',
columnsPanelShowAllButton: 'Показать все',
columnsPanelHideAllButton: 'Скрыть все',
// Filter panel text
filterPanelOperator: 'Оператор',
filterPanelAddFilter: 'Добавить фильтр',
filterPanelDeleteIconLabel: 'Удалить',
filterPanelOperators: 'Операторы',
filterPanelOperatorAnd: 'И',
filterPanelOperatorOr: 'ИЛИ',
filterPanelColumns: 'Столбцы',
filterPanelInputLabel: 'Значение',
filterPanelInputPlaceholder: 'Фильтровать значение',
// Filter operators text
filterOperatorContains: 'содержит',
filterOperatorDoesNotContain: 'не содержит',
filterOperatorEquals: 'равно',
filterOperatorStartsWith: 'начинается с',
filterOperatorEndsWith: 'заканчивается на',
filterOperatorIs: 'является',
filterOperatorNot: 'не является',
filterOperatorAfter: 'после',
filterOperatorOnOrAfter: 'в или после',
filterOperatorBefore: 'до',
filterOperatorOnOrBefore: 'в или до',
filterOperatorIsEmpty: 'пусто',
filterOperatorIsNotEmpty: 'не пусто',
filterOperatorDoesNotEqual: 'не равно',
filterOperatorIsAnyOf: 'является одним из',
// Column menu text
columnMenuLabel: 'Меню',
columnMenuShowColumns: 'Показать столбцы',
columnMenuFilter: 'Фильтр',
columnMenuHideColumn: 'Скрыть столбец',
columnMenuUnsort: 'Сбросить сортировку',
columnMenuSortAsc: 'Сортировать по возрастанию',
columnMenuSortDesc: 'Сортировать по убыванию',
// Rows selected footer text
footerRowSelected: (count: number) => (count !== 1 ? `${count.toLocaleString()} строк выбрано` : `${count.toLocaleString()} строка выбрана`),
// Pagination footer text
footerPaginationRowsPerPage: 'Строк на странице:',
}

View File

@ -1,130 +0,0 @@
{
"actions": {
"create": "Создать",
"show": "Просмотр",
"edit": "Редактировать",
"delete": "Удалить",
"save": "Сохранить"
},
"buttons": {
"edit": "Редактировать",
"refresh": "Обновить",
"delete": "Удалить",
"save": "Сохранить",
"submit": "Отправить",
"create": "Создать",
"cancel": "Отмена",
"logout": "Выход"
},
"notifications": {
"createSuccess": "Успешно создано",
"createError": "Произошла ошибка при создании",
"editSuccess": "Успешно отредактировано",
"editError": "Произошла ошибка при редактировании",
"deleteSuccess": "Успешно удалено",
"deleteError": "Произошла ошибка при удалении",
"importSuccess": "Успешно импортировано",
"importError": "Произошла ошибка при импорте",
"exportSuccess": "Успешно экспортировано",
"exportError": "Произошла ошибка при экспорте",
"success": "Успешно",
"error": "Произошла ошибка"
},
"pages": {
"login": {
"title": "Вход в аккаунт",
"fields": {
"email": "Почта",
"password": "Пароль"
},
"buttons": {
"rememberMe": "Запомнить меня",
"forgotPassword": "Забыли пароль?",
"noAccount": "Нет аккаунта?"
},
"signin": "Войти",
"signup": "Зарегистрироваться",
"errors": {
"requiredEmail": "Электронная почта обязательна",
"requiredPassword": "Пароль обязателен"
}
}
},
"country": {
"titles": {
"create": "Создать страну",
"edit": "Редактировать страну",
"show": "Показать страну"
}
},
"city": {
"titles": {
"create": "Создать город",
"edit": "Редактировать город",
"show": "Показать город"
}
},
"carrier": {
"titles": {
"create": "Создать перевозчика",
"edit": "Редактировать перевозчика",
"show": "Показать перевозчика"
}
},
"media": {
"titles": {
"create": "Создать медиа",
"edit": "Редактировать медиа",
"show": "Показать медиа"
}
},
"article": {
"titles": {
"create": "Создать статью",
"edit": "Редактировать статью",
"show": "Показать статью"
}
},
"sight": {
"titles": {
"create": "Создать достопримечательность",
"edit": "Редактировать достопримечательность",
"show": "Показать достопримечательность"
}
},
"station": {
"titles": {
"create": "Создать остановку",
"edit": "Редактировать остановку",
"show": "Показать остановку"
}
},
"snapshots": {
"titles": {
"create": "Создать снапшот",
"show": "Показать снапшот"
}
},
"vehicle": {
"titles": {
"create": "Создать транспорт",
"edit": "Редактировать транспорт",
"show": "Показать транспорт"
}
},
"route": {
"titles": {
"create": "Создать маршрут",
"edit": "Редактировать маршрут",
"show": "Показать маршрут"
}
},
"user": {
"titles": {
"create": "Создать пользователя",
"edit": "Редактировать пользователя",
"show": "Показать пользователя"
}
}
}

8
src/main.tsx Normal file
View File

@ -0,0 +1,8 @@
import { createRoot } from "react-dom/client";
import "./index.css";
import { App } from "./app/index";
const container = document.getElementById("root");
const root = createRoot(container!);
root.render(<App />);

View File

@ -0,0 +1,86 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { articlesStore, languageStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Trash2, FileText } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { DeleteModal, LanguageSwitcher } from "@widgets";
export const ArticleListPage = observer(() => {
const { articleList, getArticleList } = articlesStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
const { language } = languageStore;
useEffect(() => {
getArticleList();
}, [language]);
const columns: GridColDef[] = [
{
field: "heading",
headerName: "Название",
flex: 1,
},
{
field: "actions",
headerName: "Действия",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/media/${params.row.id}`)}>
<FileText size={20} className="text-green-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = articleList.map((article) => ({
id: article.id,
heading: article.heading,
body: article.body,
}));
return (
<>
<LanguageSwitcher />
<div className="w-full">
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
getArticleList();
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
</>
);
});

View File

@ -0,0 +1 @@
export * from "./ArticleListPage";

View File

@ -0,0 +1,202 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { carrierStore, cityStore, mediaStore } from "@shared";
import { useState, useEffect } from "react";
import { LanguageSwitcher, MediaViewer } from "@widgets";
import { HexColorPicker } from "react-colorful";
export const CarrierCreatePage = observer(() => {
const navigate = useNavigate();
const [fullName, setFullName] = useState("");
const [shortName, setShortName] = useState("");
const [cityId, setCityId] = useState<number | null>(null);
const [primaryColor, setPrimaryColor] = useState("#000000");
const [secondaryColor, setSecondaryColor] = useState("#ffffff");
const [accentColor, setAccentColor] = useState("#ff0000");
const [slogan, setSlogan] = useState("");
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
cityStore.getCities();
mediaStore.getMedia();
}, []);
const handleCreate = async () => {
try {
setIsLoading(true);
await carrierStore.createCarrier(
fullName,
shortName,
cityStore.cities.find((c) => c.id === cityId)?.name!,
cityId!,
primaryColor,
secondaryColor,
accentColor,
slogan,
selectedMediaId!
);
toast.success("Перевозчик успешно создан");
navigate("/carrier");
} catch (error) {
toast.error("Ошибка при создании перевозчика");
} finally {
setIsLoading(false);
}
};
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate("/carrier")}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<FormControl fullWidth>
<InputLabel>Город</InputLabel>
<Select
value={cityId || ""}
label="Город"
required
onChange={(e) => setCityId(e.target.value as number)}
>
{cityStore.cities.map((city) => (
<MenuItem key={city.id} value={city.id}>
{city.name}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
fullWidth
label="Полное название"
value={fullName}
required
onChange={(e) => setFullName(e.target.value)}
/>
<TextField
fullWidth
label="Короткое название"
value={shortName}
required
onChange={(e) => setShortName(e.target.value)}
/>
<div className="w-full flex flex-col gap-4">
<div className="flex items-center gap-4">
<span className="w-32">Основной цвет:</span>
<Box
sx={{
width: 40,
height: 40,
backgroundColor: primaryColor,
border: "1px solid #ccc",
cursor: "pointer",
}}
/>
<HexColorPicker color={primaryColor} onChange={setPrimaryColor} />
</div>
<div className="flex items-center gap-4">
<span className="w-32">Вторичный цвет:</span>
<Box
sx={{
width: 40,
height: 40,
backgroundColor: secondaryColor,
border: "1px solid #ccc",
cursor: "pointer",
}}
/>
<HexColorPicker
color={secondaryColor}
onChange={setSecondaryColor}
/>
</div>
<div className="flex items-center gap-4">
<span className="w-32">Акцентный цвет:</span>
<Box
sx={{
width: 40,
height: 40,
backgroundColor: accentColor,
border: "1px solid #ccc",
cursor: "pointer",
}}
/>
<HexColorPicker color={accentColor} onChange={setAccentColor} />
</div>
</div>
<TextField
fullWidth
label="Слоган"
value={slogan}
onChange={(e) => setSlogan(e.target.value)}
/>
<div className="w-full flex flex-col gap-4">
<FormControl fullWidth>
<InputLabel>Логотип</InputLabel>
<Select
value={selectedMediaId || ""}
label="Логотип"
required
onChange={(e) => setSelectedMediaId(e.target.value as string)}
>
{mediaStore.media.map((media) => (
<MenuItem key={media.id} value={media.id}>
{media.media_name || media.filename}
</MenuItem>
))}
</Select>
</FormControl>
{selectedMediaId && (
<div className="w-32 h-32">
<MediaViewer media={{ id: selectedMediaId, media_type: 1 }} />
</div>
)}
</div>
<Button
variant="contained"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={
isLoading || !fullName || !shortName || !cityId || !selectedMediaId
}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Создать"
)}
</Button>
</div>
</Paper>
);
});

View File

@ -0,0 +1,101 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { carrierStore, languageStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { DeleteModal, LanguageSwitcher } from "@widgets";
export const CarrierListPage = observer(() => {
const { carriers, getCarriers, deleteCarrier } = carrierStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null); // Lifted state
const { language } = languageStore;
useEffect(() => {
getCarriers();
}, [language]);
const columns: GridColDef[] = [
{
field: "full_name",
headerName: "Полное имя",
width: 300,
},
{
field: "short_name",
headerName: "Короткое имя",
width: 200,
},
{
field: "city",
headerName: "Город",
flex: 1,
},
{
field: "actions",
headerName: "Действия",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/carrier/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = carriers.map((carrier) => ({
id: carrier.id,
full_name: carrier.full_name,
short_name: carrier.short_name,
city: carrier.city,
}));
return (
<>
<LanguageSwitcher />
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Перевозчики</h1>
{/* <CreateButton label="Создать перевозчика" path="/carrier/create" /> */}
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
deleteCarrier(rowId);
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
</>
);
});

View File

@ -0,0 +1,120 @@
import { Paper } from "@mui/material";
import { carrierStore, mediaStore } from "@shared";
import { MediaViewer } from "@widgets";
import { observer } from "mobx-react-lite";
import { ArrowLeft } from "lucide-react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
export const CarrierPreviewPage = observer(() => {
const { id } = useParams();
const { getCarrier, carrier } = carrierStore;
const { oneMedia, getOneMedia } = mediaStore;
const navigate = useNavigate();
useEffect(() => {
(async () => {
const carrierResponse = await getCarrier(Number(id));
await getOneMedia(carrierResponse?.logo as string);
})();
}, [id]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
{carrier && (
<>
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
{/* <div className="flex gap-2">
<Button
variant="contained"
color="primary"
onClick={() => navigate(`/carrier/${id}/edit`)}
startIcon={<Pencil size={20} />}
>
Редактировать
</Button>
<Button
variant="contained"
color="error"
onClick={() => navigate(`/carrier/${id}/delete`)}
startIcon={<Trash2 size={20} />}
>
Удалить
</Button>
</div> */}
</div>
<div className="flex flex-col gap-10 w-full">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Полное имя</h1>
<p>{carrier?.full_name}</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Полное имя</h1>
<p>{carrier?.full_name}</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Город</h1>
<p>{carrier?.city}</p>
</div>
<div className="flex flex-col gap-2 ">
<h1 className="text-lg font-bold">Основной цвет</h1>
<div
className="w-min"
style={{
backgroundColor: `${carrier?.main_color}90`,
}}
>
{carrier?.main_color}
</div>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Цвет левого виджета</h1>
<div
className="w-min"
style={{
backgroundColor: `${carrier?.left_color}90`,
}}
>
{carrier?.left_color}
</div>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Цвет правого виджета</h1>
<div
className="w-min"
style={{
backgroundColor: `${carrier?.right_color}90`,
}}
>
{carrier?.right_color}
</div>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Краткое имя</h1>
<p>{carrier?.short_name}</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Логотип</h1>
<MediaViewer
media={{
id: oneMedia?.id as string,
media_type: oneMedia?.media_type as number,
filename: oneMedia?.filename,
}}
/>
</div>
</div>
</>
)}
</Paper>
);
});

View File

@ -0,0 +1,3 @@
export * from "./CarrierListPage";
export * from "./CarrierPreviewPage";
export * from "./CarrierCreatePage";

View File

@ -0,0 +1,166 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
Box,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save, ImagePlus } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { cityStore, countryStore, mediaStore } from "@shared";
import { useState, useEffect } from "react";
import { LanguageSwitcher, MediaViewer } from "@widgets";
import { SelectMediaDialog } from "@shared";
export const CityCreatePage = observer(() => {
const navigate = useNavigate();
const [name, setName] = useState("");
const [countryCode, setCountryCode] = useState("");
const [selectedMediaId, setSelectedMediaId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
useEffect(() => {
countryStore.getCountries();
mediaStore.getMedia();
}, []);
const handleCreate = async () => {
try {
setIsLoading(true);
await cityStore.createCity(
name,
countryStore.countries.find((c) => c.code === countryCode)?.name!,
countryCode,
selectedMediaId!
);
toast.success("Город успешно создан");
navigate("/city");
} catch (error) {
toast.error("Ошибка при создании города");
} finally {
setIsLoading(false);
}
};
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setSelectedMediaId(media.id);
};
const selectedMedia = selectedMediaId
? mediaStore.media.find((m) => m.id === selectedMediaId)
: null;
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate("/city")}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
fullWidth
label="Название города"
value={name}
required
onChange={(e) => setName(e.target.value)}
/>
<FormControl fullWidth>
<InputLabel>Страна</InputLabel>
<Select
value={countryCode}
label="Страна"
required
onChange={(e) => setCountryCode(e.target.value)}
>
{countryStore.countries.map((country) => (
<MenuItem key={country.code} value={country.code}>
{country.name}
</MenuItem>
))}
</Select>
</FormControl>
<div className="w-full flex flex-col gap-4">
<label className="text-sm text-gray-600">Герб города</label>
<div className="flex items-center gap-4">
<Button
variant="outlined"
onClick={() => setIsSelectMediaOpen(true)}
startIcon={<ImagePlus size={20} />}
>
Выбрать герб
</Button>
{selectedMedia && (
<span className="text-sm text-gray-600">
{selectedMedia.media_name || selectedMedia.filename}
</span>
)}
</div>
{selectedMedia && (
<Box
sx={{
width: "200px",
height: "200px",
border: "1px solid #e0e0e0",
borderRadius: "8px",
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<MediaViewer
media={{
id: selectedMedia.id,
media_type: selectedMedia.media_type,
filename: selectedMedia.filename,
}}
/>
</Box>
)}
</div>
<Button
variant="contained"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={isLoading || !name || !countryCode}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Создать"
)}
</Button>
</div>
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={3} // Тип медиа для иконок
/>
</Paper>
);
});

View File

@ -0,0 +1,96 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { cityStore, languageStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
export const CityListPage = observer(() => {
const { cities, getCities, deleteCity } = cityStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const { language } = languageStore;
useEffect(() => {
getCities();
}, [language]);
const columns: GridColDef[] = [
{
field: "country",
headerName: "Страна",
width: 150,
},
{
field: "name",
headerName: "Название",
flex: 1,
},
{
field: "actions",
headerName: "Действия",
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/city/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = cities.map((city) => ({
id: city.id,
name: city.name,
country: city.country,
}));
return (
<>
<LanguageSwitcher />
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Города</h1>
<CreateButton label="Создать город" path="/city/create" />
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
deleteCity(rowId);
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
</>
);
});

View File

@ -0,0 +1,76 @@
import { Paper } from "@mui/material";
import { cityStore, mediaStore } from "@shared";
import { MediaViewer } from "@widgets";
import { observer } from "mobx-react-lite";
import { ArrowLeft } from "lucide-react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
export const CityPreviewPage = observer(() => {
const { id } = useParams();
const { getCity, city } = cityStore;
const { oneMedia, getOneMedia } = mediaStore;
const navigate = useNavigate();
useEffect(() => {
(async () => {
const cityResponse = await getCity(id as string);
await getOneMedia(cityResponse.arms as string);
})();
}, [id]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
{/* <div className="flex gap-2">
<Button
variant="contained"
color="primary"
onClick={() => navigate(`/city/${id}/edit`)}
startIcon={<Pencil size={20} />}
>
Редактировать
</Button>
<Button
variant="contained"
color="error"
onClick={() => navigate(`/city/${id}/edit`)}
startIcon={<Trash2 size={20} />}
>
Удалить
</Button>
</div> */}
</div>
<div className="flex flex-col gap-10 w-full">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Название</h1>
<p>{city?.name}</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Страна</h1>
<p>{city?.country}</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Герб</h1>
<div className="w-[300px] h-[200px]">
<MediaViewer
media={{
id: oneMedia?.id as string,
media_type: oneMedia?.media_type as number,
filename: oneMedia?.filename,
}}
/>
</div>
</div>
</div>
</Paper>
);
});

3
src/pages/City/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from "./CityListPage";
export * from "./CityPreviewPage";
export * from "./CityCreatePage";

View File

@ -0,0 +1,75 @@
import { Button, Paper, TextField } from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { countryStore } from "@shared";
import { useState } from "react";
import { LanguageSwitcher } from "@widgets";
export const CountryCreatePage = observer(() => {
const navigate = useNavigate();
const [name, setName] = useState("");
const [code, setCode] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleCreate = async () => {
try {
setIsLoading(true);
await countryStore.createCountry(code, name);
toast.success("Страна успешно создана");
navigate("/country");
} catch (error) {
toast.error("Ошибка при создании страны");
} finally {
setIsLoading(false);
}
};
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate("/country")}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
fullWidth
label="Код страны"
value={code}
required
onChange={(e) => setCode(e.target.value)}
/>
<TextField
fullWidth
label="Название"
value={name}
required
onChange={(e) => setName(e.target.value)}
/>
<Button
variant="contained"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={isLoading || !name || !code}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Создать"
)}
</Button>
</div>
</Paper>
);
});

View File

@ -0,0 +1,86 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { countryStore, languageStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
export const CountryListPage = observer(() => {
const { countries, getCountries } = countryStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
const { language } = languageStore;
useEffect(() => {
getCountries();
}, [language]);
const columns: GridColDef[] = [
{
field: "name",
headerName: "Название",
flex: 1,
},
{
field: "actions",
headerName: "Действия",
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/country/${params.row.code}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.code);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = countries.map((country) => ({
id: country.code,
code: country.code,
name: country.name,
}));
return (
<>
<LanguageSwitcher />
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Страны</h1>
<CreateButton label="Создать страну" path="/country/create" />
</div>
<DataGrid rows={rows} columns={columns} hideFooter />
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
await countryStore.deleteCountry(rowId);
getCountries(); // Refresh the list after deletion
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
</>
);
});

View File

@ -0,0 +1,58 @@
import { Paper } from "@mui/material";
import { countryStore } from "@shared";
import { observer } from "mobx-react-lite";
import { ArrowLeft } from "lucide-react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
export const CountryPreviewPage = observer(() => {
const { id } = useParams();
const { getCountry, country } = countryStore;
const navigate = useNavigate();
useEffect(() => {
(async () => {
await getCountry(id as string);
})();
}, [id]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
{/* <div className="flex gap-2">
<Button
variant="contained"
color="primary"
onClick={() => navigate(`/user/${id}/edit`)}
startIcon={<Pencil size={20} />}
>
Редактировать
</Button>
<Button
variant="contained"
color="error"
onClick={() => navigate(`/user/${id}/delete`)}
startIcon={<Trash2 size={20} />}
>
Удалить
</Button>
</div> */}
</div>
{country && (
<div className="flex flex-col gap-10 w-full">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Название</h1>
<p>{country?.name}</p>
</div>
</div>
)}
</Paper>
);
});

View File

@ -0,0 +1,3 @@
export * from "./CountryListPage";
export * from "./CountryPreviewPage";
export * from "./CountryCreatePage";

View File

@ -0,0 +1,93 @@
import { Box, Tab, Tabs } from "@mui/material";
import {
articlesStore,
cityStore,
createSightStore,
languageStore,
} from "@shared";
import {
CreateInformationTab,
CreateLeftTab,
CreateRightTab,
LeaveAgree,
} from "@widgets";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
function a11yProps(index: number) {
return {
id: `sight-tab-${index}`,
"aria-controls": `sight-tabpanel-${index}`,
};
}
import { useBlocker } from "react-router";
export const CreateSightPage = observer(() => {
const [value, setValue] = useState(0);
const { getCities } = cityStore;
const { getArticles } = articlesStore;
const { needLeaveAgree } = createSightStore;
const handleChange = (_: React.SyntheticEvent, newValue: number) => {
setValue(newValue);
};
let blocker = useBlocker(
({ currentLocation, nextLocation }) =>
needLeaveAgree && currentLocation.pathname !== nextLocation.pathname
);
useEffect(() => {
const fetchData = async () => {
await getCities();
await getArticles(languageStore.language);
};
fetchData();
}, []);
return (
<Box
sx={{
width: "100%",
display: "flex",
flexDirection: "column",
minHeight: "100vh",
z: 10,
}}
>
<Box
sx={{
borderBottom: 1,
borderColor: "divider",
display: "flex",
justifyContent: "center",
}}
>
<Tabs
value={value}
onChange={handleChange}
aria-label="sight tabs"
sx={{
width: "100%",
"& .MuiTabs-flexContainer": {
justifyContent: "center",
},
}}
>
<Tab sx={{ flex: 1 }} label="Общая информация" {...a11yProps(0)} />
<Tab sx={{ flex: 1 }} label="Левый виджет" {...a11yProps(1)} />
<Tab sx={{ flex: 1 }} label="Правый виджет" {...a11yProps(2)} />
</Tabs>
</Box>
<div className="flex-1">
<CreateInformationTab value={value} index={0} />
<CreateLeftTab value={value} index={1} />
<CreateRightTab value={value} index={2} />
</div>
{blocker.state === "blocked" ? <LeaveAgree blocker={blocker} /> : null}
</Box>
);
});

View File

@ -0,0 +1,9 @@
import { DevicesTable } from "@widgets";
export const DevicesPage = () => {
return (
<>
<DevicesTable />
</>
);
};

View File

@ -0,0 +1,94 @@
import { Box, Tab, Tabs } from "@mui/material";
import { InformationTab, LeaveAgree, RightWidgetTab } from "@widgets";
import { LeftWidgetTab } from "@widgets";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import {
articlesStore,
cityStore,
editSightStore,
languageStore,
} from "@shared";
import { useBlocker, useParams } from "react-router-dom";
function a11yProps(index: number) {
return {
id: `sight-tab-${index}`,
"aria-controls": `sight-tabpanel-${index}`,
};
}
export const EditSightPage = observer(() => {
const [value, setValue] = useState(0);
const { sight, getSightInfo, needLeaveAgree } = editSightStore;
const { getArticles } = articlesStore;
const { language } = languageStore;
const { id } = useParams();
const { getCities } = cityStore;
let blocker = useBlocker(
({ currentLocation, nextLocation }) =>
needLeaveAgree && currentLocation.pathname !== nextLocation.pathname
);
const handleChange = (_: React.SyntheticEvent, newValue: number) => {
setValue(newValue);
};
useEffect(() => {
const fetchData = async () => {
if (id) {
await getSightInfo(+id, language);
await getArticles(language);
await getCities();
}
};
fetchData();
}, [id, language]);
return (
<Box
sx={{
width: "100%",
display: "flex",
flexDirection: "column",
minHeight: "100vh",
}}
>
<Box
sx={{
borderBottom: 1,
borderColor: "divider",
display: "flex",
justifyContent: "center",
}}
>
<Tabs
value={value}
onChange={handleChange}
aria-label="sight tabs"
sx={{
width: "100%",
"& .MuiTabs-flexContainer": {
justifyContent: "center",
},
}}
>
<Tab sx={{ flex: 1 }} label="Общая информация" {...a11yProps(0)} />
<Tab sx={{ flex: 1 }} label="Левый виджет" {...a11yProps(1)} />
<Tab sx={{ flex: 1 }} label="Правый виджет" {...a11yProps(2)} />
</Tabs>
</Box>
{sight.common.id !== 0 && (
<div className="flex-1">
<InformationTab value={value} index={0} />
<LeftWidgetTab value={value} index={1} />
<RightWidgetTab value={value} index={2} />
</div>
)}
{blocker.state === "blocked" ? <LeaveAgree blocker={blocker} /> : null}
</Box>
);
});

View File

@ -0,0 +1,119 @@
import {
TextField,
Box,
Button,
Typography,
Alert,
CircularProgress,
} from "@mui/material";
import { authStore } from "@shared";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
export const LoginPage = () => {
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const { login } = authStore;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsLoading(true);
try {
await login(email, password);
navigate("/sight");
toast.success("Вход в систему выполнен успешно");
} catch (err) {
setError(
err instanceof Error ? err.message : "Ошибка при входе в систему"
);
toast.error(
err instanceof Error ? err.message : "Ошибка при входе в систему"
);
} finally {
setIsLoading(false);
}
};
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
gap: 3,
p: 3,
}}
>
<Typography variant="h4" component="h1" gutterBottom>
Вход в систему
</Typography>
<Box
component="form"
onSubmit={handleSubmit}
sx={{
display: "flex",
flexDirection: "column",
gap: 2,
width: "100%",
maxWidth: "400px",
}}
>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<TextField
label="Email"
type="email"
variant="outlined"
fullWidth
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
error={!!error}
/>
<TextField
label="Пароль"
type="password"
variant="outlined"
fullWidth
required
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
error={!!error}
/>
<Button
variant="contained"
color="primary"
size="large"
type="submit"
disabled={isLoading}
sx={{
width: "100%",
height: "50px",
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "10px",
}}
>
{isLoading ? <CircularProgress size={24} sx={{}} /> : "Войти"}
</Button>
</Box>
</Box>
);
};

View File

@ -0,0 +1,37 @@
import * as React from "react";
import Typography from "@mui/material/Typography";
export const MainPage: React.FC = () => {
return (
<>
<Typography sx={{ marginBottom: 2 }}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Rhoncus dolor purus
non enim praesent elementum facilisis leo vel. Risus at ultrices mi
tempus imperdiet. Semper risus in hendrerit gravida rutrum quisque non
tellus. Convallis convallis tellus id interdum velit laoreet id donec
ultrices. Odio morbi quis commodo odio aenean sed adipiscing. Amet nisl
suscipit adipiscing bibendum est ultricies integer quis. Cursus euismod
quis viverra nibh cras. Metus vulputate eu scelerisque felis imperdiet
proin fermentum leo. Mauris commodo quis imperdiet massa tincidunt. Cras
tincidunt lobortis feugiat vivamus at augue. At augue eget arcu dictum
varius duis at consectetur lorem. Velit sed ullamcorper morbi tincidunt.
Lorem donec massa sapien faucibus et molestie ac.
</Typography>
<Typography sx={{ marginBottom: 2 }}>
Consequat mauris nunc congue nisi vitae suscipit. Fringilla est
ullamcorper eget nulla facilisi etiam dignissim diam. Pulvinar elementum
integer enim neque volutpat ac tincidunt. Ornare suspendisse sed nisi
lacus sed viverra tellus. Purus sit amet volutpat consequat mauris.
Elementum eu facilisis sed odio morbi. Euismod lacinia at quis risus sed
vulputate odio. Morbi tincidunt ornare massa eget egestas purus viverra
accumsan in. In hendrerit gravida rutrum quisque non tellus orci ac.
Pellentesque nec nam aliquam sem et tortor. Habitant morbi tristique
senectus et. Adipiscing elit duis tristique sollicitudin nibh sit.
Ornare aenean euismod elementum nisi quis eleifend. Commodo viverra
maecenas accumsan lacus vel facilisis. Nulla posuere sollicitudin
aliquam ultrices sagittis orci a.
</Typography>
</>
);
};

1594
src/pages/MapPage/index.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,89 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
} from "@mui/material";
import { mediaStore, MEDIA_TYPE_LABELS } from "@shared";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
export const MediaCreatePage = observer(() => {
const navigate = useNavigate();
const [name, setName] = useState("");
const [type, setType] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleCreate = async () => {
try {
setIsLoading(true);
await mediaStore.createMedia(name, type);
toast.success("Медиа успешно создано");
navigate("/media");
} catch (error) {
toast.error("Ошибка при создании медиа");
} finally {
setIsLoading(false);
}
};
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<h1 className="text-2xl font-bold">Создание медиа</h1>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
className="w-full"
label="Название"
required
value={name}
onChange={(e) => setName(e.target.value)}
/>
<FormControl fullWidth>
<InputLabel>Тип</InputLabel>
<Select
value={type}
label="Тип"
onChange={(e) => setType(e.target.value)}
required
>
{Object.entries(MEDIA_TYPE_LABELS).map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={isLoading || !name || !type}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Создать"
)}
</Button>
</div>
</Paper>
);
});

View File

@ -0,0 +1,295 @@
import { useEffect, useState, useRef } from "react";
import { observer } from "mobx-react-lite";
import { useNavigate, useParams } from "react-router-dom";
import {
Box,
Button,
CircularProgress,
FormControl,
InputLabel,
MenuItem,
Paper,
Select,
TextField,
Typography,
Alert,
Snackbar,
} from "@mui/material";
import { Save, ArrowLeft } from "lucide-react";
import { authInstance, mediaStore, MEDIA_TYPE_LABELS } from "@shared";
import { MediaViewer } from "@widgets";
export const MediaEditPage = observer(() => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [newFile, setNewFile] = useState<File | null>(null);
const [uploadDialogOpen, setUploadDialogOpen] = useState(false); // State for the upload dialog
const media = id ? mediaStore.media.find((m) => m.id === id) : null;
const [mediaName, setMediaName] = useState(media?.media_name ?? "");
const [mediaFilename, setMediaFilename] = useState(media?.filename ?? "");
const [mediaType, setMediaType] = useState(media?.media_type ?? 1);
const [availableMediaTypes, setAvailableMediaTypes] = useState<number[]>([]);
useEffect(() => {
if (id) {
mediaStore.getOneMedia(id);
}
console.log(newFile);
console.log(uploadDialogOpen);
}, [id]);
useEffect(() => {
if (media) {
setMediaName(media.media_name);
setMediaFilename(media.filename);
setMediaType(media.media_type);
// Set available media types based on current file extension
const extension = media.filename.split(".").pop()?.toLowerCase();
if (extension) {
if (["glb", "gltf"].includes(extension)) {
setAvailableMediaTypes([6]); // 3D model
} else if (["jpg", "jpeg", "png", "gif"].includes(extension)) {
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
} else if (["mp4", "webm", "mov"].includes(extension)) {
setAvailableMediaTypes([2]); // Video
}
}
}
}, [media]);
// const handleDrop = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault();
// e.stopPropagation();
// setIsDragging(false);
// const files = Array.from(e.dataTransfer.files);
// if (files.length > 0) {
// setNewFile(files[0]);
// setMediaFilename(files[0].name);
// setUploadDialogOpen(true); // Open dialog on file drop
// }
// };
// const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
// e.preventDefault();
// setIsDragging(true);
// };
// const handleDragLeave = () => {
// setIsDragging(false);
// };
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
const file = files[0];
setNewFile(file);
setMediaFilename(file.name);
// Determine media type based on file extension
const extension = file.name.split(".").pop()?.toLowerCase();
if (extension) {
if (["glb", "gltf"].includes(extension)) {
setAvailableMediaTypes([6]); // 3D model
setMediaType(6);
} else if (["jpg", "jpeg", "png", "gif"].includes(extension)) {
setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama
setMediaType(1); // Default to Photo
} else if (["mp4", "webm", "mov"].includes(extension)) {
setAvailableMediaTypes([2]); // Video
setMediaType(2);
}
}
setUploadDialogOpen(true); // Open dialog on file selection
}
};
const handleSave = async () => {
if (!id) return;
setIsLoading(true);
setError(null);
try {
await authInstance.patch(`/media/${id}`, {
media_name: mediaName,
filename: mediaFilename,
type: mediaType,
});
// If a new file was selected, the actual file upload will happen
// via the UploadMediaDialog. We just need to make sure the metadata
// is updated correctly before or after.
// Since the dialog handles the actual upload, we don't call updateMediaFile here.
setSuccess(true);
handleUploadSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save media");
} finally {
setIsLoading(false);
}
};
const handleUploadSuccess = () => {
// After successful upload in the dialog, refresh media data if needed
if (id) {
mediaStore.getOneMedia(id);
}
setNewFile(null); // Clear the new file state after successful upload
setUploadDialogOpen(false);
setSuccess(true);
};
if (!media && id) {
// Only show loading if an ID is present and media is not yet loaded
return (
<Box className="flex justify-center items-center h-screen">
<CircularProgress />
</Box>
);
}
return (
<Box className="p-6 max-w-4xl mx-auto">
<Box className="flex items-center gap-4 mb-6">
<Button
variant="outlined"
startIcon={<ArrowLeft size={20} />}
onClick={() => navigate("/media")}
>
Назад
</Button>
<Typography variant="h5">Редактирование медиа</Typography>
</Box>
<Paper className="p-6">
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileSelect}
accept="image/*,video/*,.glb,.gltf"
/>
<Box className="flex flex-col gap-6">
<Box className="flex gap-4">
<TextField
fullWidth
value={mediaName}
onChange={(e) => setMediaName(e.target.value)}
label="Название медиа"
disabled={isLoading}
/>
<TextField
fullWidth
value={mediaFilename}
onChange={(e) => setMediaFilename(e.target.value)}
label="Название файла"
disabled={isLoading}
/>
</Box>
<FormControl fullWidth sx={{ width: "50%" }}>
<InputLabel>Тип медиа</InputLabel>
<Select
value={mediaType}
label="Тип медиа"
onChange={(e) => setMediaType(Number(e.target.value))}
disabled={isLoading}
>
{availableMediaTypes.length > 0
? availableMediaTypes.map((type) => (
<MenuItem key={type} value={type}>
{
MEDIA_TYPE_LABELS[
type as keyof typeof MEDIA_TYPE_LABELS
]
}
</MenuItem>
))
: Object.entries(MEDIA_TYPE_LABELS).map(([type, label]) => (
<MenuItem key={type} value={Number(type)}>
{label}
</MenuItem>
))}
</Select>
</FormControl>
<Box className="flex gap-6">
<Paper
elevation={2}
sx={{
flex: 1,
p: 2,
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 400,
}}
>
<MediaViewer
media={{
id: id || "",
media_type: mediaType,
filename: mediaFilename,
}}
/>
</Paper>
<Box className="flex flex-col gap-4 self-end">
<Button
variant="contained"
color="primary"
startIcon={<Save size={20} />}
onClick={handleSave}
disabled={isLoading || (!mediaName && !mediaFilename)}
>
{isLoading ? <CircularProgress size={20} /> : "Сохранить"}
</Button>
{/* Only show "Replace file" button if no new file is currently selected */}
</Box>
</Box>
{error && (
<Typography color="error" className="mt-2">
{error}
</Typography>
)}
{success && (
<Typography color="success.main" className="mt-2">
Медиа успешно сохранено
</Typography>
)}
</Box>
</Paper>
<Snackbar
open={!!error}
autoHideDuration={6000}
onClose={() => setError(null)}
>
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
</Snackbar>
<Snackbar
open={success}
autoHideDuration={2000}
onClose={() => setSuccess(false)}
>
<Alert severity="success" onClose={() => setSuccess(false)}>
Медиа успешно сохранено
</Alert>
</Snackbar>
</Box>
);
});

View File

@ -0,0 +1,110 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
export const MediaListPage = observer(() => {
const { media, getMedia, deleteMedia } = mediaStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
const { language } = languageStore;
useEffect(() => {
getMedia();
}, [language]);
const columns: GridColDef[] = [
{
field: "media_name",
headerName: "Название",
flex: 1,
},
{
field: "media_type",
headerName: "Тип",
width: 200,
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<p>
{
MEDIA_TYPE_LABELS[
params.row.media_type as keyof typeof MEDIA_TYPE_LABELS
]
}
</p>
);
},
},
{
field: "actions",
headerName: "Действия",
flex: 1,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/media/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = media.map((media) => ({
id: media.id,
media_name: media.media_name,
media_type: media.media_type,
}));
return (
<>
<LanguageSwitcher />
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Медиа</h1>
<CreateButton label="Создать медиа" path="/media/create" />
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
deleteMedia(rowId.toString());
getMedia();
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
</>
);
});

View File

@ -0,0 +1,44 @@
import { mediaStore } from "@shared";
import { useEffect } from "react";
import { useParams } from "react-router-dom";
import { MediaViewer } from "@widgets";
import { observer } from "mobx-react-lite";
import { Download } from "lucide-react";
import { Button } from "@mui/material";
export const MediaPreviewPage = observer(() => {
const { id } = useParams();
const { oneMedia, getOneMedia } = mediaStore;
useEffect(() => {
getOneMedia(id!);
}, []);
return (
<div className="w-full h-[80vh] flex flex-col justify-center items-center gap-4">
<div className="w-full h-full flex justify-center items-center">
<MediaViewer className="w-full h-full" media={oneMedia!} />
</div>
{oneMedia && (
<div className="flex flex-col items-center gap-4 bg-[#998879] p-4 rounded-md">
<p className="text-white text-center">
Чтобы скачать файл, нажмите на кнопку ниже
</p>
<Button
variant="contained"
color="primary"
startIcon={<Download size={16} />}
component="a"
href={`${
import.meta.env.VITE_KRBL_MEDIA
}${id}/download?token=${localStorage.getItem("token")}`}
target="_blank"
>
Скачать
</Button>
</div>
)}
</div>
);
});

3
src/pages/Media/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from "./MediaEditPage";
export * from "./MediaListPage";
export * from "./MediaPreviewPage";

View File

@ -0,0 +1,86 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
export const RouteCreatePage = observer(() => {
const navigate = useNavigate();
const [routeNumber, setRouteNumber] = useState("");
const [direction, setDirection] = useState("");
const [isLoading, setIsLoading] = useState(false);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<h1 className="text-2xl font-bold">Создание маршрута</h1>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
className="w-full"
label="Номер маршрута"
required
value={routeNumber}
onChange={(e) => setRouteNumber(e.target.value)}
/>
<FormControl fullWidth>
<InputLabel>Направление</InputLabel>
<Select
value={direction}
label="Направление"
onChange={(e) => setDirection(e.target.value)}
required
>
<MenuItem value="forward">Прямое</MenuItem>
<MenuItem value="backward">Обратное</MenuItem>
</Select>
</FormControl>
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={async () => {
try {
setIsLoading(true);
// await createRoute(routeNumber, direction === "forward");
setIsLoading(false);
toast.success("Маршрут успешно создан");
navigate(-1);
} catch (error) {
console.error(error);
toast.error("Произошла ошибка");
} finally {
setIsLoading(false);
}
}}
disabled={isLoading}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Сохранить"
)}
</Button>
</div>
</Paper>
);
});

View File

@ -0,0 +1,115 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, routeStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
export const RouteListPage = observer(() => {
const { routes, getRoutes, deleteRoute } = routeStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null); // Lifted state
const { language } = languageStore;
useEffect(() => {
getRoutes();
}, [language]);
const columns: GridColDef[] = [
{
field: "carrier",
headerName: "Перевозчик",
width: 250,
},
{
field: "route_number",
headerName: "Номер маршрута",
flex: 1,
},
{
field: "route_direction",
headerName: "Направление",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<p
className={
params.row.route_direction === "Прямой"
? "text-green-500"
: "text-red-500"
}
>
{params.row.route_direction}
</p>
);
},
},
{
field: "actions",
headerName: "Действия",
width: 140,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/route/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = routes.map((route) => ({
id: route.id,
carrier: route.carrier,
route_number: route.route_number,
route_direction: route.route_direction ? "Прямой" : "Обратный",
}));
return (
<>
<LanguageSwitcher />
<div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Маршруты</h1>
<CreateButton label="Создать маршрут" path="/route/create" />
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
deleteRoute(rowId);
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
</>
);
});

1
src/pages/Route/index.ts Normal file
View File

@ -0,0 +1 @@
export * from "./RouteListPage";

View File

@ -0,0 +1,103 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, sightsStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Pencil, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
export const SightListPage = observer(() => {
const { sights, getSights, deleteListSight } = sightsStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
const { language } = languageStore;
useEffect(() => {
getSights();
}, [language]);
const columns: GridColDef[] = [
{
field: "name",
headerName: "Имя",
flex: 1,
},
{
field: "city",
headerName: "Город",
flex: 1,
},
{
field: "actions",
headerName: "Действия",
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/sight/${params.row.id}`)}>
<Pencil size={20} className="text-blue-500" />
</button>
{/* <button onClick={() => navigate(`/sight/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button> */}
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = sights.map((sight) => ({
id: sight.id,
name: sight.name,
city: sight.city,
}));
return (
<>
<LanguageSwitcher />
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Достопримечательности</h1>
<CreateButton
label="Создать достопримечательность"
path="/sight/create"
/>
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
await deleteListSight(Number(rowId));
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
</>
);
});

1
src/pages/Sight/index.ts Normal file
View File

@ -0,0 +1 @@
export * from "./SightListPage";

View File

@ -0,0 +1,9 @@
import { SightsTable } from "@widgets";
export const SightPage = () => {
return (
<>
<SightsTable />
</>
);
};

View File

@ -0,0 +1,69 @@
import { Button, Paper, TextField } from "@mui/material";
import { snapshotStore } from "@shared";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
export const SnapshotCreatePage = observer(() => {
const { id } = useParams();
const { getSnapshot, createSnapshot } = snapshotStore;
const navigate = useNavigate();
const [name, setName] = useState("");
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
(async () => {
await getSnapshot(id as string);
})();
}, [id]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<h1 className="text-2xl font-bold">Создание снапшота</h1>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
className="w-full"
label="Название"
required
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={async () => {
try {
setIsLoading(true);
await createSnapshot(name);
setIsLoading(false);
toast.success("Снапшот успешно создан");
} catch (error) {
console.error(error);
}
}}
disabled={isLoading}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Сохранить"
)}
</Button>
</div>
</Paper>
);
});

View File

@ -0,0 +1,125 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, snapshotStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { DatabaseBackup, Eye, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import {
CreateButton,
DeleteModal,
LanguageSwitcher,
SnapshotRestore,
} from "@widgets";
export const SnapshotListPage = observer(() => {
const { snapshots, getSnapshots, deleteSnapshot, restoreSnapshot } =
snapshotStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<string | null>(null); // Lifted state
const { language } = languageStore;
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
useEffect(() => {
getSnapshots();
}, [language]);
const columns: GridColDef[] = [
{
field: "name",
headerName: "Название",
flex: 1,
},
{
field: "parent",
headerName: "Родитель",
flex: 1,
},
{
field: "actions",
headerName: "Действия",
width: 300,
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button
onClick={() => {
setIsRestoreModalOpen(true);
setRowId(params.row.id);
}}
>
<DatabaseBackup size={20} className="text-blue-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = snapshots.map((snapshot) => ({
id: snapshot.ID,
name: snapshot.Name,
parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name,
}));
return (
<>
<LanguageSwitcher />
<div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl ">Снапшоты</h1>
<CreateButton label="Создать снапшот" path="/snapshot/create" />
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
await deleteSnapshot(rowId);
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
<SnapshotRestore
open={isRestoreModalOpen}
onDelete={async () => {
if (rowId) {
await restoreSnapshot(rowId);
}
setIsRestoreModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsRestoreModalOpen(false);
setRowId(null);
}}
/>
</>
);
});

View File

@ -0,0 +1,3 @@
export * from "./SnapshotListPage";
export * from "./SnapshotCreatePage";

View File

@ -0,0 +1,94 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { stationsStore } from "@shared";
import { useState } from "react";
export const StationCreatePage = observer(() => {
const navigate = useNavigate();
const [name, setName] = useState("");
const [systemName, setSystemName] = useState("");
const [direction, setDirection] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleCreate = async () => {
try {
setIsLoading(true);
await stationsStore.createStation(name, systemName, direction);
toast.success("Станция успешно создана");
navigate("/station");
} catch (error) {
toast.error("Ошибка при создании станции");
} finally {
setIsLoading(false);
}
};
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<h1 className="text-2xl font-bold">Создание станции</h1>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
className="w-full"
label="Название"
required
value={name}
onChange={(e) => setName(e.target.value)}
/>
<TextField
className="w-full"
label="Системное название"
required
value={systemName}
onChange={(e) => setSystemName(e.target.value)}
/>
<FormControl fullWidth>
<InputLabel>Направление</InputLabel>
<Select
value={direction}
label="Направление"
onChange={(e) => setDirection(e.target.value)}
required
>
<MenuItem value="forward">Прямое</MenuItem>
<MenuItem value="backward">Обратное</MenuItem>
</Select>
</FormControl>
<Button
variant="contained"
color="primary"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={isLoading || !name || !systemName || !direction}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Создать"
)}
</Button>
</div>
</Paper>
);
});

View File

@ -0,0 +1,117 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, stationsStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
export const StationListPage = observer(() => {
const { stations, getStations, deleteStation } = stationsStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null); // Lifted state
const { language } = languageStore;
useEffect(() => {
getStations();
}, [language]);
const columns: GridColDef[] = [
{
field: "name",
headerName: "Название",
flex: 1,
},
{
field: "system_name",
headerName: "Системное название",
flex: 1,
},
{
field: "direction",
headerName: "Направление",
width: 200,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<p
className={
params.row.direction === true ? "text-green-500" : "text-red-500"
}
>
{params.row.direction ? "Прямой" : "Обратный"}
</p>
);
},
},
{
field: "actions",
headerName: "Действия",
width: 140,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/station/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = stations.map((station) => ({
id: station.id,
name: station.name,
system_name: station.system_name,
direction: station.direction,
}));
return (
<>
<LanguageSwitcher />
<div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Станции</h1>
<CreateButton label="Создать станцию" path="/station/create" />
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
deleteStation(rowId);
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
</>
);
});

View File

@ -0,0 +1 @@
export * from "./StationListPage";

View File

@ -0,0 +1,109 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { languageStore, userStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { DeleteModal, LanguageSwitcher } from "@widgets";
export const UserListPage = observer(() => {
const { users, getUsers, deleteUser } = userStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null); // Lifted state
const { language } = languageStore;
useEffect(() => {
getUsers();
}, [language]);
const columns: GridColDef[] = [
{
field: "name",
headerName: "Имя",
width: 400,
},
{
field: "email",
headerName: "Email",
width: 400,
},
{
field: "is_admin",
headerName: "Роль",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<p
className={
params.row.is_admin === true ? "text-green-500" : "text-red-500"
}
>
{params.row.is_admin ? "Администратор" : "Пользователь"}
</p>
);
},
},
{
field: "actions",
headerName: "Действия",
flex: 1,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = users.map((user) => ({
id: user.id,
email: user.email,
is_admin: user.is_admin,
name: user.name,
}));
return (
<>
<LanguageSwitcher />
<div style={{ width: "100%" }}>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
await deleteUser(rowId);
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
</>
);
});

1
src/pages/User/index.ts Normal file
View File

@ -0,0 +1 @@
export * from "./UserListPage";

View File

@ -0,0 +1,117 @@
import {
Button,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
} from "@mui/material";
import { vehicleStore, VEHICLE_TYPES, carrierStore } from "@shared";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { LanguageSwitcher } from "@widgets";
export const VehicleCreatePage = observer(() => {
const navigate = useNavigate();
const [tailNumber, setTailNumber] = useState("");
const [type, setType] = useState("");
const [carrierId, setCarrierId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
carrierStore.getCarriers();
}, []);
const handleCreate = async () => {
try {
setIsLoading(true);
await vehicleStore.createVehicle(
Number(tailNumber),
type,
carrierStore.carriers.find((c) => c.id === carrierId)?.full_name!,
carrierId!
);
toast.success("Транспорт успешно создан");
} catch (error) {
toast.error("Ошибка при создании транспорта");
} finally {
setIsLoading(false);
}
};
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher />
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate("/vehicle")}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
fullWidth
label="Бортовой номер"
value={tailNumber}
required
onChange={(e) => setTailNumber(e.target.value)}
/>
<FormControl fullWidth>
<InputLabel>Тип</InputLabel>
<Select
value={type}
label="Тип"
required
onChange={(e) => setType(e.target.value)}
>
{VEHICLE_TYPES.map((type) => (
<MenuItem key={type.value} value={type.value}>
{type.label}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Перевозчик</InputLabel>
<Select
value={carrierId || ""}
label="Перевозчик"
required
onChange={(e) => setCarrierId(e.target.value as number)}
>
{carrierStore.carriers.map((carrier) => (
<MenuItem key={carrier.id} value={carrier.id}>
{carrier.full_name}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="contained"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={isLoading || !tailNumber || !type || !carrierId}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Создать"
)}
</Button>
</div>
</Paper>
);
});

View File

@ -0,0 +1,133 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { carrierStore, languageStore, vehicleStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Eye, Trash2 } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { VEHICLE_TYPES } from "@shared";
export const VehicleListPage = observer(() => {
const { vehicles, getVehicles, deleteVehicle } = vehicleStore;
const { carriers, getCarriers } = carrierStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [rowId, setRowId] = useState<number | null>(null);
const { language } = languageStore;
useEffect(() => {
getVehicles();
getCarriers();
}, [language]);
const columns: GridColDef[] = [
{
field: "tail_number",
headerName: "Бортовой номер",
flex: 1,
align: "center",
headerAlign: "center",
},
{
field: "type",
headerName: "Тип",
flex: 1,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
{VEHICLE_TYPES.find((type) => type.value === params.row.type)
?.label || params.row.type}
</div>
);
},
},
{
field: "carrier",
headerName: "Перевозчик",
flex: 1,
align: "center",
headerAlign: "center",
},
{
field: "city",
headerName: "Город",
flex: 1,
align: "center",
headerAlign: "center",
},
{
field: "actions",
headerName: "Действия",
flex: 1,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams) => {
return (
<div className="flex h-full gap-7 justify-center items-center">
<button onClick={() => navigate(`/vehicle/${params.row.id}`)}>
<Eye size={20} className="text-green-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
);
},
},
];
const rows = vehicles.map((vehicle) => ({
id: vehicle.vehicle.id,
tail_number: vehicle.vehicle.tail_number,
type: vehicle.vehicle.type,
carrier: vehicle.vehicle.carrier,
city: carriers.find((carrier) => carrier.id === vehicle.vehicle.carrier_id)
?.city,
}));
return (
<>
<LanguageSwitcher />
<div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Транспортные средства</h1>
<CreateButton
label="Создать транспортное средство"
path="/vehicle/create"
/>
</div>
<DataGrid
rows={rows}
columns={columns}
hideFooterPagination
hideFooter
/>
</div>
<DeleteModal
open={isDeleteModalOpen}
onDelete={async () => {
if (rowId) {
await deleteVehicle(rowId);
}
setIsDeleteModalOpen(false);
setRowId(null);
}}
onCancel={() => {
setIsDeleteModalOpen(false);
setRowId(null);
}}
/>
</>
);
});

View File

@ -0,0 +1,70 @@
import { Paper } from "@mui/material";
import { vehicleStore, VEHICLE_TYPES } from "@shared";
import { observer } from "mobx-react-lite";
import { ArrowLeft } from "lucide-react";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
export const VehiclePreviewPage = observer(() => {
const { id } = useParams();
const { getVehicle, vehicle } = vehicleStore;
const navigate = useNavigate();
useEffect(() => {
(async () => {
await getVehicle(Number(id));
})();
}, [id]);
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex justify-between items-center">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
{/* <div className="flex gap-2">
<Button
variant="contained"
color="primary"
onClick={() => navigate(`/vehicle/${id}/edit`)}
startIcon={<Pencil size={20} />}
>
Редактировать
</Button>
<Button
variant="contained"
color="error"
onClick={() => navigate(`/vehicle/${id}/delete`)}
startIcon={<Trash2 size={20} />}
>
Удалить
</Button>
</div> */}
</div>
{vehicle && (
<div className="flex flex-col gap-10 w-full">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Системный номер</h1>
<p>{vehicle?.vehicle.tail_number}</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Тип транспортного средства</h1>
<p>
{VEHICLE_TYPES.find(
(type) => type.value === vehicle?.vehicle.type
)?.label || vehicle?.vehicle.type}
</p>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-lg font-bold">Перевозчик</h1>
<p>{vehicle?.vehicle.carrier}</p>
</div>
</div>
)}
</Paper>
);
});

View File

@ -0,0 +1,3 @@
export * from "./VehicleListPage";
export * from "./VehiclePreviewPage";
export * from "./VehicleCreatePage";

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More