abstract urls

This commit is contained in:
Илья Куприец 2025-04-11 19:24:45 +03:00
parent 24a8bcad0a
commit 607012bd47
14 changed files with 1690 additions and 956 deletions

2
.env Normal file
View File

@ -0,0 +1,2 @@
VITE_KRBL_MEDIA = "https://wn.krbl.ru/media/"
VITE_KRBL_API = "https://wn.krbl.ru"

View File

@ -1,174 +1,182 @@
import type {AuthProvider} from '@refinedev/core' import type { AuthProvider } from "@refinedev/core";
import axios, {AxiosError} from 'axios' import axios, { AxiosError } from "axios";
import {BACKEND_URL} from './lib/constants'
import {jwtDecode} from 'jwt-decode'
export const TOKEN_KEY = 'refine-auth' import { jwtDecode } from "jwt-decode";
export const TOKEN_KEY = "refine-auth";
interface AuthResponse { interface AuthResponse {
token: string token: string;
user: { user: {
id: number id: number;
name: string name: string;
email: string email: string;
is_admin: boolean is_admin: boolean;
} };
} }
interface ErrorResponse { interface ErrorResponse {
message: string message: string;
} }
class AuthError extends Error { class AuthError extends Error {
constructor(message: string) { constructor(message: string) {
super(message) super(message);
this.name = 'AuthError' this.name = "AuthError";
} }
} }
interface JWTPayload { interface JWTPayload {
user_id: number user_id: number;
email: string email: string;
is_admin: boolean is_admin: boolean;
exp: number exp: number;
} }
export const authProvider: AuthProvider = { export const authProvider: AuthProvider = {
login: async ({email, password}) => { login: async ({ email, password }) => {
try { try {
const response = await axios.post<AuthResponse>(`${BACKEND_URL}/auth/login`, { const response = await axios.post<AuthResponse>(
`${import.meta.env.VITE_KRBL_API}/auth/login`,
{
email, email,
password, password,
}) }
);
const {token, user} = response.data const { token, user } = response.data;
if (token) { if (token) {
localStorage.setItem(TOKEN_KEY, token) localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem('user', JSON.stringify(user)) localStorage.setItem("user", JSON.stringify(user));
return { return {
success: true, success: true,
redirectTo: '/', redirectTo: "/",
} };
} }
throw new AuthError('Неверный email или пароль') throw new AuthError("Неверный email или пароль");
} catch (error) { } catch (error) {
const errorMessage = (error as AxiosError<ErrorResponse>)?.response?.data?.message || 'Неверный email или пароль' const errorMessage =
(error as AxiosError<ErrorResponse>)?.response?.data?.message ||
"Неверный email или пароль";
return { return {
success: false, success: false,
error: new AuthError(errorMessage), error: new AuthError(errorMessage),
} };
} }
}, },
logout: async () => { logout: async () => {
try { try {
await axios.post( await axios.post(
`${BACKEND_URL}/auth/logout`, `${import.meta.env.VITE_KRBL_API}/auth/logout`,
{}, {},
{ {
headers: { headers: {
Authorization: `Bearer ${localStorage.getItem(TOKEN_KEY)}`, Authorization: `Bearer ${localStorage.getItem(TOKEN_KEY)}`,
}, },
}, }
) );
} catch (error) { } catch (error) {
console.error('Ошибка при выходе:', error) console.error("Ошибка при выходе:", error);
} }
localStorage.removeItem(TOKEN_KEY) localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem('user') localStorage.removeItem("user");
return { return {
success: true, success: true,
redirectTo: '/login', redirectTo: "/login",
} };
}, },
check: async () => { check: async () => {
const token = localStorage.getItem(TOKEN_KEY) const token = localStorage.getItem(TOKEN_KEY);
if (!token) { if (!token) {
return { return {
authenticated: false, authenticated: false,
redirectTo: '/login', redirectTo: "/login",
} };
} }
try { try {
const response = await axios.get(`${BACKEND_URL}/auth/me`, { const response = await axios.get(
`${import.meta.env.VITE_KRBL_API}/auth/me`,
{
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
}) }
);
if (response.status === 200) { if (response.status === 200) {
// Обновляем информацию о пользователе // Обновляем информацию о пользователе
localStorage.setItem('user', JSON.stringify(response.data)) localStorage.setItem("user", JSON.stringify(response.data));
return { return {
authenticated: true, authenticated: true,
} };
} }
} catch (error) { } catch (error) {
localStorage.removeItem(TOKEN_KEY) localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem('user') localStorage.removeItem("user");
return { return {
authenticated: false, authenticated: false,
redirectTo: '/login', redirectTo: "/login",
error: new AuthError('Сессия истекла, пожалуйста, войдите снова'), error: new AuthError("Сессия истекла, пожалуйста, войдите снова"),
} };
} }
return { return {
authenticated: false, authenticated: false,
redirectTo: '/login', redirectTo: "/login",
} };
}, },
getPermissions: async () => { getPermissions: async () => {
const token = localStorage.getItem(TOKEN_KEY) const token = localStorage.getItem(TOKEN_KEY);
if (!token) return null if (!token) return null;
try { try {
const decoded = jwtDecode<JWTPayload>(token) const decoded = jwtDecode<JWTPayload>(token);
if (decoded.is_admin) { if (decoded.is_admin) {
document.body.classList.add('is-admin') document.body.classList.add("is-admin");
} else { } else {
document.body.classList.remove('is-admin') document.body.classList.remove("is-admin");
} }
return decoded.is_admin ? ['admin'] : ['user'] return decoded.is_admin ? ["admin"] : ["user"];
} catch { } catch {
document.body.classList.remove('is-admin') document.body.classList.remove("is-admin");
return ['user'] return ["user"];
} }
}, },
getIdentity: async () => { getIdentity: async () => {
const token = localStorage.getItem(TOKEN_KEY) const token = localStorage.getItem(TOKEN_KEY);
const user = localStorage.getItem('user') const user = localStorage.getItem("user");
if (!token || !user) return null if (!token || !user) return null;
try { try {
const decoded = jwtDecode<JWTPayload>(token) const decoded = jwtDecode<JWTPayload>(token);
const userData = JSON.parse(user) const userData = JSON.parse(user);
return { return {
...userData, ...userData,
is_admin: decoded.is_admin, // всегда используем значение из токена is_admin: decoded.is_admin, // всегда используем значение из токена
} };
} catch { } catch {
return null return null;
} }
}, },
onError: async (error) => { onError: async (error) => {
const status = (error as AxiosError)?.response?.status const status = (error as AxiosError)?.response?.status;
if (status === 401 || status === 403) { if (status === 401 || status === 403) {
localStorage.removeItem(TOKEN_KEY) localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem('user') localStorage.removeItem("user");
return { return {
logout: true, logout: true,
redirectTo: '/login', redirectTo: "/login",
error: new AuthError('Сессия истекла, пожалуйста, войдите снова'), error: new AuthError("Сессия истекла, пожалуйста, войдите снова"),
};
} }
} return { error };
return {error}
}, },
} };

View File

