init: Init React Application

This commit is contained in:
Илья Куприец 2025-05-29 13:21:33 +03:00
parent 9444939507
commit 17de7e495f
66 changed files with 10425 additions and 0 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
VITE_REACT_APP = 'https://wn.krbl.ru/'

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)

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,
},
})
```

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 },
],
},
},
)

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5255
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
package.json Normal file
View File

@ -0,0 +1,47 @@
{
"name": "white-nights",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/material": "^7.1.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",
"path": "^0.12.7",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.6.1",
"react-simplemde-editor": "^5.2.0",
"react-toastify": "^11.0.5",
"rehype-raw": "^7.0.0",
"tailwindcss": "^4.1.8"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^22.15.24",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"
}
}

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

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

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

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

@ -0,0 +1,33 @@
import { DevicesPage, LoginPage, MainPage, SightPage } from "@pages";
import { authStore } from "@shared";
import { Layout } from "@widgets";
import { Navigate, Outlet, Route, Routes } from "react-router-dom";
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated } = authStore;
if (!isAuthenticated) {
return <Navigate to="/login" />;
}
return children;
};
export const Router = () => {
return (
<Routes>
<Route
path="/"
element={
<Layout>
<Outlet />
</Layout>
}
>
<Route path="/" element={<MainPage />} />
<Route path="/sights" element={<SightPage />} />
<Route path="/devices" element={<DevicesPage />} />
</Route>
<Route path="/login" element={<LoginPage />} />
</Routes>
);
};

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

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,74 @@
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;
}
export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
item,
open,
}) => {
const Icon = item.icon;
const navigate = useNavigate();
return (
<ListItem
onClick={() => 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,25 @@
import List from "@mui/material/List";
import Divider from "@mui/material/Divider";
import { NAVIGATION_ITEMS } from "@shared";
import { 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} open={open} />
))}
</List>
<Divider />
<List>
{secondaryItems.map((item) => (
<NavigationItemComponent key={item.id} item={item} open={open} />
))}
</List>
</>
);
};

5
src/index.css Normal file
View File

@ -0,0 +1,5 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
button {
cursor: pointer;
}

7
src/main.tsx Normal file
View File

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

View File

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

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("/sights");
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>
</>
);
};

View File

@ -0,0 +1,61 @@
import { Box, Tab, Tabs } from "@mui/material";
import { InformationTab, RightWidgetTab } from "@widgets";
import { LeftWidgetTab } from "@widgets";
import { useState } from "react";
function a11yProps(index: number) {
return {
id: `sight-tab-${index}`,
"aria-controls": `sight-tabpanel-${index}`,
};
}
export const SightPage = () => {
const [value, setValue] = useState(0);
const handleChange = (_: React.SyntheticEvent, newValue: number) => {
setValue(newValue);
};
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>
<div className="flex-1">
<InformationTab value={value} index={0} />
<LeftWidgetTab value={value} index={1} />
<RightWidgetTab value={value} index={2} />
</div>
</Box>
);
};

4
src/pages/index.ts Normal file
View File

@ -0,0 +1,4 @@
export * from "./MainPage";
export * from "./SightPage";
export * from "./LoginPage";
export * from "./DevicesPage";

12
src/shared/api/index.tsx Normal file
View File

@ -0,0 +1,12 @@
import axios from "axios";
const authInstance = axios.create({
baseURL: "https://wn.krbl.ru",
});
authInstance.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${localStorage.getItem("token")}`;
return config;
});
export { authInstance };

View File

@ -0,0 +1,37 @@
import { Power, LucideIcon, Building2, MonitorSmartphone } from "lucide-react";
export const DRAWER_WIDTH = 300;
interface NavigationItem {
id: string;
label: string;
icon: LucideIcon;
path: string;
}
export const NAVIGATION_ITEMS: {
primary: NavigationItem[];
secondary: NavigationItem[];
} = {
primary: [
{
id: "attractions",
label: "Достопримечательности",
icon: Building2,
path: "/sights",
},
{
id: "devices",
label: "Устройства",
icon: MonitorSmartphone,
path: "/devices",
},
],
secondary: [
{
id: "logout",
label: "Выйти",
icon: Power,
path: "/logout",
},
],
};

View File

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

View File

