add news page
This commit is contained in:
1515
package-lock.json
generated
1515
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -115,6 +115,7 @@
|
||||
"@xmcl/installer": "^6.1.2",
|
||||
"@xmcl/resourcepack": "^1.2.4",
|
||||
"@xmcl/user": "^4.2.0",
|
||||
"easymde": "^2.20.0",
|
||||
"electron-debug": "^4.1.0",
|
||||
"electron-log": "^5.3.2",
|
||||
"electron-updater": "^6.3.9",
|
||||
@ -124,7 +125,9 @@
|
||||
"qr-code-styling": "^1.9.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.3.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"skinview3d": "^3.4.1",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"three": "^0.178.0",
|
||||
|
||||
@ -19,6 +19,7 @@ import Shop from './pages/Shop';
|
||||
import Marketplace from './pages/Marketplace';
|
||||
import { Registration } from './pages/Registration';
|
||||
import { FullScreenLoader } from './components/FullScreenLoader';
|
||||
import { News } from './pages/News';
|
||||
|
||||
const AuthCheck = ({ children }: { children: ReactNode }) => {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||
@ -173,6 +174,14 @@ const App = () => {
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/news"
|
||||
element={
|
||||
<AuthCheck>
|
||||
<News />
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Box>
|
||||
</Router>
|
||||
|
||||
@ -183,6 +183,112 @@ export interface VerificationStatusResponse {
|
||||
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(
|
||||
username: string,
|
||||
): Promise<VerificationStatusResponse> {
|
||||
|
||||
69
src/renderer/components/MarkdownEditor.tsx
Normal file
69
src/renderer/components/MarkdownEditor.tsx
Normal 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} />;
|
||||
};
|
||||
@ -33,23 +33,44 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
|
||||
const isRegistrationPage = location.pathname === '/registration';
|
||||
const navigate = useNavigate();
|
||||
const [coins, setCoins] = useState<number>(0);
|
||||
const [value, setValue] = useState(0);
|
||||
const [value, setValue] = useState(1);
|
||||
const [activePage, setActivePage] = useState('versions');
|
||||
const tabsWrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||
setValue(newValue);
|
||||
if (newValue === 0) {
|
||||
navigate('/');
|
||||
navigate('/news');
|
||||
} else if (newValue === 1) {
|
||||
navigate('/profile');
|
||||
navigate('/');
|
||||
} else if (newValue === 2) {
|
||||
navigate('/shop');
|
||||
navigate('/profile');
|
||||
} else if (newValue === 3) {
|
||||
navigate('/shop');
|
||||
} else if (newValue === 4) {
|
||||
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 = () => {
|
||||
navigate('/');
|
||||
};
|
||||
@ -62,7 +83,7 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
|
||||
|
||||
// Находим внутренний скроллер MUI Tabs
|
||||
const scroller = tabsWrapperRef.current.querySelector(
|
||||
'.MuiTabs-scroller'
|
||||
'.MuiTabs-scroller',
|
||||
) as HTMLDivElement | null;
|
||||
|
||||
if (!scroller) return;
|
||||
@ -194,8 +215,27 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
|
||||
variant="scrollable"
|
||||
scrollButtons={false}
|
||||
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
|
||||
label="Версии"
|
||||
disableRipple={true}
|
||||
|
||||
722
src/renderer/pages/News.tsx
Normal file
722
src/renderer/pages/News.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user