Compare commits

...

2 Commits

Author SHA1 Message Date
078f051e8a Update media select in EditSightPage and CreateSightPage 2025-06-03 14:32:42 +03:00
f0e03c3a1d Add correct edit right widget 2025-06-03 12:31:47 +03:00
18 changed files with 2033 additions and 1274 deletions
src
entities
navigation
features
navigation
pages
MediaListPage
shared
api
config
modals
SelectMediaDialog
UploadMediaDialog
store
CreateSightStore
EditSightStore
widgets
ImageUploadCard
MediaAreaForSight
SightTabs
CreateInformationTab
CreateLeftTab
CreateRightTab
InformationTab
RightWidgetTab
index.ts
tsconfig.tsbuildinfo

@ -9,18 +9,26 @@ import { useNavigate } from "react-router-dom";
interface NavigationItemProps { interface NavigationItemProps {
item: NavigationItem; item: NavigationItem;
open: boolean; open: boolean;
onClick?: () => void;
} }
export const NavigationItemComponent: React.FC<NavigationItemProps> = ({ export const NavigationItemComponent: React.FC<NavigationItemProps> = ({
item, item,
open, open,
onClick,
}) => { }) => {
const Icon = item.icon; const Icon = item.icon;
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<ListItem <ListItem
onClick={() => navigate(item.path)} onClick={() => {
if (onClick) {
onClick();
} else {
navigate(item.path);
}
}}
disablePadding disablePadding
sx={{ display: "block" }} sx={{ display: "block" }}
> >

@ -25,6 +25,7 @@ export const NavigationList = ({ open }: { open: boolean }) => {
key={item.id} key={item.id}
item={item as NavigationItem} item={item as NavigationItem}
open={open} open={open}
onClick={item.onClick ? item.onClick : undefined}
/> />
))} ))}
</List> </List>