@ -0,0 +1 @@
export const API_URL = "https://wn.krbl.ru";

6
src/shared/index.tsx Normal file
View File

@ -0,0 +1,6 @@
export * from "./config";
export * from "./lib";
export * from "./ui";
export * from "./store";
export * from "./const";
export * from "./api";

View File

@ -0,0 +1,27 @@
export const decodeJWT = (token?: string): any | null => {
if (!token) {
console.error("No token provided");
return null;
}
try {
const base64Url = token.split(".")[1];
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const jsonPayload = decodeURIComponent(
atob(base64)
.split("")
.map((c) => `%${("00" + c.charCodeAt(0).toString(16)).slice(-2)}`)
.join("")
);
const decodedPayload: any = JSON.parse(jsonPayload);
if (decodedPayload.exp && Date.now() >= decodedPayload.exp * 1000) {
return null;
}
return decodedPayload;
} catch {
return null;
}
};

2
src/shared/lib/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from "./mui/theme";
export * from "./DecodeJWT";

View File

@ -0,0 +1,17 @@
import { createTheme } from "@mui/material/styles";
export const theme = createTheme({
// You can customize your theme here
palette: {
mode: "light",
},
components: {
MuiDrawer: {
styleOverrides: {
paper: {
backgroundColor: "#fff",
},
},
},
},
});

View File

@ -0,0 +1,96 @@
import { API_URL, decodeJWT } from "@shared";
import { makeAutoObservable } from "mobx";
import axios, { AxiosError } from "axios";
type LoginResponse = {
token: string;
user: {
id: number;
name: string;
email: string;
is_admin: boolean;
};
};
class AuthStore {
payload: LoginResponse | null = null;
isLoading = false;
error: string | null = null;
constructor() {
makeAutoObservable(this);
this.initialize();
}
private initialize() {
const storedToken = localStorage.getItem("token") || undefined;
const decoded = decodeJWT(storedToken);
if (decoded) {
this.payload = decoded;
// Set the token in axios defaults for future requests
if (storedToken) {
axios.defaults.headers.common[
"Authorization"
] = `Bearer ${storedToken}`;
}
} else {
// If token is invalid or missing, clear it
this.logout();
}
}
private setAuthToken(token: string) {
localStorage.setItem("token", token);
axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
}
login = async (email: string, password: string) => {
this.isLoading = true;
this.error = null;
try {
const response = await axios.post<LoginResponse>(
`${API_URL}/auth/login`,
{
email,
password,
}
);
const { token } = response.data;
// Update auth token and store state
this.setAuthToken(token);
this.payload = response.data;
this.error = null;
} catch (error) {
if (error instanceof AxiosError) {
this.error =
error.response?.data?.message || "Ошибка при входе в систему";
} else {
this.error = "Неизвестная ошибка при входе в систему";
}
throw new Error(this.error ?? "Неизвестная ошибка при входе в систему");
} finally {
this.isLoading = false;
}
};
logout = () => {
localStorage.removeItem("token");
this.payload = null;
this.error = null;
};
get isAuthenticated() {
return !!this.payload?.token;
}
get user() {
return this.payload?.user;
}
}
export const authStore = new AuthStore();

View File

@ -0,0 +1,28 @@
import { API_URL, authInstance } from "@shared";
import { makeAutoObservable } from "mobx";
class DevicesStore {
devices: any[] = [];
uuid: string | null = null;
sendSnapshotModalOpen = false;
constructor() {
makeAutoObservable(this);
}
getDevices = async () => {
const response = await authInstance.get(`${API_URL}/devices/connected`);
console.log(response.data);
this.devices = response.data;
};
setSelectedDevice = (uuid: string) => {
this.uuid = uuid;
};
toggleSendSnapshotModal = () => {
this.sendSnapshotModalOpen = !this.sendSnapshotModalOpen;
};
}
export const devicesStore = new DevicesStore();

View File

@ -0,0 +1,15 @@
import { makeAutoObservable } from "mobx";
class LanguageStore {
language: string = "ru";
constructor() {
makeAutoObservable(this);
}
setLanguage = (language: string) => {
this.language = language;
};
}
export const languageStore = new LanguageStore();

View File

