feat: Add edit/create/list sight page

This commit is contained in:
2025-05-29 16:25:18 +03:00
parent 17de7e495f
commit e2ca6b4132
25 changed files with 1519 additions and 240 deletions
index.html
src
app
router
assets
pages
CreateSightPage
EditSightPage
LoginPage
SightPage
index.ts
shared
api
config
store
CityStore
DevicesStore
SightsStore
SnapshotStore
VehicleStore
index.ts
widgets
DevicesTable
LanguageSwitcher
ReactMarkdown
SightEdit
SightTabs
InformationTab
LeftWidgetTab
RightWidgetTab
SightsTable
index.ts

@ -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

Binary file not shown.

After

(image error) Size: 2.3 KiB

@ -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>
);
};

@ -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",

@ -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;
}; };

@ -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}
onClick={() => handleLanguageChange(lang)}
variant={language === lang ? "contained" : "outlined"} // Highlight the active language
color="primary"
sx={{ minWidth: "60px" }} // Give buttons a consistent width
> >
RU {getLanguageLabel(lang)}
</button> </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> </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";
// Мокап данных для отображения, потом это будет приходить из store/props
// Keeping this mock for demonstration, but in a real app,
// this would come from the MobX store's 'sight' object.
const mockSightData = {
name: "Эрмитаж",
address: "Дворцовая площадь, 2",
city: "Санкт-Петербург", // или city_id, если будет Select
coordinates: "59.9398, 30.3146",
logo: null, // null или URL/ID медиа
watermark_lu: null,
watermark_rd: null,
};
// Мокап для всплывающей подсказки
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.
};
export const InformationTab = ({
value,
index,
}: {
value: number;
index: number;
}) => {
return ( return (
<TabPanel value={value} index={index}> <TabPanel value={value} index={index}>
<div className="flex-1 flex flex-col relative"> <Box
<div className="flex-1 flex flex-col gap-10"> sx={{
display: "flex",
flexDirection: "column",
gap: 3,
position: "relative",
paddingBottom: "70px" /* Space for save button */,
}}
>
<BackButton /> <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"> <Box
<div className="flex flex-col gap-2 "> sx={{
<p>Логотип</p> display: "flex",
<button>Выбрать</button>
</div> gap: 4, // Added gap between the two main columns
<div className="flex flex-col gap-2"> width: "100%",
<p>Водяной знак (л.в)</p> flexDirection: "column",
<button>Выбрать</button> }}
</div> >
<div className="flex flex-col gap-2"> {/* Left column with main fields */}
<p>Водяной знак (п.в)</p> <Box
<button>Выбрать</button> sx={{
</div> flexGrow: 1,
</div> display: "flex",
</div> width: "80%",
</div> flexDirection: "column",
<button className="bg-green-400 w-min ml-auto text-white py-2 rounded-2xl px-4"> 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> </Button>
<div className="absolute top-1/2 -translate-y-1/2 right-0"> </Box>
<LanguageSwitcher /> </Box>
</div>
</div>
</TabPanel> </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>
МЕДИА
</Typography>
{/* Здесь будет 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> </Button>
</div> </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)",
gap: 2,
paddingBottom: "70px",
position: "relative",
}}
>
<BackButton /> <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 */} <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>
); );
}; };

@ -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";