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

View File

@ -1,10 +1,10 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<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" />
<title>Vite + React + TS</title>
<title>Белые ночи</title>
</head>
<body>
<div id="root"></div>

View File

@ -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 { Layout } from "@widgets";
@ -23,7 +30,9 @@ export const Router = () => {
}
>
<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>

BIN
src/assets/favicon_ship.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1,61 @@
import { Box, Tab, Tabs } from "@mui/material";
import { InformationTab, RightWidgetTab } from "@widgets";
import { LeftWidgetTab } from "@widgets";
import { useState } from "react";
function a11yProps(index: number) {
return {
id: `sight-tab-${index}`,
"aria-controls": `sight-tabpanel-${index}`,
};
}
export const 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>
);
};

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

View File

@ -26,7 +26,7 @@ export const LoginPage = () => {
try {
await login(email, password);
navigate("/sights");
navigate("/sight");
toast.success("Вход в систему выполнен успешно");
} catch (err) {
setError(

View File

@ -1,61 +1,9 @@
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}`,
};
}
import { SightsTable } from "@widgets";
export const SightPage = () => {
const [value, setValue] = useState(0);
const handleChange = (_: React.SyntheticEvent, newValue: number) => {
setValue(newValue);
};
return (
<Box
sx={{
width: "100%",
display: "flex",
flexDirection: "column",
minHeight: "100vh",
}}
>
<Box
sx={{
borderBottom: 1,
borderColor: "divider",
display: "flex",
justifyContent: "center",
}}
>
<Tabs
value={value}
onChange={handleChange}
aria-label="sight tabs"
sx={{
width: "100%",
"& .MuiTabs-flexContainer": {
justifyContent: "center",
},
}}
>
<Tab sx={{ flex: 1 }} label="Общая информация" {...a11yProps(0)} />
<Tab sx={{ flex: 1 }} label="Левый виджет" {...a11yProps(1)} />
<Tab sx={{ flex: 1 }} label="Правый виджет" {...a11yProps(2)} />
</Tabs>
</Box>
<div className="flex-1">
<InformationTab value={value} index={0} />
<LeftWidgetTab value={value} index={1} />
<RightWidgetTab value={value} index={2} />
</div>
</Box>
<>
<SightsTable />
</>
);
};

View File

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

View File

@ -1,3 +1,4 @@
import { languageStore } from "@shared";
import axios from "axios";
const authInstance = axios.create({
@ -6,6 +7,7 @@ const authInstance = axios.create({
authInstance.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${localStorage.getItem("token")}`;
config.headers["X-Language"] = languageStore.language ?? "ru";
return config;
});

View File