@ -0,0 +1,18 @@
import { authInstance } from "@shared";
import { API_URL } from "@shared";
import { makeAutoObservable } from "mobx";
class SnapshotStore {
snapshots: any[] = [];
constructor() {
makeAutoObservable(this);
}
getSnapshots = async () => {
const response = await authInstance.get(`${API_URL}/snapshots`);
this.snapshots = response.data;
};
}
export const snapshotStore = new SnapshotStore();

View File

@ -0,0 +1,17 @@
import { API_URL, authInstance } from "@shared";
import { makeAutoObservable } from "mobx";
class VehicleStore {
vehicles: any[] = [];
constructor() {
makeAutoObservable(this);
}
getVehicles = async () => {
const response = await authInstance.get(`${API_URL}/vehicle`);
this.vehicles = response.data;
};
}
export const vehicleStore = new VehicleStore();

View File

@ -0,0 +1,5 @@
export * from "./AuthStore";
export * from "./LanguageStore";
export * from "./DevicesStore";
export * from "./VehicleStore";
export * from "./SnapshotStore";

View File

@ -0,0 +1,13 @@
import { MoveLeft } from "lucide-react";
import { useNavigate } from "react-router-dom";
export const BackButton = () => {
const navigate = useNavigate();
return (
<button className="flex items-center gap-2" onClick={() => navigate(-1)}>
<MoveLeft />
Назад
</button>
);
};

View File

@ -0,0 +1,3 @@
export const Input = () => {
return <input type="text" />;
};

View File

@ -0,0 +1,45 @@
import { Modal as MuiModal, Typography, Box } from "@mui/material";
interface ModalProps {
open: boolean;
onClose: () => void;
children: React.ReactNode;
title?: string;
}
const style = {
position: "absolute" as const,
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 400,
bgcolor: "background.paper",
boxShadow: 24,
p: 4,
borderRadius: 2,
};
export const Modal = ({ open, onClose, children, title }: ModalProps) => {
return (
<MuiModal
open={open}
onClose={onClose}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<Box sx={style}>
{title && (
<Typography
id="modal-modal-title"
variant="h6"
component="h2"
gutterBottom
>
{title}
</Typography>
)}
<Box id="modal-modal-description">{children}</Box>
</Box>
</MuiModal>
);
};

View File

@ -0,0 +1,23 @@
import { Box } from "@mui/material";
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
export const TabPanel = (props: TabPanelProps) => {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
id={`sight-tabpanel-${index}`}
aria-labelledby={`sight-tab-${index}`}
{...other}
>
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
</div>
);
};