@ -1,4 +1,4 @@
import { Button, TableBody } from "@mui/material"; import { TableBody } from "@mui/material";
import { TableRow, TableCell } from "@mui/material"; import { TableRow, TableCell } from "@mui/material";
import { Table, TableHead } from "@mui/material"; import { Table, TableHead } from "@mui/material";
import { mediaStore, MEDIA_TYPE_LABELS } from "@shared"; import { mediaStore, MEDIA_TYPE_LABELS } from "@shared";
@ -27,15 +27,6 @@ export const MediaListPage = observer(() => {
return ( return (
<> <>
<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"> <Table sx={{ minWidth: 650 }} aria-label="simple table">
<TableHead> <TableHead>
<TableRow> <TableRow>

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

@ -6,7 +6,6 @@ import {
MonitorSmartphone, MonitorSmartphone,
Map, Map,
BookImage, BookImage,
Newspaper,
} from "lucide-react"; } from "lucide-react";
export const DRAWER_WIDTH = 300; export const DRAWER_WIDTH = 300;
@ -47,12 +46,12 @@ export const NAVIGATION_ITEMS: {
icon: BookImage, icon: BookImage,
path: "/media", path: "/media",
}, },
{ // {
id: "articles", // id: "articles",
label: "Статьи", // label: "Статьи",
icon: Newspaper, // icon: Newspaper,
path: "/articles", // path: "/articles",
}, // },
], ],
secondary: [ secondary: [
{ {
@ -61,6 +60,7 @@ export const NAVIGATION_ITEMS: {
icon: Power, icon: Power,
onClick: () => { onClick: () => {
authStore.logout(); authStore.logout();
window.location.href = "/login";
}, },
}, },
], ],

@ -21,12 +21,13 @@ import { MediaViewer } from "@widgets";
interface SelectMediaDialogProps { interface SelectMediaDialogProps {
open: boolean; // Corrected prop name open: boolean; // Corrected prop name
onClose: () => void; onClose: () => void;
onSelectMedia: (media: { onSelectMedia?: (media: {
id: string; id: string;
filename: string; filename: string;
media_name?: string; media_name?: string;
media_type: number; media_type: number;
}) => void; // Renamed from onSelectArticle }) => void; // Renamed from onSelectArticle
onSelectForSightMedia?: (mediaId: string) => void;
linkedMediaIds?: string[]; // Renamed from linkedArticleIds, assuming it refers to media already in use linkedMediaIds?: string[]; // Renamed from linkedArticleIds, assuming it refers to media already in use
} }
@ -35,6 +36,7 @@ export const SelectMediaDialog = observer(
open, // Corrected prop name open, // Corrected prop name
onClose, onClose,
onSelectMedia, // Renamed prop onSelectMedia, // Renamed prop
onSelectForSightMedia,
linkedMediaIds = [], // Default to empty array if not provided, renamed linkedMediaIds = [], // Default to empty array if not provided, renamed
}: SelectMediaDialogProps) => { }: SelectMediaDialogProps) => {
const { media, getMedia } = mediaStore; const { media, getMedia } = mediaStore;
@ -55,8 +57,12 @@ export const SelectMediaDialog = observer(
if (hoveredMediaId) { if (hoveredMediaId) {
const mediaItem = media.find((m) => m.id === hoveredMediaId); const mediaItem = media.find((m) => m.id === hoveredMediaId);
if (mediaItem) { if (mediaItem) {
if (onSelectForSightMedia) {
onSelectForSightMedia(mediaItem.id);
} else if (onSelectMedia) {
onSelectMedia(mediaItem); onSelectMedia(mediaItem);
} }
}
onClose(); onClose();
} }
} }
@ -114,7 +120,11 @@ export const SelectMediaDialog = observer(
key={mediaItem.id} key={mediaItem.id}
onClick={() => setHoveredMediaId(mediaItem.id)} // Call onSelectMedia onClick={() => setHoveredMediaId(mediaItem.id)} // Call onSelectMedia
onDoubleClick={() => { onDoubleClick={() => {
if (onSelectForSightMedia) {
onSelectForSightMedia(mediaItem.id);
} else if (onSelectMedia) {
onSelectMedia(mediaItem); onSelectMedia(mediaItem);
}
onClose(); onClose();
}} }}
sx={{ sx={{

@ -24,16 +24,22 @@ import { ModelViewer3D } from "@widgets";
interface UploadMediaDialogProps { interface UploadMediaDialogProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
afterUpload: (media: { afterUpload?: (media: {
id: string; id: string;
filename: string; filename: string;
media_name?: string; media_name?: string;
media_type: number; media_type: number;
}) => void; }) => void;
afterUploadSight?: (id: string) => void;
} }
export const UploadMediaDialog = observer( export const UploadMediaDialog = observer(
({ open, onClose, afterUpload }: UploadMediaDialogProps) => { ({
open,
onClose,
afterUpload,
afterUploadSight,
}: UploadMediaDialogProps) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
@ -103,8 +109,12 @@ export const UploadMediaDialog = observer(
mediaName mediaName
); );
if (media) { if (media) {
if (afterUploadSight) {
await afterUploadSight(media.id);
} else if (afterUpload) {
await afterUpload(media); await afterUpload(media);
} }
}
setSuccess(true); setSuccess(true);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to save media"); setError(err instanceof Error ? err.message : "Failed to save media");

@ -1,13 +1,13 @@
// @shared/stores/createSightStore.ts // @shared/stores/createSightStore.ts
import { import { Language, authInstance, languageInstance, mediaStore } from "@shared";
Language, import { makeAutoObservable, runInAction } from "mobx";
authInstance,
languageInstance, type MediaItem = {
articlesStore, id: string;
languageStore, filename: string;
mediaStore, media_name?: string;
} from "@shared"; media_type: number;
import { makeAutoObservable } from "mobx"; };
type SightLanguageInfo = { type SightLanguageInfo = {
name: string; name: string;
@ -15,18 +15,13 @@ type SightLanguageInfo = {
left: { left: {
heading: string; heading: string;
body: string; body: string;
media: { media: MediaItem[];
id: string;
filename: string;
media_name?: string;
media_type: number;
}[];
}; };
right: { heading: string; body: string }[]; right: { id: number; heading: string; body: string; media: MediaItem[] }[];
}; };
type SightCommonInfo = { type SightCommonInfo = {
id: number; // id: number; // ID is 0 until created
city_id: number; city_id: number;
city: string; city: string;
latitude: number; latitude: number;
@ -34,18 +29,18 @@ type SightCommonInfo = {
thumbnail: string | null; thumbnail: string | null;
watermark_lu: string | null; watermark_lu: string | null;
watermark_rd: string | null; watermark_rd: string | null;
left_article: number; left_article: number; // Can be 0 or a real ID, or placeholder like 10000000
preview_media: string | null; preview_media: string | null;
video_preview: string | null; video_preview: string | null;
}; };
// SightBaseInfo combines common info with language-specific info
// The 'id' for the sight itself will be assigned upon creation by the backend.
type SightBaseInfo = SightCommonInfo & { type SightBaseInfo = SightCommonInfo & {
[key in Language]: SightLanguageInfo; [key in Language]: SightLanguageInfo;
}; };
class CreateSightStore { const initialSightState: SightBaseInfo = {
sight: SightBaseInfo = {
id: 0,
city_id: 0, city_id: 0,
city: "", city: "",
latitude: 0, latitude: 0,
@ -56,7 +51,6 @@ class CreateSightStore {
left_article: 0, left_article: 0,
preview_media: null, preview_media: null,
video_preview: null, video_preview: null,
ru: { ru: {
name: "", name: "",
address: "", address: "",
@ -77,6 +71,9 @@ class CreateSightStore {
}, },
}; };
class CreateSightStore {
sight: SightBaseInfo = JSON.parse(JSON.stringify(initialSightState)); // Deep copy for reset
uploadMediaOpen = false; uploadMediaOpen = false;
setUploadMediaOpen = (open: boolean) => { setUploadMediaOpen = (open: boolean) => {
this.uploadMediaOpen = open; this.uploadMediaOpen = open;
@ -90,289 +87,74 @@ class CreateSightStore {
makeAutoObservable(this); makeAutoObservable(this);
} }
createNewRightArticle = () => { // --- Right Article Management ---
createNewRightArticle = async () => {
// Create article in DB for all languages
const articleRuData = {
heading: "Новый заголовок (RU)",
body: "Новый текст (RU)",
};
const articleEnData = {
heading: "New Heading (EN)",
body: "New Text (EN)",
};
const articleZhData = { heading: "新标题 (ZH)", body: "新文本 (ZH)" };
try {
const articleRes = await languageInstance("ru").post(
"/article",
articleRuData
);
const { id } = articleRes.data; // New article's ID
await languageInstance("en").patch(`/article/${id}`, articleEnData);
await languageInstance("zh").patch(`/article/${id}`, articleZhData);
runInAction(() => {
const newArticleEntry = { id, media: [] };
this.sight.ru.right.push({ ...newArticleEntry, ...articleRuData });
this.sight.en.right.push({ ...newArticleEntry, ...articleEnData });
this.sight.zh.right.push({ ...newArticleEntry, ...articleZhData });
});
return id; // Return ID for potential immediate use
} catch (error) {
console.error("Error creating new right article:", error);
throw error;
}
};
linkExistingRightArticle = async (articleId: number) => {
try {
const ruData = await languageInstance("ru").get(`/article/${articleId}`);
const enData = await languageInstance("en").get(`/article/${articleId}`);
const zhData = await languageInstance("zh").get(`/article/${articleId}`);
const mediaRes = await authInstance.get(`/article/${articleId}/media`);
const mediaData: MediaItem[] = mediaRes.data || [];
runInAction(() => {
this.sight.ru.right.push({ this.sight.ru.right.push({
heading: "Введите русский заголовок", id: articleId,
body: "Введите русский текст", heading: ruData.data.heading,
body: ruData.data.body,
media: mediaData,
}); });
this.sight.en.right.push({ this.sight.en.right.push({
heading: "Enter the English heading", id: articleId,
body: "Enter the English text", heading: enData.data.heading,
body: enData.data.body,
media: mediaData,
}); });
this.sight.zh.right.push({ this.sight.zh.right.push({
heading: "Введите китайский заголовок", id: articleId,
body: "Введите китайский текст", heading: zhData.data.heading,
body: zhData.data.body,
media: mediaData,
}); });
};
updateLeftInfo = (language: Language, heading: string, body: string) => {
this.sight[language].left.heading = heading;
this.sight[language].left.body = body;
};
clearCreateSight = () => {
this.sight = {
id: 0,
city_id: 0,
city: "",
latitude: 0,
longitude: 0,
thumbnail: null,
watermark_lu: null,
watermark_rd: null,
left_article: 0,
preview_media: null,
video_preview: null,
ru: {
name: "",
address: "",
left: { heading: "", body: "", media: [] },
right: [],
},
en: {
name: "",
address: "",
left: { heading: "", body: "", media: [] },
right: [],
},
zh: {
name: "",
address: "",
left: { heading: "", body: "", media: [] },
right: [],
},
};
};
updateSightInfo = (
content: Partial<SightLanguageInfo | SightCommonInfo>,
language?: Language
) => {
if (language) {
this.sight[language] = {
...this.sight[language],
...content,
};
} else {
this.sight = {
...this.sight,
...content,
};
}
};
unlinkLeftArticle = () => {
this.sight.left_article = 0;
this.sight.ru.left.heading = "";
this.sight.en.left.heading = "";
this.sight.zh.left.heading = "";
this.sight.ru.left.body = "";
this.sight.en.left.body = "";
this.sight.zh.left.body = "";
};
updateLeftArticle = async (articleId: number) => {
this.sight.left_article = articleId;
if (articleId) {
const ruArticleData = await languageInstance("ru").get(
`/article/${articleId}`
);
const enArticleData = await languageInstance("en").get(
`/article/${articleId}`
);
const zhArticleData = await languageInstance("zh").get(
`/article/${articleId}`
);
this.sight.ru.left.heading = ruArticleData.data.heading;
this.sight.en.left.heading = enArticleData.data.heading;
this.sight.zh.left.heading = zhArticleData.data.heading;
this.sight.ru.left.body = ruArticleData.data.body;
this.sight.en.left.body = enArticleData.data.body;
this.sight.zh.left.body = zhArticleData.data.body;
} else {
this.sight.left_article = 0;
this.sight.ru.left.heading = "";
this.sight.en.left.heading = "";
this.sight.zh.left.heading = "";
this.sight.ru.left.body = "";
this.sight.en.left.body = "";
this.sight.zh.left.body = "";
}
};
deleteLeftArticle = async (articleId: number) => {
await authInstance.delete(`/article/${articleId}`);
articlesStore.getArticles(languageStore.language);
this.sight.left_article = 0;
this.sight.ru.left.heading = "";
this.sight.en.left.heading = "";
this.sight.zh.left.heading = "";
this.sight.ru.left.body = "";
};
createLeftArticle = async () => {
const response = await languageInstance("ru").post("/article", {
heading: "Новая статья",
body: "Заполните статью контентом",
}); });
this.sight.left_article = response.data.id;
this.sight.ru.left.heading = "Новая статья ";
this.sight.en.left.heading = "";
this.sight.zh.left.heading = "";
this.sight.ru.left.body = "Заполните статью контентом";
this.sight.en.left.body = "";
this.sight.zh.left.body = "";
};
createSight = async (language: Language) => {
const rightArticles: number[] = [];
if (this.sight.left_article !== 0) {
if (this.sight.left_article == 10000000) {
const response = await languageInstance("ru").post("/article", {
heading: this.sight.ru.left.heading,
body: this.sight.ru.left.body,
});
const { id } = response.data;
await languageInstance("en").patch(`/article/${id}`, {
heading: this.sight.en.left.heading,
body: this.sight.en.left.body,
});
await languageInstance("zh").patch(`/article/${id}`, {
heading: this.sight.zh.left.heading,
body: this.sight.zh.left.body,
});
this.sight.left_article = id;
} else {
await languageInstance("ru").patch(
`/article/${this.sight.left_article}`,
{
heading: this.sight.ru.left.heading,
body: this.sight.ru.left.body,
}
);
await languageInstance("en").patch(
`/article/${this.sight.left_article}`,
{
heading: this.sight.en.left.heading,
body: this.sight.en.left.body,
}
);
await languageInstance("zh").patch(
`/article/${this.sight.left_article}`,
{
heading: this.sight.zh.left.heading,
body: this.sight.zh.left.body,
}
);
}
}
this.sight[language].right.map(async (article, index) => {
try {
const response = await languageInstance(language).post("/article", {
heading: article.heading,
body: article.body,
});
const { id } = response.data;
const anotherLanguages = ["en", "zh", "ru"].filter(
(lang) => lang !== language
);
await languageInstance(anotherLanguages[0] as Language).patch(
`/article/${id}`,
{
heading:
this.sight[anotherLanguages[0] as Language].right[index].heading,
body: this.sight[anotherLanguages[0] as Language].right[index].body,
}
);
await languageInstance(anotherLanguages[1] as Language).patch(
`/article/${id}`,
{
heading:
this.sight[anotherLanguages[1] as Language].right[index].heading,
body: this.sight[anotherLanguages[1] as Language].right[index].body,
}
);
rightArticles.push(id);
} catch (error) { } catch (error) {
console.log(error); console.error("Error linking existing right article:", error);
throw error;
} }
});
const response = await languageInstance(language).post("/sight", {
city_id: this.sight.city_id,
city: this.sight.city,
latitude: this.sight.latitude,
longitude: this.sight.longitude,
name: this.sight[language].name,
address: this.sight[language].address,
thumbnail: this.sight.thumbnail ?? null,
watermark_lu: this.sight.watermark_lu,
watermark_rd: this.sight.watermark_rd,
left_article: this.sight.left_article,
preview_media: this.sight.preview_media,
video_preview: this.sight.video_preview,
});
const { id } = response.data;
const anotherLanguages = ["en", "zh", "ru"].filter(
(lang) => lang !== language
);
await languageInstance(anotherLanguages[0] as Language).patch(
`/sight/${id}`,
{
city_id: this.sight.city_id,
city: this.sight.city,
latitude: this.sight.latitude,
longitude: this.sight.longitude,
name: this.sight[anotherLanguages[0] as Language as Language].name,
address:
this.sight[anotherLanguages[0] as Language as Language].address,
thumbnail: this.sight.thumbnail ?? null,
watermark_lu: this.sight.watermark_lu,
watermark_rd: this.sight.watermark_rd,
left_article: this.sight.left_article,
preview_media: this.sight.preview_media,
video_preview: this.sight.video_preview,
}
);
await languageInstance(anotherLanguages[1] as Language).patch(
`/sight/${id}`,
{
city_id: this.sight.city_id,
city: this.sight.city,
latitude: this.sight.latitude,
longitude: this.sight.longitude,
name: this.sight[anotherLanguages[1] as Language].name,
address: this.sight[anotherLanguages[1] as Language].address,
thumbnail: this.sight.thumbnail ?? null,
watermark_lu: this.sight.watermark_lu,
watermark_rd: this.sight.watermark_rd,
left_article: this.sight.left_article,
preview_media: this.sight.preview_media,
video_preview: this.sight.video_preview,
}
);
rightArticles.map(async (article, index) => {
await authInstance.post(`/sight/${id}/article`, {
article_id: article,
page_num: index + 1,
});
});
console.log("created");
}; };
updateRightArticleInfo = ( updateRightArticleInfo = (
@ -381,68 +163,428 @@ class CreateSightStore {
heading: string, heading: string,
body: string body: string
) => { ) => {
if (this.sight[language].right[index]) {
this.sight[language].right[index].heading = heading; this.sight[language].right[index].heading = heading;
this.sight[language].right[index].body = body; this.sight[language].right[index].body = body;
}
}; };
// "Unlink" in create mode means just removing from the list to be created with the sight
unlinkRightAritcle = (articleId: number) => {
// Changed from 'unlinkRightAritcle' spelling
runInAction(() => {
this.sight.ru.right = this.sight.ru.right.filter(
(article) => article.id !== articleId
);
this.sight.en.right = this.sight.en.right.filter(
(article) => article.id !== articleId
);
this.sight.zh.right = this.sight.zh.right.filter(
(article) => article.id !== articleId
);
});
// Note: If this article was created via createNewRightArticle, it still exists in the DB.
// Consider if an orphaned article should be deleted here or managed separately.
// For now, it just removes it from the list associated with *this specific sight creation process*.
};
deleteRightArticle = async (articleId: number) => {
try {
await authInstance.delete(`/article/${articleId}`); // Delete from backend
runInAction(() => {
// Remove from local store for all languages
this.sight.ru.right = this.sight.ru.right.filter(
(article) => article.id !== articleId
);
this.sight.en.right = this.sight.en.right.filter(
(article) => article.id !== articleId
);
this.sight.zh.right = this.sight.zh.right.filter(
(article) => article.id !== articleId
);
});
} catch (error) {
console.error("Error deleting right article:", error);
throw error;
}
};
// --- Right Article Media Management ---
createLinkWithRightArticle = async (media: MediaItem, articleId: number) => {
try {
await authInstance.post(`/article/${articleId}/media`, {
media_id: media.id,
media_order: 1, // Or calculate based on existing media.length + 1
});
runInAction(() => {
(["ru", "en", "zh"] as Language[]).forEach((lang) => {
const article = this.sight[lang].right.find(
(a) => a.id === articleId
);
if (article) {
if (!article.media) article.media = [];
article.media.unshift(media); // Add to the beginning
}
});
});
} catch (error) {
console.error("Error linking media to right article:", error);
throw error;
}
};
deleteRightArticleMedia = async (articleId: number, mediaId: string) => {
try {
await authInstance.delete(`/article/${articleId}/media`, {
data: { media_id: mediaId },
});
runInAction(() => {
(["ru", "en", "zh"] as Language[]).forEach((lang) => {
const article = this.sight[lang].right.find(
(a) => a.id === articleId
);
if (article && article.media) {
article.media = article.media.filter((m) => m.id !== mediaId);
}
});
});
} catch (error) {
console.error("Error deleting media from right article:", error);
throw error;
}
};
// --- Left Article Management (largely unchanged from your provided store) ---
updateLeftInfo = (language: Language, heading: string, body: string) => {
this.sight[language].left.heading = heading;
this.sight[language].left.body = body;
};
unlinkLeftArticle = () => {
/* ... your existing logic ... */
this.sight.left_article = 0;
this.sight.ru.left = { heading: "", body: "", media: [] };
this.sight.en.left = { heading: "", body: "", media: [] };
this.sight.zh.left = { heading: "", body: "", media: [] };
};
updateLeftArticle = async (articleId: number) => {
/* ... your existing logic ... */
this.sight.left_article = articleId;
if (articleId) {
const [ruArticleData, enArticleData, zhArticleData, mediaData] =
await Promise.all([
languageInstance("ru").get(`/article/${articleId}`),
languageInstance("en").get(`/article/${articleId}`),
languageInstance("zh").get(`/article/${articleId}`),
authInstance.get(`/article/${articleId}/media`),
]);
runInAction(() => {
this.sight.ru.left = {
heading: ruArticleData.data.heading,
body: ruArticleData.data.body,
media: mediaData.data || [],
};
this.sight.en.left = {
heading: enArticleData.data.heading,
body: enArticleData.data.body,
media: mediaData.data || [],
};
this.sight.zh.left = {
heading: zhArticleData.data.heading,
body: zhArticleData.data.body,
media: mediaData.data || [],
};
});
} else {
this.unlinkLeftArticle();
}
};
deleteLeftArticle = async (articleId: number) => {
/* ... your existing logic ... */
await authInstance.delete(`/article/${articleId}`);
// articlesStore.getArticles(languageStore.language); // If still needed
this.unlinkLeftArticle();
};
createLeftArticle = async () => {
/* ... your existing logic to create a new left article (placeholder or DB) ... */
const response = await languageInstance("ru").post("/article", {
heading: "Новая левая статья",
body: "Заполните контентом",
});
const newLeftArticleId = response.data.id;
await languageInstance("en").patch(`/article/${newLeftArticleId}`, {
heading: "New Left Article",
body: "Fill with content",
});
await languageInstance("zh").patch(`/article/${newLeftArticleId}`, {
heading: "新的左侧文章",
body: "填写内容",
});
runInAction(() => {
this.sight.left_article = newLeftArticleId; // Store the actual ID
this.sight.ru.left = {
heading: "Новая левая статья",
body: "Заполните контентом",
media: [],
};
this.sight.en.left = {
heading: "New Left Article",
body: "Fill with content",
media: [],
};
this.sight.zh.left = {
heading: "新的左侧文章",
body: "填写内容",
media: [],
};
});
return newLeftArticleId;
};
// Placeholder for a "new" unsaved left article
setNewLeftArticlePlaceholder = () => {
this.sight.left_article = 10000000; // Special placeholder ID
this.sight.ru.left = {
heading: "Новая левая статья",
body: "Заполните контентом",
media: [],
};
this.sight.en.left = {
heading: "New Left Article",
body: "Fill with content",
media: [],
};
this.sight.zh.left = {
heading: "新的左侧文章",
body: "填写内容",
media: [],
};
};
// --- Sight Preview Media ---
linkPreviewMedia = (mediaId: string) => {
this.sight.preview_media = mediaId;
};
unlinkPreviewMedia = () => {
this.sight.preview_media = null;
};
// --- General Store Methods ---
clearCreateSight = () => {
this.sight = JSON.parse(JSON.stringify(initialSightState)); // Reset to initial
};
updateSightInfo = (
content: Partial<SightLanguageInfo | SightCommonInfo>, // Corrected types
language?: Language
) => {
if (language) {
this.sight[language] = { ...this.sight[language], ...content };
} else {
// Assuming content here is for SightCommonInfo
this.sight = { ...this.sight, ...(content as Partial<SightCommonInfo>) };
}
};
// --- Main Sight Creation Logic ---
createSight = async (primaryLanguage: Language) => {
let finalLeftArticleId = this.sight.left_article;
// 1. Handle Left Article (Create if new, or use existing ID)
if (this.sight.left_article === 10000000) {
// Placeholder for new
const res = await languageInstance("ru").post("/article", {
heading: this.sight.ru.left.heading,
body: this.sight.ru.left.body,
});
finalLeftArticleId = res.data.id;
await languageInstance("en").patch(`/article/${finalLeftArticleId}`, {
heading: this.sight.en.left.heading,
body: this.sight.en.left.body,
});
await languageInstance("zh").patch(`/article/${finalLeftArticleId}`, {
heading: this.sight.zh.left.heading,
body: this.sight.zh.left.body,
});
} else if (
this.sight.left_article !== 0 &&
this.sight.left_article !== null
) {
// Existing, ensure it's up-to-date
await languageInstance("ru").patch(
`/article/${this.sight.left_article}`,
{ heading: this.sight.ru.left.heading, body: this.sight.ru.left.body }
);
await languageInstance("en").patch(
`/article/${this.sight.left_article}`,
{ heading: this.sight.en.left.heading, body: this.sight.en.left.body }
);
await languageInstance("zh").patch(
`/article/${this.sight.left_article}`,
{ heading: this.sight.zh.left.heading, body: this.sight.zh.left.body }
);
}
// else: left_article is 0, so no left article
// 2. Right articles are already created in DB and their IDs are in this.sight[lang].right.
// We just need to update their content if changed before saving the sight.
for (const lang of ["ru", "en", "zh"] as Language[]) {
for (const article of this.sight[lang].right) {
if (article.id == 0 || article.id == null) {
continue;
}
await languageInstance(lang).patch(`/article/${article.id}`, {
heading: article.heading,
body: article.body,
});
// Media for these articles are already linked via createLinkWithRightArticle
}
}
const rightArticleIdsForLink = this.sight[primaryLanguage].right.map(
(a) => a.id
);
// 3. Create Sight object in DB
const sightPayload = {
city_id: this.sight.city_id,
city: this.sight.city,
latitude: this.sight.latitude,
longitude: this.sight.longitude,
name: this.sight[primaryLanguage].name,
address: this.sight[primaryLanguage].address,
thumbnail: this.sight.thumbnail,
watermark_lu: this.sight.watermark_lu,
watermark_rd: this.sight.watermark_rd,
left_article: finalLeftArticleId === 0 ? null : finalLeftArticleId,
preview_media: this.sight.preview_media,
video_preview: this.sight.video_preview,
};
const response = await languageInstance(primaryLanguage).post(
"/sight",
sightPayload
);
const newSightId = response.data.id; // ID of the newly created sight
// 4. Update other languages for the sight
const otherLanguages = (["ru", "en", "zh"] as Language[]).filter(
(l) => l !== primaryLanguage
);
for (const lang of otherLanguages) {
await languageInstance(lang).patch(`/sight/${newSightId}`, {
city_id: this.sight.city_id,
city: this.sight.city,
latitude: this.sight.latitude,
longitude: this.sight.longitude,
name: this.sight[lang].name,
address: this.sight[lang].address,
thumbnail: this.sight.thumbnail,
watermark_lu: this.sight.watermark_lu,
watermark_rd: this.sight.watermark_rd,
left_article: finalLeftArticleId === 0 ? null : finalLeftArticleId,
preview_media: this.sight.preview_media,
video_preview: this.sight.video_preview,
});
}
// 5. Link Right Articles to the new Sight
for (let i = 0; i < rightArticleIdsForLink.length; i++) {
await authInstance.post(`/sight/${newSightId}/article`, {
article_id: rightArticleIdsForLink[i],
page_num: i + 1, // Or other logic for page_num
});
}
console.log("Sight created with ID:", newSightId);
// Optionally: this.clearCreateSight(); // To reset form after successful creation
return newSightId;
};
// --- Media Upload (Generic, used by dialogs) ---
uploadMedia = async ( uploadMedia = async (
filename: string, filename: string,
type: number, type: number,
file: File, file: File,
media_name?: string media_name?: string
) => { ): Promise<MediaItem> => {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
formData.append("filename", filename); formData.append("filename", filename);
if (media_name) { if (media_name) formData.append("media_name", media_name);
formData.append("media_name", media_name);
}
formData.append("type", type.toString()); formData.append("type", type.toString());
try { try {
const response = await authInstance.post(`/media`, formData); const response = await authInstance.post(`/media`, formData);
runInAction(() => {
this.fileToUpload = null; this.fileToUpload = null;
this.uploadMediaOpen = false; this.uploadMediaOpen = false;
mediaStore.getMedia(); });
mediaStore.getMedia(); // Refresh global media list
return { return {
id: response.data.id, id: response.data.id,
filename: filename, filename: filename, // Or response.data.filename if backend returns it
media_name: media_name, media_name: media_name, // Or response.data.media_name
media_type: type, media_type: type, // Or response.data.type
}; };
} catch (error) { } catch (error) {
console.log(error); console.error("Error uploading media:", error);
throw error; throw error;
} }
}; };
createLinkWithArticle = async (media: { // For Left Article Media
id: string; createLinkWithLeftArticle = async (media: MediaItem) => {
filename: string; if (!this.sight.left_article || this.sight.left_article === 10000000) {
media_name?: string; console.warn(
media_type: number; "Left article not selected or is a placeholder. Cannot link media yet."
}) => { );
// If it's a placeholder, we could store the media temporarily and link it after the article is created.
// For simplicity, we'll assume the article must exist.
// A more robust solution might involve creating the article first if it's a placeholder.
return;
}
try {
await authInstance.post(`/article/${this.sight.left_article}/media`, { await authInstance.post(`/article/${this.sight.left_article}/media`, {
media_id: media.id, media_id: media.id,
media_order: 1, media_order: (this.sight.ru.left.media?.length || 0) + 1,
}); });
runInAction(() => {
(["ru", "en", "zh"] as Language[]).forEach((lang) => {
if (!this.sight[lang].left.media) this.sight[lang].left.media = [];
this.sight[lang].left.media.unshift(media);
});
});
} catch (error) {
console.error("Error linking media to left article:", error);
throw error;
}
};
this.sight.ru.left.media.unshift({ deleteLeftArticleMedia = async (mediaId: string) => {
id: media.id, if (!this.sight.left_article || this.sight.left_article === 10000000)
media_type: media.media_type, return;
filename: media.filename, try {
await authInstance.delete(`/article/${this.sight.left_article}/media`, {
data: { media_id: mediaId },
}); });
runInAction(() => {
this.sight.en.left.media.unshift({ (["ru", "en", "zh"] as Language[]).forEach((lang) => {
id: media.id, if (this.sight[lang].left.media) {
media_type: media.media_type, this.sight[lang].left.media = this.sight[lang].left.media.filter(
filename: media.filename, (m) => m.id !== mediaId
);
}
}); });
this.sight.zh.left.media.unshift({
id: media.id,
media_type: media.media_type,
filename: media.filename,
}); });
} catch (error) {
console.error("Error deleting media from left article:", error);
throw error;
}
}; };
} }

@ -11,7 +11,12 @@ export type SightLanguageInfo = {
body: string; body: string;
media: { id: string; media_type: number; filename: string }[]; media: { id: string; media_type: number; filename: string }[];
}; };
right: { heading: string; body: string }[]; right: {
id: number;
heading: string;
body: string;
media: { id: string; media_type: number; filename: string }[];
}[];
}; };
export type SightCommonInfo = { export type SightCommonInfo = {
@ -83,7 +88,8 @@ class EditSightStore {
const response = await authInstance.get(`/sight/${id}`); const response = await authInstance.get(`/sight/${id}`);
const data = response.data; const data = response.data;
if (data.left_article != 0) {
if (data.left_article != 0 && data.left_article != null) {
await this.getLeftArticle(data.left_article); await this.getLeftArticle(data.left_article);
} }
@ -111,21 +117,42 @@ class EditSightStore {
}; };
getRightArticles = async (id: number) => { getRightArticles = async (id: number) => {
const responseRu = await languageInstance("ru").get(`/sight/${id}/article`); let responseRu = await languageInstance("ru").get(`/sight/${id}/article`);
const responseEn = await languageInstance("en").get(`/sight/${id}/article`); let responseEn = await languageInstance("en").get(`/sight/${id}/article`);
const responseZh = await languageInstance("zh").get(`/sight/${id}/article`); let responseZh = await languageInstance("zh").get(`/sight/${id}/article`);
// Function to fetch media for a given set of articles
const fetchMediaForArticles = async (articles: any[]) => {
const articlesWithMedia = [];
for (const article of articles) {
const responseMedia = await authInstance.get(
`/article/${article.id}/media`
);
articlesWithMedia.push({
...article,
media: responseMedia.data,
});
}
return articlesWithMedia;
};
// Fetch media for articles in each language
const ruArticlesWithMedia = await fetchMediaForArticles(responseRu.data);
const enArticlesWithMedia = await fetchMediaForArticles(responseEn.data);
const zhArticlesWithMedia = await fetchMediaForArticles(responseZh.data);
const data = { const data = {
ru: { ru: {
right: responseRu.data, right: ruArticlesWithMedia,
}, },
en: { en: {
right: responseEn.data, right: enArticlesWithMedia,
}, },
zh: { zh: {
right: responseZh.data, right: zhArticlesWithMedia,
}, },
}; };
runInAction(() => { runInAction(() => {
this.sight = { this.sight = {
...this.sight, ...this.sight,
@ -137,7 +164,6 @@ class EditSightStore {
...this.sight.en, ...this.sight.en,
right: data.en.right, right: data.en.right,
}, },
zh: { zh: {
...this.sight.zh, ...this.sight.zh,
right: data.zh.right, right: data.zh.right,
@ -235,7 +261,10 @@ class EditSightStore {
}); });
this.sight.common.left_article = createdLeftArticleId; this.sight.common.left_article = createdLeftArticleId;
} else if (this.sight.common.left_article != 0) { } else if (
this.sight.common.left_article != 0 &&
this.sight.common.left_article != null
) {
await languageInstance("ru").patch( await languageInstance("ru").patch(
`/article/${this.sight.common.left_article}`, `/article/${this.sight.common.left_article}`,
{ {
@ -279,8 +308,16 @@ class EditSightStore {
left_article: createdLeftArticleId, left_article: createdLeftArticleId,
}); });
if (this.sight.common.left_article == 0) { for (const language of ["ru", "en", "zh"] as Language[]) {
return; for (const article of this.sight[language].right) {
if (article.id == 0 || article.id == null) {
continue;
}
await languageInstance(language).patch(`/article/${article.id}`, {
heading: article.heading,
body: article.body,
});
}
} }
// await languageInstance("ru").patch( // await languageInstance("ru").patch(
@ -375,6 +412,38 @@ class EditSightStore {
); );
}; };
unlinkRightArticle = async (article_id: number) => {
await authInstance.delete(`/sight/${this.sight.common.id}/article`, {
data: {
article_id: article_id,
},
});
this.sight.ru.right = this.sight.ru.right.filter(
(article) => article.id !== article_id
);
this.sight.en.right = this.sight.en.right.filter(
(article) => article.id !== article_id
);
this.sight.zh.right = this.sight.zh.right.filter(
(article) => article.id !== article_id
);
};
deleteRightArticle = async (article_id: number) => {
this.sight.ru.right = this.sight.ru.right.filter(
(article) => article.id !== article_id
);
this.sight.en.right = this.sight.en.right.filter(
(article) => article.id !== article_id
);
this.sight.zh.right = this.sight.zh.right.filter(
(article) => article.id !== article_id
);
await authInstance.delete(`/article/${article_id}`);
};
uploadMediaOpen = false; uploadMediaOpen = false;
setUploadMediaOpen = (open: boolean) => { setUploadMediaOpen = (open: boolean) => {
this.uploadMediaOpen = open; this.uploadMediaOpen = open;
@ -442,6 +511,168 @@ class EditSightStore {
filename: media.filename, filename: media.filename,
}); });
}; };
unlinkPreviewMedia = async () => {
this.sight.common.preview_media = null;
};
linkPreviewMedia = async (mediaId: string) => {
this.sight.common.preview_media = mediaId;
};
linkArticle = async (article_id: number) => {
const response = await languageInstance("ru").get(`/article/${article_id}`);
const responseEn = await languageInstance("en").get(
`/article/${article_id}`
);
const responseZh = await languageInstance("zh").get(
`/article/${article_id}`
);
const mediaIds = await authInstance.get(`/article/${article_id}/media`);
runInAction(() => {
this.sight.ru.right.push({
id: article_id,
heading: response.data.heading,
body: response.data.body,
media: mediaIds.data,
});
this.sight.en.right.push({
id: article_id,
heading: responseEn.data.heading,
body: responseEn.data.body,
media: mediaIds.data,
});
this.sight.zh.right.push({
id: article_id,
heading: responseZh.data.heading,
body: responseZh.data.body,
media: mediaIds.data,
});
});
};
deleteRightArticleMedia = async (article_id: number, media_id: string) => {
await authInstance.delete(`/article/${article_id}/media`, {
data: {
media_id: media_id,
},
});
this.sight.ru.right = this.sight.ru.right.map((article) => {
if (article.id === article_id) {
article.media = article.media.filter((media) => media.id !== media_id);
}
return article;
});
this.sight.en.right = this.sight.en.right.map((article) => {
if (article.id === article_id) {
article.media = article.media.filter((media) => media.id !== media_id);
}
return article;
});
this.sight.zh.right = this.sight.zh.right.map((article) => {
if (article.id === article_id) {
article.media = article.media.filter((media) => media.id !== media_id);
}
return article;
});
};
createNewRightArticle = async () => {
const articleId = await languageInstance("ru").post("/article", {
heading: "Введите русский заголовок",
body: "Введите русский текст",
});
const { id } = articleId.data;
await languageInstance("en").patch(`/article/${id}`, {
heading: "Enter the English heading",
body: "Enter the English text",
});
await languageInstance("zh").patch(`/article/${id}`, {
heading: "Введите китайский заголовок",
body: "Введите китайский текст",
});
await authInstance.post(`/sight/${this.sight.common.id}/article`, {
article_id: id,
page_num: this.sight.ru.right.length + 1,
});
this.sight.ru.right.push({
id: id,
heading: "Введите русский заголовок",
body: "Введите русский текст",
media: [],
});
this.sight.en.right.push({
id: id,
heading: "Enter the English heading",
body: "Enter the English text",
media: [],
});
this.sight.zh.right.push({
id: id,
heading: "Введите китайский заголовок",
body: "Введите китайский текст",
media: [],
});
};
createLinkWithRightArticle = async (
media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
},
article_id: number
) => {
await authInstance.post(`/article/${article_id}/media`, {
media_id: media.id,
media_order: 1,
});
this.sight.ru.right = this.sight.ru.right.map((article) => {
if (article.id === article_id) {
article.media.unshift({
id: media.id,
media_type: media.media_type,
filename: media.filename,
});
}
return article;
});
this.sight.en.right = this.sight.en.right.map((article) => {
if (article.id === article_id) {
article.media.unshift({
id: media.id,
media_type: media.media_type,
filename: media.filename,
});
}
return article;
});
this.sight.zh.right = this.sight.zh.right.map((article) => {
if (article.id === article_id) {
article.media.unshift({
id: media.id,
media_type: media.media_type,
filename: media.filename,
});
}
return article;
});
};
updateRightArticleInfo = (
index: number,
language: Language,
heading: string,
body: string
) => {
this.sight[language].right[index].heading = heading;
this.sight[language].right[index].body = body;
};
} }
export const editSightStore = new EditSightStore(); export const editSightStore = new EditSightStore();

@ -0,0 +1,182 @@
import React, { useRef, useState, DragEvent, useEffect } from "react";
import { Paper, Box, Typography, Button, Tooltip } from "@mui/material";
import { X, Info } from "lucide-react"; // Assuming lucide-react for icons
import { editSightStore } from "@shared";
interface ImageUploadCardProps {
title: string;
imageKey?: "thumbnail" | "watermark_lu" | "watermark_rd";
imageUrl: string | null | undefined;
onImageClick: () => void;
onDeleteImageClick: () => void;
onSelectFileClick: () => void;
setUploadMediaOpen: (open: boolean) => void;
tooltipText?: string;
}
export const ImageUploadCard: React.FC<ImageUploadCardProps> = ({
title,
imageUrl,
onImageClick,
onDeleteImageClick,
onSelectFileClick,
setUploadMediaOpen,
tooltipText,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
const { setFileToUpload } = editSightStore;
useEffect(() => {
if (isDragOver) {
console.log("isDragOver");
}
}, [isDragOver]);
// --- Click to select file ---
const handleZoneClick = () => {
// Trigger the hidden file input click
fileInputRef.current?.click();
};
const handleFileInputChange = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
const file = event.target.files?.[0];
if (file) {
setFileToUpload(file);
setUploadMediaOpen(true);
}
// Reset the input value so selecting the same file again triggers change
event.target.value = "";
};
const token = localStorage.getItem("token");
// --- Drag and Drop Handlers ---
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); // Crucial to allow a drop
event.stopPropagation();
setIsDragOver(true);
};
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setIsDragOver(false);
};
const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); // Crucial to allow a drop
event.stopPropagation();
setIsDragOver(false);
const files = event.dataTransfer.files;
if (files && files.length > 0) {
const file = files[0];
setFileToUpload(file);
setUploadMediaOpen(true);
}
};
return (
<Paper
elevation={2}
sx={{
padding: 2,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 1,
flex: 1,
minWidth: 150,
}}
>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Typography variant="subtitle2" gutterBottom sx={{ mb: 0, mr: 0.5 }}>
{title}
</Typography>
{tooltipText && (
<Tooltip title={tooltipText}>
<Info size={16} color="gray" style={{ cursor: "pointer" }} />
</Tooltip>
)}
</Box>
<Box
sx={{
position: "relative",
width: "200px",
height: "200px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: imageUrl ? "pointer" : "default",
}}
onClick={onImageClick}
// Removed onClick on the main Box to avoid conflicts
>
{imageUrl && (
<button
className="absolute top-2 right-2"
onClick={(e) => {
e.stopPropagation();
onDeleteImageClick();
}}
>
<X color="red" />
</button>
)}
{imageUrl ? (
<img
src={`${
import.meta.env.VITE_KRBL_MEDIA
}${imageUrl}/download?token=${token}`}
alt={title}
style={{ maxWidth: "100%", maxHeight: "100%" }}
onClick={onImageClick}
/>
) : (
<div
className={`w-full flex flex-col items-center justify-center gap-3 `}
>
<div
className="flex flex-col p-5 items-center justify-center gap-3"
style={{
border: "2px dashed #ccc",
borderRadius: 1,
cursor: "pointer",
}}
onClick={handleZoneClick} // Click handler for the zone
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<p>Перетащите файл</p>
</div>
<p>или</p>
<Button
variant="contained"
color="primary"
onClick={(e) => {
e.stopPropagation(); // Prevent `handleZoneClick` from firing
onSelectFileClick(); // This button might trigger a different modal
}}
>
Выбрать файл
</Button>
{/* Hidden file input */}
<input
type="file"
ref={fileInputRef}
onChange={handleFileInputChange}
style={{ display: "none" }}
accept="image/*" // Accept only image files
/>
</div>
)}
</Box>
</Paper>
);
};

@ -0,0 +1,107 @@
import { Box, Button } from "@mui/material";
import { editSightStore, SelectMediaDialog, UploadMediaDialog } from "@shared";
import { Upload } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState, DragEvent, useRef } from "react";
export const MediaAreaForSight = observer(
({
onFilesDrop, // 👈 Проп для обработки загруженных файлов
onFinishUpload,
}: {
onFilesDrop?: (files: File[]) => void;
onFinishUpload?: (mediaId: string) => void;
}) => {
const [selectMediaDialogOpen, setSelectMediaDialogOpen] = useState(false);
const [uploadMediaDialogOpen, setUploadMediaDialogOpen] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { setFileToUpload } = editSightStore;
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length && onFilesDrop) {
setFileToUpload(files[0]);
}
setUploadMediaDialogOpen(true);
};
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
const handleClick = () => {
fileInputRef.current?.click();
};
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
if (files.length && onFilesDrop) {
setFileToUpload(files[0]);
onFilesDrop(files);
setUploadMediaDialogOpen(true);
}
// Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
event.target.value = "";
};
return (
<>
<input
type="file"
ref={fileInputRef}
onChange={handleFileSelect}
accept="image/*,video/*,.glb,.gltf"
multiple
style={{ display: "none" }}
/>
<Box className="w-full flex flex-col items-center justify-center border rounded-md p-4">
<div className="w-full flex flex-col items-center justify-center">
<div
className={`w-full h-40 flex flex-col justify-center items-center text-gray-400 border-dashed border-2 rounded-md border-gray-400 p-4 cursor-pointer hover:bg-gray-50 ${
isDragging ? "bg-blue-100 border-blue-400" : ""
}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={handleClick}
>
<Upload size={32} className="mb-2" />
Перетащите медиа файлы сюда или нажмите для выбора
</div>
<div>или</div>
<Button
variant="contained"
color="primary"
onClick={() => setSelectMediaDialogOpen(true)}
>
Выбрать существующие медиа файлы
</Button>
</div>
</Box>
<UploadMediaDialog
open={uploadMediaDialogOpen}
onClose={() => setUploadMediaDialogOpen(false)}
afterUploadSight={onFinishUpload}
/>
<SelectMediaDialog
open={selectMediaDialogOpen}
onClose={() => setSelectMediaDialogOpen(false)}
onSelectForSightMedia={onFinishUpload}
/>
</>
);
}
);

@ -3,9 +3,6 @@ import {
TextField, TextField,
Box, Box,
Autocomplete, Autocomplete,
Typography,
Paper,
Tooltip,
MenuItem, MenuItem,
Menu as MuiMenu, Menu as MuiMenu,
} from "@mui/material"; } from "@mui/material";
@ -20,9 +17,9 @@ import {
SightLanguageInfo, SightLanguageInfo,
SightCommonInfo, SightCommonInfo,
createSightStore, createSightStore,
UploadMediaDialog,
} from "@shared"; } from "@shared";
import { LanguageSwitcher } from "@widgets"; import { ImageUploadCard, LanguageSwitcher } from "@widgets";
import { Info, X } from "lucide-react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -33,20 +30,16 @@ import { toast } from "react-toastify";
export const CreateInformationTab = observer( export const CreateInformationTab = observer(
({ value, index }: { value: number; index: number }) => { ({ value, index }: { value: number; index: number }) => {
const { cities } = cityStore; const { cities } = cityStore;
const [, setIsMediaModalOpen] = useState(false);
const [mediaId, setMediaId] = useState<string>(""); const [mediaId, setMediaId] = useState<string>("");
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const { language } = languageStore; const { language } = languageStore;
const { sight, updateSightInfo, createSight } = createSightStore; const { sight, updateSightInfo, createSight } = createSightStore;
const data = sight[language]; const data = sight[language];
const [, setCity] = useState<number>(sight.city_id ?? 0); const [, setCity] = useState<number>(sight.city_id ?? 0);
const [coordinates, setCoordinates] = useState<string>(`0 0`); const [coordinates, setCoordinates] = useState<string>(`0 0`);
const token = localStorage.getItem("token");
// Menu state for each media button // Menu state for each media button
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null); const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const [activeMenuType, setActiveMenuType] = useState< const [activeMenuType, setActiveMenuType] = useState<
@ -109,6 +102,7 @@ export const CreateInformationTab = observer(
}); });
setActiveMenuType(null); setActiveMenuType(null);
}; };
return ( return (
<> <>
<TabPanel value={value} index={index}> <TabPanel value={value} index={index}>
@ -242,271 +236,77 @@ export const CreateInformationTab = observer(
flexDirection: { xs: "column", sm: "row" }, // Stack on extra small, side-by-side on small and up flexDirection: { xs: "column", sm: "row" }, // Stack on extra small, side-by-side on small and up
}} }}
> >
<Paper <ImageUploadCard
elevation={2} title="Логотип"
sx={{ imageKey="thumbnail"
padding: 2, imageUrl={sight.thumbnail}
display: "flex", onImageClick={() => {
flexDirection: "column", setIsPreviewMediaOpen(true);
alignItems: "center", setMediaId(sight.thumbnail ?? "");
gap: 1,
flex: 1,
minWidth: 150, // Ensure a minimum width
}} }}
> onDeleteImageClick={() => {
<Box sx={{ display: "flex", alignItems: "center" }}>
<Typography
variant="subtitle2"
gutterBottom
sx={{ mb: 0, mr: 0.5 }}
>
Логотип
</Typography>
</Box>
<Box
sx={{
position: "relative",
width: "200px",
height: "200px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: sight.thumbnail ? "pointer" : "default",
}}
onClick={() => {
setIsMediaModalOpen(true);
}}
>
{sight.thumbnail && (
<button
className="absolute top-2 right-2"
onClick={() => {
handleChange({ handleChange({
thumbnail: null, thumbnail: null,
}); });
setActiveMenuType(null); setActiveMenuType(null);
}} }}
> onSelectFileClick={() => {
<X color="red" /> setActiveMenuType("thumbnail");
</button>
)}
{sight.thumbnail ? (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
sight.thumbnail
}/download?token=${token}`}
alt="Логотип"
style={{ maxWidth: "100%", maxHeight: "100%" }}
onClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(sight.thumbnail ?? "");
}}
/>
) : (
<div className="w-full flex flex-col items-center justify-center gap-3">
<div className="w-full h-20 border rounded-md flex flex-col items-center transition-all duration-300 justify-center border-dashed border-gray-300 cursor-pointer hover:bg-gray-100">
<p>Перетащите файл</p>
</div>
<p>или</p>
<Button
variant="contained"
color="primary"
onClick={() => {
setIsAddMediaOpen(true); setIsAddMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("thumbnail"); setActiveMenuType("thumbnail");
}} }}
>
Выбрать файл
</Button>
</div>
)}
</Box>
</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={"asf"}>
<Info
size={16}
color="gray"
style={{ cursor: "pointer" }}
/> />
</Tooltip>
</Box>
<Box <ImageUploadCard
sx={{ title="Водяной знак (л.в)"
position: "relative", imageKey="watermark_lu"
width: "200px", imageUrl={sight.watermark_lu}
height: "200px", onImageClick={() => {
setIsPreviewMediaOpen(true);
display: "flex", setMediaId(sight.watermark_lu ?? "");
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: sight.watermark_lu ? "pointer" : "default",
}} }}
onClick={() => { onDeleteImageClick={() => {
setIsMediaModalOpen(true);
}}
>
{sight.watermark_lu && (
<button
className="absolute top-2 right-2"
onClick={() => {
handleChange({ handleChange({
watermark_lu: null, watermark_lu: null,
}); });
setActiveMenuType(null); setActiveMenuType(null);
}} }}
> onSelectFileClick={() => {
<X color="red" />
</button>
)}
{sight.watermark_lu ? (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
sight.watermark_lu
}/download?token=${token}`}
alt="Логотип"
style={{ maxWidth: "100%", maxHeight: "100%" }}
onClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(sight.watermark_lu ?? "");
}}
/>
) : (
<div className="w-full flex flex-col items-center justify-center gap-3">
<div className="w-full h-20 border rounded-md flex flex-col items-center transition-all duration-300 justify-center border-dashed border-gray-300 cursor-pointer hover:bg-gray-100">
<p>Перетащите файл</p>
</div>
<p>или</p>
<Button
variant="contained"
color="primary"
onClick={() => {
setActiveMenuType("watermark_lu"); setActiveMenuType("watermark_lu");
setIsAddMediaOpen(true); setIsAddMediaOpen(true);
}} }}
> setUploadMediaOpen={() => {
Выбрать файл setIsUploadMediaOpen(true);
</Button> setActiveMenuType("watermark_lu");
</div>
)}
</Box>
</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={"asfaf"}>
<Info
size={16}
color="gray"
style={{ cursor: "pointer" }}
/> />
</Tooltip>
</Box>
<Box
sx={{
position: "relative",
width: "200px",
height: "200px",
display: "flex", <ImageUploadCard
alignItems: "center", title="Водяной знак (п.в)"
justifyContent: "center", imageKey="watermark_rd"
borderRadius: 1, imageUrl={sight.watermark_rd}
mb: 1, onImageClick={() => {
cursor: sight.watermark_rd ? "pointer" : "default", setIsPreviewMediaOpen(true);
setMediaId(sight.watermark_rd ?? "");
}} }}
onClick={() => { onDeleteImageClick={() => {
setIsMediaModalOpen(true);
}}
>
{sight.watermark_rd && (
<button
className="absolute top-2 right-2"
onClick={() => {
handleChange({ handleChange({
watermark_rd: null, watermark_rd: null,
}); });
setActiveMenuType(null); setActiveMenuType(null);
}} }}
> onSelectFileClick={() => {
<X color="red" />
</button>
)}
{sight.watermark_rd ? (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
sight.watermark_rd
}/download?token=${token}`}
alt="Логотип"
style={{ maxWidth: "100%", maxHeight: "100%" }}
onClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(sight.watermark_rd ?? "");
}}
/>
) : (
<div className="w-full flex flex-col items-center justify-center gap-3">
<div className="w-full h-20 border rounded-md flex flex-col items-center transition-all duration-300 justify-center border-dashed border-gray-300 cursor-pointer hover:bg-gray-100">
<p>Перетащите файл</p>
</div>
<p>или</p>
<Button
variant="contained"
color="primary"
onClick={() => {
setActiveMenuType("watermark_rd"); setActiveMenuType("watermark_rd");
setIsAddMediaOpen(true); setIsAddMediaOpen(true);
}} }}
> setUploadMediaOpen={() => {
Выбрать файл setIsUploadMediaOpen(true);
</Button> setActiveMenuType("watermark_rd");
</div> }}
)} />
</Box>
</Paper>
</Box> </Box>
</Box> </Box>
</Box> </Box>
@ -576,6 +376,18 @@ export const CreateInformationTab = observer(
onClose={() => setIsPreviewMediaOpen(false)} onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId} mediaId={mediaId}
/> />
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
afterUpload={(media) => {
handleChange({
[activeMenuType ?? "thumbnail"]: media.id,
});
setActiveMenuType(null);
setIsUploadMediaOpen(false);
}}
/>
</> </>
); );
} }

