feat: Add edit/create/list
sight page
This commit is contained in:
parent
17de7e495f
commit
e2ca6b4132
@ -1,10 +1,10 @@
|
|||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/png" href="/favicon_ship.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Vite + React + TS</title>
|
<title>Белые ночи</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
@ -1,4 +1,11 @@
|
|||||||
import { DevicesPage, LoginPage, MainPage, SightPage } from "@pages";
|
import {
|
||||||
|
CreateSightPage,
|
||||||
|
DevicesPage,
|
||||||
|
EditSightPage,
|
||||||
|
LoginPage,
|
||||||
|
MainPage,
|
||||||
|
SightPage,
|
||||||
|
} from "@pages";
|
||||||
import { authStore } from "@shared";
|
import { authStore } from "@shared";
|
||||||
import { Layout } from "@widgets";
|
import { Layout } from "@widgets";
|
||||||
|
|
||||||
@ -23,7 +30,9 @@ export const Router = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route path="/" element={<MainPage />} />
|
<Route path="/" element={<MainPage />} />
|
||||||
<Route path="/sights" element={<SightPage />} />
|
<Route path="/sight" element={<SightPage />} />
|
||||||
|
<Route path="/sight/:id" element={<EditSightPage />} />
|
||||||
|
<Route path="/sight/create" element={<CreateSightPage />} />
|
||||||
<Route path="/devices" element={<DevicesPage />} />
|
<Route path="/devices" element={<DevicesPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
BIN
src/assets/favicon_ship.png
Normal file
BIN
src/assets/favicon_ship.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.3 KiB |
61
src/pages/CreateSightPage/index.tsx
Normal file
61
src/pages/CreateSightPage/index.tsx
Normal 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 CreateSightPage = () => {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
78
src/pages/EditSightPage/index.tsx
Normal file
78
src/pages/EditSightPage/index.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { Box, Tab, Tabs } from "@mui/material";
|
||||||
|
import { InformationTab, RightWidgetTab } from "@widgets";
|
||||||
|
import { LeftWidgetTab } from "@widgets";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { languageStore, sightsStore } from "@shared";
|
||||||
|
import { 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, getSight } = sightsStore;
|
||||||
|
const { language } = languageStore;
|
||||||
|
const { id } = useParams();
|
||||||
|
|
||||||
|
const handleChange = (_: React.SyntheticEvent, newValue: number) => {
|
||||||
|
setValue(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
if (id) {
|
||||||
|
await getSight(Number(id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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 && (
|
||||||
|
<div className="flex-1">
|
||||||
|
<InformationTab value={value} index={0} />
|
||||||
|
<LeftWidgetTab data={sight} value={value} index={1} />
|
||||||
|
<RightWidgetTab data={sight} value={value} index={2} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
@ -26,7 +26,7 @@ export const LoginPage = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await login(email, password);
|
await login(email, password);
|
||||||
navigate("/sights");
|
navigate("/sight");
|
||||||
toast.success("Вход в систему выполнен успешно");
|
toast.success("Вход в систему выполнен успешно");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
|
@ -1,61 +1,9 @@
|
|||||||
import { Box, Tab, Tabs } from "@mui/material";
|
import { SightsTable } from "@widgets";
|
||||||
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 = () => {
|
export const SightPage = () => {
|
||||||
const [value, setValue] = useState(0);
|
|
||||||
|
|
||||||
const handleChange = (_: React.SyntheticEvent, newValue: number) => {
|
|
||||||
setValue(newValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<>
|
||||||
sx={{
|
<SightsTable />
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
export * from "./MainPage";
|
export * from "./MainPage";
|
||||||
export * from "./SightPage";
|
export * from "./EditSightPage";
|
||||||
export * from "./LoginPage";
|
export * from "./LoginPage";
|
||||||
export * from "./DevicesPage";
|
export * from "./DevicesPage";
|
||||||
|
export * from "./SightPage";
|
||||||
|
export * from "./CreateSightPage";
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { languageStore } from "@shared";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
const authInstance = axios.create({
|
const authInstance = axios.create({
|
||||||
@ -6,6 +7,7 @@ const authInstance = axios.create({
|
|||||||
|
|
||||||
authInstance.interceptors.request.use((config) => {
|
authInstance.interceptors.request.use((config) => {
|
||||||
config.headers.Authorization = `Bearer ${localStorage.getItem("token")}`;
|
config.headers.Authorization = `Bearer ${localStorage.getItem("token")}`;
|
||||||
|
config.headers["X-Language"] = languageStore.language ?? "ru";
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@ export const NAVIGATION_ITEMS: {
|
|||||||
id: "attractions",
|
id: "attractions",
|
||||||
label: "Достопримечательности",
|
label: "Достопримечательности",
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
path: "/sights",
|
path: "/sight",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "devices",
|
id: "devices",
|
||||||
|
25
src/shared/store/CityStore/index.tsx
Normal file
25
src/shared/store/CityStore/index.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { authInstance } from "@shared";
|
||||||
|
import { makeAutoObservable } from "mobx";
|
||||||
|
|
||||||
|
type City = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
country_code: string;
|
||||||
|
country: string;
|
||||||
|
arms?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
class CityStore {
|
||||||
|
cities: City[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
makeAutoObservable(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCities = async () => {
|
||||||
|
const response = await authInstance.get("/city");
|
||||||
|
this.cities = response.data;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cityStore = new CityStore();
|
@ -2,7 +2,7 @@ import { API_URL, authInstance } from "@shared";
|
|||||||
import { makeAutoObservable } from "mobx";
|
import { makeAutoObservable } from "mobx";
|
||||||
|
|
||||||
class DevicesStore {
|
class DevicesStore {
|
||||||
devices: any[] = [];
|
devices: string[] = [];
|
||||||
uuid: string | null = null;
|
uuid: string | null = null;
|
||||||
sendSnapshotModalOpen = false;
|
sendSnapshotModalOpen = false;
|
||||||
|
|
||||||
@ -12,7 +12,6 @@ class DevicesStore {
|
|||||||
|
|
||||||
getDevices = async () => {
|
getDevices = async () => {
|
||||||
const response = await authInstance.get(`${API_URL}/devices/connected`);
|
const response = await authInstance.get(`${API_URL}/devices/connected`);
|
||||||
console.log(response.data);
|
|
||||||
this.devices = response.data;
|
this.devices = response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
87
src/shared/store/SightsStore/index.tsx
Normal file
87
src/shared/store/SightsStore/index.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { authInstance } from "@shared";
|
||||||
|
import { makeAutoObservable, runInAction } from "mobx";
|
||||||
|
|
||||||
|
export type Language = "ru" | "en" | "zh";
|
||||||
|
|
||||||
|
export type MultilingualContent = {
|
||||||
|
[key in Language]: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
address: string;
|
||||||
|
// Add other fields that need to be multilingual
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Sight = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
city_id: number;
|
||||||
|
city: string;
|
||||||
|
address: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
thumbnail: string | null;
|
||||||
|
watermark_lu: string | null;
|
||||||
|
watermark_rd: string | null;
|
||||||
|
left_article: number;
|
||||||
|
preview_media: string;
|
||||||
|
video_preview: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
class SightsStore {
|
||||||
|
sights: Sight[] = [];
|
||||||
|
sight: Sight | null = null;
|
||||||
|
cachedMultilingualContent: MultilingualContent | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
makeAutoObservable(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSights = async () => {
|
||||||
|
const response = await authInstance.get("/sight");
|
||||||
|
runInAction(() => {
|
||||||
|
this.sights = response.data;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
getSight = async (id: number) => {
|
||||||
|
const response = await authInstance.get(`/sight/${id}`);
|
||||||
|
|
||||||
|
runInAction(() => {
|
||||||
|
this.sight = response.data;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
setCachedMultilingualContent = (content: MultilingualContent) => {
|
||||||
|
runInAction(() => {
|
||||||
|
this.cachedMultilingualContent = content;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updateCachedLanguageContent = (
|
||||||
|
language: Language,
|
||||||
|
content: Partial<MultilingualContent[Language]>
|
||||||
|
) => {
|
||||||
|
runInAction(() => {
|
||||||
|
if (!this.cachedMultilingualContent) {
|
||||||
|
this.cachedMultilingualContent = {
|
||||||
|
ru: { name: "", description: "", address: "" },
|
||||||
|
en: { name: "", description: "", address: "" },
|
||||||
|
zh: { name: "", description: "", address: "" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.cachedMultilingualContent[language] = {
|
||||||
|
...this.cachedMultilingualContent[language],
|
||||||
|
...content,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
clearCachedMultilingualContent = () => {
|
||||||
|
runInAction(() => {
|
||||||
|
this.cachedMultilingualContent = null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sightsStore = new SightsStore();
|
@ -2,8 +2,15 @@ import { authInstance } from "@shared";
|
|||||||
import { API_URL } from "@shared";
|
import { API_URL } from "@shared";
|
||||||
import { makeAutoObservable } from "mobx";
|
import { makeAutoObservable } from "mobx";
|
||||||
|
|
||||||
|
type Snapshot = {
|
||||||
|
ID: string;
|
||||||
|
Name: string;
|
||||||
|
ParentID: string;
|
||||||
|
CreationTime: string;
|
||||||
|
};
|
||||||
|
|
||||||
class SnapshotStore {
|
class SnapshotStore {
|
||||||
snapshots: any[] = [];
|
snapshots: Snapshot[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
makeAutoObservable(this);
|
makeAutoObservable(this);
|
||||||
|
@ -1,8 +1,27 @@
|
|||||||
import { API_URL, authInstance } from "@shared";
|
import { API_URL, authInstance } from "@shared";
|
||||||
import { makeAutoObservable } from "mobx";
|
import { makeAutoObservable } from "mobx";
|
||||||
|
|
||||||
|
type Vehicle = {
|
||||||
|
vehicle: {
|
||||||
|
id: number;
|
||||||
|
tail_number: number;
|
||||||
|
type: number;
|
||||||
|
carrier_id: number;
|
||||||
|
carrier: string;
|
||||||
|
uuid?: string;
|
||||||
|
};
|
||||||
|
device_status?: {
|
||||||
|
device_uuid: string;
|
||||||
|
online: boolean;
|
||||||
|
gps_ok: boolean;
|
||||||
|
media_service_ok: boolean;
|
||||||
|
last_update: string;
|
||||||
|
is_connected: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
class VehicleStore {
|
class VehicleStore {
|
||||||
vehicles: any[] = [];
|
vehicles: Vehicle[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
makeAutoObservable(this);
|
makeAutoObservable(this);
|
||||||
|
@ -3,3 +3,5 @@ export * from "./LanguageStore";
|
|||||||
export * from "./DevicesStore";
|
export * from "./DevicesStore";
|
||||||
export * from "./VehicleStore";
|
export * from "./VehicleStore";
|
||||||
export * from "./SnapshotStore";
|
export * from "./SnapshotStore";
|
||||||
|
export * from "./SightsStore";
|
||||||
|
export * from "./CityStore";
|
||||||
|
@ -6,7 +6,13 @@ import TableHead from "@mui/material/TableHead";
|
|||||||
import TableRow from "@mui/material/TableRow";
|
import TableRow from "@mui/material/TableRow";
|
||||||
import Paper from "@mui/material/Paper";
|
import Paper from "@mui/material/Paper";
|
||||||
import { Check, RotateCcw, Send, X } from "lucide-react";
|
import { Check, RotateCcw, Send, X } from "lucide-react";
|
||||||
import { devicesStore, Modal, snapshotStore, vehicleStore } from "@shared";
|
import {
|
||||||
|
authInstance,
|
||||||
|
devicesStore,
|
||||||
|
Modal,
|
||||||
|
snapshotStore,
|
||||||
|
vehicleStore,
|
||||||
|
} from "@shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { Button, Checkbox } from "@mui/material";
|
import { Button, Checkbox } from "@mui/material";
|
||||||
@ -82,13 +88,15 @@ export const DevicesTable = observer(() => {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSendSnapshot = (uuid: string[]) => {
|
const handleSendSnapshot = (uuid: string) => {
|
||||||
setSelectedDevice(uuid);
|
setSelectedDevice(uuid);
|
||||||
toggleSendSnapshotModal();
|
toggleSendSnapshotModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReloadStatus = (uuid: string) => {
|
const handleReloadStatus = async (uuid: string) => {
|
||||||
setSelectedDevice(uuid);
|
setSelectedDevice(uuid);
|
||||||
|
await authInstance.post(`/devices/${uuid}/request-status`);
|
||||||
|
await getDevices();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectDevice = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleSelectDevice = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@ -101,6 +109,13 @@ export const DevicesTable = observer(() => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSendSnapshotAction = async (uuid: string, snapshotId: string) => {
|
||||||
|
await authInstance.post(`/devices/${uuid}/force-snapshot`, {
|
||||||
|
snapshot_id: snapshotId,
|
||||||
|
});
|
||||||
|
await getDevices();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TableContainer component={Paper}>
|
<TableContainer component={Paper}>
|
||||||
@ -113,7 +128,7 @@ export const DevicesTable = observer(() => {
|
|||||||
color="primary"
|
color="primary"
|
||||||
disabled={selectedDevices.length === 0}
|
disabled={selectedDevices.length === 0}
|
||||||
className="ml-auto"
|
className="ml-auto"
|
||||||
onClick={() => handleSendSnapshot(selectedDevices)}
|
onClick={() => handleSendSnapshot(uuid ?? "")}
|
||||||
>
|
>
|
||||||
Отправить снапшот
|
Отправить снапшот
|
||||||
</Button>
|
</Button>
|
||||||
@ -195,7 +210,11 @@ export const DevicesTable = observer(() => {
|
|||||||
<div className="mt-5 flex flex-col gap-2 max-h-[300px] overflow-y-auto">
|
<div className="mt-5 flex flex-col gap-2 max-h-[300px] overflow-y-auto">
|
||||||
{snapshots &&
|
{snapshots &&
|
||||||
snapshots.map((snapshot) => (
|
snapshots.map((snapshot) => (
|
||||||
<button className="p-2 rounded-xl bg-slate-100" key={snapshot.id}>
|
<button
|
||||||
|
onClick={() => handleSendSnapshotAction(uuid!, snapshot.ID)}
|
||||||
|
className="p-2 rounded-xl bg-slate-100"
|
||||||
|
key={snapshot.ID}
|
||||||
|
>
|
||||||
{snapshot.Name}
|
{snapshot.Name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
@ -1,29 +1,63 @@
|
|||||||
import { languageStore } from "@shared";
|
import { languageStore } from "@shared";
|
||||||
|
import { Button } from "@mui/material"; // Only Button is needed
|
||||||
|
import { useEffect, useCallback } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
|
const LANGUAGES = ["ru", "en", "zh"] as const;
|
||||||
|
type Language = (typeof LANGUAGES)[number];
|
||||||
|
|
||||||
export const LanguageSwitcher = observer(() => {
|
export const LanguageSwitcher = observer(() => {
|
||||||
const { language, setLanguage } = languageStore;
|
const { language, setLanguage } = languageStore;
|
||||||
|
|
||||||
|
// Memoize getLanguageLabel for consistent rendering
|
||||||
|
const getLanguageLabel = useCallback((lang: Language) => {
|
||||||
|
switch (lang) {
|
||||||
|
case "ru":
|
||||||
|
return "RU";
|
||||||
|
case "en":
|
||||||
|
return "EN";
|
||||||
|
case "zh":
|
||||||
|
return "ZH";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyPress = (event: KeyboardEvent) => {
|
||||||
|
// Keep Ctrl+S for language cycling
|
||||||
|
if (event.key.toLowerCase() === "f3") {
|
||||||
|
event.preventDefault(); // Prevent browser save dialog
|
||||||
|
const currentIndex = LANGUAGES.indexOf(language as Language);
|
||||||
|
const nextIndex = (currentIndex + 1) % LANGUAGES.length;
|
||||||
|
setLanguage(LANGUAGES[nextIndex]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyPress);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyPress);
|
||||||
|
};
|
||||||
|
}, [language, setLanguage]); // Only language is a dependency now
|
||||||
|
|
||||||
|
const handleLanguageChange = (lang: Language) => {
|
||||||
|
setLanguage(lang);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="fixed top-1/2 -translate-y-1/2 right-0 flex flex-col gap-2 p-4 ">
|
||||||
<button
|
{/* Added some styling for better visualization */}
|
||||||
className={`p-3 ${language === "ru" ? "bg-blue-500" : ""}`}
|
{LANGUAGES.map((lang) => (
|
||||||
onClick={() => setLanguage("ru")}
|
<Button
|
||||||
>
|
key={lang}
|
||||||
RU
|
onClick={() => handleLanguageChange(lang)}
|
||||||
</button>
|
variant={language === lang ? "contained" : "outlined"} // Highlight the active language
|
||||||
<button
|
color="primary"
|
||||||
className={`p-3 ${language === "en" ? "bg-blue-500" : ""}`}
|
sx={{ minWidth: "60px" }} // Give buttons a consistent width
|
||||||
onClick={() => setLanguage("en")}
|
>
|
||||||
>
|
{getLanguageLabel(lang)}
|
||||||
EN
|
</Button>
|
||||||
</button>
|
))}
|
||||||
<button
|
|
||||||
className={`p-3 ${language === "zh" ? "bg-blue-500" : ""}`}
|
|
||||||
onClick={() => setLanguage("zh")}
|
|
||||||
>
|
|
||||||
zh
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -12,17 +12,16 @@ export const ReactMarkdownComponent = ({ value }: { value: string }) => {
|
|||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
},
|
},
|
||||||
"& h1, & h2, & h3, & h4, & h5, & h6": {
|
"& h1, & h2, & h3, & h4, & h5, & h6": {
|
||||||
color: "primary.main",
|
color: "white",
|
||||||
mt: 2,
|
mt: 2,
|
||||||
mb: 1,
|
mb: 1,
|
||||||
},
|
},
|
||||||
"& p": {
|
"& p": {
|
||||||
mb: 2,
|
mb: 2,
|
||||||
color: (theme) =>
|
color: "white",
|
||||||
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
|
|
||||||
},
|
},
|
||||||
"& a": {
|
"& a": {
|
||||||
color: "primary.main",
|
color: "white",
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
textDecoration: "underline",
|
textDecoration: "underline",
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { Unlink } from "lucide-react";
|
import { ImagePlus, Unlink } from "lucide-react";
|
||||||
|
|
||||||
import { Trash2 } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
import { TextField } from "@mui/material";
|
import { Box, TextField } from "@mui/material";
|
||||||
import { ReactMarkdownEditor } from "@widgets";
|
import { ReactMarkdownEditor } from "@widgets";
|
||||||
|
|
||||||
export const SightEdit = () => {
|
export const SightEdit = () => {
|
||||||
@ -24,9 +24,52 @@ export const SightEdit = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 w-[350px]">
|
<div className="flex flex-col gap-3 w-[350px]">
|
||||||
<p>Превью</p>
|
<p className="mb-4">Превью</p>
|
||||||
|
<Box
|
||||||
|
className="rounded-2xl overflow-hidden"
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
minHeight: 500,
|
||||||
|
|
||||||
<div className=" w-full bg-red-500">1</div>
|
background: "#877361",
|
||||||
|
borderColor: "grey.300",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!false && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: 200,
|
||||||
|
|
||||||
|
backgroundColor: "grey.300",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ImagePlus size={48} color="grey" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
background: "#877361",
|
||||||
|
borderColor: "grey.300",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: 40,
|
||||||
|
|
||||||
|
backgroundColor: "#a39487",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
></Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,48 +1,484 @@
|
|||||||
import { TextField } from "@mui/material";
|
import {
|
||||||
import { BackButton, TabPanel } from "@shared";
|
Button,
|
||||||
|
TextField,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { BackButton, Sight, sightsStore, TabPanel, Language } from "@shared";
|
||||||
import { LanguageSwitcher } from "@widgets";
|
import { LanguageSwitcher } from "@widgets";
|
||||||
|
import { ImagePlus, Info } from "lucide-react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
|
||||||
export const InformationTab = ({
|
// Мокап данных для отображения, потом это будет приходить из store/props
|
||||||
value,
|
// Keeping this mock for demonstration, but in a real app,
|
||||||
index,
|
// this would come from the MobX store's 'sight' object.
|
||||||
}: {
|
const mockSightData = {
|
||||||
value: number;
|
name: "Эрмитаж",
|
||||||
index: number;
|
address: "Дворцовая площадь, 2",
|
||||||
}) => {
|
city: "Санкт-Петербург", // или city_id, если будет Select
|
||||||
return (
|
coordinates: "59.9398, 30.3146",
|
||||||
<TabPanel value={value} index={index}>
|
logo: null, // null или URL/ID медиа
|
||||||
<div className="flex-1 flex flex-col relative">
|
watermark_lu: null,
|
||||||
<div className="flex-1 flex flex-col gap-10">
|
watermark_rd: null,
|
||||||
<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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Мокап для всплывающей подсказки
|
||||||
|
const watermarkTooltipText = "При наведении открывается просмотр в поп-апе";
|
||||||
|
const logoTooltipText = "При наведении открывается просмотр логотипа в поп-апе";
|
||||||
|
|
||||||
|
export const InformationTab = observer(
|
||||||
|
({ value, index }: { value: number; index: number }) => {
|
||||||
|
const {
|
||||||
|
sight,
|
||||||
|
cachedMultilingualContent,
|
||||||
|
updateCachedLanguageContent,
|
||||||
|
clearCachedMultilingualContent,
|
||||||
|
// Assuming you'll have an action to update the main sight object
|
||||||
|
updateSight,
|
||||||
|
} = sightsStore;
|
||||||
|
|
||||||
|
// Initialize local states with data from the MobX store's 'sight'
|
||||||
|
const [address, setAddress] = useState(sight?.address ?? "");
|
||||||
|
const [city, setCity] = useState(sight?.city ?? "");
|
||||||
|
const [coordinates, setCoordinates] = useState(
|
||||||
|
sight?.latitude && sight?.longitude
|
||||||
|
? `${sight.latitude}, ${sight.longitude}`
|
||||||
|
: ""
|
||||||
|
);
|
||||||
|
const [currentLanguage, setCurrentLanguage] = useState<Language>("ru");
|
||||||
|
const pathname = useLocation().pathname;
|
||||||
|
|
||||||
|
// Effect to initialize local states when `sight` data becomes available or changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (sight) {
|
||||||
|
setAddress(sight.address ?? "");
|
||||||
|
setCity(sight.city ?? "");
|
||||||
|
setCoordinates(
|
||||||
|
sight.latitude && sight.longitude
|
||||||
|
? `${sight.latitude}, ${sight.longitude}`
|
||||||
|
: ""
|
||||||
|
);
|
||||||
|
// Initialize cached content if not already set
|
||||||
|
if (!cachedMultilingualContent) {
|
||||||
|
sightsStore.setCachedMultilingualContent({
|
||||||
|
ru: { name: sight.name, description: "", address: sight.address },
|
||||||
|
en: { name: "", description: "", address: "" },
|
||||||
|
zh: { name: "", description: "", address: "" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [sight, cachedMultilingualContent]); // Add cachedMultilingualContent to dependencies
|
||||||
|
|
||||||
|
// Effect to clear cached content when the route changes
|
||||||
|
useEffect(() => {
|
||||||
|
clearCachedMultilingualContent();
|
||||||
|
}, [pathname, clearCachedMultilingualContent]);
|
||||||
|
|
||||||
|
const handleLanguageChange = (lang: Language) => {
|
||||||
|
setCurrentLanguage(lang);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectMedia = (
|
||||||
|
type: "logo" | "watermark_lu" | "watermark_rd"
|
||||||
|
) => {
|
||||||
|
// Here will be logic for opening modal window for media selection
|
||||||
|
console.log("Select media for:", type);
|
||||||
|
// In a real application, you might open a dialog here
|
||||||
|
// and update the sight object with the selected media URL/ID.
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
// Parse coordinates back to latitude and longitude
|
||||||
|
let latitude: number | undefined;
|
||||||
|
let longitude: number | undefined;
|
||||||
|
const coordsArray = coordinates
|
||||||
|
.split(",")
|
||||||
|
.map((coord) => parseFloat(coord.trim()));
|
||||||
|
if (
|
||||||
|
coordsArray.length === 2 &&
|
||||||
|
!isNaN(coordsArray[0]) &&
|
||||||
|
!isNaN(coordsArray[1])
|
||||||
|
) {
|
||||||
|
latitude = coordsArray[0];
|
||||||
|
longitude = coordsArray[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the updated sight data
|
||||||
|
const updatedSightData = {
|
||||||
|
...sight, // Keep existing sight data
|
||||||
|
address: address,
|
||||||
|
city: city,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
// Assuming logo and watermark updates would happen via handleSelectMedia
|
||||||
|
// and then be reflected in the sight object in the store.
|
||||||
|
};
|
||||||
|
|
||||||
|
// Here we would save both the sight data and the multilingual content
|
||||||
|
console.log("Saving general information and multilingual content...", {
|
||||||
|
updatedSightData,
|
||||||
|
multilingualContent: cachedMultilingualContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call an action from your store to save the data
|
||||||
|
// For example:
|
||||||
|
// sightsStore.saveSight({ ...updatedSightData, multilingualContent: cachedMultilingualContent });
|
||||||
|
// You might have a specific action in your store for saving all this data.
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabPanel value={value} index={index}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 3,
|
||||||
|
position: "relative",
|
||||||
|
paddingBottom: "70px" /* Space for save button */,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BackButton />
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
|
||||||
|
gap: 4, // Added gap between the two main columns
|
||||||
|
width: "100%",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left column with main fields */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
display: "flex",
|
||||||
|
width: "80%",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 2.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label={`Название (${currentLanguage.toUpperCase()})`}
|
||||||
|
value={cachedMultilingualContent?.[currentLanguage]?.name ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateCachedLanguageContent(currentLanguage, {
|
||||||
|
name: e.target.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={`Описание (${currentLanguage.toUpperCase()})`}
|
||||||
|
value={
|
||||||
|
cachedMultilingualContent?.[currentLanguage]?.description ??
|
||||||
|
""
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateCachedLanguageContent(currentLanguage, {
|
||||||
|
description: e.target.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Адрес"
|
||||||
|
value={address}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAddress(e.target.value);
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Город"
|
||||||
|
value={city}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCity(e.target.value);
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Координаты"
|
||||||
|
value={coordinates}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCoordinates(e.target.value);
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
helperText="Формат: широта, долгота (например, 59.9398, 30.3146)"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Правая колонка для логотипа и водяных знаков */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Водяные знаки */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-around",
|
||||||
|
width: "80%",
|
||||||
|
gap: 2,
|
||||||
|
flexDirection: { xs: "column", sm: "row" }, // Stack on extra small, side-by-side on small and up
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={2}
|
||||||
|
sx={{
|
||||||
|
padding: 2,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1,
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 150, // Ensure a minimum width
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
gutterBottom
|
||||||
|
sx={{ mb: 0, mr: 0.5 }}
|
||||||
|
>
|
||||||
|
Логотип
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title={watermarkTooltipText}>
|
||||||
|
<Info
|
||||||
|
size={16}
|
||||||
|
color="gray"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
backgroundColor: "grey.200",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: 1,
|
||||||
|
mb: 1,
|
||||||
|
cursor: mockSightData.watermark_lu
|
||||||
|
? "pointer"
|
||||||
|
: "default", // Only clickable if there's an image
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: mockSightData.watermark_lu
|
||||||
|
? "grey.300"
|
||||||
|
: "grey.200",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onClick={() =>
|
||||||
|
mockSightData.watermark_lu &&
|
||||||
|
handleSelectMedia("watermark_lu")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{mockSightData.watermark_lu ? (
|
||||||
|
<img
|
||||||
|
src={mockSightData.watermark_lu}
|
||||||
|
alt="Знак л.в"
|
||||||
|
style={{ maxWidth: "100%", maxHeight: "100%" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ImagePlus size={24} color="grey" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleSelectMedia("watermark_lu")}
|
||||||
|
>
|
||||||
|
Выбрать
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
<Paper
|
||||||
|
elevation={2}
|
||||||
|
sx={{
|
||||||
|
padding: 2,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1,
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 150, // Ensure a minimum width
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
gutterBottom
|
||||||
|
sx={{ mb: 0, mr: 0.5 }}
|
||||||
|
>
|
||||||
|
Водяной знак (л.в)
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title={watermarkTooltipText}>
|
||||||
|
<Info
|
||||||
|
size={16}
|
||||||
|
color="gray"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
backgroundColor: "grey.200",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: 1,
|
||||||
|
mb: 1,
|
||||||
|
cursor: mockSightData.watermark_lu
|
||||||
|
? "pointer"
|
||||||
|
: "default", // Only clickable if there's an image
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: mockSightData.watermark_lu
|
||||||
|
? "grey.300"
|
||||||
|
: "grey.200",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onClick={() =>
|
||||||
|
mockSightData.watermark_lu &&
|
||||||
|
handleSelectMedia("watermark_lu")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{mockSightData.watermark_lu ? (
|
||||||
|
<img
|
||||||
|
src={mockSightData.watermark_lu}
|
||||||
|
alt="Знак л.в"
|
||||||
|
style={{ maxWidth: "100%", maxHeight: "100%" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ImagePlus size={24} color="grey" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleSelectMedia("watermark_lu")}
|
||||||
|
>
|
||||||
|
Выбрать
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper
|
||||||
|
elevation={2}
|
||||||
|
sx={{
|
||||||
|
padding: 2,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1,
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 150, // Ensure a minimum width
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
gutterBottom
|
||||||
|
sx={{ mb: 0, mr: 0.5 }}
|
||||||
|
>
|
||||||
|
Водяной знак (п.в)
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title={watermarkTooltipText}>
|
||||||
|
<Info
|
||||||
|
size={16}
|
||||||
|
color="gray"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
backgroundColor: "grey.200",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: 1,
|
||||||
|
mb: 1,
|
||||||
|
cursor: mockSightData.watermark_rd
|
||||||
|
? "pointer"
|
||||||
|
: "default", // Only clickable if there's an image
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: mockSightData.watermark_rd
|
||||||
|
? "grey.300"
|
||||||
|
: "grey.200",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onClick={() =>
|
||||||
|
mockSightData.watermark_rd &&
|
||||||
|
handleSelectMedia("watermark_rd")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{mockSightData.watermark_rd ? (
|
||||||
|
<img
|
||||||
|
src={mockSightData.watermark_rd}
|
||||||
|
alt="Знак п.в"
|
||||||
|
style={{ maxWidth: "100%", maxHeight: "100%" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ImagePlus size={24} color="grey" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleSelectMedia("watermark_rd")}
|
||||||
|
>
|
||||||
|
Выбрать
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* LanguageSwitcher positioned at the top right */}
|
||||||
|
<Box sx={{ position: "absolute", top: 0, right: 0, zIndex: 1 }}>
|
||||||
|
<LanguageSwitcher onLanguageChange={handleLanguageChange} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Save Button fixed at the bottom right */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
padding: 2,
|
||||||
|
backgroundColor: "background.paper", // To ensure it stands out over content
|
||||||
|
width: "100%", // Take full width to cover content below it
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end", // Align to the right
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button variant="contained" color="success" onClick={handleSave}>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</TabPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
@ -1,60 +1,269 @@
|
|||||||
import { TextField } from "@mui/material";
|
import { Box, Button, TextField, Paper, Typography } from "@mui/material";
|
||||||
import { BackButton, TabPanel } from "@shared";
|
import { BackButton, Sight, TabPanel } from "@shared";
|
||||||
import { ReactMarkdownComponent, ReactMarkdownEditor } from "@widgets";
|
import { ReactMarkdownComponent, ReactMarkdownEditor } from "@widgets";
|
||||||
import { Trash2 } from "lucide-react";
|
import { Unlink, Trash2, ImagePlus } from "lucide-react";
|
||||||
import { Unlink } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
// Мокап данных для левой статьи
|
||||||
|
const mockLeftArticle = {
|
||||||
|
title: "История основания",
|
||||||
|
markdownContent: `## Заголовок статьи H2
|
||||||
|
|
||||||
|
Какой-то **текст** для левой статьи.
|
||||||
|
Можно использовать *markdown*.
|
||||||
|
|
||||||
|
- Список 1
|
||||||
|
- Список 2
|
||||||
|
|
||||||
|
[Ссылка на Яндекс](https://ya.ru)
|
||||||
|
`,
|
||||||
|
media: null, // null или URL/ID медиа
|
||||||
|
};
|
||||||
|
|
||||||
export const LeftWidgetTab = ({
|
export const LeftWidgetTab = ({
|
||||||
value,
|
value,
|
||||||
index,
|
index,
|
||||||
|
data,
|
||||||
}: {
|
}: {
|
||||||
value: number;
|
value: number;
|
||||||
index: number;
|
index: number;
|
||||||
|
data?: Sight;
|
||||||
}) => {
|
}) => {
|
||||||
const [leftArticleData, setLeftArticleData] = useState(" ");
|
const [articleTitle, setArticleTitle] = useState(mockLeftArticle.title);
|
||||||
|
const [markdownContent, setMarkdownContent] = useState(
|
||||||
|
mockLeftArticle.markdownContent
|
||||||
|
);
|
||||||
|
const [articleMedia, setArticleMedia] = useState<string | null>(
|
||||||
|
mockLeftArticle.media
|
||||||
|
); // Для превью медиа
|
||||||
|
|
||||||
|
const handleSelectMediaForArticle = () => {
|
||||||
|
// Логика открытия модального окна для выбора медиа для статьи
|
||||||
|
console.log("Select media fo r left article");
|
||||||
|
// Для примера, установим моковое изображение
|
||||||
|
// setArticleMedia("https://via.placeholder.com/350x200.png?text=Article+Media");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnlinkArticle = () => {
|
||||||
|
console.log("Unlink left article");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteArticle = () => {
|
||||||
|
console.log("Delete left article");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
console.log("Saving left widget...");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabPanel value={value} index={index}>
|
<TabPanel value={value} index={index}>
|
||||||
<div className="flex flex-col gap-5">
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 3,
|
||||||
|
paddingBottom: "70px",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<BackButton />
|
<BackButton />
|
||||||
|
|
||||||
<div className="flex items-center justify-between px-5 h-14 rounded-md border">
|
<Paper
|
||||||
<p className="text-2xl">Левая статья</p>
|
elevation={2}
|
||||||
<div className="flex items-center gap-5">
|
sx={{
|
||||||
<button className="flex items-center gap-2 border border-gray-300 bg-blue-100 rounded-md px-2 py-1">
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingX: 2.5,
|
||||||
|
paddingY: 1.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6">Левая статья</Typography>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
startIcon={<Unlink size={18} />}
|
||||||
|
onClick={handleUnlinkArticle}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
Открепить
|
Открепить
|
||||||
<Unlink />
|
</Button>
|
||||||
</button>
|
<Button
|
||||||
<button className="flex items-center gap-2 border border-gray-300 bg-red-100 rounded-md px-2 py-1">
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
startIcon={<Trash2 size={18} />}
|
||||||
|
onClick={handleDeleteArticle}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
Удалить
|
Удалить
|
||||||
<Trash2 />
|
</Button>
|
||||||
</button>
|
</Box>
|
||||||
</div>
|
</Paper>
|
||||||
</div>
|
|
||||||
<div className="flex gap-5">
|
|
||||||
<div className="flex flex-col gap-5 flex-1">
|
|
||||||
<TextField sx={{ width: "30%" }} label="Название" />
|
|
||||||
|
|
||||||
<ReactMarkdownEditor
|
<Box sx={{ display: "flex", gap: 3, flexGrow: 1 }}>
|
||||||
value={leftArticleData}
|
{/* Левая колонка: Редактирование */}
|
||||||
onChange={setLeftArticleData}
|
<Box
|
||||||
|
sx={{ flex: 2, display: "flex", flexDirection: "column", gap: 2 }}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="Название информации" // На макете "Название" для статьи, потом "Информация"
|
||||||
|
value={articleTitle}
|
||||||
|
onChange={(e) => setArticleTitle(e.target.value)}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ width: "100%" }} // Примерная ширина как на макете
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
{/* Редактор Markdown */}
|
||||||
<p>Предпросмотр</p>
|
<Typography variant="subtitle2" sx={{ mt: 1 }}>
|
||||||
<div className="bg-yellow-200 w-[350px] h-full">
|
Текст
|
||||||
<div className="bg-red-100 w-full h-[200px]"></div>
|
</Typography>
|
||||||
<div className="bg-blue-100 w-full text-lg p-3"></div>
|
<ReactMarkdownEditor
|
||||||
<div className="bg-green-100 p-3 prose max-w-none">
|
value={markdownContent}
|
||||||
<ReactMarkdownComponent value={leftArticleData} />
|
onChange={setMarkdownContent}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
{/* Блок МЕДИА для статьи */}
|
||||||
</div>
|
<Paper elevation={1} sx={{ padding: 2, mt: 1 }}>
|
||||||
<button className="bg-green-400 w-min ml-auto text-white py-2 rounded-2xl px-4">
|
<Typography variant="h6" gutterBottom>
|
||||||
Сохранить
|
МЕДИА
|
||||||
</button>
|
</Typography>
|
||||||
</div>
|
{/* Здесь будет UI для управления медиа статьи */}
|
||||||
|
{articleMedia ? (
|
||||||
|
<Box sx={{ mb: 1 }}>
|
||||||
|
<img
|
||||||
|
src={articleMedia}
|
||||||
|
alt="Article media"
|
||||||
|
style={{
|
||||||
|
maxWidth: "100%",
|
||||||
|
maxHeight: "150px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: 100,
|
||||||
|
backgroundColor: "grey.100",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: 1,
|
||||||
|
mb: 1,
|
||||||
|
border: "2px dashed",
|
||||||
|
borderColor: "grey.300",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography color="text.secondary">Нет медиа</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Button variant="contained" onClick={handleSelectMediaForArticle}>
|
||||||
|
Выбрать/Загрузить медиа
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Правая колонка: Предпросмотр */}
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||||
|
<Typography variant="h6">Предпросмотр</Typography>
|
||||||
|
<Paper
|
||||||
|
elevation={3}
|
||||||
|
sx={{
|
||||||
|
width: "100%", // Ширина как на макете ~350px
|
||||||
|
minWidth: 320,
|
||||||
|
maxWidth: 400,
|
||||||
|
height: "auto", // Автоматическая высота или можно задать minHeight
|
||||||
|
minHeight: 500,
|
||||||
|
backgroundColor: "#877361", // Желтоватый фон
|
||||||
|
overflowY: "auto",
|
||||||
|
padding: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Медиа в превью (если есть) */}
|
||||||
|
{articleMedia && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: 200,
|
||||||
|
backgroundColor: "grey.300",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={articleMedia}
|
||||||
|
alt="Превью медиа"
|
||||||
|
style={{
|
||||||
|
objectFit: "cover",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{!articleMedia && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: 200,
|
||||||
|
backgroundColor: "grey.300",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ImagePlus size={48} color="grey" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Заголовок в превью */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
backgroundColor: "#877361",
|
||||||
|
color: "white",
|
||||||
|
padding: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="h5"
|
||||||
|
component="h2"
|
||||||
|
sx={{ wordBreak: "break-word" }}
|
||||||
|
>
|
||||||
|
{articleTitle || "Название информации"}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Текст статьи в превью */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
padding: 2,
|
||||||
|
backgroundColor: "#877361",
|
||||||
|
flexGrow: 1,
|
||||||
|
color: "white",
|
||||||
|
"& img": { maxWidth: "100%" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ReactMarkdownComponent value={markdownContent} />
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ position: "absolute", bottom: 0, right: 0, padding: 2 }}>
|
||||||
|
<Button variant="contained" color="success" onClick={handleSave}>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,73 +1,264 @@
|
|||||||
import { BackButton, TabPanel } from "@shared";
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
List,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemText,
|
||||||
|
Paper,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { BackButton, Sight, TabPanel } from "@shared";
|
||||||
import { SightEdit } from "@widgets";
|
import { SightEdit } from "@widgets";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
// Мокап данных для списка блоков правого виджета
|
||||||
|
const mockRightWidgetBlocks = [
|
||||||
|
{ id: "preview_media", name: "Превью-медиа", type: "special" }, // Особый блок?
|
||||||
|
{ id: "article_1", name: "1. История", type: "article" },
|
||||||
|
{ id: "article_2", name: "2. Факты", type: "article" },
|
||||||
|
{
|
||||||
|
id: "article_3",
|
||||||
|
name: "3. Блокада (Пример длинного названия)",
|
||||||
|
type: "article",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Мокап данных для выбранного блока для редактирования
|
||||||
|
// В реальности это будет объект Article из API
|
||||||
|
const mockSelectedBlockData = {
|
||||||
|
id: "article_1",
|
||||||
|
heading: "История основания Санкт-Петербурга",
|
||||||
|
body: "## Начало\nГород был основан 27 мая 1703 года Петром I...",
|
||||||
|
media: [
|
||||||
|
// Предполагаем, что у статьи может быть несколько медиа
|
||||||
|
// { id: "media_1", url: "https://via.placeholder.com/300x200.png?text=History+Image+1", type: "image" }
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
export const RightWidgetTab = ({
|
export const RightWidgetTab = ({
|
||||||
value,
|
value,
|
||||||
index,
|
index,
|
||||||
|
data,
|
||||||
}: {
|
}: {
|
||||||
value: number;
|
value: number;
|
||||||
index: number;
|
index: number;
|
||||||
|
data?: Sight;
|
||||||
}) => {
|
}) => {
|
||||||
|
const [rightWidgetBlocks, setRightWidgetBlocks] = useState(
|
||||||
|
mockRightWidgetBlocks
|
||||||
|
);
|
||||||
|
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(
|
||||||
|
mockRightWidgetBlocks[1]?.id || null
|
||||||
|
); // Выбираем первый "article" по умолчанию
|
||||||
|
|
||||||
|
const handleSelectBlock = (blockId: string) => {
|
||||||
|
setSelectedBlockId(blockId);
|
||||||
|
// Здесь будет логика загрузки данных для выбранного блока, если они не загружены
|
||||||
|
console.log("Selected block:", blockId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddBlock = () => {
|
||||||
|
// Логика открытия модала/формы для создания нового блока/статьи
|
||||||
|
// или выбора существующей статьи для привязки
|
||||||
|
console.log("Add new block");
|
||||||
|
const newBlockId = `article_${Date.now()}`;
|
||||||
|
setRightWidgetBlocks([
|
||||||
|
...rightWidgetBlocks,
|
||||||
|
{
|
||||||
|
id: newBlockId,
|
||||||
|
name: `${
|
||||||
|
rightWidgetBlocks.filter((b) => b.type === "article").length + 1
|
||||||
|
}. Новый блок`,
|
||||||
|
type: "article",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setSelectedBlockId(newBlockId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
console.log("Saving right widget...");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Находим данные для редактирования на основе selectedBlockId
|
||||||
|
// В реальном приложении эти данные будут приходить из store или загружаться по API
|
||||||
|
const currentBlockToEdit =
|
||||||
|
selectedBlockId === mockSelectedBlockData.id
|
||||||
|
? mockSelectedBlockData
|
||||||
|
: selectedBlockId
|
||||||
|
? {
|
||||||
|
id: selectedBlockId,
|
||||||
|
heading:
|
||||||
|
rightWidgetBlocks.find((b) => b.id === selectedBlockId)?.name ||
|
||||||
|
"Заголовок...",
|
||||||
|
body: "Содержимое...",
|
||||||
|
media: [],
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabPanel value={value} index={index}>
|
<TabPanel value={value} index={index}>
|
||||||
{/* Ensure the main container takes full height and uses flexbox for layout */}
|
<Box
|
||||||
<div className="flex flex-col h-full min-h-[600px]">
|
sx={{
|
||||||
{/* Content area with back button and main layout */}
|
display: "flex",
|
||||||
<div className="flex-1 flex flex-col gap-6 p-4">
|
flexDirection: "column",
|
||||||
{" "}
|
height: "100%",
|
||||||
{/* Added padding for better spacing */}
|
minHeight: "calc(100vh - 200px)",
|
||||||
<BackButton />
|
gap: 2,
|
||||||
<div className="flex flex-1 gap-6">
|
paddingBottom: "70px",
|
||||||
{" "}
|
position: "relative",
|
||||||
{/* 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">
|
<BackButton />
|
||||||
{" "}
|
|
||||||
{/* 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 */}
|
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}>
|
||||||
<div className="flex justify-end p-4">
|
{/* Левая колонка: Список блоков/статей */}
|
||||||
{" "}
|
<Paper
|
||||||
{/* Wrapper for save button, added padding */}
|
elevation={2}
|
||||||
<button className="bg-green-500 hover:bg-green-600 text-white py-2.5 px-6 rounded-lg transition-colors font-medium shadow-md">
|
sx={{
|
||||||
{" "}
|
width: 260, // Ширина как на макете
|
||||||
{/* Added shadow */}
|
minWidth: 240,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: 1.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<List
|
||||||
|
dense
|
||||||
|
sx={{
|
||||||
|
overflowY: "auto",
|
||||||
|
flexGrow: 1,
|
||||||
|
maxHeight:
|
||||||
|
"calc(100% - 60px)" /* Adjust based on button size */,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rightWidgetBlocks.map((block) => (
|
||||||
|
<ListItemButton
|
||||||
|
key={block.id}
|
||||||
|
selected={selectedBlockId === block.id}
|
||||||
|
onClick={() => handleSelectBlock(block.id)}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 1,
|
||||||
|
mb: 0.5,
|
||||||
|
backgroundColor:
|
||||||
|
selectedBlockId === block.id
|
||||||
|
? "primary.light"
|
||||||
|
: "transparent",
|
||||||
|
"&.Mui-selected": {
|
||||||
|
backgroundColor: "primary.main",
|
||||||
|
color: "primary.contrastText",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "primary.dark",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor:
|
||||||
|
selectedBlockId !== block.id
|
||||||
|
? "action.hover"
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={block.name}
|
||||||
|
primaryTypographyProps={{
|
||||||
|
fontWeight:
|
||||||
|
selectedBlockId === block.id ? "bold" : "normal",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
pt: 1.5,
|
||||||
|
borderTop: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleAddBlock}
|
||||||
|
startIcon={<Plus />}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Добавить блок
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Правая колонка: Редактор выбранного блока (SightEdit) */}
|
||||||
|
<Paper
|
||||||
|
elevation={2}
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
padding: 2.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
overflowY: "auto", // Если контент будет больше
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentBlockToEdit ? (
|
||||||
|
<>
|
||||||
|
<SightEdit
|
||||||
|
onUnlink={() => console.log("Unlink block:", selectedBlockId)}
|
||||||
|
onDelete={() => {
|
||||||
|
console.log("Delete block:", selectedBlockId);
|
||||||
|
setRightWidgetBlocks((blocks) =>
|
||||||
|
blocks.filter((b) => b.id !== selectedBlockId)
|
||||||
|
);
|
||||||
|
setSelectedBlockId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Paper elevation={1} sx={{ padding: 2, mt: 1, width: "75%" }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
МЕДИА
|
||||||
|
</Typography>
|
||||||
|
{/* Здесь будет UI для управления медиа статьи */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: 100,
|
||||||
|
backgroundColor: "grey.100",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: 1,
|
||||||
|
mb: 1,
|
||||||
|
border: "2px dashed",
|
||||||
|
borderColor: "grey.300",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography color="text.secondary">Нет медиа</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button variant="contained">Выбрать/Загрузить медиа</Button>
|
||||||
|
</Paper>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Блок МЕДИА для статьи */}
|
||||||
|
|
||||||
|
<Box sx={{ position: "absolute", bottom: 0, right: 0, padding: 2 }}>
|
||||||
|
<Button variant="contained" color="success" onClick={handleSave}>
|
||||||
Сохранить
|
Сохранить
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
108
src/widgets/SightsTable/index.tsx
Normal file
108
src/widgets/SightsTable/index.tsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
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 { authInstance, cityStore, languageStore, sightsStore } from "@shared";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Button, Checkbox } from "@mui/material";
|
||||||
|
import { LanguageSwitcher } from "@widgets";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
function createData(id: number, name: string, city: string) {
|
||||||
|
return { id, name, city };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = (sights: any[], cities: any[]) => {
|
||||||
|
return sights.map((sight) => {
|
||||||
|
const city = cities.find((city) => city?.id === sight?.city_id);
|
||||||
|
return createData(sight?.id, sight?.name, city?.name ?? "Нет данных");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SightsTable = observer(() => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { language } = languageStore;
|
||||||
|
const { sights, getSights } = sightsStore;
|
||||||
|
const { cities, getCities } = cityStore;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
await getSights();
|
||||||
|
await getCities();
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, [language, getSights, getCities]);
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
await authInstance.delete(`/sight/${id}`);
|
||||||
|
await getSights();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
<div className="flex justify-end p-3 gap-5">
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => navigate("/sight/create")}
|
||||||
|
>
|
||||||
|
Создать
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Table sx={{ minWidth: 650 }} aria-label="simple table">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell align="center">Название</TableCell>
|
||||||
|
<TableCell align="center">Город</TableCell>
|
||||||
|
<TableCell align="center">Действия</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{rows(sights, cities)?.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row?.id}
|
||||||
|
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
|
||||||
|
className="flex items-center"
|
||||||
|
>
|
||||||
|
<TableCell
|
||||||
|
sx={{ width: "33%" }}
|
||||||
|
align="center"
|
||||||
|
component="th"
|
||||||
|
scope="row"
|
||||||
|
>
|
||||||
|
{row?.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ width: "33%" }} align="center">
|
||||||
|
{row?.city}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="center" className="py-3">
|
||||||
|
<div className="flex justify-center items-center gap-3">
|
||||||
|
<button
|
||||||
|
className="rounded-md px-3 py-1.5 transition-transform transform hover:scale-105"
|
||||||
|
onClick={() => navigate(`/sight/${row?.id}`)}
|
||||||
|
>
|
||||||
|
<Pencil size={18} className="text-blue-500" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md px-3 py-1.5 transition-transform transform hover:scale-105"
|
||||||
|
onClick={() => handleDelete(row?.id)}
|
||||||
|
>
|
||||||
|
<Trash2 size={18} className="text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
@ -6,3 +6,4 @@ export * from "./ReactMarkdownEditor";
|
|||||||
export * from "./SightEdit";
|
export * from "./SightEdit";
|
||||||
export * from "./LanguageSwitcher";
|
export * from "./LanguageSwitcher";
|
||||||
export * from "./DevicesTable";
|
export * from "./DevicesTable";
|
||||||
|
export * from "./SightsTable";
|
||||||
|
Loading…
Reference in New Issue
Block a user