3
src/shared/ui/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from "./TabPanel";
export * from "./BackButton";
export * from "./Modal";

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,206 @@
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import Paper from "@mui/material/Paper";
import { Check, RotateCcw, Send, X } from "lucide-react";
import { devicesStore, Modal, snapshotStore, vehicleStore } from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Button, Checkbox } from "@mui/material";
const formatDate = (dateString: string | undefined) => {
if (!dateString) return "Нет данных";
try {
const date = new Date(dateString);
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(date);
} catch (error) {
console.error("Error formatting date:", error);
return "Некорректная дата";
}
};
function createData(
uuid: string,
online: boolean,
lastUpdate: string,
gps: boolean,
media: boolean,
connection: boolean
) {
return { uuid, online, lastUpdate, gps, media, connection };
}
const rows = (devices: any[], vehicles: any[]) => {
return devices.map((device) => {
const { device_status } = vehicles.find(
(v) => v?.device_status?.device_uuid === device
);
const findVehicle = vehicles.find((v) => v?.vehicle?.uuid === device);
console.log(findVehicle);
return createData(
findVehicle?.vehicle?.tail_number ?? "1243000",
device_status?.online,
device_status?.last_update,
device_status?.gps_ok,
device_status?.media_service_ok,
device_status?.is_connected
);
});
};
export const DevicesTable = observer(() => {
const {
devices,
getDevices,
uuid,
setSelectedDevice,
sendSnapshotModalOpen,
toggleSendSnapshotModal,
} = devicesStore;
const { snapshots, getSnapshots } = snapshotStore;
const { vehicles, getVehicles } = vehicleStore;
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
useEffect(() => {
const fetchData = async () => {
await getVehicles();
await getDevices();
await getSnapshots();
};
fetchData();
}, []);
const handleSendSnapshot = (uuid: string[]) => {
setSelectedDevice(uuid);
toggleSendSnapshotModal();
};
const handleReloadStatus = (uuid: string) => {
setSelectedDevice(uuid);
};
const handleSelectDevice = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
setSelectedDevices([...selectedDevices, event.target.value]);
} else {
setSelectedDevices(
selectedDevices.filter((device) => device !== event.target.value)
);
}
};
return (
<>
<TableContainer component={Paper}>
<div className="flex justify-end p-3 gap-5">
<Button variant="contained" color="primary">
Выбрать все
</Button>
<Button
variant="contained"
color="primary"
disabled={selectedDevices.length === 0}
className="ml-auto"
onClick={() => handleSendSnapshot(selectedDevices)}
>
Отправить снапшот
</Button>
</div>
<Table sx={{ minWidth: 650 }} aria-label="simple table">
<TableHead>
<TableRow>
<TableCell align="center"></TableCell>
<TableCell align="center">Бортовой номер</TableCell>
<TableCell align="center">Онлайн</TableCell>
<TableCell align="center">Последнее обновление</TableCell>
<TableCell align="center">ГПС</TableCell>
<TableCell align="center">Медиа-данные</TableCell>
<TableCell align="center">Подключение</TableCell>
<TableCell align="center">Перезапросить</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows(devices, vehicles).map((row) => (
<TableRow
key={row?.uuid}
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
className="flex items-center"
>
<TableCell align="center">
<Checkbox
className="h-full"
onChange={handleSelectDevice}
value={row?.uuid}
/>
</TableCell>
<TableCell align="center" component="th" scope="row">
{row?.uuid}
</TableCell>
<TableCell align="center">
{row?.online ? (
<Check className="m-auto text-green-500" />
) : (
<X className="m-auto text-red-500" />
)}
</TableCell>
<TableCell align="center">
{formatDate(row?.lastUpdate)}
</TableCell>
<TableCell align="center">
{row?.gps ? (
<Check className="m-auto text-green-500" />
) : (
<X className="m-auto text-red-500" />
)}
</TableCell>
<TableCell align="center">
{row?.media ? (
<Check className="m-auto text-green-500" />
) : (
<X className="m-auto text-red-500" />
)}
</TableCell>
<TableCell align="center">
{row?.connection ? (
<Check className="m-auto text-green-500" />
) : (
<X className="m-auto text-red-500" />
)}
</TableCell>
<TableCell align="center" className="flex justify-center">
<button onClick={() => handleReloadStatus(row?.uuid ?? "")}>
<RotateCcw className="m-auto" />
</button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Modal open={sendSnapshotModalOpen} onClose={toggleSendSnapshotModal}>
<p>Выбрать снапшот</p>
<div className="mt-5 flex flex-col gap-2 max-h-[300px] overflow-y-auto">
{snapshots &&
snapshots.map((snapshot) => (
<button className="p-2 rounded-xl bg-slate-100" key={snapshot.id}>
{snapshot.Name}
</button>
))}
</div>
</Modal>
</>
);
});

View File

@ -0,0 +1,29 @@
import { languageStore } from "@shared";
import { observer } from "mobx-react-lite";
export const LanguageSwitcher = observer(() => {
const { language, setLanguage } = languageStore;
return (
<div className="flex flex-col gap-2">
<button
className={`p-3 ${language === "ru" ? "bg-blue-500" : ""}`}
onClick={() => setLanguage("ru")}
>
RU
</button>
<button
className={`p-3 ${language === "en" ? "bg-blue-500" : ""}`}
onClick={() => setLanguage("en")}
>
EN
</button>
<button
className={`p-3 ${language === "zh" ? "bg-blue-500" : ""}`}
onClick={() => setLanguage("zh")}
>
zh
</button>
</div>
);
});

View File