@ -32,7 +32,7 @@ export const CreateLeftTab = observer(
deleteLeftArticle, deleteLeftArticle,
createLeftArticle, createLeftArticle,
unlinkLeftArticle, unlinkLeftArticle,
createLinkWithArticle, createLinkWithLeftArticle,
} = createSightStore; } = createSightStore;
const { const {
deleteMedia, deleteMedia,
@ -75,10 +75,10 @@ export const CreateLeftTab = observer(
media_name?: string; media_name?: string;
media_type: number; media_type: number;
}) => { }) => {
await createLinkWithArticle(media); await createLinkWithLeftArticle(media);
setIsSelectMediaDialogOpen(false); setIsSelectMediaDialogOpen(false);
}, },
[createLinkWithArticle] [createLinkWithLeftArticle]
); );
const handleArticleSelect = useCallback( const handleArticleSelect = useCallback(
@ -437,7 +437,7 @@ export const CreateLeftTab = observer(
afterUpload={async (media) => { afterUpload={async (media) => {
setUploadMediaOpen(false); setUploadMediaOpen(false);
setFileToUpload(null); setFileToUpload(null);
await createLinkWithArticle(media); await createLinkWithLeftArticle(media);
}} }}
/> />
<SelectArticleModal <SelectArticleModal

@ -7,40 +7,185 @@ import {
MenuItem, MenuItem,
TextField, TextField,
} from "@mui/material"; } from "@mui/material";
import { BackButton, createSightStore, languageStore, TabPanel } from "@shared"; import {
BackButton,
createSightStore,
languageStore,
SelectArticleModal,
TabPanel,
SelectMediaDialog, // Import
UploadMediaDialog, // Import
} from "@shared";
import { import {
LanguageSwitcher, LanguageSwitcher,
MediaArea, // Import
MediaAreaForSight, // Import
ReactMarkdownComponent, ReactMarkdownComponent,
ReactMarkdownEditor, ReactMarkdownEditor,
} from "@widgets"; } from "@widgets";
import { ImagePlus, Plus } from "lucide-react"; import { ImagePlus, Plus, X } from "lucide-react"; // Import X
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useState } from "react"; import { useState, useEffect } from "react"; // Added useEffect
import { MediaViewer } from "../../MediaViewer/index";
import { toast } from "react-toastify";
type MediaItemShared = {
// Define if not already available from @shared
id: string;
filename: string;
media_name?: string;
media_type: number;
};
// --- RightWidgetTab (Parent) Component ---
export const CreateRightTab = observer( export const CreateRightTab = observer(
({ value, index }: { value: number; index: number }) => { ({ value, index }: { value: number; index: number }) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const { sight, createNewRightArticle, updateRightArticleInfo } = const {
createSightStore; sight,
createNewRightArticle,
updateRightArticleInfo,
linkPreviewMedia,
unlinkPreviewMedia,
createLinkWithRightArticle,
deleteRightArticleMedia,
setFileToUpload, // From store
setUploadMediaOpen, // From store
uploadMediaOpen, // From store
unlinkRightAritcle, // Corrected spelling
deleteRightArticle,
linkExistingRightArticle,
createSight,
clearCreateSight, // For resetting form
} = createSightStore;
const { language } = languageStore; const { language } = languageStore;
const [selectArticleDialogOpen, setSelectArticleDialogOpen] =
useState(false);
const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>( const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>(
null null
); );
const open = Boolean(anchorEl); const [type, setType] = useState<"article" | "media">("media");
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] =
useState(false);
const [mediaTarget, setMediaTarget] = useState<
"sightPreview" | "rightArticle" | null
>(null);
// Reset activeArticleIndex if language changes and index is out of bounds
useEffect(() => {
if (
activeArticleIndex !== null &&
activeArticleIndex >= sight[language].right.length
) {
setActiveArticleIndex(null);
setType("media"); // Default back to media preview if selected article disappears
}
}, [language, sight[language].right, activeArticleIndex]);
const openMenu = Boolean(anchorEl);
const handleClickMenu = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget); setAnchorEl(event.currentTarget);
}; };
const handleClose = () => { const handleCloseMenu = () => {
setAnchorEl(null); setAnchorEl(null);
}; };
const handleSave = () => {
console.log("Saving right widget..."); const handleSave = async () => {
try {
await createSight(language);
toast.success("Достопримечательность успешно создана!");
clearCreateSight(); // Reset form
setActiveArticleIndex(null);
setType("media");
// Potentially navigate away: history.push('/sights-list');
} catch (error) {
console.error("Failed to save sight:", error);
toast.error("Ошибка при создании достопримечательности.");
}
}; };
const handleSelectArticle = (index: number) => { const handleDisplayArticleFromList = (idx: number) => {
setActiveArticleIndex(index); setActiveArticleIndex(idx);
setType("article");
};
const handleCreateNewLocalArticle = async () => {
handleCloseMenu();
try {
const newArticleId = await createNewRightArticle();
// Automatically select the new article if ID is returned
const newIndex = sight[language].right.findIndex(
(a) => a.id === newArticleId
);
if (newIndex > -1) {
setActiveArticleIndex(newIndex);
setType("article");
} else {
// Fallback if findIndex fails (should not happen if store updates correctly)
setActiveArticleIndex(sight[language].right.length - 1);
setType("article");
}
} catch (error) {
toast.error("Не удалось создать новую статью.");
}
};
const handleSelectExistingArticleAndLink = async (
selectedArticleId: number
) => {
try {
await linkExistingRightArticle(selectedArticleId);
setSelectArticleDialogOpen(false); // Close dialog
const newIndex = sight[language].right.findIndex(
(a) => a.id === selectedArticleId
);
if (newIndex > -1) {
setActiveArticleIndex(newIndex);
setType("article");
}
} catch (error) {
toast.error("Не удалось привязать существующую статью.");
}
};
const currentRightArticle =
activeArticleIndex !== null && sight[language].right[activeArticleIndex]
? sight[language].right[activeArticleIndex]
: null;
// Media Handling for Dialogs
const handleOpenUploadMedia = () => {
setUploadMediaOpen(true);
};
const handleOpenSelectMediaDialog = (
target: "sightPreview" | "rightArticle"
) => {
setMediaTarget(target);
setIsSelectMediaDialogOpen(true);
};
const handleMediaSelectedFromDialog = async (media: MediaItemShared) => {
setIsSelectMediaDialogOpen(false);
if (mediaTarget === "sightPreview") {
await linkPreviewMedia(media.id);
} else if (mediaTarget === "rightArticle" && currentRightArticle) {
await createLinkWithRightArticle(media, currentRightArticle.id);
}
setMediaTarget(null);
};
const handleMediaUploaded = async (media: MediaItemShared) => {
// After UploadMediaDialog finishes
setUploadMediaOpen(false);
setFileToUpload(null);
if (mediaTarget === "sightPreview") {
linkPreviewMedia(media.id);
} else if (mediaTarget === "rightArticle" && currentRightArticle) {
await createLinkWithRightArticle(media, currentRightArticle.id);
}
setMediaTarget(null); // Reset target
}; };
return ( return (
@ -51,7 +196,7 @@ export const CreateRightTab = observer(
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
height: "100%", height: "100%",
minHeight: "calc(100vh - 200px)", // Adjust as needed minHeight: "calc(100vh - 200px)",
gap: 2, gap: 2,
paddingBottom: "70px", // Space for the save button paddingBottom: "70px", // Space for the save button
position: "relative", position: "relative",
@ -60,224 +205,238 @@ export const CreateRightTab = observer(
<BackButton /> <BackButton />
<Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}> <Box sx={{ display: "flex", flexGrow: 1, gap: 2.5 }}>
{/* Left Column: Navigation & Article List */}
<Box className="flex flex-col w-[75%] gap-2"> <Box className="flex flex-col w-[75%] gap-2">
<Box className="w-full flex gap-2 "> <Box className="w-full flex gap-2 ">
<Box className="relative w-[20%] h-[70vh] flex flex-col rounded-2xl overflow-y-auto gap-3 border border-gray-300 p-3"> <Box className="relative w-[20%] h-[70vh] flex flex-col rounded-2xl overflow-y-auto gap-3 border border-gray-300 p-3">
<Box className="flex flex-col gap-3 max-h-[60vh] overflow-y-auto"> <Box className="flex flex-col gap-3 max-h-[60vh] overflow-y-auto">
<Box <Box
onClick={() => { onClick={() => {
// setMediaType("preview"); setType("media");
// setActiveArticleIndex(null); // Optional: deselect article when switching to general media view
}} }}
className="w-full bg-gray-200 p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-300" className={`w-full p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-300 ${
type === "media"
? "bg-green-300 font-semibold"
: "bg-green-200"
}`}
> >
<Typography>Предпросмотр медиа</Typography> <Typography>Предпросмотр медиа</Typography>
</Box> </Box>
{sight[language].right.map((article, index) => ( {sight[language].right.map((article, artIdx) => (
<Box <Box
key={index} key={article.id || artIdx} // article.id should be preferred
className="w-full bg-gray-200 p-4 rounded-2xl text-sm cursor-pointer hover:bg-gray-300 transition-all duration-300" className={`w-full p-4 rounded-2xl text-sm cursor-pointer hover:bg-gray-300 transition-all duration-300 ${
onClick={() => { type === "article" && activeArticleIndex === artIdx
handleSelectArticle(index); ? "bg-blue-300 font-semibold"
}} : "bg-gray-200"
}`}
onClick={() => handleDisplayArticleFromList(artIdx)}
> >
<Typography>{article.heading}</Typography> <Typography noWrap title={article.heading}>
{article.heading || "Без названия"}
</Typography>
</Box> </Box>
))} ))}
</Box> </Box>
<button <button
className="w-10 h-10 bg-blue-500 rounded-full absolute bottom-5 left-5 flex items-center justify-center" className="w-10 h-10 bg-blue-500 rounded-full absolute bottom-5 left-5 flex items-center justify-center hover:bg-blue-600"
onClick={handleClick} onClick={handleClickMenu}
aria-controls={openMenu ? "add-article-menu" : undefined}
aria-haspopup="true"
aria-expanded={openMenu ? "true" : undefined}
> >
<Plus size={20} color="white" /> <Plus size={20} color="white" />
</button> </button>
<Menu <Menu
id="basic-menu" id="add-article-menu"
anchorEl={anchorEl} anchorEl={anchorEl}
open={open} open={openMenu}
onClose={handleClose} onClose={handleCloseMenu}
MenuListProps={{ MenuListProps={{ "aria-labelledby": "basic-button" }}
"aria-labelledby": "basic-button", sx={{ mt: 1 }}
}}
sx={{
mt: 1,
}}
>
<MenuItem
onClick={() => {
createNewRightArticle();
handleClose();
}}
> >
<MenuItem onClick={handleCreateNewLocalArticle}>
<Typography>Создать новую</Typography> <Typography>Создать новую</Typography>
</MenuItem> </MenuItem>
<MenuItem <MenuItem
onClick={() => { onClick={() => {
handleClose(); setSelectArticleDialogOpen(true);
handleCloseMenu();
}} }}
> >
<Typography>Выбрать существующую статью</Typography> <Typography>Выбрать существующую статью</Typography>
</MenuItem> </MenuItem>
</Menu> </Menu>
</Box> </Box>
<Box className="w-[80%] h-[70vh] border border-gray-300 rounded-2xl p-3">
{activeArticleIndex !== null && ( {/* Main content area: Article Editor or Sight Media Preview */}
<> {type === "article" && currentRightArticle ? (
<Box className="flex justify-end gap-2 mb-3"> <Box className="w-[80%] border border-gray-300 rounded-2xl p-3 flex flex-col gap-2 overflow-hidden">
<Button variant="contained" color="primary"> <Box className="flex justify-end gap-2 mb-1 flex-shrink-0">
<Button
variant="outlined"
color="warning"
size="small"
onClick={() => {
if (currentRightArticle) {
unlinkRightAritcle(currentRightArticle.id); // Corrected function name
setActiveArticleIndex(null);
setType("media");
}
}}
>
Открепить Открепить
</Button> </Button>
<Button
<Button variant="contained" color="success"> variant="contained"
color="error"
size="small"
onClick={async () => {
if (
currentRightArticle &&
window.confirm(
`Удалить статью "${currentRightArticle.heading}" окончательно?`
)
) {
try {
await deleteRightArticle(currentRightArticle.id);
setActiveArticleIndex(null);
setType("media");
toast.success("Статья удалена");
} catch {
toast.error("Не удалось удалить статью");
}
}
}}
>
Удалить Удалить
</Button> </Button>
</Box> </Box>
<Box sx={{ display: "flex", gap: 3, flexGrow: 1 }}>
{/* Левая колонка: Редактирование */}
<Box <Box
sx={{ sx={{
flex: 2, flexGrow: 1,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: 2, gap: 2,
overflowY: "auto",
pt: 7,
}} }}
> >
<TextField <TextField
label="Название информации" label="Название информации (правый виджет)"
value={ value={currentRightArticle.heading}
sight[language].right[activeArticleIndex].heading
}
onChange={(e) => onChange={(e) =>
activeArticleIndex !== null &&
updateRightArticleInfo( updateRightArticleInfo(
activeArticleIndex, activeArticleIndex,
language, language,
e.target.value, e.target.value,
sight[language].right[activeArticleIndex].body currentRightArticle.body
) )
} }
variant="outlined" variant="outlined"
fullWidth fullWidth
/> />
<Box sx={{ minHeight: 200, flexGrow: 1 }}>
<ReactMarkdownEditor <ReactMarkdownEditor
value={ value={currentRightArticle.body}
sight[language].right[activeArticleIndex].body onChange={(mdValue) =>
} activeArticleIndex !== null &&
onChange={(value) =>
updateRightArticleInfo( updateRightArticleInfo(
activeArticleIndex, activeArticleIndex,
language, language,
sight[language].right[activeArticleIndex] currentRightArticle.heading,
.heading, mdValue || ""
value
) )
} }
/> />
</Box> </Box>
{/* Блок МЕДИА для статьи */} <MediaArea
{/* <Paper elevation={1} sx={{ padding: 2, mt: 1 }}> articleId={currentRightArticle.id} // Needs a real ID
<Typography variant="h6" gutterBottom> mediaIds={currentRightArticle.media || []}
МЕДИА onFilesDrop={(files) => {
</Typography> if (files.length > 0) {
{data.left.media ? ( setFileToUpload(files[0]);
<Box sx={{ mb: 1 }}> setMediaTarget("rightArticle");
<img handleOpenUploadMedia();
src={data.left.media.filename} }
alt="Selected media"
style={{
maxWidth: "100%",
maxHeight: "150px",
objectFit: "contain",
}} }}
deleteMedia={deleteRightArticleMedia}
setSelectMediaDialogOpen={() =>
handleOpenSelectMediaDialog("rightArticle")
}
/> />
</Box> </Box>
) : ( </Box>
<Box ) : type === "media" ? (
sx={{ <Box className="w-[80%] h-[70vh] border border-gray-300 rounded-2xl p-3 relative flex justify-center items-center">
width: "100%", {type === "media" && (
height: 100, <Box className="w-[80%] border border-gray-300 rounded-2xl relative">
backgroundColor: "grey.100", {sight.preview_media && (
display: "flex", <>
alignItems: "center", <Box className="absolute top-4 right-4">
justifyContent: "center", <button
borderRadius: 1, className="w-10 h-10 flex items-center justify-center"
mb: 1, onClick={unlinkPreviewMedia}
border: "2px dashed", >
borderColor: "grey.300", <X size={20} color="red" />
</button>
</Box>
<MediaViewer
media={{
id: sight.preview_media || "",
media_type: 1,
filename: sight.preview_media || "",
}} }}
> />
<Typography color="text.secondary">Нет медиа</Typography>
</Box>
)}
<Button
variant="contained"
startIcon={<ImagePlus size={18} />}
onClick={handleOpenMediaDialog}
>
Выбрать/Загрузить медиа
</Button>
{data.left.media && (
<Button
variant="outlined"
color="error"
size="small"
sx={{ ml: 1 }}
onClick={() =>
updateSightInfo(
languageStore.language,
{
left: {
heading: data.left.heading,
body: data.left.body,
media: null,
},
},
false
)
}
>
Удалить медиа
</Button>
)}
</Paper> */}
</Box>
</> </>
)} )}
{!sight.preview_media && (
<MediaAreaForSight
onFinishUpload={(mediaId) => {
linkPreviewMedia(mediaId);
}}
onFilesDrop={() => {}}
/>
)}
</Box>
)}
</Box>
) : (
<Box className="w-[80%] h-[70vh] border border-gray-300 rounded-2xl p-3 flex justify-center items-center">
<Typography variant="h6" color="text.secondary">
Выберите статью слева или секцию "Предпросмотр медиа"
</Typography>
</Box>
)}
</Box> </Box>
</Box> </Box>
</Box>
{/* Right Column: Live Preview */}
<Box className="w-[25%] mr-10"> <Box className="w-[25%] mr-10">
{activeArticleIndex !== null && ( {type === "article" && currentRightArticle && (
<Paper <Paper
className="flex-1 flex flex-col rounded-2xl" className="flex-1 flex flex-col rounded-2xl"
elevation={2} elevation={2}
sx={{ height: "75vh", overflow: "hidden" }}
> >
<Box <Box
className="rounded-2xl overflow-hidden" className="rounded-2xl overflow-hidden"
sx={{ sx={{
width: "100%", width: "100%",
height: "75vh", height: "100%",
background: "#877361", background: "#877361", // Theme background
borderColor: "grey.300",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
}} }}
> >
{false ? ( {currentRightArticle.media &&
<Box currentRightArticle.media.length > 0 ? (
sx={{ <MediaViewer media={currentRightArticle.media[0]} />
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Typography color="white">Загрузка...</Typography>
</Box>
) : ( ) : (
<>
<Box <Box
sx={{ sx={{
width: "100%", width: "100%",
@ -291,83 +450,144 @@ export const CreateRightTab = observer(
> >
<ImagePlus size={48} color="white" /> <ImagePlus size={48} color="white" />
</Box> </Box>
)}
<Box <Box
sx={{ sx={{
width: "100%", width: "100%",
minHeight: "70px", minHeight: "70px", // Fixed height for heading container
background: "#877361", background: "#877361", // Consistent with theme
display: "flex", display: "flex",
flexShrink: 0, flexShrink: 0,
flex: 1,
alignItems: "center", alignItems: "center",
borderBottom: "1px solid rgba(255,255,255,0.1)", borderBottom: "1px solid rgba(255,255,255,0.1)",
px: 2, px: 2,
}}
>
<Typography variant="h6" color="white">
{sight[language].right[activeArticleIndex]
.heading || "Выберите статью"}
</Typography>
</Box>
<Box
sx={{
px: 2,
flexGrow: 1,
overflowY: "auto",
backgroundColor: "#877361",
color: "white",
py: 1, py: 1,
}} }}
> >
{sight[language].right[activeArticleIndex].body ? ( <Typography
variant="h6"
color="white"
noWrap
title={currentRightArticle.heading}
>
{currentRightArticle.heading || "Заголовок"}
</Typography>
</Box>
<Box
sx={{
px: 2,
py: 1,
flexGrow: 1,
overflowY: "auto",
backgroundColor: "#877361",
color: "white",
"&::-webkit-scrollbar": { width: "8px" },
"&::-webkit-scrollbar-thumb": {
backgroundColor: "rgba(255,255,255,0.3)",
borderRadius: "4px",
},
}}
>
{currentRightArticle.body ? (
<ReactMarkdownComponent <ReactMarkdownComponent
value={ value={currentRightArticle.body}
sight[language].right[activeArticleIndex].body
}
/> />
) : ( ) : (
<Typography <Typography
color="rgba(255,255,255,0.7)" color="rgba(255,255,255,0.7)"
sx={{ textAlign: "center", mt: 4 }} sx={{ textAlign: "center", mt: 4 }}
> >
Предпросмотр статьи появится здесь Содержимое статьи...
</Typography> </Typography>
)} )}
</Box> </Box>
</> </Box>
</Paper>
)} )}
{/* Optional: Preview for sight.preview_media when type === "media" */}
{type === "media" && sight.preview_media && (
<Paper
className="flex-1 flex flex-col rounded-2xl"
elevation={2}
sx={{ height: "75vh", overflow: "hidden" }}
>
<Box
sx={{
width: "100%",
height: "100%",
background: "#877361",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<MediaViewer
media={{
id: sight.preview_media,
filename: sight.preview_media,
media_type: 1,
}}
/>
</Box> </Box>
</Paper> </Paper>
)} )}
</Box> </Box>
</Box> </Box>
{/* Sticky Save Button Footer */}
<Box <Box
sx={{ sx={{
position: "absolute", position: "absolute",
bottom: 0, bottom: 0,
left: 0, // ensure it spans from left
right: 0, right: 0,
padding: 2, padding: 2,
backgroundColor: "background.paper", // Ensure button is visible backgroundColor: "background.paper",
width: "100%", // Cover the full width to make it a sticky footer borderTop: "1px solid", // Add a subtle top border
borderColor: "divider", // Use theme's divider color
width: "100%",
display: "flex", display: "flex",
justifyContent: "flex-end", justifyContent: "flex-end",
boxShadow: "0 -2px 5px rgba(0,0,0,0.1)", // Optional shadow
}} }}
> >
<Button variant="contained" color="success" onClick={handleSave}> <Button
Сохранить изменения variant="contained"
color="success"
onClick={handleSave}
size="large"
>
Сохранить достопримечательность
</Button> </Button>
</Box> </Box>
</Box> </Box>
{/*
{/* Modals */}
<SelectArticleModal <SelectArticleModal
open={openedType === "article"} open={selectArticleDialogOpen}
onClose={handleCloseSelectModal} onClose={() => setSelectArticleDialogOpen(false)}
onSelectArticle={handleSelectArticle} onSelectArticle={handleSelectExistingArticleAndLink}
linkedArticleIds={linkedArticleIds} // Pass IDs of already linked/added right articles to exclude them from selection
/> */} linkedArticleIds={sight[language].right.map((article) => article.id)}
/>
<UploadMediaDialog
open={uploadMediaOpen} // From store
onClose={() => {
setUploadMediaOpen(false);
setFileToUpload(null); // Clear file if dialog is closed without upload
setMediaTarget(null);
}}
afterUpload={handleMediaUploaded} // This will use the mediaTarget
/>
<SelectMediaDialog
open={isSelectMediaDialogOpen}
onClose={() => {
setIsSelectMediaDialogOpen(false);
setMediaTarget(null);
}}
onSelectMedia={handleMediaSelectedFromDialog}
/>
</TabPanel> </TabPanel>
); );
} }

@ -3,9 +3,6 @@ import {
TextField, TextField,
Box, Box,
Autocomplete, Autocomplete,
Typography,
Paper,
Tooltip,
MenuItem, MenuItem,
Menu as MuiMenu, Menu as MuiMenu,
} from "@mui/material"; } from "@mui/material";
@ -20,9 +17,9 @@ import {
PreviewMediaDialog, PreviewMediaDialog,
SightLanguageInfo, SightLanguageInfo,
SightCommonInfo, SightCommonInfo,
UploadMediaDialog,
} from "@shared"; } from "@shared";
import { LanguageSwitcher } from "@widgets"; import { ImageUploadCard, LanguageSwitcher } from "@widgets";
import { Info, ImagePlus } from "lucide-react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -34,10 +31,10 @@ import { toast } from "react-toastify";
export const InformationTab = observer( export const InformationTab = observer(
({ value, index }: { value: number; index: number }) => { ({ value, index }: { value: number; index: number }) => {
const { cities } = cityStore; const { cities } = cityStore;
const [, setIsMediaModalOpen] = useState(false);
const [mediaId, setMediaId] = useState<string>(""); const [mediaId, setMediaId] = useState<string>("");
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const { language } = languageStore; const { language } = languageStore;
const { sight, updateSightInfo, updateSight } = editSightStore; const { sight, updateSightInfo, updateSight } = editSightStore;
@ -45,8 +42,6 @@ export const InformationTab = observer(
const [, setCity] = useState<number>(sight.common.city_id ?? 0); const [, setCity] = useState<number>(sight.common.city_id ?? 0);
const [coordinates, setCoordinates] = useState<string>(`0 0`); const [coordinates, setCoordinates] = useState<string>(`0 0`);
const token = localStorage.getItem("token");
// Menu state for each media button // Menu state for each media button
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null); const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
const [activeMenuType, setActiveMenuType] = useState< const [activeMenuType, setActiveMenuType] = useState<
@ -76,12 +71,22 @@ export const InformationTab = observer(
handleMenuClose(); handleMenuClose();
}; };
const handleMediaSelect = () => { const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
if (!activeMenuType) return; if (!activeMenuType) return;
handleChange(
// Close the dialog language as Language,
setIsAddMediaOpen(false); {
[activeMenuType ?? "thumbnail"]: media.id,
},
true
);
setActiveMenuType(null); setActiveMenuType(null);
setIsUploadMediaOpen(false);
}; };
const handleChange = ( const handleChange = (
@ -225,207 +230,87 @@ export const InformationTab = observer(
flexDirection: { xs: "column", sm: "row" }, // Stack on extra small, side-by-side on small and up flexDirection: { xs: "column", sm: "row" }, // Stack on extra small, side-by-side on small and up
}} }}
> >
<Paper <ImageUploadCard
elevation={2} title="Логотип"
sx={{ imageKey="thumbnail"
padding: 2, imageUrl={sight.common.thumbnail}
display: "flex", onImageClick={() => {
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>
</Box>
<Box
sx={{
position: "relative",
width: "200px",
height: "200px",
backgroundColor: "grey.200",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: sight.common.thumbnail ? "pointer" : "default",
"&:hover": {
backgroundColor: sight.common.thumbnail
? "red.300"
: "grey.200",
},
}}
onClick={() => {
setIsMediaModalOpen(true);
}}
>
{sight.common.thumbnail ? (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
sight.common.thumbnail
}/download?token=${token}`}
alt="Логотип"
style={{ maxWidth: "100%", maxHeight: "100%" }}
onClick={() => {
setIsPreviewMediaOpen(true); setIsPreviewMediaOpen(true);
setMediaId(sight.common.thumbnail ?? ""); setMediaId(sight.common.thumbnail ?? "");
}} }}
/> onDeleteImageClick={() => {
) : ( handleChange(
<ImagePlus size={24} color="grey" /> language as Language,
)} {
</Box> thumbnail: null,
</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={"asf"}>
<Info
size={16}
color="gray"
style={{ cursor: "pointer" }}
/>
</Tooltip>
</Box>
<Box
sx={{
position: "relative",
width: "200px",
height: "200px",
backgroundColor: "grey.200",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: sight.common.watermark_lu
? "pointer"
: "default",
"&:hover": {
backgroundColor: sight.common.watermark_lu
? "grey.300"
: "grey.200",
}, },
true
);
setActiveMenuType(null);
}} }}
onClick={() => { onSelectFileClick={() => {
setActiveMenuType("thumbnail");
setIsAddMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("thumbnail");
}}
/>
<ImageUploadCard
title="Водяной знак (л.в)"
imageKey="watermark_lu"
imageUrl={sight.common.watermark_lu}
onImageClick={() => {
setIsPreviewMediaOpen(true); setIsPreviewMediaOpen(true);
setMediaId(sight.common.watermark_lu ?? ""); setMediaId(sight.common.watermark_lu ?? "");
}} }}
> onDeleteImageClick={() => {
{sight.common.watermark_lu ? ( handleChange(
<img language as Language,
src={`${import.meta.env.VITE_KRBL_MEDIA}${ {
sight.common.watermark_lu watermark_lu: null,
}/download?token=${token}`}
alt="Знак л.в"
style={{ maxWidth: "100%", maxHeight: "100%" }}
onClick={() => {
setIsMediaModalOpen(true);
setMediaId(sight.common.watermark_lu ?? "");
}}
/>
) : (
<ImagePlus size={24} color="grey" />
)}
</Box>
</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={"asfaf"}>
<Info
size={16}
color="gray"
style={{ cursor: "pointer" }}
/>
</Tooltip>
</Box>
<Box
sx={{
position: "relative",
width: "200px",
height: "200px",
backgroundColor: "grey.200",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1,
mb: 1,
cursor: sight.common.watermark_rd
? "pointer"
: "default",
"&:hover": {
backgroundColor: sight.common.watermark_rd
? "grey.300"
: "grey.200",
}, },
true
);
setActiveMenuType(null);
}} }}
onClick={() => { onSelectFileClick={() => {
setIsMediaModalOpen(true); setActiveMenuType("watermark_lu");
setMediaId(sight.common.watermark_rd ?? ""); setIsAddMediaOpen(true);
}} }}
> setUploadMediaOpen={() => {
{sight.common.watermark_rd ? ( setIsUploadMediaOpen(true);
<img setActiveMenuType("watermark_lu");
src={`${import.meta.env.VITE_KRBL_MEDIA}${ }}
sight.common.watermark_rd />
}/download?token=${token}`} <ImageUploadCard
alt="Знак п.в" title="Водяной знак (п.в)"
style={{ maxWidth: "100%", maxHeight: "100%" }} imageKey="watermark_rd"
onClick={() => { imageUrl={sight.common.watermark_rd}
onImageClick={() => {
setIsPreviewMediaOpen(true); setIsPreviewMediaOpen(true);
setMediaId(sight.common.watermark_rd ?? ""); setMediaId(sight.common.watermark_rd ?? "");
}} }}
onDeleteImageClick={() => {
handleChange(
language as Language,
{
watermark_rd: null,
},
true
);
setActiveMenuType(null);
}}
onSelectFileClick={() => {
setActiveMenuType("watermark_rd");
setIsAddMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("watermark_rd");
}}
/> />
) : (
<ImagePlus size={24} color="grey" />
)}
</Box>
</Paper>
</Box> </Box>
</Box> </Box>
</Box> </Box>
@ -488,6 +373,21 @@ export const InformationTab = observer(
onSelectMedia={handleMediaSelect} onSelectMedia={handleMediaSelect}
/> />
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
afterUpload={(media) => {
handleChange(
language as Language,
{
[activeMenuType ?? "thumbnail"]: media.id,
},
true
);
setActiveMenuType(null);
setIsUploadMediaOpen(false);
}}
/>
<PreviewMediaDialog <PreviewMediaDialog
open={isPreviewMediaOpen} open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)} onClose={() => setIsPreviewMediaOpen(false)}

@ -9,41 +9,66 @@ import {
} from "@mui/material"; } from "@mui/material";
import { import {
BackButton, BackButton,
createSightStore,
editSightStore, editSightStore,
languageStore, languageStore,
SelectArticleModal, SelectArticleModal,
SelectMediaDialog,
TabPanel, TabPanel,
UploadMediaDialog,
} from "@shared"; } from "@shared";
import { import {
LanguageSwitcher, LanguageSwitcher,
MediaArea,
MediaAreaForSight,
ReactMarkdownComponent, ReactMarkdownComponent,
ReactMarkdownEditor, ReactMarkdownEditor,
} from "@widgets"; } from "@widgets";
import { ImagePlus, Plus } from "lucide-react"; import { ImagePlus, Plus, X } from "lucide-react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { MediaViewer } from "../../MediaViewer/index";
export const RightWidgetTab = observer( export const RightWidgetTab = observer(
({ value, index }: { value: number; index: number }) => { ({ value, index }: { value: number; index: number }) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const { createNewRightArticle, updateRightArticleInfo } = createSightStore;
const { sight, getRightArticles, updateSight } = editSightStore;
const { language } = languageStore;
const {
sight,
updateRightArticleInfo,
getRightArticles,
updateSight,
unlinkPreviewMedia,
linkPreviewMedia,
unlinkRightArticle,
deleteRightArticle,
linkArticle,
deleteRightArticleMedia,
createLinkWithRightArticle,
setFileToUpload,
createNewRightArticle,
} = editSightStore;
const [uploadMediaOpen, setUploadMediaOpen] = useState(false);
const { language } = languageStore;
const [type, setType] = useState<"article" | "media">("media");
useEffect(() => { useEffect(() => {
const fetchData = async () => {
if (sight.common.id) { if (sight.common.id) {
getRightArticles(sight.common.id); await getRightArticles(sight.common.id);
} }
};
fetchData();
console.log(sight[language].right);
}, [sight.common.id]); }, [sight.common.id]);
const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>( const [activeArticleIndex, setActiveArticleIndex] = useState<number | null>(
null null
); );
const [isSelectModalOpen, setIsSelectModalOpen] = useState(false); const [isSelectModalOpen, setIsSelectModalOpen] = useState(false);
const [isSelectMediaModalOpen, setIsSelectMediaModalOpen] = useState(false);
const open = Boolean(anchorEl); const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => { const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget); setAnchorEl(event.currentTarget);
}; };
@ -69,16 +94,32 @@ export const RightWidgetTab = observer(
setIsSelectModalOpen(false); setIsSelectModalOpen(false);
}; };
const handleArticleSelect = () => { const handleArticleSelect = (id: number) => {
// TODO: Implement article selection logic linkArticle(id);
handleCloseSelectModal(); handleCloseSelectModal();
}; };
const handleMediaSelected = async (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
await createLinkWithRightArticle(
media,
sight[language].right[activeArticleIndex || 0].id
);
};
const handleSave = async () => { const handleSave = async () => {
await updateSight(); await updateSight();
toast.success("Достопримечательность сохранена"); toast.success("Достопримечательность сохранена");
}; };
useEffect(() => {
console.log(sight[language].right);
}, [sight[language].right]);
return ( return (
<TabPanel value={value} index={index}> <TabPanel value={value} index={index}>
<LanguageSwitcher /> <LanguageSwitcher />
@ -101,17 +142,20 @@ export const RightWidgetTab = observer(
<Box className="relative w-[20%] h-[70vh] flex flex-col rounded-2xl overflow-y-auto gap-3 border border-gray-300 p-3"> <Box className="relative w-[20%] h-[70vh] flex flex-col rounded-2xl overflow-y-auto gap-3 border border-gray-300 p-3">
<Box className="flex flex-col gap-3 max-h-[60vh] overflow-y-auto "> <Box className="flex flex-col gap-3 max-h-[60vh] overflow-y-auto ">
<Box <Box
// onClick={() => setMediaType("preview")} onClick={() => setType("media")}
className="w-full bg-gray-200 p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-300" className="w-full bg-green-200 p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-300"
> >
<Typography>Предпросмотр медиа</Typography> <Typography>Предпросмотр медиа</Typography>
</Box> </Box>
{sight[language].right.length > 0 &&
{sight[language].right.map((article, index) => ( sight[language].right.map((article, index) => (
<Box <Box
key={index} key={index}
className="w-full bg-gray-200 p-4 rounded-2xl text-sm cursor-pointer hover:bg-gray-300 transition-all duration-300" className="w-full bg-gray-200 p-4 rounded-2xl text-sm cursor-pointer hover:bg-gray-300 transition-all duration-300"
onClick={() => handleSelectArticle(index)} onClick={() => {
handleSelectArticle(index);
setType("article");
}}
> >
<Typography>{article.heading}</Typography> <Typography>{article.heading}</Typography>
</Box> </Box>
@ -142,14 +186,33 @@ export const RightWidgetTab = observer(
</Menu> </Menu>
</Box> </Box>
{type === "article" && (
<Box className="w-[80%] border border-gray-300 rounded-2xl p-3"> <Box className="w-[80%] border border-gray-300 rounded-2xl p-3">
{activeArticleIndex !== null && ( {activeArticleIndex !== null && (
<> <>
<Box className="flex justify-end gap-2 mb-3"> <Box className="flex justify-end gap-2 mb-3">
<Button variant="contained" color="primary"> <Button
variant="contained"
color="primary"
onClick={() => {
unlinkRightArticle(
sight[language].right[activeArticleIndex].id
);
setActiveArticleIndex(null);
}}
>
Открепить Открепить
</Button> </Button>
<Button variant="contained" color="success"> <Button
variant="contained"
color="success"
onClick={() => {
deleteRightArticle(
sight[language].right[activeArticleIndex].id
);
setActiveArticleIndex(null);
}}
>
Удалить Удалить
</Button> </Button>
</Box> </Box>
@ -166,7 +229,8 @@ export const RightWidgetTab = observer(
<TextField <TextField
label="Название информации" label="Название информации"
value={ value={
sight[language].right[activeArticleIndex].heading sight[language].right[activeArticleIndex]
.heading
} }
onChange={(e) => onChange={(e) =>
updateRightArticleInfo( updateRightArticleInfo(
@ -194,19 +258,65 @@ export const RightWidgetTab = observer(
) )
} }
/> />
{/* <MediaArea
articleId={1} <MediaArea
mediaIds={[]} articleId={
deleteMedia={() => {}} sight[language].right[activeArticleIndex].id
/> */} }
mediaIds={
sight[language].right[activeArticleIndex].media
}
onFilesDrop={(files) => {
setFileToUpload(files[0]);
setUploadMediaOpen(true);
}}
deleteMedia={deleteRightArticleMedia}
setSelectMediaDialogOpen={() => {
setIsSelectMediaModalOpen(true);
}}
/>
</Box> </Box>
</Box> </Box>
</> </>
)} )}
</Box> </Box>
)}
{type === "media" && (
<Box className="w-[80%] border border-gray-300 rounded-2xl relative">
{sight.common.preview_media && (
<>
<Box className="absolute top-4 right-4">
<button
className="w-10 h-10 flex items-center justify-center"
onClick={unlinkPreviewMedia}
>
<X size={20} color="red" />
</button>
</Box>
<MediaViewer
media={{
id: sight.common.preview_media || "",
media_type: 1,
filename: sight.common.preview_media || "",
}}
/>
</>
)}
{!sight.common.preview_media && (
<MediaAreaForSight
onFinishUpload={(mediaId) => {
linkPreviewMedia(mediaId);
}}
onFilesDrop={() => {}}
/>
)}
</Box>
)}
</Box> </Box>
</Box> </Box>
{type === "article" && (
<Box className="w-[25%] mr-10"> <Box className="w-[25%] mr-10">
{activeArticleIndex !== null && ( {activeArticleIndex !== null && (
<Paper <Paper
@ -224,11 +334,20 @@ export const RightWidgetTab = observer(
flexDirection: "column", flexDirection: "column",
}} }}
> >
{sight[language].right[activeArticleIndex].media.length >
0 ? (
<MediaViewer
media={
sight[language].right[activeArticleIndex].media[0]
}
/>
) : (
<Box <Box
sx={{ sx={{
width: "100%", width: "100%",
height: 200, height: 200,
flexShrink: 0, flexShrink: 0,
backgroundColor: "rgba(0,0,0,0.1)", backgroundColor: "rgba(0,0,0,0.1)",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
@ -237,14 +356,16 @@ export const RightWidgetTab = observer(
> >
<ImagePlus size={48} color="white" /> <ImagePlus size={48} color="white" />
</Box> </Box>
)}
<Box <Box
sx={{ sx={{
width: "100%", width: "100%",
minHeight: "70px", height: "70px",
background: "#877361", background: "#877361",
display: "flex", display: "flex",
flexShrink: 0, flexShrink: 0,
alignItems: "center", alignItems: "center",
borderBottom: "1px solid rgba(255,255,255,0.1)", borderBottom: "1px solid rgba(255,255,255,0.1)",
px: 2, px: 2,
@ -260,6 +381,8 @@ export const RightWidgetTab = observer(
sx={{ sx={{
px: 2, px: 2,
flexGrow: 1, flexGrow: 1,
flex: 1,
minHeight: "300px",
overflowY: "auto", overflowY: "auto",
backgroundColor: "#877361", backgroundColor: "#877361",
color: "white", color: "white",
@ -268,7 +391,9 @@ export const RightWidgetTab = observer(
> >
{sight[language].right[activeArticleIndex].body ? ( {sight[language].right[activeArticleIndex].body ? (
<ReactMarkdownComponent <ReactMarkdownComponent
value={sight[language].right[activeArticleIndex].body} value={
sight[language].right[activeArticleIndex].body
}
/> />
) : ( ) : (
<Typography <Typography
@ -283,6 +408,7 @@ export const RightWidgetTab = observer(
</Paper> </Paper>
)} )}
</Box> </Box>
)}
</Box> </Box>
<Box <Box
@ -303,11 +429,29 @@ export const RightWidgetTab = observer(
</Box> </Box>
</Box> </Box>
<UploadMediaDialog
open={uploadMediaOpen}
onClose={() => setUploadMediaOpen(false)}
afterUpload={async (media) => {
setUploadMediaOpen(false);
setFileToUpload(null);
await createLinkWithRightArticle(
media,
sight[language].right[activeArticleIndex || 0].id
);
}}
/>
<SelectArticleModal <SelectArticleModal
open={isSelectModalOpen} open={isSelectModalOpen}
onClose={handleCloseSelectModal} onClose={handleCloseSelectModal}
onSelectArticle={handleArticleSelect} onSelectArticle={handleArticleSelect}
/> />
<SelectMediaDialog
open={isSelectMediaModalOpen}
onClose={() => setIsSelectMediaModalOpen(false)}
onSelectMedia={handleMediaSelected}
/>
</TabPanel> </TabPanel>
); );
} }

@ -10,3 +10,5 @@ export * from "./SightsTable";
export * from "./MediaViewer"; export * from "./MediaViewer";
export * from "./MediaArea"; export * from "./MediaArea";
export * from "./ModelViewer3D"; export * from "./ModelViewer3D";
export * from "./MediaAreaForSight";
export * from "./ImageUploadCard";

@ -1 +1 @@
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/app/index.tsx","./src/app/router/index.tsx","./src/entities/index.ts","./src/entities/navigation/index.ts","./src/entities/navigation/model/index.ts","./src/entities/navigation/ui/index.tsx","./src/features/index.ts","./src/features/navigation/index.ts","./src/features/navigation/ui/index.tsx","./src/pages/index.ts","./src/pages/createmediapage/index.tsx","./src/pages/createsightpage/index.tsx","./src/pages/devicespage/index.tsx","./src/pages/editmediapage/index.tsx","./src/pages/editsightpage/index.tsx","./src/pages/loginpage/index.tsx","./src/pages/mainpage/index.tsx","./src/pages/mappage/index.tsx","./src/pages/medialistpage/index.tsx","./src/pages/previewmediapage/index.tsx","./src/pages/sightpage/index.tsx","./src/shared/index.tsx","./src/shared/api/index.tsx","./src/shared/config/constants.tsx","./src/shared/config/index.ts","./src/shared/const/index.ts","./src/shared/lib/index.ts","./src/shared/lib/decodejwt/index.ts","./src/shared/lib/mui/theme.ts","./src/shared/modals/index.ts","./src/shared/modals/previewmediadialog/index.tsx","./src/shared/modals/selectarticledialog/index.tsx","./src/shared/modals/selectmediadialog/index.tsx","./src/shared/modals/uploadmediadialog/index.tsx","./src/shared/store/index.ts","./src/shared/store/articlesstore/index.tsx","./src/shared/store/authstore/index.tsx","./src/shared/store/citystore/index.tsx","./src/shared/store/createsightstore/index.tsx","./src/shared/store/devicesstore/index.tsx","./src/shared/store/editsightstore/index.tsx","./src/shared/store/languagestore/index.tsx","./src/shared/store/mediastore/index.tsx","./src/shared/store/sightsstore/index.tsx","./src/shared/store/snapshotstore/index.ts","./src/shared/store/vehiclestore/index.ts","./src/shared/ui/index.ts","./src/shared/ui/backbutton/index.tsx","./src/shared/ui/coordinatesinput/index.tsx","./src/shared/ui/input/index.tsx","./src/shared/ui/modal/index.tsx","./src/shared/ui/tabpanel/index.tsx","./src/widgets/index.ts","./src/widgets/devicestable/index.tsx","./src/widgets/languageswitcher/index.tsx","./src/widgets/layout/index.tsx","./src/widgets/layout/ui/appbar.tsx","./src/widgets/layout/ui/drawer.tsx","./src/widgets/layout/ui/drawerheader.tsx","./src/widgets/mediaarea/index.tsx","./src/widgets/mediaviewer/threeview.tsx","./src/widgets/mediaviewer/index.tsx","./src/widgets/modelviewer3d/index.tsx","./src/widgets/reactmarkdown/index.tsx","./src/widgets/reactmarkdowneditor/index.tsx","./src/widgets/sightedit/index.tsx","./src/widgets/sightheader/index.ts","./src/widgets/sightheader/ui/index.tsx","./src/widgets/sighttabs/index.ts","./src/widgets/sighttabs/createinformationtab/mediauploadbox.tsx","./src/widgets/sighttabs/createinformationtab/index.tsx","./src/widgets/sighttabs/createlefttab/index.tsx","./src/widgets/sighttabs/createrighttab/index.tsx","./src/widgets/sighttabs/informationtab/index.tsx","./src/widgets/sighttabs/leftwidgettab/index.tsx","./src/widgets/sighttabs/rightwidgettab/index.tsx","./src/widgets/sightstable/index.tsx","./src/widgets/modals/index.ts","./src/widgets/modals/selectarticledialog/index.tsx"],"version":"5.8.3"} {"root":["./src/main.tsx","./src/vite-env.d.ts","./src/app/index.tsx","./src/app/router/index.tsx","./src/entities/index.ts","./src/entities/navigation/index.ts","./src/entities/navigation/model/index.ts","./src/entities/navigation/ui/index.tsx","./src/features/index.ts","./src/features/navigation/index.ts","./src/features/navigation/ui/index.tsx","./src/pages/index.ts","./src/pages/createmediapage/index.tsx","./src/pages/createsightpage/index.tsx","./src/pages/devicespage/index.tsx","./src/pages/editmediapage/index.tsx","./src/pages/editsightpage/index.tsx","./src/pages/loginpage/index.tsx","./src/pages/mainpage/index.tsx","./src/pages/mappage/index.tsx","./src/pages/medialistpage/index.tsx","./src/pages/previewmediapage/index.tsx","./src/pages/sightpage/index.tsx","./src/shared/index.tsx","./src/shared/api/index.tsx","./src/shared/config/constants.tsx","./src/shared/config/index.ts","./src/shared/const/index.ts","./src/shared/lib/index.ts","./src/shared/lib/decodejwt/index.ts","./src/shared/lib/mui/theme.ts","./src/shared/modals/index.ts","./src/shared/modals/previewmediadialog/index.tsx","./src/shared/modals/selectarticledialog/index.tsx","./src/shared/modals/selectmediadialog/index.tsx","./src/shared/modals/uploadmediadialog/index.tsx","./src/shared/store/index.ts","./src/shared/store/articlesstore/index.tsx","./src/shared/store/authstore/index.tsx","./src/shared/store/citystore/index.tsx","./src/shared/store/createsightstore/index.tsx","./src/shared/store/devicesstore/index.tsx","./src/shared/store/editsightstore/index.tsx","./src/shared/store/languagestore/index.tsx","./src/shared/store/mediastore/index.tsx","./src/shared/store/sightsstore/index.tsx","./src/shared/store/snapshotstore/index.ts","./src/shared/store/vehiclestore/index.ts","./src/shared/ui/index.ts","./src/shared/ui/backbutton/index.tsx","./src/shared/ui/coordinatesinput/index.tsx","./src/shared/ui/input/index.tsx","./src/shared/ui/modal/index.tsx","./src/shared/ui/tabpanel/index.tsx","./src/widgets/index.ts","./src/widgets/devicestable/index.tsx","./src/widgets/imageuploadcard/index.tsx","./src/widgets/languageswitcher/index.tsx","./src/widgets/layout/index.tsx","./src/widgets/layout/ui/appbar.tsx","./src/widgets/layout/ui/drawer.tsx","./src/widgets/layout/ui/drawerheader.tsx","./src/widgets/mediaarea/index.tsx","./src/widgets/mediaareaforsight/index.tsx","./src/widgets/mediaviewer/threeview.tsx","./src/widgets/mediaviewer/index.tsx","./src/widgets/modelviewer3d/index.tsx","./src/widgets/reactmarkdown/index.tsx","./src/widgets/reactmarkdowneditor/index.tsx","./src/widgets/sightedit/index.tsx","./src/widgets/sightheader/index.ts","./src/widgets/sightheader/ui/index.tsx","./src/widgets/sighttabs/index.ts","./src/widgets/sighttabs/createinformationtab/mediauploadbox.tsx","./src/widgets/sighttabs/createinformationtab/index.tsx","./src/widgets/sighttabs/createlefttab/index.tsx","./src/widgets/sighttabs/createrighttab/index.tsx","./src/widgets/sighttabs/informationtab/index.tsx","./src/widgets/sighttabs/leftwidgettab/index.tsx","./src/widgets/sighttabs/rightwidgettab/index.tsx","./src/widgets/sightstable/index.tsx","./src/widgets/modals/index.ts","./src/widgets/modals/selectarticledialog/index.tsx"],"version":"5.8.3"}