add news page

This commit is contained in:
2025-12-06 02:26:35 +05:00
parent 3e62bd7c27
commit 2e6b2d7add
7 changed files with 2465 additions and 11 deletions

1515
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -115,6 +115,7 @@
"@xmcl/installer": "^6.1.2", "@xmcl/installer": "^6.1.2",
"@xmcl/resourcepack": "^1.2.4", "@xmcl/resourcepack": "^1.2.4",
"@xmcl/user": "^4.2.0", "@xmcl/user": "^4.2.0",
"easymde": "^2.20.0",
"electron-debug": "^4.1.0", "electron-debug": "^4.1.0",
"electron-log": "^5.3.2", "electron-log": "^5.3.2",
"electron-updater": "^6.3.9", "electron-updater": "^6.3.9",
@ -124,7 +125,9 @@
"qr-code-styling": "^1.9.2", "qr-code-styling": "^1.9.2",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.3.0", "react-router-dom": "^7.3.0",
"remark-gfm": "^4.0.1",
"skinview3d": "^3.4.1", "skinview3d": "^3.4.1",
"stream-browserify": "^3.0.0", "stream-browserify": "^3.0.0",
"three": "^0.178.0", "three": "^0.178.0",

View File

@ -19,6 +19,7 @@ import Shop from './pages/Shop';
import Marketplace from './pages/Marketplace'; import Marketplace from './pages/Marketplace';
import { Registration } from './pages/Registration'; import { Registration } from './pages/Registration';
import { FullScreenLoader } from './components/FullScreenLoader'; import { FullScreenLoader } from './components/FullScreenLoader';
import { News } from './pages/News';
const AuthCheck = ({ children }: { children: ReactNode }) => { const AuthCheck = ({ children }: { children: ReactNode }) => {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null); const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
@ -173,6 +174,14 @@ const App = () => {
</AuthCheck> </AuthCheck>
} }
/> />
<Route
path="/news"
element={
<AuthCheck>
<News />
</AuthCheck>
}
/>
</Routes> </Routes>
</Box> </Box>
</Router> </Router>

View File