@ -17,7 +17,7 @@ export const NAVIGATION_ITEMS: {
id: "attractions",
label: "Достопримечательности",
icon: Building2,
path: "/sights",
path: "/sight",
},
{
id: "devices",

View 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();

View File

@ -2,7 +2,7 @@ import { API_URL, authInstance } from "@shared";
import { makeAutoObservable } from "mobx";
class DevicesStore {
devices: any[] = [];
devices: string[] = [];
uuid: string | null = null;
sendSnapshotModalOpen = false;
@ -12,7 +12,6 @@ class DevicesStore {
getDevices = async () => {
const response = await authInstance.get(`${API_URL}/devices/connected`);
console.log(response.data);
this.devices = response.data;
};

View 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();

View File

@ -2,8 +2,15 @@ import { authInstance } from "@shared";
import { API_URL } from "@shared";
import { makeAutoObservable } from "mobx";
type Snapshot = {
ID: string;
Name: string;
ParentID: string;
CreationTime: string;
};
class SnapshotStore {
snapshots: any[] = [];
snapshots: Snapshot[] = [];
constructor() {
makeAutoObservable(this);

View File

@ -1,8 +1,27 @@
import { API_URL, authInstance } from "@shared";
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 {
vehicles: any[] = [];
vehicles: Vehicle[] = [];
constructor() {
makeAutoObservable(this);

View File

@ -3,3 +3,5 @@ export * from "./LanguageStore";
export * from "./DevicesStore";
export * from "./VehicleStore";
export * from "./SnapshotStore";
export * from "./SightsStore";
export * from "./CityStore";

View File

@ -6,7 +6,13 @@ import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import Paper from "@mui/material/Paper";
import { Check, RotateCcw, Send, X } from "lucide-react";
import { devicesStore, Modal, snapshotStore, vehicleStore } from "@shared";
import {
authInstance,
devicesStore,
Modal,
snapshotStore,
vehicleStore,
} from "@shared";
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Button, Checkbox } from "@mui/material";
@ -82,13 +88,15 @@ export const DevicesTable = observer(() => {
fetchData();
}, []);
const handleSendSnapshot = (uuid: string[]) => {
const handleSendSnapshot = (uuid: string) => {
setSelectedDevice(uuid);
toggleSendSnapshotModal();
};
const handleReloadStatus = (uuid: string) => {
const handleReloadStatus = async (uuid: string) => {
setSelectedDevice(uuid);
await authInstance.post(`/devices/${uuid}/request-status`);
await getDevices();
};
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 (
<>
<TableContainer component={Paper}>
@ -113,7 +128,7 @@ export const DevicesTable = observer(() => {
color="primary"
disabled={selectedDevices.length === 0}
className="ml-auto"
onClick={() => handleSendSnapshot(selectedDevices)}
onClick={() => handleSendSnapshot(uuid ?? "")}
>
Отправить снапшот
</Button>
@ -195,7 +210,11 @@ export const DevicesTable = observer(() => {
<div className="mt-5 flex flex-col gap-2 max-h-[300px] overflow-y-auto">
{snapshots &&
snapshots.map((snapshot) => (
<button className="p-2 rounded-xl bg-slate-100" key={snapshot.id}>
<button
onClick={() => handleSendSnapshotAction(uuid!, snapshot.ID)}
className="p-2 rounded-xl bg-slate-100"
key={snapshot.ID}
>
{snapshot.Name}
</button>
))}

View File

@ -1,29 +1,63 @@
import { languageStore } from "@shared";
import { Button } from "@mui/material"; // Only Button is needed
import { useEffect, useCallback } from "react";
import { observer } from "mobx-react-lite";
const LANGUAGES = ["ru", "en", "zh"] as const;
type Language = (typeof LANGUAGES)[number];
export const LanguageSwitcher = observer(() => {
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 (
<div className="flex flex-col gap-2">
<button
className={`p-3 ${language === "ru" ? "bg-blue-500" : ""}`}
onClick={() => setLanguage("ru")}
>
RU
</button>
<button
className={`p-3 ${language === "en" ? "bg-blue-500" : ""}`}
onClick={() => setLanguage("en")}
>
EN
</button>
<button
className={`p-3 ${language === "zh" ? "bg-blue-500" : ""}`}
onClick={() => setLanguage("zh")}
>
zh
</button>
<div className="fixed top-1/2 -translate-y-1/2 right-0 flex flex-col gap-2 p-4 ">
{/* Added some styling for better visualization */}
{LANGUAGES.map((lang) => (
<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
>
{getLanguageLabel(lang)}
</Button>
))}
</div>
);
});

View File

@ -12,17 +12,16 @@ export const ReactMarkdownComponent = ({ value }: { value: string }) => {
borderRadius: 1,
},
"& h1, & h2, & h3, & h4, & h5, & h6": {
color: "primary.main",
color: "white",
mt: 2,
mb: 1,
},
"& p": {
mb: 2,
color: (theme) =>
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
color: "white",
},
"& a": {
color: "primary.main",
color: "white",
textDecoration: "none",
"&:hover": {
textDecoration: "underline",

View File

@ -1,7 +1,7 @@
import { Unlink } from "lucide-react";
import { ImagePlus, Unlink } from "lucide-react";
import { Trash2 } from "lucide-react";
import { TextField } from "@mui/material";
import { Box, TextField } from "@mui/material";
import { ReactMarkdownEditor } from "@widgets";
export const SightEdit = () => {
@ -24,9 +24,52 @@ export const SightEdit = () => {
</div>
<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>
);

View File

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

View File

@ -1,60 +1,269 @@
import { TextField } from "@mui/material";
import { BackButton, TabPanel } from "@shared";
import { Box, Button, TextField, Paper, Typography } from "@mui/material";
import { BackButton, Sight, TabPanel } from "@shared";
import { ReactMarkdownComponent, ReactMarkdownEditor } from "@widgets";
import { Trash2 } from "lucide-react";
import { Unlink } from "lucide-react";
import { Unlink, Trash2, ImagePlus } from "lucide-react";
import { useState } from "react";
// Мокап данных для левой статьи
const mockLeftArticle = {
title: "История основания",
markdownContent: `## Заголовок статьи H2
Какой-то **текст** для левой статьи.
Можно использовать *markdown*.
- Список 1
- Список 2
[Ссылка на Яндекс](https://ya.ru)
`,
media: null, // null или URL/ID медиа
};
export const LeftWidgetTab = ({
value,
index,
data,
}: {
value: 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 (
<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 />
<div className="flex items-center justify-between px-5 h-14 rounded-md border">
<p className="text-2xl">Левая статья</p>
<div className="flex items-center gap-5">
<button className="flex items-center gap-2 border border-gray-300 bg-blue-100 rounded-md px-2 py-1">
<Paper
elevation={2}
sx={{
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 className="flex items-center gap-2 border border-gray-300 bg-red-100 rounded-md px-2 py-1">
</Button>
<Button
variant="outlined"
color="error"
startIcon={<Trash2 size={18} />}
onClick={handleDeleteArticle}
size="small"
>
Удалить
<Trash2 />
</button>
</div>
</div>
<div className="flex gap-5">
<div className="flex flex-col gap-5 flex-1">
<TextField sx={{ width: "30%" }} label="Название" />
</Button>
</Box>
</Paper>
<ReactMarkdownEditor
value={leftArticleData}
onChange={setLeftArticleData}
<Box sx={{ display: "flex", gap: 3, flexGrow: 1 }}>
{/* Левая колонка: Редактирование */}
<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">
<p>Предпросмотр</p>
<div className="bg-yellow-200 w-[350px] h-full">
<div className="bg-red-100 w-full h-[200px]"></div>
<div className="bg-blue-100 w-full text-lg p-3"></div>
<div className="bg-green-100 p-3 prose max-w-none">
<ReactMarkdownComponent value={leftArticleData} />
</div>
</div>
</div>
</div>
<button className="bg-green-400 w-min ml-auto text-white py-2 rounded-2xl px-4">
Сохранить
</button>
</div>
{/* Редактор Markdown */}
<Typography variant="subtitle2" sx={{ mt: 1 }}>
Текст
</Typography>
<ReactMarkdownEditor
value={markdownContent}
onChange={setMarkdownContent}
/>
{/* Блок МЕДИА для статьи */}
<Paper elevation={1} sx={{ padding: 2, mt: 1 }}>
<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>
</Box>
</Box>
</TabPanel>
);
};

View File

@ -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 { 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 = ({
value,
index,
data,
}: {
value: 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 (
<TabPanel value={value} index={index}>
{/* Ensure the main container takes full height and uses flexbox for layout */}
<div className="flex flex-col h-full min-h-[600px]">
{/* Content area with back button and main layout */}
<div className="flex-1 flex flex-col gap-6 p-4">
{" "}
{/* Added padding for better spacing */}
<BackButton />
<div className="flex flex-1 gap-6">
{" "}
{/* flex-1 allows this div to take remaining height */}
{/* Left sidebar */}
<div className="flex flex-col justify-between w-[240px] shrink-0 bg-gray-500 rounded-lg p-3">
{" "}
{/* Added background and padding */}
<div className="flex flex-col gap-3">
<div className="border rounded-lg p-3 bg-white font-medium shadow-sm">
{" "}
{/* Adjusted background and added shadow */}
Превью медиа
</div>
<div className="border rounded-lg p-3 bg-white hover:bg-gray-100 transition-colors cursor-pointer shadow-sm">
{" "}
{/* Adjusted background and added shadow */}1 История
</div>
<div className="border rounded-lg p-3 bg-white hover:bg-gray-100 transition-colors cursor-pointer shadow-sm">
{" "}
{/* Adjusted background and added shadow */}2 Факты
</div>
</div>
<button className="w-10 h-10 rounded-full bg-blue-500 hover:bg-blue-600 text-white p-3transition-colors flex items-center justify-center">
{" "}
{/* Added margin-top */}
<Plus />
</button>
</div>
{/* Main content area */}
<div className="flex-1 border rounded-lg p-6 bg-white shadow-md">
{" "}
{/* Added shadow for depth */}
{/* Content within the main area */}
<SightEdit />
{/* Replaced '1' with more descriptive content */}
</div>
</div>
</div>
<Box
sx={{
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: "calc(100vh - 200px)",
gap: 2,
paddingBottom: "70px",
position: "relative",
}}
>
<BackButton />
{/* Save button at the bottom, aligned to the right */}
<div className="flex justify-end p-4">
{" "}
{/* Wrapper for save button, added padding */}
<button className="bg-green-500 hover:bg-green-600 text-white py-2.5 px-6 rounded-lg transition-colors font-medium shadow-md">
{" "}
{/* Added shadow */}
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}>
{/* Левая колонка: Список блоков/статей */}
<Paper
elevation={2}
sx={{
width: 260, // Ширина как на макете
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>
</div>
</div>
</Button>
</Box>
</Box>
</TabPanel>
);
};

View 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>
</>
);
});

View File

@ -6,3 +6,4 @@ export * from "./ReactMarkdownEditor";
export * from "./SightEdit";
export * from "./LanguageSwitcher";
export * from "./DevicesTable";
export * from "./SightsTable";