@ -0,0 +1,67 @@
import * as React from "react";
import Box from "@mui/material/Box";
import Toolbar from "@mui/material/Toolbar";
import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
import { Menu, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { useTheme } from "@mui/material/styles";
import { AppBar } from "./ui/AppBar";
import { Drawer } from "./ui/Drawer";
import { DrawerHeader } from "./ui/DrawerHeader";
import { NavigationList } from "@features";
interface LayoutProps {
children: React.ReactNode;
}
export const Layout: React.FC<LayoutProps> = ({ children }) => {
const theme = useTheme();
const [open, setOpen] = React.useState(false);
const handleDrawerOpen = () => {
setOpen(true);
};
const handleDrawerClose = () => {
setOpen(false);
};
return (
<Box sx={{ display: "flex" }}>
<AppBar position="fixed" open={open}>
<Toolbar>
<IconButton
color="inherit"
aria-label="open drawer"
onClick={handleDrawerOpen}
edge="start"
sx={[
{
marginRight: 5,
},
open && { display: "none" },
]}
>
<Menu />
</IconButton>
</Toolbar>
</AppBar>
<Drawer variant="permanent" open={open}>
<DrawerHeader>
<IconButton onClick={handleDrawerClose}>
{theme.direction === "rtl" ? (
<ChevronRightIcon />
) : (
<ChevronLeftIcon />
)}
</IconButton>
</DrawerHeader>
<NavigationList open={open} />
</Drawer>
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
<DrawerHeader />
{children}
</Box>
</Box>
);
};

View File

@ -0,0 +1,27 @@
import { styled } from "@mui/material/styles";
import MuiAppBar, {
type AppBarProps as MuiAppBarProps,
} from "@mui/material/AppBar";
import { DRAWER_WIDTH } from "@shared";
interface AppBarProps extends MuiAppBarProps {
open?: boolean;
}
export const AppBar = styled(MuiAppBar, {
shouldForwardProp: (prop) => prop !== "open",
})<AppBarProps>(({ theme, open }) => ({
zIndex: theme.zIndex.drawer + 1,
transition: theme.transitions.create(["width", "margin"], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
...(open && {
marginLeft: DRAWER_WIDTH,
width: `calc(100% - ${DRAWER_WIDTH}px)`,
transition: theme.transitions.create(["width", "margin"], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
}),
}));

View File

@ -0,0 +1,42 @@
import { styled } from "@mui/material/styles";
import MuiDrawer from "@mui/material/Drawer";
import type { Theme, CSSObject } from "@mui/material/styles";
import { DRAWER_WIDTH } from "@shared";
const openedMixin = (theme: Theme): CSSObject => ({
width: DRAWER_WIDTH,
transition: theme.transitions.create("width", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
overflowX: "hidden",
});
const closedMixin = (theme: Theme): CSSObject => ({
transition: theme.transitions.create("width", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
overflowX: "hidden",
width: `calc(${theme.spacing(7)} + 1px)`,
[theme.breakpoints.up("sm")]: {
width: `calc(${theme.spacing(8)} + 1px)`,
},
});
export const Drawer = styled(MuiDrawer, {
shouldForwardProp: (prop) => prop !== "open",
})(({ theme, open }) => ({
width: DRAWER_WIDTH,
flexShrink: 0,
whiteSpace: "nowrap",
boxSizing: "border-box",
...(open && {
...openedMixin(theme),
"& .MuiDrawer-paper": openedMixin(theme),
}),
...(!open && {
...closedMixin(theme),
"& .MuiDrawer-paper": closedMixin(theme),
}),
}));

View File

@ -0,0 +1,10 @@
import { styled } from "@mui/material/styles";
import type { Theme } from "@mui/material/styles";
export const DrawerHeader = styled("div")(({ theme }: { theme: Theme }) => ({
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
padding: theme.spacing(0, 1),
...theme.mixins.toolbar,
}));

View File

@ -0,0 +1,50 @@
import { Box } from "@mui/material";
import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw";
export const ReactMarkdownComponent = ({ value }: { value: string }) => {
return (
<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]}>{value}</ReactMarkdown>
</Box>
);
};

View File

@ -0,0 +1,109 @@
import { styled } from "@mui/material/styles";
import SimpleMDE, { SimpleMDEReactProps } from "react-simplemde-editor";
import "easymde/dist/easymde.min.css";
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: 1000,
},
"& .guide": {
display: "none",
},
}));
export const ReactMarkdownEditor = (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

@ -0,0 +1,33 @@
import { Unlink } from "lucide-react";
import { Trash2 } from "lucide-react";
import { TextField } from "@mui/material";
import { ReactMarkdownEditor } from "@widgets";
export const SightEdit = () => {
return (
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-3">
<div className="flex items-center gap-2 justify-end">
<button className="flex items-center gap-2 border border-gray-300 bg-blue-100 rounded-md px-2 py-1">
Открепить
<Unlink />
</button>
<button className="flex items-center gap-2 border border-gray-300 bg-red-100 rounded-md px-2 py-1">
Удалить
<Trash2 />
</button>
</div>
<TextField label="Заголовок" />
<ReactMarkdownEditor />
</div>
<div className="flex flex-col gap-3 w-[350px]">
<p>Превью</p>
<div className=" w-full bg-red-500">1</div>
</div>
</div>
);
};

View File

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

View File

@ -0,0 +1,3 @@
export const SightHeader = () => {
return <div>SightHeader</div>;
};

View File

@ -0,0 +1,48 @@
import { TextField } from "@mui/material";
import { BackButton, TabPanel } from "@shared";
import { LanguageSwitcher } from "@widgets";
export const InformationTab = ({
value,
index,
}: {
value: number;
index: number;
}) => {
return (
<TabPanel value={value} index={index}>
<div className="flex-1 flex flex-col relative">
<div className="flex-1 flex flex-col gap-10">
<BackButton />
<div className="flex flex-col gap-5 w-1/2">
<TextField label="Название" />
<TextField label="Адрес" />
<TextField label="Город" />
<TextField label="Координаты" />
<div className="flex justify-around w-full mt-20">
<div className="flex flex-col gap-2 ">
<p>Логотип</p>
<button>Выбрать</button>
</div>
<div className="flex flex-col gap-2">
<p>Водяной знак (л.в)</p>
<button>Выбрать</button>
</div>
<div className="flex flex-col gap-2">
<p>Водяной знак (п.в)</p>
<button>Выбрать</button>
</div>
</div>
</div>
</div>
<button className="bg-green-400 w-min ml-auto text-white py-2 rounded-2xl px-4">
Сохранить
</button>
<div className="absolute top-1/2 -translate-y-1/2 right-0">
<LanguageSwitcher />
</div>
</div>
</TabPanel>
);
};

View File

@ -0,0 +1,60 @@
import { TextField } from "@mui/material";
import { BackButton, TabPanel } from "@shared";
import { ReactMarkdownComponent, ReactMarkdownEditor } from "@widgets";
import { Trash2 } from "lucide-react";
import { Unlink } from "lucide-react";
import { useState } from "react";
export const LeftWidgetTab = ({
value,
index,
}: {
value: number;
index: number;
}) => {
const [leftArticleData, setLeftArticleData] = useState(" ");
return (
<TabPanel value={value} index={index}>
<div className="flex flex-col gap-5">
<BackButton />
<div className="flex items-center justify-between px-5 h-14 rounded-md border">
<p className="text-2xl">Левая статья</p>
<div className="flex items-center gap-5">
<button className="flex items-center gap-2 border border-gray-300 bg-blue-100 rounded-md px-2 py-1">
Открепить
<Unlink />
</button>
<button className="flex items-center gap-2 border border-gray-300 bg-red-100 rounded-md px-2 py-1">
Удалить
<Trash2 />
</button>
</div>
</div>
<div className="flex gap-5">
<div className="flex flex-col gap-5 flex-1">
<TextField sx={{ width: "30%" }} label="Название" />
<ReactMarkdownEditor
value={leftArticleData}
onChange={setLeftArticleData}
/>
</div>
<div className="flex flex-col gap-2">
<p>Предпросмотр</p>
<div className="bg-yellow-200 w-[350px] h-full">
<div className="bg-red-100 w-full h-[200px]"></div>
<div className="bg-blue-100 w-full text-lg p-3"></div>
<div className="bg-green-100 p-3 prose max-w-none">
<ReactMarkdownComponent value={leftArticleData} />
</div>
</div>
</div>
</div>
<button className="bg-green-400 w-min ml-auto text-white py-2 rounded-2xl px-4">
Сохранить
</button>
</div>
</TabPanel>
);
};

View File

@ -0,0 +1,73 @@
import { BackButton, TabPanel } from "@shared";
import { SightEdit } from "@widgets";
import { Plus } from "lucide-react";
export const RightWidgetTab = ({
value,
index,
}: {
value: number;
index: number;
}) => {
return (
<TabPanel value={value} index={index}>
{/* Ensure the main container takes full height and uses flexbox for layout */}
<div className="flex flex-col h-full min-h-[600px]">
{/* Content area with back button and main layout */}
<div className="flex-1 flex flex-col gap-6 p-4">
{" "}
{/* Added padding for better spacing */}
<BackButton />
<div className="flex flex-1 gap-6">
{" "}
{/* flex-1 allows this div to take remaining height */}
{/* Left sidebar */}
<div className="flex flex-col justify-between w-[240px] shrink-0 bg-gray-500 rounded-lg p-3">
{" "}
{/* Added background and padding */}
<div className="flex flex-col gap-3">
<div className="border rounded-lg p-3 bg-white font-medium shadow-sm">
{" "}
{/* Adjusted background and added shadow */}
Превью медиа
</div>
<div className="border rounded-lg p-3 bg-white hover:bg-gray-100 transition-colors cursor-pointer shadow-sm">
{" "}
{/* Adjusted background and added shadow */}1 История
</div>
<div className="border rounded-lg p-3 bg-white hover:bg-gray-100 transition-colors cursor-pointer shadow-sm">
{" "}
{/* Adjusted background and added shadow */}2 Факты
</div>
</div>
<button className="w-10 h-10 rounded-full bg-blue-500 hover:bg-blue-600 text-white p-3transition-colors flex items-center justify-center">
{" "}
{/* Added margin-top */}
<Plus />
</button>
</div>
{/* Main content area */}
<div className="flex-1 border rounded-lg p-6 bg-white shadow-md">
{" "}
{/* Added shadow for depth */}
{/* Content within the main area */}
<SightEdit />
{/* Replaced '1' with more descriptive content */}
</div>
</div>
</div>
{/* Save button at the bottom, aligned to the right */}
<div className="flex justify-end p-4">
{" "}
{/* Wrapper for save button, added padding */}
<button className="bg-green-500 hover:bg-green-600 text-white py-2.5 px-6 rounded-lg transition-colors font-medium shadow-md">
{" "}
{/* Added shadow */}
Сохранить
</button>
</div>
</div>
</TabPanel>
);
};

View File

@ -0,0 +1,3 @@
export * from "./InformationTab";
export * from "./LeftWidgetTab";
export * from "./RightWidgetTab";

8
src/widgets/index.ts Normal file
View File

@ -0,0 +1,8 @@
export * from "./Layout";
export * from "./SightHeader";
export * from "./SightTabs";
export * from "./ReactMarkdown";
export * from "./ReactMarkdownEditor";
export * from "./SightEdit";
export * from "./LanguageSwitcher";
export * from "./DevicesTable";

35
tsconfig.app.json Normal file
View File

@ -0,0 +1,35 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@shared": ["src/shared"],
"@entities": ["src/entities"],
"@features": ["src/features"],
"@widgets": ["src/widgets"],
"@pages": ["src/pages"],
"@app": ["src/app"]
}
},
"include": ["src"]
}

30
tsconfig.json Normal file
View File

@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@shared": ["src/shared"],
"@entities": ["src/entities"],
"@features": ["src/features"],
"@widgets": ["src/widgets"],
"@pages": ["src/pages"],
"@app": ["src/app"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

33
tsconfig.node.json Normal file
View File

@ -0,0 +1,33 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@shared": ["src/shared"],
"@entities": ["src/entities"],
"@features": ["src/features"],
"@widgets": ["src/widgets"],
"@pages": ["src/pages"],
"@app": ["src/app"]
}
},
"include": ["vite.config.ts"]
}

19
vite.config.ts Normal file
View File

@ -0,0 +1,19 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@shared": path.resolve(__dirname, "src/shared"),
"@entities": path.resolve(__dirname, "src/entities"),
"@features": path.resolve(__dirname, "src/features"),
"@widgets": path.resolve(__dirname, "src/widgets"),
"@pages": path.resolve(__dirname, "src/pages"),
"@app": path.resolve(__dirname, "src/app"),
},
},
});

3342
yarn.lock Normal file

File diff suppressed because it is too large Load Diff