@ -183,6 +183,112 @@ export interface VerificationStatusResponse {
is_verified: boolean; is_verified: boolean;
} }
export interface NewsItem {
id: string;
title: string;
markdown: string;
preview?: string;
tags?: string[];
is_published?: boolean;
created_at: string;
updated_at: string;
}
export interface CreateNewsPayload {
title: string;
preview?: string;
markdown: string;
is_published: boolean;
}
export interface MeResponse {
username: string;
uuid: string;
is_admin: boolean;
}
export async function fetchMe(): Promise<MeResponse> {
const { accessToken, clientToken } = getAuthTokens();
if (!accessToken || !clientToken) {
throw new Error('Нет токенов авторизации');
}
const params = new URLSearchParams({
accessToken,
clientToken,
});
const response = await fetch(`${API_BASE_URL}/auth/me?${params.toString()}`);
if (!response.ok) {
throw new Error('Не удалось получить данные пользователя');
}
return await response.json();
}
export async function createNews(payload: CreateNewsPayload) {
const { accessToken, clientToken } = getAuthTokens(); // ← используем launcher_config
if (!accessToken || !clientToken) {
throw new Error('Необходимо войти в лаунчер, чтобы публиковать новости');
}
const formData = new FormData();
formData.append('accessToken', accessToken);
formData.append('clientToken', clientToken);
formData.append('title', payload.title);
formData.append('markdown', payload.markdown);
formData.append('preview', payload.preview || '');
formData.append('is_published', String(payload.is_published));
const response = await fetch(`${API_BASE_URL}/news`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || 'Ошибка при создании новости');
}
return await response.json();
}
export async function deleteNews(id: string): Promise<void> {
const { accessToken, clientToken } = getAuthTokens();
if (!accessToken || !clientToken) {
throw new Error('Необходимо войти в лаунчер');
}
const params = new URLSearchParams({
accessToken,
clientToken,
});
const response = await fetch(
`${API_BASE_URL}/news/${id}?${params.toString()}`,
{
method: 'DELETE',
},
);
if (!response.ok) {
const text = await response.text();
throw new Error(text || 'Не удалось удалить новость');
}
}
export async function fetchNews(): Promise<NewsItem[]> {
const response = await fetch(`${API_BASE_URL}/news`);
if (!response.ok) {
throw new Error('Не удалось получить новости');
}
return await response.json();
}
export async function getVerificationStatus( export async function getVerificationStatus(
username: string, username: string,
): Promise<VerificationStatusResponse> { ): Promise<VerificationStatusResponse> {

View File

@ -0,0 +1,69 @@
// components/MarkdownEditor.tsx
import { useEffect, useRef } from 'react';
import EasyMDE from 'easymde';
import 'easymde/dist/easymde.min.css';
interface MarkdownEditorProps {
value: string;
onChange: (value: string) => void;
}
export const MarkdownEditor = ({ value, onChange }: MarkdownEditorProps) => {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const editorRef = useRef<EasyMDE | null>(null);
// Один раз создаём EasyMDE поверх textarea
useEffect(() => {
if (!textareaRef.current) return;
if (editorRef.current) return; // уже создан
const instance = new EasyMDE({
element: textareaRef.current,
initialValue: value,
spellChecker: false,
minHeight: '200px',
toolbar: [
'bold',
'italic',
'strikethrough',
'|',
'heading',
'quote',
'unordered-list',
'ordered-list',
'|',
'link',
'image',
'|',
'preview',
'side-by-side',
'fullscreen',
'|',
'guide',
],
status: false,
});
instance.codemirror.on('change', () => {
onChange(instance.value());
});
editorRef.current = instance;
// При анмаунте красиво убрать за собой
return () => {
instance.toTextArea();
editorRef.current = null;
};
}, []);
// Если извне поменяли value — обновляем редактор
useEffect(() => {
if (editorRef.current && editorRef.current.value() !== value) {
editorRef.current.value(value);
}
}, [value]);
// Сам текстариа — просто якорь для EasyMDE
return <textarea ref={textareaRef} />;
};

View File

@ -33,23 +33,44 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
const isRegistrationPage = location.pathname === '/registration'; const isRegistrationPage = location.pathname === '/registration';
const navigate = useNavigate(); const navigate = useNavigate();
const [coins, setCoins] = useState<number>(0); const [coins, setCoins] = useState<number>(0);
const [value, setValue] = useState(0); const [value, setValue] = useState(1);
const [activePage, setActivePage] = useState('versions'); const [activePage, setActivePage] = useState('versions');
const tabsWrapperRef = useRef<HTMLDivElement | null>(null); const tabsWrapperRef = useRef<HTMLDivElement | null>(null);
const handleChange = (event: React.SyntheticEvent, newValue: number) => { const handleChange = (event: React.SyntheticEvent, newValue: number) => {
setValue(newValue); setValue(newValue);
if (newValue === 0) { if (newValue === 0) {
navigate('/'); navigate('/news');
} else if (newValue === 1) { } else if (newValue === 1) {
navigate('/profile'); navigate('/');
} else if (newValue === 2) { } else if (newValue === 2) {
navigate('/shop'); navigate('/profile');
} else if (newValue === 3) { } else if (newValue === 3) {
navigate('/shop');
} else if (newValue === 4) {
navigate('/marketplace'); navigate('/marketplace');
} }
}; };
useEffect(() => {
if (location.pathname === '/news') {
setValue(0);
setActivePage('news');
} else if (location.pathname === '/') {
setValue(1);
setActivePage('versions');
} else if (location.pathname.startsWith('/profile')) {
setValue(2);
setActivePage('profile');
} else if (location.pathname.startsWith('/shop')) {
setValue(3);
setActivePage('shop');
} else if (location.pathname.startsWith('/marketplace')) {
setValue(4);
setActivePage('marketplace');
}
}, [location.pathname]);
const handleLaunchPage = () => { const handleLaunchPage = () => {
navigate('/'); navigate('/');
}; };
@ -62,7 +83,7 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
// Находим внутренний скроллер MUI Tabs // Находим внутренний скроллер MUI Tabs
const scroller = tabsWrapperRef.current.querySelector( const scroller = tabsWrapperRef.current.querySelector(
'.MuiTabs-scroller' '.MuiTabs-scroller',
) as HTMLDivElement | null; ) as HTMLDivElement | null;
if (!scroller) return; if (!scroller) return;
@ -194,8 +215,27 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
variant="scrollable" variant="scrollable"
scrollButtons={false} scrollButtons={false}
disableRipple={true} disableRipple={true}
sx={{ maxWidth: "42vw"}} sx={{ maxWidth: '42vw' }}
> >
<Tab
label="Новости"
disableRipple={true}
onClick={() => {
setActivePage('news');
}}
sx={{
color: 'white',
fontFamily: 'Benzin-Bold',
fontSize: '0.7em',
'&.Mui-selected': {
color: 'rgba(255, 77, 77, 1)',
},
'&:hover': {
color: 'rgb(177, 52, 52)',
},
transition: 'all 0.3s ease',
}}
/>
<Tab <Tab
label="Версии" label="Версии"
disableRipple={true} disableRipple={true}

722
src/renderer/pages/News.tsx Normal file
View File

@ -0,0 +1,722 @@
import { useEffect, useState } from 'react';
import {
Box,
Typography,
Paper,
Stack,
Chip,
IconButton,
TextField,
Button,
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { fetchNews, NewsItem, createNews, fetchMe, deleteNews } from '../api';
import { FullScreenLoader } from '../components/FullScreenLoader';
import { MarkdownEditor } from '../components/MarkdownEditor';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
export const News = () => {
const [news, setNews] = useState<NewsItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
// Markdown-рендерер (динамический импорт, чтобы не ругался CommonJS)
const [ReactMarkdown, setReactMarkdown] = useState<any>(null);
const [remarkGfm, setRemarkGfm] = useState<any>(null);
// --- Админский редактор ---
const [creating, setCreating] = useState(false);
const [title, setTitle] = useState('');
const [preview, setPreview] = useState('');
const [markdown, setMarkdown] = useState('');
const [isAdmin, setIsAdmin] = useState(false);
useEffect(() => {
(async () => {
try {
const me = await fetchMe();
setIsAdmin(me.is_admin);
} catch {
setIsAdmin(false);
}
})();
}, []);
// Загружаем react-markdown + remark-gfm
useEffect(() => {
(async () => {
const md = await import('react-markdown');
const gfm = await import('remark-gfm');
setReactMarkdown(() => md.default);
setRemarkGfm(() => gfm.default);
})();
}, []);
// Загрузка списка новостей
useEffect(() => {
const load = async () => {
try {
const data = await fetchNews();
setNews(data);
} catch (e: any) {
setError(e?.message || 'Не удалось загрузить новости');
} finally {
setLoading(false);
}
};
load();
}, []);
const handleToggleExpand = (id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
};
const handleCreateNews = async () => {
if (!title.trim() || !markdown.trim()) {
setError('У новости должны быть заголовок и текст');
return;
}
setError(null);
setCreating(true);
try {
await createNews({
title: title.trim(),
preview: preview.trim() || undefined,
markdown,
is_published: true,
});
const updated = await fetchNews();
setNews(updated);
// Сброс формы
setTitle('');
setPreview('');
setMarkdown('');
} catch (e: any) {
setError(e?.message || 'Не удалось создать новость');
} finally {
setCreating(false);
}
};
const handleDeleteNews = async (id: string) => {
const confirmed = window.confirm('Точно удалить эту новость?');
if (!confirmed) return;
try {
await deleteNews(id);
setNews((prev) => prev.filter((n) => n.id !== id));
} catch (e: any) {
setError(e?.message || 'Не удалось удалить новость');
}
};
// ждём пока react-markdown / remark-gfm загрузятся
if (!ReactMarkdown || !remarkGfm) {
return <FullScreenLoader />;
}
if (loading) {
return <FullScreenLoader />;
}
if (error && news.length === 0) {
return (
<Box
sx={{
mt: '10vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
px: '3vw',
}}
>
<Typography
sx={{
color: '#ff8080',
fontFamily: 'Benzin-Bold',
textAlign: 'center',
}}
>
{error}
</Typography>
</Box>
);
}
return (
<Box
sx={{
mt: '7vh',
px: '7vw',
pb: '4vh',
maxHeight: '90vh',
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: '2vh',
}}
>
{/* Заголовок страницы */}
<Box
sx={{
mb: '2vh',
}}
>
<Typography
variant="h3"
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '3vw',
backgroundImage:
'linear-gradient(71deg, #F27121 0%, #E940CD 60%, #8A2387 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
textTransform: 'uppercase',
letterSpacing: '0.08em',
}}
>
Новости
</Typography>
<Typography
variant="body2"
sx={{
mt: 0.5,
color: 'rgba(255,255,255,0.6)',
}}
>
Последние обновления лаунчера, сервера и ивентов
</Typography>
</Box>
{/* Админский редактор */}
{isAdmin && (
<Paper
sx={{
mb: 3,
p: 2.5,
borderRadius: '1.5vw',
background:
'linear-gradient(145deg, rgba(255,255,255,0.06), rgba(0,0,0,0.85))',
border: '1px solid rgba(255, 255, 255, 0.15)',
boxShadow: '0 18px 45px rgba(0, 0, 0, 0.7)',
backdropFilter: 'blur(14px)',
}}
>
<Typography
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '1.1vw',
mb: 1.5,
color: 'rgba(255,255,255,0.9)',
}}
>
Создать новость
</Typography>
<TextField
label="Заголовок"
fullWidth
variant="outlined"
value={title}
onChange={(e) => setTitle(e.target.value)}
sx={{
mb: 2,
'& .MuiInputBase-root': {
backgroundColor: 'rgba(0,0,0,0.6)',
color: 'white',
},
'& .MuiInputLabel-root': {
color: 'rgba(255,255,255,0.7)',
},
}}
/>
<TextField
label="Краткий превью-текст (опционально)"
fullWidth
variant="outlined"
value={preview}
onChange={(e) => setPreview(e.target.value)}
sx={{
mb: 2,
'& .MuiInputBase-root': {
backgroundColor: 'rgba(0,0,0,0.6)',
color: 'white',
},
'& .MuiInputLabel-root': {
color: 'rgba(255,255,255,0.7)',
},
}}
/>
<Box
sx={{
mb: 2,
'& .EasyMDEContainer': {
backgroundColor: 'rgba(0,0,0,0.6)',
borderRadius: '0.8vw',
overflow: 'hidden',
border: '1px solid rgba(255,255,255,0.15)',
},
'& .editor-toolbar': {
background: 'transparent',
borderBottom: '1px solid rgba(255, 255, 255, 1)',
color: 'white',
},
'& .editor-toolbar .fa': {
color: 'white',
},
'& .CodeMirror': {
backgroundColor: 'transparent',
color: 'white',
},
}}
>
<MarkdownEditor value={markdown} onChange={setMarkdown} />
</Box>
{error && (
<Typography
sx={{
color: '#ff8080',
fontSize: '0.8vw',
}}
>
{error}
</Typography>
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
<Button
variant="contained"
disabled={creating}
onClick={handleCreateNews}
sx={{
px: 3,
py: 0.8,
borderRadius: '999px',
textTransform: 'uppercase',
fontFamily: 'Benzin-Bold',
fontSize: '0.8vw',
letterSpacing: '0.08em',
backgroundImage:
'linear-gradient(71deg, #F27121 0%, #E940CD 60%, #8A2387 100%)',
boxShadow: '0 12px 30px rgba(0,0,0,0.9)',
'&:hover': {
boxShadow: '0 18px 40px rgba(0,0,0,1)',
filter: 'brightness(1.05)',
},
}}
>
{creating ? 'Публикация...' : 'Опубликовать'}
</Button>
</Box>
</Paper>
)}
{/* Если новостей нет */}
{news.length === 0 && (
<Box
sx={{
mt: '5vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
px: '3vw',
}}
>
<Typography
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '2vw',
backgroundImage:
'linear-gradient(71deg, #F27121 0%, #E940CD 60%, #8A2387 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
textAlign: 'center',
}}
>
Новостей пока нет
</Typography>
</Box>
)}
{/* Список новостей */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: '1.8vh',
}}
>
{news.map((item) => {
const isExpanded = expandedId === item.id;
const shortContent = item.preview || item.markdown;
const fullContent = item.markdown;
const contentToRender = isExpanded ? fullContent : shortContent;
const isImageUrl =
!isExpanded &&
typeof shortContent === 'string' &&
/^https?:\/\/.*\.(png|jpe?g|gif|webp)$/i.test(shortContent.trim());
return (
<Paper
key={item.id}
sx={{
position: 'relative',
overflow: 'hidden',
mb: 1,
p: 2.5,
borderRadius: '1.5vw',
background:
'linear-gradient(145deg, rgba(255,255,255,0.06), rgba(0,0,0,0.85))',
border: '1px solid rgba(255, 255, 255, 0.08)',
boxShadow: '0 18px 45px rgba(0, 0, 0, 0.7)',
backdropFilter: 'blur(14px)',
width: '80vw',
transition:
'transform 0.25s ease, box-shadow 0.25s.ease, border-color 0.25s ease',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: '0 24px 60px rgba(0, 0, 0, 0.9)',
borderColor: 'rgba(242,113,33,0.5)',
},
}}
>
{/* Шапка новости */}
<Box
sx={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: 2,
mb: 1.5,
}}
>
<Box sx={{ flexGrow: 1 }}>
<Typography
variant="h6"
sx={{
color: 'white',
fontFamily: 'Benzin-Bold',
fontSize: '2.5vw',
mb: 0.5,
textShadow: '0 0 18px rgba(0,0,0,0.8)',
}}
>
{item.title}
</Typography>
{item.created_at && (
<Typography
variant="body2"
sx={{
color: 'rgba(255,255,255,0.5)',
fontSize: '0.85vw',
}}
>
{new Date(item.created_at).toLocaleString()}
</Typography>
)}
{item.tags && item.tags.length > 0 && (
<Stack
direction="row"
spacing={1}
sx={{ mt: 1, flexWrap: 'wrap' }}
>
{item.tags.map((tag) => (
<Chip
key={tag}
label={tag}
size="small"
sx={{
fontSize: '0.7vw',
color: 'rgba(255,255,255,0.85)',
borderRadius: '999px',
border: '1px solid rgba(242,113,33,0.6)',
background:
'linear-gradient(120deg, rgba(242,113,33,0.18), rgba(233,64,87,0.12), rgba(138,35,135,0.16))',
backdropFilter: 'blur(12px)',
}}
/>
))}
</Stack>
)}
</Box>
<IconButton
onClick={() => handleToggleExpand(item.id)}
sx={{
ml: 1,
alignSelf: 'center',
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.25s ease, background 0.25s ease',
background:
'linear-gradient(140deg, rgba(242,113,33,0.15), rgba(233,64,87,0.15))',
borderRadius: '1.4vw',
'&:hover': {
background:
'linear-gradient(140deg, rgba(242,113,33,0.4), rgba(233,64,87,0.4))',
},
}}
>
<ExpandMoreIcon
sx={{ color: 'rgba(255,255,255,0.9)', fontSize: '1.4vw' }}
/>
</IconButton>
{isAdmin && (
<IconButton
onClick={() => handleDeleteNews(item.id)}
sx={{
mt: 0.5,
backgroundColor: 'rgba(255, 77, 77, 0.1)',
borderRadius: '1.4vw',
'&:hover': {
backgroundColor: 'rgba(255, 77, 77, 0.3)',
},
}}
>
<DeleteOutlineIcon
sx={{
color: 'rgba(255, 120, 120, 0.9)',
fontSize: '1.2vw',
}}
/>
</IconButton>
)}
</Box>
{/* Контент */}
<Box
sx={{
position: 'relative',
mt: 1,
display: 'flex',
justifyContent: 'center',
}}
>
{isImageUrl ? (
<Box
component="img"
src={(shortContent as string).trim()}
alt={item.title}
sx={{
maxHeight: '30vh',
objectFit: 'cover',
borderRadius: '1.2vw',
mb: 2,
}}
/>
) : (
<Box
sx={{
maxHeight: isExpanded ? 'none' : '12em',
overflow: 'hidden',
pr: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ node, ...props }) => (
<Typography
{...props}
sx={{
color: 'rgba(255,255,255,0.9)',
fontSize: '1.5vw',
lineHeight: 1.6,
mb: 1,
whiteSpace: 'pre-line', // ← вот это
'&:last-of-type': { mb: 0 },
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
/>
),
strong: ({ node, ...props }) => (
<Box
component="strong"
sx={{
fontWeight: 700,
color: 'rgba(255,255,255,1)',
}}
{...props}
/>
),
em: ({ node, ...props }) => (
<Box
component="em"
sx={{ fontStyle: 'italic' }}
{...props}
/>
),
del: ({ node, ...props }) => (
<Box
component="del"
sx={{ textDecoration: 'line-through' }}
{...props}
/>
),
a: ({ node, ...props }) => (
<Box
component="a"
{...props}
sx={{
color: '#F27121',
textDecoration: 'none',
borderBottom: '1px solid rgba(242,113,33,0.6)',
'&:hover': {
color: '#E940CD',
borderBottomColor: 'rgba(233,64,205,0.8)',
},
}}
target="_blank"
rel="noopener noreferrer"
/>
),
li: ({ node, ordered, ...props }) => (
<li
style={{
color: 'rgba(255,255,255,0.9)',
fontSize: '1.5vw',
marginBottom: '0.3em',
}}
{...props}
/>
),
ul: ({ node, ...props }) => (
<ul
style={{
paddingLeft: '1.3em',
marginTop: '0.3em',
marginBottom: '0.8em',
}}
{...props}
/>
),
ol: ({ node, ...props }) => (
<ol
style={{
paddingLeft: '1.3em',
marginTop: '0.3em',
marginBottom: '0.8em',
}}
{...props}
/>
),
img: ({ node, ...props }) => (
<Box
component="img"
{...props}
sx={{
maxHeight: '30vh',
objectFit: 'cover',
borderRadius: '1.2vw',
my: 2,
}}
/>
),
h1: ({ node, ...props }) => (
<Typography
{...props}
variant="h5"
sx={{
fontFamily: 'Benzin-Bold',
color: 'white',
mb: 1,
}}
/>
),
h2: ({ node, ...props }) => (
<Typography
{...props}
variant="h6"
sx={{
fontFamily: 'Benzin-Bold',
color: 'white',
mb: 1,
}}
/>
),
h3: ({ node, ...props }) => (
<Typography
{...props}
variant="h7"
sx={{
fontFamily: 'Benzin-Bold',
color: 'white',
mb: 1,
}}
/>
),
}}
>
{contentToRender}
</ReactMarkdown>
</Box>
)}
{!isExpanded && !isImageUrl && (
<Box
sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '3.5em',
// background:
// 'linear-gradient(to top, rgba(0, 0, 0, 0.43), rgba(0,0,0,0))',
pointerEvents: 'none',
}}
/>
)}
</Box>
<Box
sx={{
mt: 1.5,
display: 'flex',
justifyContent: 'flex-end',
}}
>
<Typography
onClick={() => handleToggleExpand(item.id)}
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '0.8vw',
textTransform: 'uppercase',
letterSpacing: '0.08em',
cursor: 'pointer',
backgroundImage:
'linear-gradient(71deg, #F27121 0%, #E940CD 60%, #8A2387 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
textShadow: '0 0 15px rgba(0,0,0,0.9)',
'&:hover': {
opacity: 0.85,
},
}}
>
{isExpanded ? 'Свернуть' : 'Читать полностью'}
</Typography>
</Box>
</Paper>
);
})}
</Box>
</Box>
);
};