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/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",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
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 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
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