@ -1,133 +1,170 @@
import {Typography, Button, Box, Accordion, AccordionSummary, AccordionDetails, useTheme, TextField} from '@mui/material' import {
import ExpandMoreIcon from '@mui/icons-material/ExpandMore' Typography,
import {axiosInstance} from '../providers/data' Button,
import {BACKEND_URL} from '../lib/constants' Box,
import {useForm, Controller} from 'react-hook-form' Accordion,
import {MarkdownEditor} from './MarkdownEditor' AccordionSummary,
import React, {useState, useCallback} from 'react' AccordionDetails,
import {useDropzone} from 'react-dropzone' useTheme,
import {ALLOWED_IMAGE_TYPES, ALLOWED_VIDEO_TYPES} from '../components/media/MediaFormUtils' TextField,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { axiosInstance } from "../providers/data";
import { useForm, Controller } from "react-hook-form";
import { MarkdownEditor } from "./MarkdownEditor";
import React, { useState, useCallback } from "react";
import { useDropzone } from "react-dropzone";
import {
ALLOWED_IMAGE_TYPES,
ALLOWED_VIDEO_TYPES,
} from "../components/media/MediaFormUtils";
const MemoizedSimpleMDE = React.memo(MarkdownEditor) const MemoizedSimpleMDE = React.memo(MarkdownEditor);
type MediaFile = { type MediaFile = {
file: File file: File;
preview: string preview: string;
uploading: boolean uploading: boolean;
mediaId?: number mediaId?: number;
} };
type Props = { type Props = {
parentId: string | number parentId: string | number;
parentResource: string parentResource: string;
childResource: string childResource: string;
title: string title: string;
} };
export const CreateSightArticle = ({parentId, parentResource, childResource, title}: Props) => { export const CreateSightArticle = ({
const theme = useTheme() parentId,
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]) parentResource,
childResource,
title,
}: Props) => {
const theme = useTheme();
const [mediaFiles, setMediaFiles] = useState<MediaFile[]>([]);
const { const {
register: registerItem, register: registerItem,
control: controlItem, control: controlItem,
handleSubmit: handleSubmitItem, handleSubmit: handleSubmitItem,
reset: resetItem, reset: resetItem,
formState: {errors: itemErrors}, formState: { errors: itemErrors },
} = useForm({ } = useForm({
defaultValues: { defaultValues: {
heading: '', heading: "",
body: '', body: "",
}, },
}) });
const simpleMDEOptions = React.useMemo( const simpleMDEOptions = React.useMemo(
() => ({ () => ({
placeholder: 'Введите контент в формате Markdown...', placeholder: "Введите контент в формате Markdown...",
spellChecker: false, spellChecker: false,
}), }),
[], []
) );
const onDrop = useCallback((acceptedFiles: File[]) => { const onDrop = useCallback((acceptedFiles: File[]) => {
const newFiles = acceptedFiles.map((file) => ({ const newFiles = acceptedFiles.map((file) => ({
file, file,
preview: URL.createObjectURL(file), preview: URL.createObjectURL(file),
uploading: false, uploading: false,
})) }));
setMediaFiles((prev) => [...prev, ...newFiles]) setMediaFiles((prev) => [...prev, ...newFiles]);
}, []) }, []);
const {getRootProps, getInputProps, isDragActive} = useDropzone({ const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop, onDrop,
accept: { accept: {
'image/*': ALLOWED_IMAGE_TYPES, "image/*": ALLOWED_IMAGE_TYPES,
'video/*': ALLOWED_VIDEO_TYPES, "video/*": ALLOWED_VIDEO_TYPES,
}, },
multiple: true, multiple: true,
}) });
const uploadMedia = async (mediaFile: MediaFile) => { const uploadMedia = async (mediaFile: MediaFile) => {
const formData = new FormData() const formData = new FormData();
formData.append('media_name', mediaFile.file.name) formData.append("media_name", mediaFile.file.name);
formData.append('filename', mediaFile.file.name) formData.append("filename", mediaFile.file.name);
formData.append('type', mediaFile.file.type.startsWith('image/') ? '1' : '2') formData.append(
formData.append('file', mediaFile.file) "type",
mediaFile.file.type.startsWith("image/") ? "1" : "2"
);
formData.append("file", mediaFile.file);
const response = await axiosInstance.post(`${BACKEND_URL}/media`, formData) const response = await axiosInstance.post(
return response.data.id `${import.meta.env.VITE_KRBL_API}/media`,
} formData
);
return response.data.id;
};
const handleCreate = async (data: {heading: string; body: string}) => { const handleCreate = async (data: { heading: string; body: string }) => {
try { try {
// Создаем статью // Создаем статью
const response = await axiosInstance.post(`${BACKEND_URL}/${childResource}`, data) const response = await axiosInstance.post(
const itemId = response.data.id `${import.meta.env.VITE_KRBL_API}/${childResource}`,
data
);
const itemId = response.data.id;
// Получаем существующие статьи для определения порядкового номера // Получаем существующие статьи для определения порядкового номера
const existingItemsResponse = await axiosInstance.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`) const existingItemsResponse = await axiosInstance.get(
const existingItems = existingItemsResponse.data || [] `${
const nextPageNum = existingItems.length + 1 import.meta.env.VITE_KRBL_API
}/${parentResource}/${parentId}/${childResource}`
);
const existingItems = existingItemsResponse.data || [];
const nextPageNum = existingItems.length + 1;
// Привязываем статью к достопримечательности // Привязываем статью к достопримечательности
await axiosInstance.post(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}/`, { await axiosInstance.post(
`${
import.meta.env.VITE_KRBL_API
}/${parentResource}/${parentId}/${childResource}/`,
{
[`${childResource}_id`]: itemId, [`${childResource}_id`]: itemId,
page_num: nextPageNum, page_num: nextPageNum,
}) }
);
// Загружаем все медиа файлы и получаем их ID // Загружаем все медиа файлы и получаем их ID
const mediaIds = await Promise.all( const mediaIds = await Promise.all(
mediaFiles.map(async (mediaFile) => { mediaFiles.map(async (mediaFile) => {
return await uploadMedia(mediaFile) return await uploadMedia(mediaFile);
}), })
) );
// Привязываем все медиа к статье // Привязываем все медиа к статье
await Promise.all( await Promise.all(
mediaIds.map((mediaId, index) => mediaIds.map((mediaId, index) =>
axiosInstance.post(`${BACKEND_URL}/article/${itemId}/media/`, { axiosInstance.post(
`${import.meta.env.VITE_KRBL_API}/article/${itemId}/media/`,
{
media_id: mediaId, media_id: mediaId,
media_order: index + 1, media_order: index + 1,
}), }
),
) )
)
);
resetItem() resetItem();
setMediaFiles([]) setMediaFiles([]);
window.location.reload() window.location.reload();
} catch (err: any) { } catch (err: any) {
console.error('Error creating item:', err) console.error("Error creating item:", err);
}
} }
};
const removeMedia = (index: number) => { const removeMedia = (index: number) => {
setMediaFiles((prev) => { setMediaFiles((prev) => {
const newFiles = [...prev] const newFiles = [...prev];
URL.revokeObjectURL(newFiles[index].preview) URL.revokeObjectURL(newFiles[index].preview);
newFiles.splice(index, 1) newFiles.splice(index, 1);
return newFiles return newFiles;
}) });
} };
return ( return (
<Accordion> <Accordion>
@ -143,76 +180,95 @@ export const CreateSightArticle = ({parentId, parentResource, childResource, tit
Создать {title} Создать {title}
</Typography> </Typography>
</AccordionSummary> </AccordionSummary>
<AccordionDetails sx={{background: theme.palette.background.paper}}> <AccordionDetails sx={{ background: theme.palette.background.paper }}>
<Box component="form" onSubmit={handleSubmitItem(handleCreate)}> <Box component="form" onSubmit={handleSubmitItem(handleCreate)}>
<TextField <TextField
{...registerItem('heading', { {...registerItem("heading", {
required: 'Это поле является обязательным', required: "Это поле является обязательным",
})} })}
error={!!(itemErrors as any)?.heading} error={!!(itemErrors as any)?.heading}
helperText={(itemErrors as any)?.heading?.message} helperText={(itemErrors as any)?.heading?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="text" type="text"
label="Заголовок *" label="Заголовок *"
/> />
<Controller control={controlItem} name="body" rules={{required: 'Это поле является обязательным'}} defaultValue="" render={({field: {onChange, value}}) => <MemoizedSimpleMDE value={value} onChange={onChange} options={simpleMDEOptions} className="my-markdown-editor" />} /> <Controller
control={controlItem}
name="body"
rules={{ required: "Это поле является обязательным" }}
defaultValue=""
render={({ field: { onChange, value } }) => (
<MemoizedSimpleMDE
value={value}
onChange={onChange}
options={simpleMDEOptions}
className="my-markdown-editor"
/>
)}
/>
{/* Dropzone для медиа файлов */} {/* Dropzone для медиа файлов */}
<Box sx={{mt: 2, mb: 2}}> <Box sx={{ mt: 2, mb: 2 }}>
<Box <Box
{...getRootProps()} {...getRootProps()}
sx={{ sx={{
border: '2px dashed', border: "2px dashed",
borderColor: isDragActive ? 'primary.main' : 'grey.300', borderColor: isDragActive ? "primary.main" : "grey.300",
borderRadius: 1, borderRadius: 1,
p: 2, p: 2,
textAlign: 'center', textAlign: "center",
cursor: 'pointer', cursor: "pointer",
'&:hover': { "&:hover": {
borderColor: 'primary.main', borderColor: "primary.main",
}, },
}} }}
> >
<input {...getInputProps()} /> <input {...getInputProps()} />
<Typography>{isDragActive ? 'Перетащите файлы сюда...' : 'Перетащите файлы сюда или кликните для выбора'}</Typography> <Typography>
{isDragActive
? "Перетащите файлы сюда..."
: "Перетащите файлы сюда или кликните для выбора"}
</Typography>
</Box> </Box>
{/* Превью загруженных файлов */} {/* Превью загруженных файлов */}
<Box sx={{mt: 2, display: 'flex', flexWrap: 'wrap', gap: 1}}> <Box sx={{ mt: 2, display: "flex", flexWrap: "wrap", gap: 1 }}>
{mediaFiles.map((mediaFile, index) => ( {mediaFiles.map((mediaFile, index) => (
<Box <Box
key={mediaFile.preview} key={mediaFile.preview}
sx={{ sx={{
position: 'relative', position: "relative",
width: 100, width: 100,
height: 100, height: 100,
}} }}
> >
{mediaFile.file.type.startsWith('image/') ? ( {mediaFile.file.type.startsWith("image/") ? (
<img <img
src={mediaFile.preview} src={mediaFile.preview}
alt={mediaFile.file.name} alt={mediaFile.file.name}
style={{ style={{
width: '100%', width: "100%",
height: '100%', height: "100%",
objectFit: 'cover', objectFit: "cover",
}} }}
/> />
) : ( ) : (
<Box <Box
sx={{ sx={{
width: '100%', width: "100%",
height: '100%', height: "100%",
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
bgcolor: 'grey.200', bgcolor: "grey.200",
}} }}
> >
<Typography variant="caption">{mediaFile.file.name}</Typography> <Typography variant="caption">
{mediaFile.file.name}
</Typography>
</Box> </Box>
)} )}
<Button <Button
@ -220,10 +276,10 @@ export const CreateSightArticle = ({parentId, parentResource, childResource, tit
color="error" color="error"
onClick={() => removeMedia(index)} onClick={() => removeMedia(index)}
sx={{ sx={{
position: 'absolute', position: "absolute",
top: 0, top: 0,
right: 0, right: 0,
minWidth: 'auto', minWidth: "auto",
width: 20, width: 20,
height: 20, height: 20,
p: 0, p: 0,
@ -236,16 +292,16 @@ export const CreateSightArticle = ({parentId, parentResource, childResource, tit
</Box> </Box>
</Box> </Box>
<Box sx={{mt: 2, display: 'flex', gap: 2}}> <Box sx={{ mt: 2, display: "flex", gap: 2 }}>
<Button variant="contained" color="primary" type="submit"> <Button variant="contained" color="primary" type="submit">
Создать Создать
</Button> </Button>
<Button <Button
variant="outlined" variant="outlined"
onClick={() => { onClick={() => {
resetItem() resetItem();
mediaFiles.forEach((file) => URL.revokeObjectURL(file.preview)) mediaFiles.forEach((file) => URL.revokeObjectURL(file.preview));
setMediaFiles([]) setMediaFiles([]);
}} }}
> >
Очистить Очистить
@ -254,5 +310,5 @@ export const CreateSightArticle = ({parentId, parentResource, childResource, tit
</Box> </Box>
</AccordionDetails> </AccordionDetails>
</Accordion> </Accordion>
) );
} };

View File

@ -1,127 +1,169 @@
import {useState, useEffect} from 'react' import { useState, useEffect } from "react";
import {Stack, Typography, Button, FormControl, Grid, Box, Accordion, AccordionSummary, AccordionDetails, useTheme, TextField, Autocomplete} from '@mui/material' import {
import ExpandMoreIcon from '@mui/icons-material/ExpandMore' Stack,
import {axiosInstance} from '../providers/data' Typography,
import {BACKEND_URL} from '../lib/constants' Button,
import {Link} from 'react-router' FormControl,
import {TOKEN_KEY} from '../authProvider' Grid,
Box,
Accordion,
AccordionSummary,
AccordionDetails,
useTheme,
TextField,
Autocomplete,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { axiosInstance } from "../providers/data";
import { Link } from "react-router";
import { TOKEN_KEY } from "../authProvider";
type Field<T> = { type Field<T> = {
label: string label: string;
data: keyof T data: keyof T;
render?: (value: any) => React.ReactNode render?: (value: any) => React.ReactNode;
} };
type ExtraFieldConfig = { type ExtraFieldConfig = {
type: 'number' type: "number";
label: string label: string;
minValue: number minValue: number;
maxValue: (linkedItems: any[]) => number maxValue: (linkedItems: any[]) => number;
} };
type LinkedItemsProps<T> = { type LinkedItemsProps<T> = {
parentId: string | number parentId: string | number;
parentResource: string parentResource: string;
childResource: string childResource: string;
fields: Field<T>[] fields: Field<T>[];
title: string title: string;
type: 'show' | 'edit' type: "show" | "edit";
extraField?: ExtraFieldConfig extraField?: ExtraFieldConfig;
} };
export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentId, parentResource, childResource, fields, title, type}: LinkedItemsProps<T>) => { export const LinkedItems = <T extends { id: number; [key: string]: any }>({
const [items, setItems] = useState<T[]>([]) parentId,
const [linkedItems, setLinkedItems] = useState<T[]>([]) parentResource,
const [selectedItemId, setSelectedItemId] = useState<number | null>(null) childResource,
const [pageNum, setPageNum] = useState<number>(1) fields,
const [isLoading, setIsLoading] = useState<boolean>(true) title,
const [mediaOrder, setMediaOrder] = useState<number>(1) type,
const theme = useTheme() }: LinkedItemsProps<T>) => {
const [items, setItems] = useState<T[]>([]);
const [linkedItems, setLinkedItems] = useState<T[]>([]);
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
const [pageNum, setPageNum] = useState<number>(1);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [mediaOrder, setMediaOrder] = useState<number>(1);
const theme = useTheme();
useEffect(() => { useEffect(() => {
if (parentId) { if (parentId) {
axiosInstance axiosInstance
.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`) .get(
`${
import.meta.env.VITE_KRBL_API
}/${parentResource}/${parentId}/${childResource}`
)
.then((response) => { .then((response) => {
setLinkedItems(response?.data || []) setLinkedItems(response?.data || []);
}) })
.catch(() => { .catch(() => {
setLinkedItems([]) setLinkedItems([]);
}) });
} }
}, [parentId, parentResource, childResource]) }, [parentId, parentResource, childResource]);
useEffect(() => { useEffect(() => {
if (type === 'edit') { if (type === "edit") {
axiosInstance axiosInstance
.get(`${BACKEND_URL}/${childResource}/`) .get(`${import.meta.env.VITE_KRBL_API}/${childResource}/`)
.then((response) => { .then((response) => {
setItems(response?.data || []) setItems(response?.data || []);
setIsLoading(false) setIsLoading(false);
}) })
.catch(() => { .catch(() => {
setItems([]) setItems([]);
setIsLoading(false) setIsLoading(false);
}) });
} else { } else {
setIsLoading(false) setIsLoading(false);
} }
}, [childResource, type]) }, [childResource, type]);
useEffect(() => { useEffect(() => {
if (childResource === 'article' && parentResource === 'sight') { if (childResource === "article" && parentResource === "sight") {
setPageNum(linkedItems.length + 1) setPageNum(linkedItems.length + 1);
} }
}, [linkedItems, childResource, parentResource]) }, [linkedItems, childResource, parentResource]);
const availableItems = items.filter((item) => !linkedItems.some((linked) => linked.id === item.id)) const availableItems = items.filter(
(item) => !linkedItems.some((linked) => linked.id === item.id)
);
const linkItem = () => { const linkItem = () => {
if (selectedItemId !== null) { if (selectedItemId !== null) {
const requestData = const requestData =
childResource === 'article' childResource === "article"
? { ? {
[`${childResource}_id`]: selectedItemId, [`${childResource}_id`]: selectedItemId,
page_num: pageNum, page_num: pageNum,
} }
: childResource === 'media' : childResource === "media"
? { ? {
[`${childResource}_id`]: selectedItemId, [`${childResource}_id`]: selectedItemId,
media_order: mediaOrder, media_order: mediaOrder,
} }
: { : {
[`${childResource}_id`]: selectedItemId, [`${childResource}_id`]: selectedItemId,
} };
axiosInstance axiosInstance
.post(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`, requestData) .post(
`${
import.meta.env.VITE_KRBL_API
}/${parentResource}/${parentId}/${childResource}`,
requestData
)
.then(() => { .then(() => {
axiosInstance.get(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`).then((response) => { axiosInstance
setLinkedItems(response?.data || []) .get(
setSelectedItemId(null) `${
if (childResource === 'article') { import.meta.env.VITE_KRBL_API
setPageNum(pageNum + 1) }/${parentResource}/${parentId}/${childResource}`
)
.then((response) => {
setLinkedItems(response?.data || []);
setSelectedItemId(null);
if (childResource === "article") {
setPageNum(pageNum + 1);
} }
}) });
}) })
.catch((error) => { .catch((error) => {
console.error('Error linking item:', error) console.error("Error linking item:", error);
}) });
}
} }
};
const deleteItem = (itemId: number) => { const deleteItem = (itemId: number) => {
axiosInstance axiosInstance
.delete(`${BACKEND_URL}/${parentResource}/${parentId}/${childResource}`, { .delete(
data: {[`${childResource}_id`]: itemId}, `${
}) import.meta.env.VITE_KRBL_API
}/${parentResource}/${parentId}/${childResource}`,
{
data: { [`${childResource}_id`]: itemId },
}
)
.then(() => { .then(() => {
setLinkedItems((prev) => prev.filter((item) => item.id !== itemId)) setLinkedItems((prev) => prev.filter((item) => item.id !== itemId));
}) })
.catch((error) => { .catch((error) => {
console.error('Error unlinking item:', error) console.error("Error unlinking item:", error);
}) });
} };
return ( return (
<Accordion> <Accordion>
@ -137,7 +179,7 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI
</Typography> </Typography>
</AccordionSummary> </AccordionSummary>
<AccordionDetails sx={{background: theme.palette.background.paper}}> <AccordionDetails sx={{ background: theme.palette.background.paper }}>
<Stack gap={2}> <Stack gap={2}>
<Grid container gap={1.25}> <Grid container gap={1.25}>
{isLoading ? ( {isLoading ? (
@ -149,48 +191,55 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI
to={`/${childResource}/show/${item.id}`} to={`/${childResource}/show/${item.id}`}
key={index} key={index}
sx={{ sx={{
marginTop: '8px', marginTop: "8px",
padding: '14px', padding: "14px",
borderRadius: 2, borderRadius: 2,
border: `2px solid ${theme.palette.divider}`, border: `2px solid ${theme.palette.divider}`,
width: childResource === 'article' ? '100%' : 'auto', width: childResource === "article" ? "100%" : "auto",
textDecoration: 'none', textDecoration: "none",
color: 'inherit', color: "inherit",
display: 'block', display: "block",
'&:hover': { "&:hover": {
backgroundColor: theme.palette.action.hover, backgroundColor: theme.palette.action.hover,
}, },
}} }}
> >
<Stack gap={0.25}> <Stack gap={0.25}>
{childResource === 'media' && item.id && ( {childResource === "media" && item.id && (
<img <img
src={`https://wn.krbl.ru/media/${item.id}/download?token=${localStorage.getItem(TOKEN_KEY)}`} src={`${import.meta.env.VITE_KRBL_MEDIA}/${
item.id
}/download?token=${localStorage.getItem(TOKEN_KEY)}`}
alt={String(item.media_name)} alt={String(item.media_name)}
style={{ style={{
width: '100%', width: "100%",
height: '120px', height: "120px",
objectFit: 'contain', objectFit: "contain",
marginBottom: '8px', marginBottom: "8px",
borderRadius: 4, borderRadius: 4,
}} }}
/> />
)} )}
{fields.map(({label, data, render}) => ( {fields.map(({ label, data, render }) => (
<Typography variant="body2" color="textSecondary" key={String(data)}> <Typography
<strong>{label}:</strong> {render ? render(item[data]) : item[data]} variant="body2"
color="textSecondary"
key={String(data)}
>
<strong>{label}:</strong>{" "}
{render ? render(item[data]) : item[data]}
</Typography> </Typography>
))} ))}
{type === 'edit' && ( {type === "edit" && (
<Button <Button
variant="outlined" variant="outlined"
color="error" color="error"
size="small" size="small"
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault();
deleteItem(item.id) deleteItem(item.id);
}} }}
sx={{mt: 1.5}} sx={{ mt: 1.5 }}
> >
Отвязать Отвязать
</Button> </Button>
@ -203,31 +252,48 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI
)} )}
</Grid> </Grid>
{type === 'edit' && ( {type === "edit" && (
<Stack gap={2}> <Stack gap={2}>
<Typography variant="subtitle1">Добавить {title}</Typography> <Typography variant="subtitle1">Добавить {title}</Typography>
<Autocomplete <Autocomplete
fullWidth fullWidth
value={availableItems.find((item) => item.id === selectedItemId) || null} value={
onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)} availableItems.find((item) => item.id === selectedItemId) ||
null
}
onChange={(_, newValue) =>
setSelectedItemId(newValue?.id || null)
}
options={availableItems} options={availableItems}
getOptionLabel={(item) => String(item[fields[0].data])} getOptionLabel={(item) => String(item[fields[0].data])}
renderInput={(params) => <TextField {...params} label={`Выберите ${title}`} fullWidth />} renderInput={(params) => (
isOptionEqualToValue={(option, value) => option.id === value?.id} <TextField
filterOptions={(options, {inputValue}) => { {...params}
label={`Выберите ${title}`}
fullWidth
/>
)}
isOptionEqualToValue={(option, value) =>
option.id === value?.id
}
filterOptions={(options, { inputValue }) => {
// return options.filter((option) => String(option[fields[0].data]).toLowerCase().includes(inputValue.toLowerCase())) // return options.filter((option) => String(option[fields[0].data]).toLowerCase().includes(inputValue.toLowerCase()))
const searchWords = inputValue const searchWords = inputValue
.toLowerCase() .toLowerCase()
.split(' ') .split(" ")
.filter((word) => word.length > 0) .filter((word) => word.length > 0);
return options.filter((option) => { return options.filter((option) => {
const optionWords = String(option[fields[0].data]).toLowerCase().split(' ') const optionWords = String(option[fields[0].data])
return searchWords.every((searchWord) => optionWords.some((word) => word.startsWith(searchWord))) .toLowerCase()
}) .split(" ");
return searchWords.every((searchWord) =>
optionWords.some((word) => word.startsWith(searchWord))
);
});
}} }}
/> />
{childResource === 'article' && ( {childResource === "article" && (
<FormControl fullWidth> <FormControl fullWidth>
<TextField <TextField
type="number" type="number"
@ -235,35 +301,39 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI
name="page_num" name="page_num"
value={pageNum} value={pageNum}
onChange={(e) => { onChange={(e) => {
const newValue = Number(e.target.value) const newValue = Number(e.target.value);
const minValue = linkedItems.length + 1 // page number on articles lenght const minValue = linkedItems.length + 1; // page number on articles lenght
setPageNum(newValue < minValue ? minValue : newValue) setPageNum(newValue < minValue ? minValue : newValue);
}} }}
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
/> />
</FormControl> </FormControl>
)} )}
{childResource === 'media' && type === 'edit' && ( {childResource === "media" && type === "edit" && (
<FormControl fullWidth> <FormControl fullWidth>
<TextField <TextField
type="number" type="number"
label="Порядок отображения медиа" label="Порядок отображения медиа"
value={mediaOrder} value={mediaOrder}
onChange={(e) => { onChange={(e) => {
const newValue = Number(e.target.value) const newValue = Number(e.target.value);
const maxValue = linkedItems.length + 1 const maxValue = linkedItems.length + 1;
const value = Math.max(1, Math.min(newValue, maxValue)) const value = Math.max(1, Math.min(newValue, maxValue));
setMediaOrder(value) setMediaOrder(value);
}} }}
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
/> />
</FormControl> </FormControl>
)} )}
<Button variant="contained" onClick={linkItem} disabled={!selectedItemId}> <Button
variant="contained"
onClick={linkItem}
disabled={!selectedItemId}
>
Добавить Добавить
</Button> </Button>
</Stack> </Stack>
@ -271,5 +341,5 @@ export const LinkedItems = <T extends {id: number; [key: string]: any}>({parentI
</Stack> </Stack>
</AccordionDetails> </AccordionDetails>
</Accordion> </Accordion>
) );
} };

View File

@ -1,11 +1,9 @@
export const BACKEND_URL = 'https://wn.krbl.ru'
export const MEDIA_TYPES = [ export const MEDIA_TYPES = [
{label: 'Фото', value: 1}, { label: "Фото", value: 1 },
{label: 'Видео', value: 2}, { label: "Видео", value: 2 },
] ];
export const VEHICLE_TYPES = [ export const VEHICLE_TYPES = [
{label: 'Трамвай', value: 1}, { label: "Трамвай", value: 1 },
{label: 'Троллейбус', value: 2}, { label: "Троллейбус", value: 2 },
] ];

View File

@ -1,19 +1,19 @@
import {Box, TextField, Typography, Paper} from '@mui/material' import { Box, TextField, Typography, Paper } from "@mui/material";
import {Edit} from '@refinedev/mui' import { Edit } from "@refinedev/mui";
import {useForm} from '@refinedev/react-hook-form' import { useForm } from "@refinedev/react-hook-form";
import {Controller} from 'react-hook-form' import { Controller } from "react-hook-form";
import {useParams} from 'react-router' import { useParams } from "react-router";
import React, {useState, useEffect} from 'react' import React, { useState, useEffect } from "react";
import ReactMarkdown from 'react-markdown' import ReactMarkdown from "react-markdown";
import {useList} from '@refinedev/core' import { useList } from "@refinedev/core";
import {MarkdownEditor} from '../../components/MarkdownEditor' import { MarkdownEditor } from "../../components/MarkdownEditor";
import {LinkedItems} from '../../components/LinkedItems' import { LinkedItems } from "../../components/LinkedItems";
import {MediaItem, mediaFields} from './types' import { MediaItem, mediaFields } from "./types";
import {TOKEN_KEY} from '../../authProvider' import { TOKEN_KEY } from "../../authProvider";
import 'easymde/dist/easymde.min.css' import "easymde/dist/easymde.min.css";
const MemoizedSimpleMDE = React.memo(MarkdownEditor) const MemoizedSimpleMDE = React.memo(MarkdownEditor);
export const ArticleEdit = () => { export const ArticleEdit = () => {
const { const {
@ -21,55 +21,59 @@ export const ArticleEdit = () => {
register, register,
control, control,
watch, watch,
formState: {errors}, formState: { errors },
} = useForm() } = useForm();
const {id: articleId} = useParams<{id: string}>() const { id: articleId } = useParams<{ id: string }>();
const [preview, setPreview] = useState('') const [preview, setPreview] = useState("");
const [headingPreview, setHeadingPreview] = useState('') const [headingPreview, setHeadingPreview] = useState("");
// Получаем привязанные медиа // Получаем привязанные медиа
const {data: mediaData} = useList<MediaItem>({ const { data: mediaData } = useList<MediaItem>({
resource: `article/${articleId}/media`, resource: `article/${articleId}/media`,
queryOptions: { queryOptions: {
enabled: !!articleId, enabled: !!articleId,
}, },
}) });
// Следим за изменениями в полях body и heading // Следим за изменениями в полях body и heading
const bodyContent = watch('body') const bodyContent = watch("body");
const headingContent = watch('heading') const headingContent = watch("heading");
useEffect(() => { useEffect(() => {
setPreview(bodyContent || '') setPreview(bodyContent || "");
}, [bodyContent]) }, [bodyContent]);
useEffect(() => { useEffect(() => {
setHeadingPreview(headingContent || '') setHeadingPreview(headingContent || "");
}, [headingContent]) }, [headingContent]);
const simpleMDEOptions = React.useMemo( const simpleMDEOptions = React.useMemo(
() => ({ () => ({
placeholder: 'Введите контент в формате Markdown...', placeholder: "Введите контент в формате Markdown...",
spellChecker: false, spellChecker: false,
}), }),
[], []
) );
return ( return (
<Edit saveButtonProps={saveButtonProps}> <Edit saveButtonProps={saveButtonProps}>
<Box sx={{display: 'flex', gap: 2}}> <Box sx={{ display: "flex", gap: 2 }}>
{/* Форма редактирования */} {/* Форма редактирования */}
<Box component="form" sx={{flex: 1, display: 'flex', flexDirection: 'column'}} autoComplete="off"> <Box
component="form"
sx={{ flex: 1, display: "flex", flexDirection: "column" }}
autoComplete="off"
>
<TextField <TextField
{...register('heading', { {...register("heading", {
required: 'Это поле является обязательным', required: "Это поле является обязательным",
})} })}
error={!!(errors as any)?.heading} error={!!(errors as any)?.heading}
helperText={(errors as any)?.heading?.message} helperText={(errors as any)?.heading?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="text" type="text"
label="Заголовок *" label="Заголовок *"
name="heading" name="heading"
@ -78,9 +82,9 @@ export const ArticleEdit = () => {
<Controller <Controller
control={control} control={control}
name="body" name="body"
rules={{required: 'Это поле является обязательным'}} rules={{ required: "Это поле является обязательным" }}
defaultValue="" defaultValue=""
render={({field: {onChange, value}}) => ( render={({ field: { onChange, value } }) => (
<MemoizedSimpleMDE <MemoizedSimpleMDE
value={value} // markdown value={value} // markdown
onChange={onChange} onChange={onChange}
@ -90,7 +94,16 @@ export const ArticleEdit = () => {
)} )}
/> />
{articleId && <LinkedItems<MediaItem> type="edit" parentId={articleId} parentResource="article" childResource="media" fields={mediaFields} title="медиа" />} {articleId && (
<LinkedItems<MediaItem>
type="edit"
parentId={articleId}
parentResource="article"
childResource="media"
fields={mediaFields}
title="медиа"
/>
)}
</Box> </Box>
{/* Блок предпросмотра */} {/* Блок предпросмотра */}
@ -98,14 +111,15 @@ export const ArticleEdit = () => {
sx={{ sx={{
flex: 1, flex: 1,
p: 2, p: 2,
maxHeight: 'calc(100vh - 200px)', maxHeight: "calc(100vh - 200px)",
overflowY: 'auto', overflowY: "auto",
position: 'sticky', position: "sticky",
top: 16, top: 16,
borderRadius: 2, borderRadius: 2,
border: '1px solid', border: "1px solid",
borderColor: 'primary.main', borderColor: "primary.main",
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : '#fff'), bgcolor: (theme) =>
theme.palette.mode === "dark" ? "background.paper" : "#fff",
}} }}
> >
<Typography variant="h6" gutterBottom color="primary"> <Typography variant="h6" gutterBottom color="primary">
@ -117,7 +131,8 @@ export const ArticleEdit = () => {
variant="h4" variant="h4"
gutterBottom gutterBottom
sx={{ sx={{
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'), color: (theme) =>
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
mb: 3, mb: 3,
}} }}
> >
@ -127,39 +142,41 @@ export const ArticleEdit = () => {
{/* Markdown контент */} {/* Markdown контент */}
<Box <Box
sx={{ sx={{
'& img': { "& img": {
maxWidth: '100%', maxWidth: "100%",
height: 'auto', height: "auto",
borderRadius: 1, borderRadius: 1,
}, },
'& h1, & h2, & h3, & h4, & h5, & h6': { "& h1, & h2, & h3, & h4, & h5, & h6": {
color: 'primary.main', color: "primary.main",
mt: 2, mt: 2,
mb: 1, mb: 1,
}, },
'& p': { "& p": {
mb: 2, mb: 2,
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'), color: (theme) =>
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
}, },
'& a': { "& a": {
color: 'primary.main', color: "primary.main",
textDecoration: 'none', textDecoration: "none",
'&:hover': { "&:hover": {
textDecoration: 'underline', textDecoration: "underline",
}, },
}, },
'& blockquote': { "& blockquote": {
borderLeft: '4px solid', borderLeft: "4px solid",
borderColor: 'primary.main', borderColor: "primary.main",
pl: 2, pl: 2,
my: 2, my: 2,
color: 'text.secondary', color: "text.secondary",
}, },
'& code': { "& code": {
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'grey.900' : 'grey.100'), bgcolor: (theme) =>
theme.palette.mode === "dark" ? "grey.900" : "grey.100",
p: 0.5, p: 0.5,
borderRadius: 0.5, borderRadius: 0.5,
color: 'primary.main', color: "primary.main",
}, },
}} }}
> >
@ -168,15 +185,15 @@ export const ArticleEdit = () => {
{/* Привязанные медиа */} {/* Привязанные медиа */}
{mediaData?.data && mediaData.data.length > 0 && ( {mediaData?.data && mediaData.data.length > 0 && (
<Box sx={{mb: 3}}> <Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" gutterBottom color="primary"> <Typography variant="subtitle1" gutterBottom color="primary">
Привязанные медиа: Привязанные медиа:
</Typography> </Typography>
<Box <Box
sx={{ sx={{
display: 'flex', display: "flex",
gap: 1, gap: 1,
flexWrap: 'wrap', flexWrap: "wrap",
mb: 2, mb: 2,
}} }}
> >
@ -187,18 +204,20 @@ export const ArticleEdit = () => {
width: 120, width: 120,
height: 120, height: 120,
borderRadius: 1, borderRadius: 1,
overflow: 'hidden', overflow: "hidden",
border: '1px solid', border: "1px solid",
borderColor: 'primary.main', borderColor: "primary.main",
}} }}
> >
<img <img
src={`https://wn.krbl.ru/media/${media.id}/download?token=${localStorage.getItem(TOKEN_KEY)}`} src={`${import.meta.env.VITE_KRBL_MEDIA}${
media.id
}/download?token=${localStorage.getItem(TOKEN_KEY)}`}
alt={media.media_name} alt={media.media_name}
style={{ style={{
width: '100%', width: "100%",
height: '100%', height: "100%",
objectFit: 'cover', objectFit: "cover",
}} }}
/> />
</Box> </Box>
@ -209,5 +228,5 @@ export const ArticleEdit = () => {
</Paper> </Paper>
</Box> </Box>
</Edit> </Edit>
) );
} };

View File

@ -1,60 +1,114 @@
import {Box, Stack, Typography} from '@mui/material' import { Box, Stack, Typography } from "@mui/material";
import {useShow} from '@refinedev/core' import { useShow } from "@refinedev/core";
import {Show, TextFieldComponent as TextField} from '@refinedev/mui' import { Show, TextFieldComponent as TextField } from "@refinedev/mui";
import {TOKEN_KEY} from '../../authProvider' import { TOKEN_KEY } from "../../authProvider";
export type FieldType = { export type FieldType = {
label: string label: string;
data: any data: any;
render?: (value: any) => React.ReactNode render?: (value: any) => React.ReactNode;
} };
export const CarrierShow = () => { export const CarrierShow = () => {
const {query} = useShow({}) const { query } = useShow({});
const {data, isLoading} = query const { data, isLoading } = query;
const record = data?.data const record = data?.data;
const fields: FieldType[] = [ const fields: FieldType[] = [
{label: 'Полное имя', data: 'full_name'}, { label: "Полное имя", data: "full_name" },
{label: 'Короткое имя', data: 'short_name'}, { label: "Короткое имя", data: "short_name" },
{label: 'Город', data: 'city'}, { label: "Город", data: "city" },
{ {
label: 'Основной цвет', label: "Основной цвет",
data: 'main_color', data: "main_color",
render: (value: string) => <Box sx={{display: 'grid', placeItems: 'center', width: 'fit-content', paddingInline: '6px', height: '100%', backgroundColor: `${value}20`, borderRadius: 1}}>{value}</Box>, render: (value: string) => (
<Box
sx={{
display: "grid",
placeItems: "center",
width: "fit-content",
paddingInline: "6px",
height: "100%",
backgroundColor: `${value}20`,
borderRadius: 1,
}}
>
{value}
</Box>
),
}, },
{ {
label: 'Цвет левого виджета', label: "Цвет левого виджета",
data: 'left_color', data: "left_color",
render: (value: string) => <Box sx={{display: 'grid', placeItems: 'center', width: 'fit-content', paddingInline: '6px', height: '100%', backgroundColor: `${value}20`, borderRadius: 1}}>{value}</Box>, render: (value: string) => (
<Box
sx={{
display: "grid",
placeItems: "center",
width: "fit-content",
paddingInline: "6px",
height: "100%",
backgroundColor: `${value}20`,
borderRadius: 1,
}}
>
{value}
</Box>
),
}, },
{ {
label: 'Цвет правого виджета', label: "Цвет правого виджета",
data: 'right_color', data: "right_color",
render: (value: string) => <Box sx={{display: 'grid', placeItems: 'center', width: 'fit-content', paddingInline: '6px', height: '100%', backgroundColor: `${value}20`, borderRadius: 1}}>{value}</Box>, render: (value: string) => (
<Box
sx={{
display: "grid",
placeItems: "center",
width: "fit-content",
paddingInline: "6px",
height: "100%",
backgroundColor: `${value}20`,
borderRadius: 1,
}}
>
{value}
</Box>
),
}, },
{label: 'Слоган', data: 'slogan'}, { label: "Слоган", data: "slogan" },
{ {
label: 'Логотип', label: "Логотип",
data: 'logo', data: "logo",
render: (value: number) => <img src={`https://wn.krbl.ru/media/${value}/download?token=${localStorage.getItem(TOKEN_KEY)}`} alt={String(value)} style={{maxWidth: '10%', objectFit: 'contain', borderRadius: 8}} />, render: (value: number) => (
<img
src={`${
import.meta.env.VITE_KRBL_MEDIA
}${value}/download?token=${localStorage.getItem(TOKEN_KEY)}`}
alt={String(value)}
style={{ maxWidth: "10%", objectFit: "contain", borderRadius: 8 }}
/>
),
}, },
] ];
return ( return (
<Show isLoading={isLoading}> <Show isLoading={isLoading}>
<Stack gap={4}> <Stack gap={4}>
{fields.map(({label, data, render}) => ( {fields.map(({ label, data, render }) => (
<Stack key={data} gap={1}> <Stack key={data} gap={1}>
<Typography variant="body1" fontWeight="bold"> <Typography variant="body1" fontWeight="bold">
{label} {label}
</Typography> </Typography>
{render ? render(record?.[data]) : <TextField value={record?.[data]} />} {render ? (
render(record?.[data])
) : (
<TextField value={record?.[data]} />
)}
</Stack> </Stack>
))} ))}
</Stack> </Stack>
</Show> </Show>
) );
} };

View File

@ -1,35 +1,51 @@
import {Stack, Typography} from '@mui/material' import { Stack, Typography } from "@mui/material";
import {useShow} from '@refinedev/core' import { useShow } from "@refinedev/core";
import {Show, TextFieldComponent as TextField} from '@refinedev/mui' import { Show, TextFieldComponent as TextField } from "@refinedev/mui";
import {TOKEN_KEY} from '../../authProvider' import { TOKEN_KEY } from "../../authProvider";
export const CityShow = () => { export const CityShow = () => {
const {query} = useShow({}) const { query } = useShow({});
const {data, isLoading} = query const { data, isLoading } = query;
const record = data?.data const record = data?.data;
const fields = [ const fields = [
// {label: 'ID', data: 'id'}, // {label: 'ID', data: 'id'},
{label: 'Название', data: 'name'}, { label: "Название", data: "name" },
// {label: 'Код страны', data: 'country_code'}, // {label: 'Код страны', data: 'country_code'},
{label: 'Страна', data: 'country'}, { label: "Страна", data: "country" },
{label: 'Герб', data: 'arms', render: (value: number) => <img src={`https://wn.krbl.ru/media/${value}/download?token=${localStorage.getItem(TOKEN_KEY)}`} alt={String(value)} style={{maxWidth: '10%', objectFit: 'contain', borderRadius: 8}} />}, {
] label: "Герб",
data: "arms",
render: (value: number) => (
<img
src={`${
import.meta.env.VITE_KRBL_MEDIA
}${value}/download?token=${localStorage.getItem(TOKEN_KEY)}`}
alt={String(value)}
style={{ maxWidth: "10%", objectFit: "contain", borderRadius: 8 }}
/>
),
},
];
return ( return (
<Show isLoading={isLoading}> <Show isLoading={isLoading}>
<Stack gap={4}> <Stack gap={4}>
{fields.map(({label, data, render}) => ( {fields.map(({ label, data, render }) => (
<Stack key={data} gap={1}> <Stack key={data} gap={1}>
<Typography variant="body1" fontWeight="bold"> <Typography variant="body1" fontWeight="bold">
{label} {label}
</Typography> </Typography>
{render ? render(record?.[data]) : <TextField value={record?.[data]} />} {render ? (
render(record?.[data])
) : (
<TextField value={record?.[data]} />
)}
</Stack> </Stack>
))} ))}
</Stack> </Stack>
</Show> </Show>
) );
} };

View File

@ -1,26 +1,36 @@
import {Box, TextField, Button, Typography, Autocomplete} from '@mui/material' import {
import {Edit} from '@refinedev/mui' Box,
import {useForm} from '@refinedev/react-hook-form' TextField,
import {useEffect} from 'react' Button,
import {useShow} from '@refinedev/core' Typography,
import {Controller} from 'react-hook-form' Autocomplete,
} from "@mui/material";
import { Edit } from "@refinedev/mui";
import { useForm } from "@refinedev/react-hook-form";
import { useEffect } from "react";
import { useShow } from "@refinedev/core";
import { Controller } from "react-hook-form";
import {MEDIA_TYPES} from '../../lib/constants' import { MEDIA_TYPES } from "../../lib/constants";
import {ALLOWED_IMAGE_TYPES, ALLOWED_VIDEO_TYPES, useMediaFileUpload} from '../../components/media/MediaFormUtils' import {
import {TOKEN_KEY} from '../../authProvider' ALLOWED_IMAGE_TYPES,
ALLOWED_VIDEO_TYPES,
useMediaFileUpload,
} from "../../components/media/MediaFormUtils";
import { TOKEN_KEY } from "../../authProvider";
type MediaFormValues = { type MediaFormValues = {
media_name: string media_name: string;
media_type: number media_type: number;
file?: File file?: File;
} };
export const MediaEdit = () => { export const MediaEdit = () => {
const { const {
saveButtonProps, saveButtonProps,
refineCore: {onFinish}, refineCore: { onFinish },
register, register,
formState: {errors}, formState: { errors },
setValue, setValue,
handleSubmit, handleSubmit,
watch, watch,
@ -29,32 +39,42 @@ export const MediaEdit = () => {
control, control,
} = useForm<MediaFormValues>({ } = useForm<MediaFormValues>({
defaultValues: { defaultValues: {
media_name: '', media_name: "",
media_type: '', media_type: "",
file: undefined, file: undefined,
}, },
}) });
const {query} = useShow() const { query } = useShow();
const {data} = query const { data } = query;
const record = data?.data const record = data?.data;
const selectedMediaType = watch('media_type') const selectedMediaType = watch("media_type");
const {selectedFile, previewUrl, setPreviewUrl, handleFileChange, handleMediaTypeChange} = useMediaFileUpload({ const {
selectedFile,
previewUrl,
setPreviewUrl,
handleFileChange,
handleMediaTypeChange,
} = useMediaFileUpload({
selectedMediaType, selectedMediaType,
setError, setError,
clearErrors, clearErrors,
setValue, setValue,
}) });
useEffect(() => { useEffect(() => {
if (record?.id) { if (record?.id) {
setPreviewUrl(`https://wn.krbl.ru/media/${record.id}/download?token=${localStorage.getItem(TOKEN_KEY)}`) setPreviewUrl(
setValue('media_name', record?.media_name || '') `${import.meta.env.VITE_KRBL_MEDIA}${
setValue('media_type', record?.media_type) record.id
}/download?token=${localStorage.getItem(TOKEN_KEY)}`
);
setValue("media_name", record?.media_name || "");
setValue("media_type", record?.media_type);
} }
}, [record, setValue, setPreviewUrl]) }, [record, setValue, setPreviewUrl]);
return ( return (
<Edit <Edit
@ -66,57 +86,98 @@ export const MediaEdit = () => {
media_name: data.media_name, media_name: data.media_name,
filename: selectedFile?.name || record?.filename, filename: selectedFile?.name || record?.filename,
type: Number(data.media_type), type: Number(data.media_type),
} };
onFinish(formData) onFinish(formData);
}), }),
}} }}
> >
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off"> <Box
component="form"
sx={{ display: "flex", flexDirection: "column" }}
autoComplete="off"
>
<Controller <Controller
control={control} control={control}
name="media_type" name="media_type"
rules={{ rules={{
required: 'Это поле является обязательным', required: "Это поле является обязательным",
}} }}
defaultValue={null} defaultValue={null}
render={({field}) => ( render={({ field }) => (
<Autocomplete <Autocomplete
options={MEDIA_TYPES} options={MEDIA_TYPES}
value={MEDIA_TYPES.find((option) => option.value === field.value) || null} value={
MEDIA_TYPES.find((option) => option.value === field.value) ||
null
}
onChange={(_, value) => { onChange={(_, value) => {
field.onChange(value?.value || null) field.onChange(value?.value || null);
handleMediaTypeChange(value?.value || null) handleMediaTypeChange(value?.value || null);
}} }}
getOptionLabel={(item) => { getOptionLabel={(item) => {
return item ? item.label : '' return item ? item.label : "";
}} }}
isOptionEqualToValue={(option, value) => { isOptionEqualToValue={(option, value) => {
return option.value === value?.value return option.value === value?.value;
}} }}
renderInput={(params) => <TextField {...params} label="Тип" margin="normal" variant="outlined" error={!!errors.media_type} helperText={(errors as any)?.media_type?.message} required />} renderInput={(params) => (
<TextField
{...params}
label="Тип"
margin="normal"
variant="outlined"
error={!!errors.media_type}
helperText={(errors as any)?.media_type?.message}
required
/>
)}
/> />
)} )}
/> />
<TextField <TextField
{...register('media_name', { {...register("media_name", {
required: 'Это поле является обязательным', required: "Это поле является обязательным",
})} })}
error={!!(errors as any)?.media_name} error={!!(errors as any)?.media_name}
helperText={(errors as any)?.media_name?.message} helperText={(errors as any)?.media_name?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="text" type="text"
label="Название *" label="Название *"
name="media_name" name="media_name"
/> />
<Box display="flex" flexDirection="column-reverse" alignItems="center" gap={4} style={{marginTop: 10}}> <Box
<Box display="flex" flexDirection="column" alignItems="center" gap={2}> display="flex"
<Button variant="contained" component="label" disabled={!selectedMediaType}> flexDirection="column-reverse"
{selectedFile ? 'Изменить файл' : 'Загрузить файл'} alignItems="center"
<input type="file" hidden onChange={handleFileChange} accept={selectedMediaType === 1 ? ALLOWED_IMAGE_TYPES.join(',') : ALLOWED_VIDEO_TYPES.join(',')} /> gap={4}
style={{ marginTop: 10 }}
>
<Box
display="flex"
flexDirection="column"
alignItems="center"
gap={2}
>
<Button
variant="contained"
component="label"
disabled={!selectedMediaType}
>
{selectedFile ? "Изменить файл" : "Загрузить файл"}
<input
type="file"
hidden
onChange={handleFileChange}
accept={
selectedMediaType === 1
? ALLOWED_IMAGE_TYPES.join(",")
: ALLOWED_VIDEO_TYPES.join(",")
}
/>
</Button> </Button>
{selectedFile && ( {selectedFile && (
@ -134,11 +195,15 @@ export const MediaEdit = () => {
{previewUrl && selectedMediaType === 1 && ( {previewUrl && selectedMediaType === 1 && (
<Box mt={2} display="flex" justifyContent="center"> <Box mt={2} display="flex" justifyContent="center">
<img src={previewUrl} alt="Preview" style={{maxWidth: '200px', borderRadius: 8}} /> <img
src={previewUrl}
alt="Preview"
style={{ maxWidth: "200px", borderRadius: 8 }}
/>
</Box> </Box>
)} )}
</Box> </Box>
</Box> </Box>
</Edit> </Edit>
) );
} };

View File

@ -1,39 +1,42 @@
import {Stack, Typography, Box, Button} from '@mui/material' import { Stack, Typography, Box, Button } from "@mui/material";
import {useShow} from '@refinedev/core' import { useShow } from "@refinedev/core";
import {Show, TextFieldComponent as TextField} from '@refinedev/mui' import { Show, TextFieldComponent as TextField } from "@refinedev/mui";
import {MEDIA_TYPES} from '../../lib/constants' import { MEDIA_TYPES } from "../../lib/constants";
import {TOKEN_KEY} from '../../authProvider' import { TOKEN_KEY } from "../../authProvider";
export const MediaShow = () => { export const MediaShow = () => {
const {query} = useShow({}) const { query } = useShow({});
const {data, isLoading} = query const { data, isLoading } = query;
const record = data?.data const record = data?.data;
const token = localStorage.getItem(TOKEN_KEY) const token = localStorage.getItem(TOKEN_KEY);
const fields = [ const fields = [
// {label: 'Название файла', data: 'filename'}, // {label: 'Название файла', data: 'filename'},
{label: 'Название', data: 'media_name'}, { label: "Название", data: "media_name" },
{ {
label: 'Тип', label: "Тип",
data: 'media_type', data: "media_type",
render: (value: number) => MEDIA_TYPES.find((type) => type.value === value)?.label || value, render: (value: number) =>
MEDIA_TYPES.find((type) => type.value === value)?.label || value,
}, },
// {label: 'ID', data: 'id'}, // {label: 'ID', data: 'id'},
] ];
return ( return (
<Show isLoading={isLoading}> <Show isLoading={isLoading}>
<Stack gap={4}> <Stack gap={4}>
{record && record.media_type === 1 && ( {record && record.media_type === 1 && (
<img <img
src={`https://wn.krbl.ru/media/${record?.id}/download?token=${token}`} src={`${import.meta.env.VITE_KRBL_MEDIA}${
record?.id
}/download?token=${token}`}
alt={record?.filename} alt={record?.filename}
style={{ style={{
maxWidth: '100%', maxWidth: "100%",
height: '40vh', height: "40vh",
objectFit: 'contain', objectFit: "contain",
borderRadius: 8, borderRadius: 8,
}} }}
/> />
@ -43,36 +46,45 @@ export const MediaShow = () => {
<Box <Box
sx={{ sx={{
p: 2, p: 2,
border: '1px solid text.pimary', border: "1px solid text.pimary",
borderRadius: 2, borderRadius: 2,
bgcolor: 'primary.light', bgcolor: "primary.light",
width: 'fit-content', width: "fit-content",
}} }}
> >
<Typography <Typography
variant="body1" variant="body1"
gutterBottom gutterBottom
sx={{ sx={{
color: '#FFFFFF', color: "#FFFFFF",
}} }}
> >
Видео доступно для скачивания по ссылке: Видео доступно для скачивания по ссылке:
</Typography> </Typography>
<Button variant="contained" href={`https://wn.krbl.ru/media/${record?.id}/download?token=${token}`} target="_blank" sx={{mt: 1, width: '100%'}}> <Button
variant="contained"
href={`${import.meta.env.VITE_KRBL_MEDIA}${
record?.id
}/download?token=${token}`}
target="_blank"
sx={{ mt: 1, width: "100%" }}
>
Скачать видео Скачать видео
</Button> </Button>
</Box> </Box>
)} )}
{fields.map(({label, data, render}) => ( {fields.map(({ label, data, render }) => (
<Stack key={data} gap={1}> <Stack key={data} gap={1}>
<Typography variant="body1" fontWeight="bold"> <Typography variant="body1" fontWeight="bold">
{label} {label}
</Typography> </Typography>
<TextField value={render ? render(record?.[data]) : record?.[data]} /> <TextField
value={render ? render(record?.[data]) : record?.[data]}
/>
</Stack> </Stack>
))} ))}
</Stack> </Stack>
</Show> </Show>
) );
} };

View File

@ -1,73 +1,102 @@
import {Autocomplete, Box, TextField, FormControlLabel, Checkbox, Typography} from '@mui/material' import {
import {Create, useAutocomplete} from '@refinedev/mui' Autocomplete,
import {useForm} from '@refinedev/react-hook-form' Box,
import {Controller} from 'react-hook-form' TextField,
FormControlLabel,
Checkbox,
Typography,
} from "@mui/material";
import { Create, useAutocomplete } from "@refinedev/mui";
import { useForm } from "@refinedev/react-hook-form";
import { Controller } from "react-hook-form";
export const RouteCreate = () => { export const RouteCreate = () => {
const { const {
saveButtonProps, saveButtonProps,
refineCore: {formLoading}, refineCore: { formLoading },
register, register,
control, control,
formState: {errors}, formState: { errors },
} = useForm({ } = useForm({
refineCoreProps: { refineCoreProps: {
resource: 'route/', resource: "route/",
}, },
}) });
const {autocompleteProps: carrierAutocompleteProps} = useAutocomplete({ const { autocompleteProps: carrierAutocompleteProps } = useAutocomplete({
resource: 'carrier', resource: "carrier",
onSearch: (value) => [ onSearch: (value) => [
{ {
field: 'short_name', field: "short_name",
operator: 'contains', operator: "contains",
value, value,
}, },
], ],
}) });
return ( return (
<Create isLoading={formLoading} saveButtonProps={saveButtonProps}> <Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
<Box component="form" sx={{display: 'flex', flexDirection: 'column'}} autoComplete="off"> <Box
component="form"
sx={{ display: "flex", flexDirection: "column" }}
autoComplete="off"
>
<Controller <Controller
control={control} control={control}
name="carrier_id" name="carrier_id"
rules={{required: 'Это поле является обязательным'}} rules={{ required: "Это поле является обязательным" }}
defaultValue={null} defaultValue={null}
render={({field}) => ( render={({ field }) => (
<Autocomplete <Autocomplete
{...carrierAutocompleteProps} {...carrierAutocompleteProps}
value={carrierAutocompleteProps.options.find((option) => option.id === field.value) || null} value={
carrierAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => { onChange={(_, value) => {
field.onChange(value?.id || '') field.onChange(value?.id || "");
}} }}
getOptionLabel={(item) => { getOptionLabel={(item) => {
return item ? item.short_name : '' return item ? item.short_name : "";
}} }}
isOptionEqualToValue={(option, value) => { isOptionEqualToValue={(option, value) => {
return option.id === value?.id return option.id === value?.id;
}} }}
filterOptions={(options, {inputValue}) => { filterOptions={(options, { inputValue }) => {
return options.filter((option) => option.short_name.toLowerCase().includes(inputValue.toLowerCase())) return options.filter((option) =>
option.short_name
.toLowerCase()
.includes(inputValue.toLowerCase())
);
}} }}
renderInput={(params) => <TextField {...params} label="Выберите перевозчика" margin="normal" variant="outlined" error={!!errors.carrier_id} helperText={(errors as any)?.carrier_id?.message} required />} renderInput={(params) => (
<TextField
{...params}
label="Выберите перевозчика"
margin="normal"
variant="outlined"
error={!!errors.carrier_id}
helperText={(errors as any)?.carrier_id?.message}
required
/>
)}
/> />
)} )}
/> />
<TextField <TextField
{...register('route_number', { {...register("route_number", {
required: 'Это поле является обязательным', required: "Это поле является обязательным",
setValueAs: (value) => String(value), setValueAs: (value) => String(value),
})} })}
error={!!(errors as any)?.route_number} error={!!(errors as any)?.route_number}
helperText={(errors as any)?.route_number?.message} helperText={(errors as any)?.route_number?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="text" type="text"
label={'Номер маршрута *'} label={"Номер маршрута *"}
name="route_number" name="route_number"
/> />
@ -75,143 +104,169 @@ export const RouteCreate = () => {
name="route_direction" // boolean name="route_direction" // boolean
control={control} control={control}
defaultValue={false} defaultValue={false}
render={({field}: {field: any}) => <FormControlLabel label="Прямой маршрут? *" control={<Checkbox {...field} checked={field.value} onChange={(e) => field.onChange(e.target.checked)} />} />} render={({ field }: { field: any }) => (
<FormControlLabel
label="Прямой маршрут? *"
control={
<Checkbox
{...field}
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
/> />
<Typography variant="caption" color="textSecondary" sx={{mt: 0, mb: 1}}> }
/>
)}
/>
<Typography
variant="caption"
color="textSecondary"
sx={{ mt: 0, mb: 1 }}
>
(Прямой / Обратный) (Прямой / Обратный)
</Typography> </Typography>
<TextField <TextField
{...register('path', { {...register("path", {
required: 'Это поле является обязательным', required: "Это поле является обязательным",
setValueAs: (value: string) => { setValueAs: (value: string) => {
try { try {
// Парсим строку в массив массивов // Парсим строку в массив массивов
return JSON.parse(value) return JSON.parse(value);
} catch { } catch {
return [] return [];
} }
}, },
validate: (value: unknown) => { validate: (value: unknown) => {
if (!Array.isArray(value)) return 'Неверный формат' if (!Array.isArray(value)) return "Неверный формат";
if (!value.every((point: unknown) => Array.isArray(point) && point.length === 2)) { if (
return 'Каждая точка должна быть массивом из двух координат' !value.every(
(point: unknown) => Array.isArray(point) && point.length === 2
)
) {
return "Каждая точка должна быть массивом из двух координат";
} }
if (!value.every((point: unknown[]) => point.every((coord: unknown) => !isNaN(Number(coord)) && typeof coord === 'number'))) { if (
return 'Координаты должны быть числами' !value.every((point: unknown[]) =>
point.every(
(coord: unknown) =>
!isNaN(Number(coord)) && typeof coord === "number"
)
)
) {
return "Координаты должны быть числами";
} }
return true return true;
}, },
})} })}
error={!!(errors as any)?.path} error={!!(errors as any)?.path}
helperText={(errors as any)?.path?.message} // 'Формат: [[lat1,lon1], [lat2,lon2], ...]' helperText={(errors as any)?.path?.message} // 'Формат: [[lat1,lon1], [lat2,lon2], ...]'
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="text" type="text"
label={'Координаты маршрута *'} label={"Координаты маршрута *"}
name="path" name="path"
placeholder="[[1.1, 2.2], [2.1, 4.5]]" placeholder="[[1.1, 2.2], [2.1, 4.5]]"
/> />
<TextField <TextField
{...register('route_sys_number', { {...register("route_sys_number", {
required: 'Это поле является обязательным', required: "Это поле является обязательным",
})} })}
error={!!(errors as any)?.route_sys_number} error={!!(errors as any)?.route_sys_number}
helperText={(errors as any)?.route_sys_number?.message} helperText={(errors as any)?.route_sys_number?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="number" type="number"
label={'Системный номер маршрута *'} label={"Системный номер маршрута *"}
name="route_sys_number" name="route_sys_number"
/> />
<TextField <TextField
{...register('governor_appeal', { {...register("governor_appeal", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
})} })}
error={!!(errors as any)?.governor_appeal} error={!!(errors as any)?.governor_appeal}
helperText={(errors as any)?.governor_appeal?.message} helperText={(errors as any)?.governor_appeal?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="number" type="number"
label={'Обращение губернатора'} label={"Обращение губернатора"}
name="governor_appeal" name="governor_appeal"
/> />
<TextField <TextField
{...register('scale_min', { {...register("scale_min", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
})} })}
error={!!(errors as any)?.scale_min} error={!!(errors as any)?.scale_min}
helperText={(errors as any)?.scale_min?.message} helperText={(errors as any)?.scale_min?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="number" type="number"
label={'Масштаб (мин)'} label={"Масштаб (мин)"}
name="scale_min" name="scale_min"
/> />
<TextField <TextField
{...register('scale_max', { {...register("scale_max", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
})} })}
error={!!(errors as any)?.scale_max} error={!!(errors as any)?.scale_max}
helperText={(errors as any)?.scale_max?.message} helperText={(errors as any)?.scale_max?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="number" type="number"
label={'Масштаб (макс)'} label={"Масштаб (макс)"}
name="scale_max" name="scale_max"
/> />
<TextField <TextField
{...register('rotate', { {...register("rotate", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
})} })}
error={!!(errors as any)?.rotate} error={!!(errors as any)?.rotate}
helperText={(errors as any)?.rotate?.message} helperText={(errors as any)?.rotate?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="number" type="number"
label={'Поворот'} label={"Поворот"}
name="rotate" name="rotate"
/> />
<TextField <TextField
{...register('center_latitude', { {...register("center_latitude", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
})} })}
error={!!(errors as any)?.center_latitude} error={!!(errors as any)?.center_latitude}
helperText={(errors as any)?.center_latitude?.message} helperText={(errors as any)?.center_latitude?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="number" type="number"
label={'Центр. широта'} label={"Центр. широта"}
name="center_latitude" name="center_latitude"
/> />
<TextField <TextField
{...register('center_longitude', { {...register("center_longitude", {
// required: 'Это поле является обязательным', // required: 'Это поле является обязательным',
})} })}
error={!!(errors as any)?.center_longitude} error={!!(errors as any)?.center_longitude}
helperText={(errors as any)?.center_longitude?.message} helperText={(errors as any)?.center_longitude?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="number" type="number"
label={'Центр. долгота'} label={"Центр. долгота"}
name="center_longitude" name="center_longitude"
/> />
</Box> </Box>
</Create> </Create>
) );
} };

View File

@ -1,191 +1,248 @@
import {Autocomplete, Box, TextField, Typography, Paper} from '@mui/material' import { Autocomplete, Box, TextField, Typography, Paper } from "@mui/material";
import {Create, useAutocomplete} from '@refinedev/mui' import { Create, useAutocomplete } from "@refinedev/mui";
import {useForm} from '@refinedev/react-hook-form' import { useForm } from "@refinedev/react-hook-form";
import {Controller} from 'react-hook-form' import { Controller } from "react-hook-form";
import {Link} from 'react-router' import { Link } from "react-router";
import React, {useState, useEffect} from 'react' import React, { useState, useEffect } from "react";
import {TOKEN_KEY} from '../../authProvider' import { TOKEN_KEY } from "../../authProvider";
export const SightCreate = () => { export const SightCreate = () => {
const { const {
saveButtonProps, saveButtonProps,
refineCore: {formLoading}, refineCore: { formLoading },
register, register,
control, control,
watch, watch,
formState: {errors}, formState: { errors },
} = useForm({ } = useForm({
refineCoreProps: { refineCoreProps: {
resource: 'sight/', resource: "sight/",
}, },
}) });
// Состояния для предпросмотра // Состояния для предпросмотра
const [namePreview, setNamePreview] = useState('') const [namePreview, setNamePreview] = useState("");
const [coordinatesPreview, setCoordinatesPreview] = useState({latitude: '', longitude: ''}) const [coordinatesPreview, setCoordinatesPreview] = useState({
const [cityPreview, setCityPreview] = useState('') latitude: "",
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null) longitude: "",
const [watermarkLUPreview, setWatermarkLUPreview] = useState<string | null>(null) });
const [watermarkRDPreview, setWatermarkRDPreview] = useState<string | null>(null) const [cityPreview, setCityPreview] = useState("");
const [leftArticlePreview, setLeftArticlePreview] = useState('') const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
const [previewArticlePreview, setPreviewArticlePreview] = useState('') const [watermarkLUPreview, setWatermarkLUPreview] = useState<string | null>(
null
);
const [watermarkRDPreview, setWatermarkRDPreview] = useState<string | null>(
null
);
const [leftArticlePreview, setLeftArticlePreview] = useState("");
const [previewArticlePreview, setPreviewArticlePreview] = useState("");
// Автокомплиты // Автокомплиты
const {autocompleteProps: cityAutocompleteProps} = useAutocomplete({ const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({
resource: 'city', resource: "city",
onSearch: (value) => [ onSearch: (value) => [
{ {
field: 'name', field: "name",
operator: 'contains', operator: "contains",
value, value,
}, },
], ],
}) });
const {autocompleteProps: mediaAutocompleteProps} = useAutocomplete({ const { autocompleteProps: mediaAutocompleteProps } = useAutocomplete({
resource: 'media', resource: "media",
onSearch: (value) => [ onSearch: (value) => [
{ {
field: 'media_name', field: "media_name",
operator: 'contains', operator: "contains",
value, value,
}, },
], ],
}) });
const {autocompleteProps: articleAutocompleteProps} = useAutocomplete({ const { autocompleteProps: articleAutocompleteProps } = useAutocomplete({
resource: 'article', resource: "article",
onSearch: (value) => [ onSearch: (value) => [
{ {
field: 'heading', field: "heading",
operator: 'contains', operator: "contains",
value, value,
}, },
], ],
}) });
// Следим за изменениями во всех полях // Следим за изменениями во всех полях
const nameContent = watch('name') const nameContent = watch("name");
const latitudeContent = watch('latitude') const latitudeContent = watch("latitude");
const longitudeContent = watch('longitude') const longitudeContent = watch("longitude");
const cityContent = watch('city_id') const cityContent = watch("city_id");
const thumbnailContent = watch('thumbnail') const thumbnailContent = watch("thumbnail");
const watermarkLUContent = watch('watermark_lu') const watermarkLUContent = watch("watermark_lu");
const watermarkRDContent = watch('watermark_rd') const watermarkRDContent = watch("watermark_rd");
const leftArticleContent = watch('left_article') const leftArticleContent = watch("left_article");
const previewArticleContent = watch('preview_article') const previewArticleContent = watch("preview_article");
// Обновляем состояния при изменении полей // Обновляем состояния при изменении полей
useEffect(() => { useEffect(() => {
setNamePreview(nameContent || '') setNamePreview(nameContent || "");
}, [nameContent]) }, [nameContent]);
useEffect(() => { useEffect(() => {
setCoordinatesPreview({ setCoordinatesPreview({
latitude: latitudeContent || '', latitude: latitudeContent || "",
longitude: longitudeContent || '', longitude: longitudeContent || "",
}) });
}, [latitudeContent, longitudeContent]) }, [latitudeContent, longitudeContent]);
useEffect(() => { useEffect(() => {
const selectedCity = cityAutocompleteProps.options.find((option) => option.id === cityContent) const selectedCity = cityAutocompleteProps.options.find(
setCityPreview(selectedCity?.name || '') (option) => option.id === cityContent
}, [cityContent, cityAutocompleteProps.options]) );
setCityPreview(selectedCity?.name || "");
}, [cityContent, cityAutocompleteProps.options]);
useEffect(() => { useEffect(() => {
const selectedThumbnail = mediaAutocompleteProps.options.find((option) => option.id === thumbnailContent) const selectedThumbnail = mediaAutocompleteProps.options.find(
setThumbnailPreview(selectedThumbnail ? `https://wn.krbl.ru/media/${selectedThumbnail.id}/download?token=${localStorage.getItem(TOKEN_KEY)}` : null) (option) => option.id === thumbnailContent
}, [thumbnailContent, mediaAutocompleteProps.options]) );
setThumbnailPreview(
selectedThumbnail
? `${import.meta.env.VITE_KRBL_MEDIA}${
selectedThumbnail.id
}/download?token=${localStorage.getItem(TOKEN_KEY)}`
: null
);
}, [thumbnailContent, mediaAutocompleteProps.options]);
useEffect(() => { useEffect(() => {
const selectedWatermarkLU = mediaAutocompleteProps.options.find((option) => option.id === watermarkLUContent) const selectedWatermarkLU = mediaAutocompleteProps.options.find(
setWatermarkLUPreview(selectedWatermarkLU ? `https://wn.krbl.ru/media/${selectedWatermarkLU.id}/download?token=${localStorage.getItem(TOKEN_KEY)}` : null) (option) => option.id === watermarkLUContent
}, [watermarkLUContent, mediaAutocompleteProps.options]) );
setWatermarkLUPreview(
selectedWatermarkLU
? `${import.meta.env.VITE_KRBL_MEDIA}${
selectedWatermarkLU.id
}/download?token=${localStorage.getItem(TOKEN_KEY)}`
: null
);
}, [watermarkLUContent, mediaAutocompleteProps.options]);
useEffect(() => { useEffect(() => {
const selectedWatermarkRD = mediaAutocompleteProps.options.find((option) => option.id === watermarkRDContent) const selectedWatermarkRD = mediaAutocompleteProps.options.find(
setWatermarkRDPreview(selectedWatermarkRD ? `https://wn.krbl.ru/media/${selectedWatermarkRD.id}/download?token=${localStorage.getItem(TOKEN_KEY)}` : null) (option) => option.id === watermarkRDContent
}, [watermarkRDContent, mediaAutocompleteProps.options]) );
setWatermarkRDPreview(
selectedWatermarkRD
? `${import.meta.env.VITE_KRBL_MEDIA}${
selectedWatermarkRD.id
}/download?token=${localStorage.getItem(TOKEN_KEY)}`
: null
);
}, [watermarkRDContent, mediaAutocompleteProps.options]);
useEffect(() => { useEffect(() => {
const selectedLeftArticle = articleAutocompleteProps.options.find((option) => option.id === leftArticleContent) const selectedLeftArticle = articleAutocompleteProps.options.find(
setLeftArticlePreview(selectedLeftArticle?.heading || '') (option) => option.id === leftArticleContent
}, [leftArticleContent, articleAutocompleteProps.options]) );
setLeftArticlePreview(selectedLeftArticle?.heading || "");
}, [leftArticleContent, articleAutocompleteProps.options]);
useEffect(() => { useEffect(() => {
const selectedPreviewArticle = articleAutocompleteProps.options.find((option) => option.id === previewArticleContent) const selectedPreviewArticle = articleAutocompleteProps.options.find(
setPreviewArticlePreview(selectedPreviewArticle?.heading || '') (option) => option.id === previewArticleContent
}, [previewArticleContent, articleAutocompleteProps.options]) );
setPreviewArticlePreview(selectedPreviewArticle?.heading || "");
}, [previewArticleContent, articleAutocompleteProps.options]);
return ( return (
<Create isLoading={formLoading} saveButtonProps={saveButtonProps}> <Create isLoading={formLoading} saveButtonProps={saveButtonProps}>
<Box sx={{display: 'flex', gap: 2}}> <Box sx={{ display: "flex", gap: 2 }}>
{/* Форма создания */} {/* Форма создания */}
<Box component="form" sx={{flex: 1, display: 'flex', flexDirection: 'column'}} autoComplete="off"> <Box
component="form"
sx={{ flex: 1, display: "flex", flexDirection: "column" }}
autoComplete="off"
>
<TextField <TextField
{...register('name', { {...register("name", {
required: 'Это поле является обязательным', required: "Это поле является обязательным",
})} })}
error={!!(errors as any)?.name} error={!!(errors as any)?.name}
helperText={(errors as any)?.name?.message} helperText={(errors as any)?.name?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="text" type="text"
label={'Название *'} label={"Название *"}
name="name" name="name"
/> />
<TextField <TextField
{...register('latitude', { {...register("latitude", {
required: 'Это поле является обязательным', required: "Это поле является обязательным",
valueAsNumber: true, valueAsNumber: true,
})} })}
error={!!(errors as any)?.latitude} error={!!(errors as any)?.latitude}
helperText={(errors as any)?.latitude?.message} helperText={(errors as any)?.latitude?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="number" type="number"
label={'Широта *'} label={"Широта *"}
name="latitude" name="latitude"
/> />
<TextField <TextField
{...register('longitude', { {...register("longitude", {
required: 'Это поле является обязательным', required: "Это поле является обязательным",
valueAsNumber: true, valueAsNumber: true,
})} })}
error={!!(errors as any)?.longitude} error={!!(errors as any)?.longitude}
helperText={(errors as any)?.longitude?.message} helperText={(errors as any)?.longitude?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="number" type="number"
label={'Долгота *'} label={"Долгота *"}
name="longitude" name="longitude"
/> />
<Controller <Controller
control={control} control={control}
name="city_id" name="city_id"
rules={{required: 'Это поле является обязательным'}} rules={{ required: "Это поле является обязательным" }}
defaultValue={null} defaultValue={null}
render={({field}) => ( render={({ field }) => (
<Autocomplete <Autocomplete
{...cityAutocompleteProps} {...cityAutocompleteProps}
value={cityAutocompleteProps.options.find((option) => option.id === field.value) || null} value={
cityAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => { onChange={(_, value) => {
field.onChange(value?.id || '') field.onChange(value?.id || "");
}} }}
getOptionLabel={(item) => { getOptionLabel={(item) => {
return item ? item.name : '' return item ? item.name : "";
}} }}
isOptionEqualToValue={(option, value) => { isOptionEqualToValue={(option, value) => {
return option.id === value?.id return option.id === value?.id;
}} }}
filterOptions={(options, {inputValue}) => { filterOptions={(options, { inputValue }) => {
return options.filter((option) => option.name.toLowerCase().includes(inputValue.toLowerCase())) return options.filter((option) =>
option.name.toLowerCase().includes(inputValue.toLowerCase())
);
}} }}
renderInput={(params) => <TextField {...params} label="Выберите город" margin="normal" variant="outlined" error={!!errors.city_id} helperText={(errors as any)?.city_id?.message} required />} renderInput={(params) => (
<TextField
{...params}
label="Выберите город"
margin="normal"
variant="outlined"
error={!!errors.city_id}
helperText={(errors as any)?.city_id?.message}
required
/>
)}
/> />
)} )}
/> />
@ -194,23 +251,41 @@ export const SightCreate = () => {
control={control} control={control}
name="thumbnail" name="thumbnail"
defaultValue={null} defaultValue={null}
render={({field}) => ( render={({ field }) => (
<Autocomplete <Autocomplete
{...mediaAutocompleteProps} {...mediaAutocompleteProps}
value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null} value={
mediaAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => { onChange={(_, value) => {
field.onChange(value?.id || '') field.onChange(value?.id || "");
}} }}
getOptionLabel={(item) => { getOptionLabel={(item) => {
return item ? item.media_name : '' return item ? item.media_name : "";
}} }}
isOptionEqualToValue={(option, value) => { isOptionEqualToValue={(option, value) => {
return option.id === value?.id return option.id === value?.id;
}} }}
filterOptions={(options, {inputValue}) => { filterOptions={(options, { inputValue }) => {
return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase())) return options.filter((option) =>
option.media_name
.toLowerCase()
.includes(inputValue.toLowerCase())
);
}} }}
renderInput={(params) => <TextField {...params} label="Выберите обложку" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />} renderInput={(params) => (
<TextField
{...params}
label="Выберите обложку"
margin="normal"
variant="outlined"
error={!!errors.arms}
helperText={(errors as any)?.arms?.message}
required
/>
)}
/> />
)} )}
/> />
@ -219,23 +294,41 @@ export const SightCreate = () => {
control={control} control={control}
name="watermark_lu" name="watermark_lu"
defaultValue={null} defaultValue={null}
render={({field}) => ( render={({ field }) => (
<Autocomplete <Autocomplete
{...mediaAutocompleteProps} {...mediaAutocompleteProps}
value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null} value={
mediaAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => { onChange={(_, value) => {
field.onChange(value?.id || '') field.onChange(value?.id || "");
}} }}
getOptionLabel={(item) => { getOptionLabel={(item) => {
return item ? item.media_name : '' return item ? item.media_name : "";
}} }}
isOptionEqualToValue={(option, value) => { isOptionEqualToValue={(option, value) => {
return option.id === value?.id return option.id === value?.id;
}} }}
filterOptions={(options, {inputValue}) => { filterOptions={(options, { inputValue }) => {
return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase())) return options.filter((option) =>
option.media_name
.toLowerCase()
.includes(inputValue.toLowerCase())
);
}} }}
renderInput={(params) => <TextField {...params} label="Выберите водный знак (Левый верх)" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />} renderInput={(params) => (
<TextField
{...params}
label="Выберите водный знак (Левый верх)"
margin="normal"
variant="outlined"
error={!!errors.arms}
helperText={(errors as any)?.arms?.message}
required
/>
)}
/> />
)} )}
/> />
@ -244,23 +337,41 @@ export const SightCreate = () => {
control={control} control={control}
name="watermark_rd" name="watermark_rd"
defaultValue={null} defaultValue={null}
render={({field}) => ( render={({ field }) => (
<Autocomplete <Autocomplete
{...mediaAutocompleteProps} {...mediaAutocompleteProps}
value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null} value={
mediaAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => { onChange={(_, value) => {
field.onChange(value?.id || '') field.onChange(value?.id || "");
}} }}
getOptionLabel={(item) => { getOptionLabel={(item) => {
return item ? item.media_name : '' return item ? item.media_name : "";
}} }}
isOptionEqualToValue={(option, value) => { isOptionEqualToValue={(option, value) => {
return option.id === value?.id return option.id === value?.id;
}} }}
filterOptions={(options, {inputValue}) => { filterOptions={(options, { inputValue }) => {
return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase())) return options.filter((option) =>
option.media_name
.toLowerCase()
.includes(inputValue.toLowerCase())
);
}} }}
renderInput={(params) => <TextField {...params} label="Выберите водный знак (Правый низ)" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />} renderInput={(params) => (
<TextField
{...params}
label="Выберите водный знак (Правый низ)"
margin="normal"
variant="outlined"
error={!!errors.arms}
helperText={(errors as any)?.arms?.message}
required
/>
)}
/> />
)} )}
/> />
@ -269,23 +380,41 @@ export const SightCreate = () => {
control={control} control={control}
name="left_article" name="left_article"
defaultValue={null} defaultValue={null}
render={({field}) => ( render={({ field }) => (
<Autocomplete <Autocomplete
{...articleAutocompleteProps} {...articleAutocompleteProps}
value={articleAutocompleteProps.options.find((option) => option.id === field.value) || null} value={
articleAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => { onChange={(_, value) => {
field.onChange(value?.id || '') field.onChange(value?.id || "");
}} }}
getOptionLabel={(item) => { getOptionLabel={(item) => {
return item ? item.heading : '' return item ? item.heading : "";
}} }}
isOptionEqualToValue={(option, value) => { isOptionEqualToValue={(option, value) => {
return option.id === value?.id return option.id === value?.id;
}} }}
filterOptions={(options, {inputValue}) => { filterOptions={(options, { inputValue }) => {
return options.filter((option) => option.heading.toLowerCase().includes(inputValue.toLowerCase())) return options.filter((option) =>
option.heading
.toLowerCase()
.includes(inputValue.toLowerCase())
);
}} }}
renderInput={(params) => <TextField {...params} label="Левая статья" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />} renderInput={(params) => (
<TextField
{...params}
label="Левая статья"
margin="normal"
variant="outlined"
error={!!errors.arms}
helperText={(errors as any)?.arms?.message}
required
/>
)}
/> />
)} )}
/> />
@ -294,23 +423,41 @@ export const SightCreate = () => {
control={control} control={control}
name="preview_article" name="preview_article"
defaultValue={null} defaultValue={null}
render={({field}) => ( render={({ field }) => (
<Autocomplete <Autocomplete
{...articleAutocompleteProps} {...articleAutocompleteProps}
value={articleAutocompleteProps.options.find((option) => option.id === field.value) || null} value={
articleAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => { onChange={(_, value) => {
field.onChange(value?.id || '') field.onChange(value?.id || "");
}} }}
getOptionLabel={(item) => { getOptionLabel={(item) => {
return item ? item.heading : '' return item ? item.heading : "";
}} }}
isOptionEqualToValue={(option, value) => { isOptionEqualToValue={(option, value) => {
return option.id === value?.id return option.id === value?.id;
}} }}
filterOptions={(options, {inputValue}) => { filterOptions={(options, { inputValue }) => {
return options.filter((option) => option.heading.toLowerCase().includes(inputValue.toLowerCase())) return options.filter((option) =>
option.heading
.toLowerCase()
.includes(inputValue.toLowerCase())
);
}} }}
renderInput={(params) => <TextField {...params} label="Cтатья-предпросмотр" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />} renderInput={(params) => (
<TextField
{...params}
label="Cтатья-предпросмотр"
margin="normal"
variant="outlined"
error={!!errors.arms}
helperText={(errors as any)?.arms?.message}
required
/>
)}
/> />
)} )}
/> />
@ -321,14 +468,15 @@ export const SightCreate = () => {
sx={{ sx={{
flex: 1, flex: 1,
p: 2, p: 2,
maxHeight: 'calc(100vh - 200px)', maxHeight: "calc(100vh - 200px)",
overflowY: 'auto', overflowY: "auto",
position: 'sticky', position: "sticky",
top: 16, top: 16,
borderRadius: 2, borderRadius: 2,
border: '1px solid', border: "1px solid",
borderColor: 'primary.main', borderColor: "primary.main",
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : '#fff'), bgcolor: (theme) =>
theme.palette.mode === "dark" ? "background.paper" : "#fff",
}} }}
> >
<Typography variant="h6" gutterBottom color="primary"> <Typography variant="h6" gutterBottom color="primary">
@ -336,34 +484,57 @@ export const SightCreate = () => {
</Typography> </Typography>
{/* Название */} {/* Название */}
<Typography variant="h4" gutterBottom sx={{color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800')}}> <Typography
variant="h4"
gutterBottom
sx={{
color: (theme) =>
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
}}
>
{namePreview} {namePreview}
</Typography> </Typography>
{/* Город */} {/* Город */}
<Typography variant="body1" sx={{mb: 2}}> <Typography variant="body1" sx={{ mb: 2 }}>
<Box component="span" sx={{color: 'text.secondary'}}> <Box component="span" sx={{ color: "text.secondary" }}>
Город:{' '} Город:{" "}
</Box> </Box>
<Box component="span" sx={{color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800')}}> <Box
component="span"
sx={{
color: (theme) =>
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
}}
>
{cityPreview} {cityPreview}
</Box> </Box>
</Typography> </Typography>
{/* Координаты */} {/* Координаты */}
<Typography variant="body1" sx={{mb: 2}}> <Typography variant="body1" sx={{ mb: 2 }}>
<Box component="span" sx={{color: 'text.secondary'}}> <Box component="span" sx={{ color: "text.secondary" }}>
Координаты:{' '} Координаты:{" "}
</Box> </Box>
<Box component="span" sx={{color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800')}}> <Box
component="span"
sx={{
color: (theme) =>
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
}}
>
{coordinatesPreview.latitude}, {coordinatesPreview.longitude} {coordinatesPreview.latitude}, {coordinatesPreview.longitude}
</Box> </Box>
</Typography> </Typography>
{/* Обложка */} {/* Обложка */}
{thumbnailPreview && ( {thumbnailPreview && (
<Box sx={{mb: 2}}> <Box sx={{ mb: 2 }}>
<Typography variant="body1" gutterBottom sx={{color: 'text.secondary'}}> <Typography
variant="body1"
gutterBottom
sx={{ color: "text.secondary" }}
>
Обложка: Обложка:
</Typography> </Typography>
<Box <Box
@ -371,25 +542,33 @@ export const SightCreate = () => {
src={thumbnailPreview} src={thumbnailPreview}
alt="Обложка" alt="Обложка"
sx={{ sx={{
maxWidth: '100%', maxWidth: "100%",
height: 'auto', height: "auto",
borderRadius: 1, borderRadius: 1,
border: '1px solid', border: "1px solid",
borderColor: 'primary.main', borderColor: "primary.main",
}} }}
/> />
</Box> </Box>
)} )}
{/* Водяные знаки */} {/* Водяные знаки */}
<Box sx={{mb: 2}}> <Box sx={{ mb: 2 }}>
<Typography variant="body1" gutterBottom sx={{color: 'text.secondary'}}> <Typography
variant="body1"
gutterBottom
sx={{ color: "text.secondary" }}
>
Водяные знаки: Водяные знаки:
</Typography> </Typography>
<Box sx={{display: 'flex', gap: 2}}> <Box sx={{ display: "flex", gap: 2 }}>
{watermarkLUPreview && ( {watermarkLUPreview && (
<Box> <Box>
<Typography variant="body2" gutterBottom sx={{color: 'text.secondary'}}> <Typography
variant="body2"
gutterBottom
sx={{ color: "text.secondary" }}
>
Левый верхний: Левый верхний:
</Typography> </Typography>
<Box <Box
@ -399,17 +578,21 @@ export const SightCreate = () => {
sx={{ sx={{
width: 100, width: 100,
height: 100, height: 100,
objectFit: 'cover', objectFit: "cover",
borderRadius: 1, borderRadius: 1,
border: '1px solid', border: "1px solid",
borderColor: 'primary.main', borderColor: "primary.main",
}} }}
/> />
</Box> </Box>
)} )}
{watermarkRDPreview && ( {watermarkRDPreview && (
<Box> <Box>
<Typography variant="body2" gutterBottom sx={{color: 'text.secondary'}}> <Typography
variant="body2"
gutterBottom
sx={{ color: "text.secondary" }}
>
Правый нижний: Правый нижний:
</Typography> </Typography>
<Box <Box
@ -419,10 +602,10 @@ export const SightCreate = () => {
sx={{ sx={{
width: 100, width: 100,
height: 100, height: 100,
objectFit: 'cover', objectFit: "cover",
borderRadius: 1, borderRadius: 1,
border: '1px solid', border: "1px solid",
borderColor: 'primary.main', borderColor: "primary.main",
}} }}
/> />
</Box> </Box>
@ -432,22 +615,27 @@ export const SightCreate = () => {
{/* Связанные статьи */} {/* Связанные статьи */}
<Box> <Box>
<Typography variant="body1" gutterBottom sx={{color: 'text.secondary'}}> <Typography
variant="body1"
gutterBottom
sx={{ color: "text.secondary" }}
>
Связанные статьи: Связанные статьи:
</Typography> </Typography>
{leftArticlePreview && ( {leftArticlePreview && (
<Typography variant="body1" gutterBottom> <Typography variant="body1" gutterBottom>
<Box component="span" sx={{color: 'text.secondary'}}> <Box component="span" sx={{ color: "text.secondary" }}>
Левая статья:{' '} Левая статья:{" "}
</Box> </Box>
<Box <Box
component={Link} component={Link}
to={`/article/show/${watch('left_article')}`} to={`/article/show/${watch("left_article")}`}
sx={{ sx={{
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'), color: (theme) =>
textDecoration: 'none', theme.palette.mode === "dark" ? "grey.300" : "grey.800",
'&:hover': { textDecoration: "none",
textDecoration: 'underline', "&:hover": {
textDecoration: "underline",
}, },
}} }}
> >
@ -457,17 +645,18 @@ export const SightCreate = () => {
)} )}
{previewArticlePreview && ( {previewArticlePreview && (
<Typography variant="body1" gutterBottom> <Typography variant="body1" gutterBottom>
<Box component="span" sx={{color: 'text.secondary'}}> <Box component="span" sx={{ color: "text.secondary" }}>
Статья-предпросмотр:{' '} Статья-предпросмотр:{" "}
</Box> </Box>
<Box <Box
component={Link} component={Link}
to={`/article/show/${watch('preview_article')}`} to={`/article/show/${watch("preview_article")}`}
sx={{ sx={{
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'), color: (theme) =>
textDecoration: 'none', theme.palette.mode === "dark" ? "grey.300" : "grey.800",
'&:hover': { textDecoration: "none",
textDecoration: 'underline', "&:hover": {
textDecoration: "underline",
}, },
}} }}
> >
@ -479,5 +668,5 @@ export const SightCreate = () => {
</Paper> </Paper>
</Box> </Box>
</Create> </Create>
) );
} };

View File

@ -1,194 +1,248 @@
import {Autocomplete, Box, TextField, Paper, Typography} from '@mui/material' import { Autocomplete, Box, TextField, Paper, Typography } from "@mui/material";
import {Edit, useAutocomplete} from '@refinedev/mui' import { Edit, useAutocomplete } from "@refinedev/mui";
import {useForm} from '@refinedev/react-hook-form' import { useForm } from "@refinedev/react-hook-form";
import {Controller} from 'react-hook-form' import { Controller } from "react-hook-form";
import {useParams} from 'react-router' import { useParams } from "react-router";
import React, {useState, useEffect} from 'react' import React, { useState, useEffect } from "react";
import {LinkedItems} from '../../components/LinkedItems' import { LinkedItems } from "../../components/LinkedItems";
import {CreateSightArticle} from '../../components/CreateSightArticle' import { CreateSightArticle } from "../../components/CreateSightArticle";
import {ArticleItem, articleFields} from './types' import { ArticleItem, articleFields } from "./types";
import {TOKEN_KEY} from '../../authProvider' import { TOKEN_KEY } from "../../authProvider";
import {Link} from 'react-router' import { Link } from "react-router";
export const SightEdit = () => { export const SightEdit = () => {
const {id: sightId} = useParams<{id: string}>() const { id: sightId } = useParams<{ id: string }>();
const { const {
saveButtonProps, saveButtonProps,
register, register,
control, control,
watch, watch,
formState: {errors}, formState: { errors },
} = useForm({}) } = useForm({});
const {autocompleteProps: cityAutocompleteProps} = useAutocomplete({ const { autocompleteProps: cityAutocompleteProps } = useAutocomplete({
resource: 'city', resource: "city",
onSearch: (value) => [ onSearch: (value) => [
{ {
field: 'name', field: "name",
operator: 'contains', operator: "contains",
value, value,
}, },
], ],
}) });
const {autocompleteProps: mediaAutocompleteProps} = useAutocomplete({ const { autocompleteProps: mediaAutocompleteProps } = useAutocomplete({
resource: 'media', resource: "media",
onSearch: (value) => [ onSearch: (value) => [
{ {
field: 'media_name', field: "media_name",
operator: 'contains', operator: "contains",
value, value,
}, },
], ],
}) });
const {autocompleteProps: articleAutocompleteProps} = useAutocomplete({ const { autocompleteProps: articleAutocompleteProps } = useAutocomplete({
resource: 'article', resource: "article",
onSearch: (value) => [ onSearch: (value) => [
{ {
field: 'heading', field: "heading",
operator: 'contains', operator: "contains",
value, value,
}, },
], ],
}) });
// Состояния для предпросмотра // Состояния для предпросмотра
const [namePreview, setNamePreview] = useState('') const [namePreview, setNamePreview] = useState("");
const [coordinatesPreview, setCoordinatesPreview] = useState({ const [coordinatesPreview, setCoordinatesPreview] = useState({
latitude: '', latitude: "",
longitude: '', longitude: "",
}) });
const [cityPreview, setCityPreview] = useState('') const [cityPreview, setCityPreview] = useState("");
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null) const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
const [watermarkLUPreview, setWatermarkLUPreview] = useState<string | null>(null) const [watermarkLUPreview, setWatermarkLUPreview] = useState<string | null>(
const [watermarkRDPreview, setWatermarkRDPreview] = useState<string | null>(null) null
const [leftArticlePreview, setLeftArticlePreview] = useState('') );
const [previewArticlePreview, setPreviewArticlePreview] = useState('') const [watermarkRDPreview, setWatermarkRDPreview] = useState<string | null>(
null
);
const [leftArticlePreview, setLeftArticlePreview] = useState("");
const [previewArticlePreview, setPreviewArticlePreview] = useState("");
// Следим за изменениями во всех полях // Следим за изменениями во всех полях
const nameContent = watch('name') const nameContent = watch("name");
const latitudeContent = watch('latitude') const latitudeContent = watch("latitude");
const longitudeContent = watch('longitude') const longitudeContent = watch("longitude");
const cityContent = watch('city_id') const cityContent = watch("city_id");
const thumbnailContent = watch('thumbnail') const thumbnailContent = watch("thumbnail");
const watermarkLUContent = watch('watermark_lu') const watermarkLUContent = watch("watermark_lu");
const watermarkRDContent = watch('watermark_rd') const watermarkRDContent = watch("watermark_rd");
const leftArticleContent = watch('left_article') const leftArticleContent = watch("left_article");
const previewArticleContent = watch('preview_article') const previewArticleContent = watch("preview_article");
// Обновляем состояния при изменении полей // Обновляем состояния при изменении полей
useEffect(() => { useEffect(() => {
setNamePreview(nameContent || '') setNamePreview(nameContent || "");
}, [nameContent]) }, [nameContent]);
useEffect(() => { useEffect(() => {
setCoordinatesPreview({ setCoordinatesPreview({
latitude: latitudeContent || '', latitude: latitudeContent || "",
longitude: longitudeContent || '', longitude: longitudeContent || "",
}) });
}, [latitudeContent, longitudeContent]) }, [latitudeContent, longitudeContent]);
useEffect(() => { useEffect(() => {
const selectedCity = cityAutocompleteProps.options.find((option) => option.id === cityContent) const selectedCity = cityAutocompleteProps.options.find(
setCityPreview(selectedCity?.name || '') (option) => option.id === cityContent
}, [cityContent, cityAutocompleteProps.options]) );
setCityPreview(selectedCity?.name || "");
}, [cityContent, cityAutocompleteProps.options]);
useEffect(() => { useEffect(() => {
const selectedThumbnail = mediaAutocompleteProps.options.find((option) => option.id === thumbnailContent) const selectedThumbnail = mediaAutocompleteProps.options.find(
setThumbnailPreview(selectedThumbnail ? `https://wn.krbl.ru/media/${selectedThumbnail.id}/download?token=${localStorage.getItem(TOKEN_KEY)}` : null) (option) => option.id === thumbnailContent
}, [thumbnailContent, mediaAutocompleteProps.options]) );
setThumbnailPreview(
selectedThumbnail
? `${import.meta.env.VITE_KRBL_MEDIA}${
selectedThumbnail.id
}/download?token=${localStorage.getItem(TOKEN_KEY)}`
: null
);
}, [thumbnailContent, mediaAutocompleteProps.options]);
useEffect(() => { useEffect(() => {
const selectedWatermarkLU = mediaAutocompleteProps.options.find((option) => option.id === watermarkLUContent) const selectedWatermarkLU = mediaAutocompleteProps.options.find(
setWatermarkLUPreview(selectedWatermarkLU ? `https://wn.krbl.ru/media/${selectedWatermarkLU.id}/download?token=${localStorage.getItem(TOKEN_KEY)}` : null) (option) => option.id === watermarkLUContent
}, [watermarkLUContent, mediaAutocompleteProps.options]) );
setWatermarkLUPreview(
selectedWatermarkLU
? `${import.meta.env.VITE_KRBL_MEDIA}${
selectedWatermarkLU.id
}/download?token=${localStorage.getItem(TOKEN_KEY)}`
: null
);
}, [watermarkLUContent, mediaAutocompleteProps.options]);
useEffect(() => { useEffect(() => {
const selectedWatermarkRD = mediaAutocompleteProps.options.find((option) => option.id === watermarkRDContent) const selectedWatermarkRD = mediaAutocompleteProps.options.find(
setWatermarkRDPreview(selectedWatermarkRD ? `https://wn.krbl.ru/media/${selectedWatermarkRD.id}/download?token=${localStorage.getItem(TOKEN_KEY)}` : null) (option) => option.id === watermarkRDContent
}, [watermarkRDContent, mediaAutocompleteProps.options]) );
setWatermarkRDPreview(
selectedWatermarkRD
? `${import.meta.env.VITE_KRBL_MEDIA}${
selectedWatermarkRD.id
}/download?token=${localStorage.getItem(TOKEN_KEY)}`
: null
);
}, [watermarkRDContent, mediaAutocompleteProps.options]);
useEffect(() => { useEffect(() => {
const selectedLeftArticle = articleAutocompleteProps.options.find((option) => option.id === leftArticleContent) const selectedLeftArticle = articleAutocompleteProps.options.find(
setLeftArticlePreview(selectedLeftArticle?.heading || '') (option) => option.id === leftArticleContent
}, [leftArticleContent, articleAutocompleteProps.options]) );
setLeftArticlePreview(selectedLeftArticle?.heading || "");
}, [leftArticleContent, articleAutocompleteProps.options]);
useEffect(() => { useEffect(() => {
const selectedPreviewArticle = articleAutocompleteProps.options.find((option) => option.id === previewArticleContent) const selectedPreviewArticle = articleAutocompleteProps.options.find(
setPreviewArticlePreview(selectedPreviewArticle?.heading || '') (option) => option.id === previewArticleContent
}, [previewArticleContent, articleAutocompleteProps.options]) );
setPreviewArticlePreview(selectedPreviewArticle?.heading || "");
}, [previewArticleContent, articleAutocompleteProps.options]);
return ( return (
<Edit saveButtonProps={saveButtonProps}> <Edit saveButtonProps={saveButtonProps}>
<Box sx={{display: 'flex', gap: 2}}> <Box sx={{ display: "flex", gap: 2 }}>
{/* Форма редактирования */} {/* Форма редактирования */}
<Box component="form" sx={{flex: 1, display: 'flex', flexDirection: 'column'}} autoComplete="off"> <Box
component="form"
sx={{ flex: 1, display: "flex", flexDirection: "column" }}
autoComplete="off"
>
<TextField <TextField
{...register('name', { {...register("name", {
required: 'Это поле является обязательным', required: "Это поле является обязательным",
})} })}
error={!!(errors as any)?.name} error={!!(errors as any)?.name}
helperText={(errors as any)?.name?.message} helperText={(errors as any)?.name?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="text" type="text"
label={'Название *'} label={"Название *"}
name="name" name="name"
/> />
<TextField <TextField
{...register('latitude', { {...register("latitude", {
required: 'Это поле является обязательным', required: "Это поле является обязательным",
valueAsNumber: true, valueAsNumber: true,
})} })}
error={!!(errors as any)?.latitude} error={!!(errors as any)?.latitude}
helperText={(errors as any)?.latitude?.message} helperText={(errors as any)?.latitude?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="number" type="number"
label={'Широта *'} label={"Широта *"}
name="latitude" name="latitude"
/> />
<TextField <TextField
{...register('longitude', { {...register("longitude", {
required: 'Это поле является обязательным', required: "Это поле является обязательным",
valueAsNumber: true, valueAsNumber: true,
})} })}
error={!!(errors as any)?.longitude} error={!!(errors as any)?.longitude}
helperText={(errors as any)?.longitude?.message} helperText={(errors as any)?.longitude?.message}
margin="normal" margin="normal"
fullWidth fullWidth
InputLabelProps={{shrink: true}} InputLabelProps={{ shrink: true }}
type="number" type="number"
label={'Долгота *'} label={"Долгота *"}
name="longitude" name="longitude"
/> />
<Controller <Controller
control={control} control={control}
name="city_id" name="city_id"
rules={{required: 'Это поле является обязательным'}} rules={{ required: "Это поле является обязательным" }}
defaultValue={null} defaultValue={null}
render={({field}) => ( render={({ field }) => (
<Autocomplete <Autocomplete
{...cityAutocompleteProps} {...cityAutocompleteProps}
value={cityAutocompleteProps.options.find((option) => option.id === field.value) || null} value={
cityAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => { onChange={(_, value) => {
field.onChange(value?.id || '') field.onChange(value?.id || "");
}} }}
getOptionLabel={(item) => { getOptionLabel={(item) => {
return item ? item.name : '' return item ? item.name : "";
}} }}
isOptionEqualToValue={(option, value) => { isOptionEqualToValue={(option, value) => {
return option.id === value?.id return option.id === value?.id;
}} }}
filterOptions={(options, {inputValue}) => { filterOptions={(options, { inputValue }) => {
return options.filter((option) => option.name.toLowerCase().includes(inputValue.toLowerCase())) return options.filter((option) =>
option.name.toLowerCase().includes(inputValue.toLowerCase())
);
}} }}
renderInput={(params) => <TextField {...params} label="Выберите город" margin="normal" variant="outlined" error={!!errors.city_id} helperText={(errors as any)?.city_id?.message} required />} renderInput={(params) => (
<TextField
{...params}
label="Выберите город"
margin="normal"
variant="outlined"
error={!!errors.city_id}
helperText={(errors as any)?.city_id?.message}
required
/>
)}
/> />
)} )}
/> />
@ -197,23 +251,41 @@ export const SightEdit = () => {
control={control} control={control}
name="thumbnail" name="thumbnail"
defaultValue={null} defaultValue={null}
render={({field}) => ( render={({ field }) => (
<Autocomplete <Autocomplete
{...mediaAutocompleteProps} {...mediaAutocompleteProps}
value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null} value={
mediaAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => { onChange={(_, value) => {
field.onChange(value?.id || '') field.onChange(value?.id || "");
}} }}
getOptionLabel={(item) => { getOptionLabel={(item) => {
return item ? item.media_name : '' return item ? item.media_name : "";
}} }}
isOptionEqualToValue={(option, value) => { isOptionEqualToValue={(option, value) => {
return option.id === value?.id return option.id === value?.id;
}} }}
filterOptions={(options, {inputValue}) => { filterOptions={(options, { inputValue }) => {
return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase())) return options.filter((option) =>
option.media_name
.toLowerCase()
.includes(inputValue.toLowerCase())
);
}} }}
renderInput={(params) => <TextField {...params} label="Выберите обложку" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />} renderInput={(params) => (
<TextField
{...params}
label="Выберите обложку"
margin="normal"
variant="outlined"
error={!!errors.arms}
helperText={(errors as any)?.arms?.message}
required
/>
)}
/> />
)} )}
/> />
@ -222,23 +294,41 @@ export const SightEdit = () => {
control={control} control={control}
name="watermark_lu" name="watermark_lu"
defaultValue={null} defaultValue={null}
render={({field}) => ( render={({ field }) => (
<Autocomplete <Autocomplete
{...mediaAutocompleteProps} {...mediaAutocompleteProps}
value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null} value={
mediaAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => { onChange={(_, value) => {
field.onChange(value?.id || '') field.onChange(value?.id || "");
}} }}
getOptionLabel={(item) => { getOptionLabel={(item) => {
return item ? item.media_name : '' return item ? item.media_name : "";
}} }}
isOptionEqualToValue={(option, value) => { isOptionEqualToValue={(option, value) => {
return option.id === value?.id return option.id === value?.id;
}} }}
filterOptions={(options, {inputValue}) => { filterOptions={(options, { inputValue }) => {
return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase())) return options.filter((option) =>
option.media_name
.toLowerCase()
.includes(inputValue.toLowerCase())
);
}} }}
renderInput={(params) => <TextField {...params} label="Выберите водный знак (Левый верх)" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />} renderInput={(params) => (
<TextField
{...params}
label="Выберите водный знак (Левый верх)"
margin="normal"
variant="outlined"
error={!!errors.arms}
helperText={(errors as any)?.arms?.message}
required
/>
)}
/> />
)} )}
/> />
@ -247,23 +337,41 @@ export const SightEdit = () => {
control={control} control={control}
name="watermark_rd" name="watermark_rd"
defaultValue={null} defaultValue={null}
render={({field}) => ( render={({ field }) => (
<Autocomplete <Autocomplete
{...mediaAutocompleteProps} {...mediaAutocompleteProps}
value={mediaAutocompleteProps.options.find((option) => option.id === field.value) || null} value={
mediaAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => { onChange={(_, value) => {
field.onChange(value?.id || '') field.onChange(value?.id || "");
}} }}
getOptionLabel={(item) => { getOptionLabel={(item) => {
return item ? item.media_name : '' return item ? item.media_name : "";
}} }}
isOptionEqualToValue={(option, value) => { isOptionEqualToValue={(option, value) => {
return option.id === value?.id return option.id === value?.id;
}} }}
filterOptions={(options, {inputValue}) => { filterOptions={(options, { inputValue }) => {
return options.filter((option) => option.media_name.toLowerCase().includes(inputValue.toLowerCase())) return options.filter((option) =>
option.media_name
.toLowerCase()
.includes(inputValue.toLowerCase())
);
}} }}
renderInput={(params) => <TextField {...params} label="Выберите водный знак (Правый низ)" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />} renderInput={(params) => (
<TextField
{...params}
label="Выберите водный знак (Правый низ)"
margin="normal"
variant="outlined"
error={!!errors.arms}
helperText={(errors as any)?.arms?.message}
required
/>
)}
/> />
)} )}
/> />
@ -272,23 +380,41 @@ export const SightEdit = () => {
control={control} control={control}
name="left_article" name="left_article"
defaultValue={null} defaultValue={null}
render={({field}) => ( render={({ field }) => (
<Autocomplete <Autocomplete
{...articleAutocompleteProps} {...articleAutocompleteProps}
value={articleAutocompleteProps.options.find((option) => option.id === field.value) || null} value={
articleAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => { onChange={(_, value) => {
field.onChange(value?.id || '') field.onChange(value?.id || "");
}} }}
getOptionLabel={(item) => { getOptionLabel={(item) => {
return item ? item.heading : '' return item ? item.heading : "";
}} }}
isOptionEqualToValue={(option, value) => { isOptionEqualToValue={(option, value) => {
return option.id === value?.id return option.id === value?.id;
}} }}
filterOptions={(options, {inputValue}) => { filterOptions={(options, { inputValue }) => {
return options.filter((option) => option.heading.toLowerCase().includes(inputValue.toLowerCase())) return options.filter((option) =>
option.heading
.toLowerCase()
.includes(inputValue.toLowerCase())
);
}} }}
renderInput={(params) => <TextField {...params} label="Левая статья" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />} renderInput={(params) => (
<TextField
{...params}
label="Левая статья"
margin="normal"
variant="outlined"
error={!!errors.arms}
helperText={(errors as any)?.arms?.message}
required
/>
)}
/> />
)} )}
/> />
@ -297,23 +423,41 @@ export const SightEdit = () => {
control={control} control={control}
name="preview_article" name="preview_article"
defaultValue={null} defaultValue={null}
render={({field}) => ( render={({ field }) => (
<Autocomplete <Autocomplete
{...articleAutocompleteProps} {...articleAutocompleteProps}
value={articleAutocompleteProps.options.find((option) => option.id === field.value) || null} value={
articleAutocompleteProps.options.find(
(option) => option.id === field.value
) || null
}
onChange={(_, value) => { onChange={(_, value) => {
field.onChange(value?.id || '') field.onChange(value?.id || "");
}} }}
getOptionLabel={(item) => { getOptionLabel={(item) => {
return item ? item.heading : '' return item ? item.heading : "";
}} }}
isOptionEqualToValue={(option, value) => { isOptionEqualToValue={(option, value) => {
return option.id === value?.id return option.id === value?.id;
}} }}
filterOptions={(options, {inputValue}) => { filterOptions={(options, { inputValue }) => {
return options.filter((option) => option.heading.toLowerCase().includes(inputValue.toLowerCase())) return options.filter((option) =>
option.heading
.toLowerCase()
.includes(inputValue.toLowerCase())
);
}} }}
renderInput={(params) => <TextField {...params} label="Cтатья-предпросмотр" margin="normal" variant="outlined" error={!!errors.arms} helperText={(errors as any)?.arms?.message} required />} renderInput={(params) => (
<TextField
{...params}
label="Cтатья-предпросмотр"
margin="normal"
variant="outlined"
error={!!errors.arms}
helperText={(errors as any)?.arms?.message}
required
/>
)}
/> />
)} )}
/> />
@ -324,14 +468,15 @@ export const SightEdit = () => {
sx={{ sx={{
flex: 1, flex: 1,
p: 2, p: 2,
maxHeight: 'calc(100vh - 200px)', maxHeight: "calc(100vh - 200px)",
overflowY: 'auto', overflowY: "auto",
position: 'sticky', position: "sticky",
top: 16, top: 16,
borderRadius: 2, borderRadius: 2,
border: '1px solid', border: "1px solid",
borderColor: 'primary.main', borderColor: "primary.main",
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'background.paper' : '#fff'), bgcolor: (theme) =>
theme.palette.mode === "dark" ? "background.paper" : "#fff",
}} }}
> >
<Typography variant="h6" gutterBottom color="primary"> <Typography variant="h6" gutterBottom color="primary">
@ -343,7 +488,8 @@ export const SightEdit = () => {
variant="h4" variant="h4"
gutterBottom gutterBottom
sx={{ sx={{
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'), color: (theme) =>
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
mb: 3, mb: 3,
}} }}
> >
@ -351,29 +497,45 @@ export const SightEdit = () => {
</Typography> </Typography>
{/* Город */} {/* Город */}
<Typography variant="body1" sx={{mb: 2}}> <Typography variant="body1" sx={{ mb: 2 }}>
<Box component="span" sx={{color: 'text.secondary'}}> <Box component="span" sx={{ color: "text.secondary" }}>
Город:{' '} Город:{" "}
</Box> </Box>
<Box component="span" sx={{color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800')}}> <Box
component="span"
sx={{
color: (theme) =>
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
}}
>
{cityPreview} {cityPreview}
</Box> </Box>
</Typography> </Typography>
{/* Координаты */} {/* Координаты */}
<Typography variant="body1" sx={{mb: 2}}> <Typography variant="body1" sx={{ mb: 2 }}>
<Box component="span" sx={{color: 'text.secondary'}}> <Box component="span" sx={{ color: "text.secondary" }}>
Координаты:{' '} Координаты:{" "}
</Box> </Box>
<Box component="span" sx={{color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800')}}> <Box
component="span"
sx={{
color: (theme) =>
theme.palette.mode === "dark" ? "grey.300" : "grey.800",
}}
>
{coordinatesPreview.latitude}, {coordinatesPreview.longitude} {coordinatesPreview.latitude}, {coordinatesPreview.longitude}
</Box> </Box>
</Typography> </Typography>
{/* Обложка */} {/* Обложка */}
{thumbnailPreview && ( {thumbnailPreview && (
<Box sx={{mb: 2}}> <Box sx={{ mb: 2 }}>
<Typography variant="body1" gutterBottom sx={{color: 'text.secondary'}}> <Typography
variant="body1"
gutterBottom
sx={{ color: "text.secondary" }}
>
Обложка: Обложка:
</Typography> </Typography>
<Box <Box
@ -381,25 +543,33 @@ export const SightEdit = () => {
src={thumbnailPreview} src={thumbnailPreview}
alt="Обложка" alt="Обложка"
sx={{ sx={{
maxWidth: '100%', maxWidth: "100%",
height: '40vh', height: "40vh",
borderRadius: 2, borderRadius: 2,
border: '1px solid', border: "1px solid",
borderColor: 'primary.main', borderColor: "primary.main",
}} }}
/> />
</Box> </Box>
)} )}
{/* Водяные знаки */} {/* Водяные знаки */}
<Box sx={{mb: 2}}> <Box sx={{ mb: 2 }}>
<Typography variant="body1" gutterBottom sx={{color: 'text.secondary'}}> <Typography
variant="body1"
gutterBottom
sx={{ color: "text.secondary" }}
>
Водяные знаки: Водяные знаки:
</Typography> </Typography>
<Box sx={{display: 'flex', gap: 2}}> <Box sx={{ display: "flex", gap: 2 }}>
{watermarkLUPreview && ( {watermarkLUPreview && (
<Box> <Box>
<Typography variant="body2" gutterBottom sx={{color: 'text.secondary'}}> <Typography
variant="body2"
gutterBottom
sx={{ color: "text.secondary" }}
>
Левый верхний: Левый верхний:
</Typography> </Typography>
<Box <Box
@ -409,17 +579,21 @@ export const SightEdit = () => {
sx={{ sx={{
width: 100, width: 100,
height: 100, height: 100,
objectFit: 'cover', objectFit: "cover",
borderRadius: 1, borderRadius: 1,
border: '1px solid', border: "1px solid",
borderColor: 'primary.main', borderColor: "primary.main",
}} }}
/> />
</Box> </Box>
)} )}
{watermarkRDPreview && ( {watermarkRDPreview && (
<Box> <Box>
<Typography variant="body2" gutterBottom sx={{color: 'text.secondary'}}> <Typography
variant="body2"
gutterBottom
sx={{ color: "text.secondary" }}
>
Правый нижний: Правый нижний:
</Typography> </Typography>
<Box <Box
@ -429,10 +603,10 @@ export const SightEdit = () => {
sx={{ sx={{
width: 100, width: 100,
height: 100, height: 100,
objectFit: 'cover', objectFit: "cover",
borderRadius: 1, borderRadius: 1,
border: '1px solid', border: "1px solid",
borderColor: 'primary.main', borderColor: "primary.main",
}} }}
/> />
</Box> </Box>
@ -447,17 +621,18 @@ export const SightEdit = () => {
</Typography> */} </Typography> */}
{leftArticlePreview && ( {leftArticlePreview && (
<Typography variant="body1" gutterBottom> <Typography variant="body1" gutterBottom>
<Box component="span" sx={{color: 'text.secondary'}}> <Box component="span" sx={{ color: "text.secondary" }}>
Левая статья:{' '} Левая статья:{" "}
</Box> </Box>
<Box <Box
component={Link} component={Link}
to={`/article/show/${watch('left_article')}`} to={`/article/show/${watch("left_article")}`}
sx={{ sx={{
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'), color: (theme) =>
textDecoration: 'none', theme.palette.mode === "dark" ? "grey.300" : "grey.800",
'&:hover': { textDecoration: "none",
textDecoration: 'underline', "&:hover": {
textDecoration: "underline",
}, },
}} }}
> >
@ -467,17 +642,18 @@ export const SightEdit = () => {
)} )}
{previewArticlePreview && ( {previewArticlePreview && (
<Typography variant="body1" gutterBottom> <Typography variant="body1" gutterBottom>
<Box component="span" sx={{color: 'text.secondary'}}> <Box component="span" sx={{ color: "text.secondary" }}>
Статья-предпросмотр:{' '} Статья-предпросмотр:{" "}
</Box> </Box>
<Box <Box
component={Link} component={Link}
to={`/article/show/${watch('preview_article')}`} to={`/article/show/${watch("preview_article")}`}
sx={{ sx={{
color: (theme) => (theme.palette.mode === 'dark' ? 'grey.300' : 'grey.800'), color: (theme) =>
textDecoration: 'none', theme.palette.mode === "dark" ? "grey.300" : "grey.800",
'&:hover': { textDecoration: "none",
textDecoration: 'underline', "&:hover": {
textDecoration: "underline",
}, },
}} }}
> >
@ -490,12 +666,24 @@ export const SightEdit = () => {
</Box> </Box>
{sightId && ( {sightId && (
<Box sx={{mt: 3}}> <Box sx={{ mt: 3 }}>
<LinkedItems<ArticleItem> type="edit" parentId={sightId} parentResource="sight" childResource="article" fields={articleFields} title="статьи" /> <LinkedItems<ArticleItem>
type="edit"
parentId={sightId}
parentResource="sight"
childResource="article"
fields={articleFields}
title="статьи"
/>
<CreateSightArticle parentId={sightId} parentResource="sight" childResource="article" title="статью" /> <CreateSightArticle
parentId={sightId}
parentResource="sight"
childResource="article"
title="статью"
/>
</Box> </Box>
)} )}
</Edit> </Edit>
) );
} };

View File

@ -1,25 +1,27 @@
import dataProvider from '@refinedev/simple-rest' import dataProvider from "@refinedev/simple-rest";
import axios from 'axios' import axios from "axios";
import {BACKEND_URL} from '../lib/constants'
import {TOKEN_KEY} from '../authProvider'
import Cookies from 'js-cookie'
export const axiosInstance = axios.create() import { TOKEN_KEY } from "../authProvider";
import Cookies from "js-cookie";
export const axiosInstance = axios.create();
axiosInstance.interceptors.request.use((config) => { axiosInstance.interceptors.request.use((config) => {
// Добавляем токен авторизации // Добавляем токен авторизации
const token = localStorage.getItem(TOKEN_KEY) const token = localStorage.getItem(TOKEN_KEY);
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`;
} }
// Добавляем язык в кастомный заголовок // Добавляем язык в кастомный заголовок
const lang = Cookies.get('lang') || 'ru' const lang = Cookies.get("lang") || "ru";
config.headers['X-Language'] = lang // или 'Accept-Language' config.headers["X-Language"] = lang; // или 'Accept-Language'
// console.log('Request headers:', config.headers) // console.log('Request headers:', config.headers)
return config return config;
}) });
export const customDataProvider = dataProvider(BACKEND_URL, axiosInstance) const apiUrl = import.meta.env.VITE_KRBL_API;
export const customDataProvider = dataProvider(apiUrl, axiosInstance);