PostsPage überarbeitet und extra seite für create news erstellt
This commit is contained in:
16
src/App.jsx
16
src/App.jsx
@@ -7,6 +7,7 @@ import LoginPage from './pages/LoginPage';
|
|||||||
import RegisterPage from './pages/RegisterPage';
|
import RegisterPage from './pages/RegisterPage';
|
||||||
import UsersPage from './pages/UsersPage';
|
import UsersPage from './pages/UsersPage';
|
||||||
import PostsPage from './pages/PostsPage';
|
import PostsPage from './pages/PostsPage';
|
||||||
|
import CreateNewsPage from './pages/CreateNewsPage';
|
||||||
import PushPage from './pages/PushPage';
|
import PushPage from './pages/PushPage';
|
||||||
|
|
||||||
const theme = createTheme({
|
const theme = createTheme({
|
||||||
@@ -50,10 +51,11 @@ export default function App() {
|
|||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<Route index element={<Navigate to="/users" replace />} />
|
||||||
<Route
|
<Route
|
||||||
path="users"
|
path="users"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute allowedRoles={["ADMIN"]}>
|
<PrivateRoute allowedRoles={['ADMIN']}>
|
||||||
<UsersPage />
|
<UsersPage />
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
@@ -61,15 +63,23 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="posts"
|
path="posts"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute allowedRoles={["ADMIN", "MODERATOR"]}>
|
<PrivateRoute allowedRoles={['ADMIN', 'REPORTER']}>
|
||||||
<PostsPage />
|
<PostsPage />
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="posts/create"
|
||||||
|
element={
|
||||||
|
<PrivateRoute allowedRoles={['ADMIN', 'REPORTER']}>
|
||||||
|
<CreateNewsPage />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="push"
|
path="push"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute allowedRoles={["ADMIN"]}>
|
<PrivateRoute allowedRoles={['ADMIN']}>
|
||||||
<PushPage />
|
<PushPage />
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
|
|||||||
222
src/pages/CreateNewsPage.jsx
Normal file
222
src/pages/CreateNewsPage.jsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Box, Typography, Card, CardContent, TextField, Button,
|
||||||
|
Alert, CircularProgress, Stack, IconButton, Divider,
|
||||||
|
ToggleButton, ToggleButtonGroup, Collapse, Chip,
|
||||||
|
} from '@mui/material';
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||||
|
import AddPhotoAlternateOutlinedIcon from '@mui/icons-material/AddPhotoAlternateOutlined';
|
||||||
|
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
|
||||||
|
import NotificationsOffOutlinedIcon from '@mui/icons-material/NotificationsOffOutlined';
|
||||||
|
import StarIcon from '@mui/icons-material/Star';
|
||||||
|
import axiosInstance from '../api/axiosInstance';
|
||||||
|
|
||||||
|
const EMPTY_FORM = {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
category: '',
|
||||||
|
pushNotification: false,
|
||||||
|
pushMessage: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CreateNewsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [form, setForm] = useState(EMPTY_FORM);
|
||||||
|
const [files, setFiles] = useState([]);
|
||||||
|
const [previews, setPreviews] = useState([]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setForm((prev) => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e) => {
|
||||||
|
const selected = Array.from(e.target.files);
|
||||||
|
setFiles((prev) => [...prev, ...selected]);
|
||||||
|
const newPreviews = selected.map((f) => URL.createObjectURL(f));
|
||||||
|
setPreviews((prev) => [...prev, ...newPreviews]);
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFile = (index) => {
|
||||||
|
URL.revokeObjectURL(previews[index]);
|
||||||
|
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
setPreviews((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
const jsonPayload = {
|
||||||
|
title: form.title,
|
||||||
|
description: form.description,
|
||||||
|
category: form.category,
|
||||||
|
pushNotification: form.pushNotification,
|
||||||
|
...(form.pushNotification && form.pushMessage ? { pushMessage: form.pushMessage } : {}),
|
||||||
|
};
|
||||||
|
formData.append('data', new Blob([JSON.stringify(jsonPayload)], { type: 'application/json' }));
|
||||||
|
files.forEach((file) => formData.append('images', file));
|
||||||
|
|
||||||
|
await axiosInstance.post('/api/news', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
navigate('/posts');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.message ?? 'Erstellen fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValid = form.title && form.description && form.category;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||||
|
<IconButton onClick={() => navigate('/posts')} size="small">
|
||||||
|
<ArrowBackIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Typography variant="h5" fontWeight={600}>News erstellen</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Card sx={{ maxWidth: 680 }}>
|
||||||
|
<CardContent sx={{ p: 3 }}>
|
||||||
|
<Box component="form" onSubmit={handleSubmit}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
{error && <Alert severity="error">{error}</Alert>}
|
||||||
|
|
||||||
|
<TextField label="Titel" name="title" value={form.title} onChange={handleChange} required fullWidth autoFocus />
|
||||||
|
<TextField label="Beschreibung" name="description" value={form.description} onChange={handleChange} required fullWidth multiline rows={6} />
|
||||||
|
<TextField label="Kategorie" name="category" value={form.category} onChange={handleChange} required fullWidth />
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Bild-Upload */}
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={500}>Bilder</Typography>
|
||||||
|
{files.length > 0 && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Das erste Bild wird als Vorschaubild verwendet
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
{previews.map((src, i) => (
|
||||||
|
<Box
|
||||||
|
key={i}
|
||||||
|
sx={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 1.5,
|
||||||
|
p: 1, borderRadius: 1,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: i === 0 ? 'primary.main' : 'divider',
|
||||||
|
bgcolor: i === 0 ? 'primary.50' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={src}
|
||||||
|
alt={`Vorschau ${i + 1}`}
|
||||||
|
sx={{ width: 64, height: 64, objectFit: 'cover', borderRadius: 1, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||||
|
{i === 0 && (
|
||||||
|
<Chip
|
||||||
|
icon={<StarIcon sx={{ fontSize: '13px !important' }} />}
|
||||||
|
label="Vorschaubild"
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
sx={{ height: 20, fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{files[i]?.name}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="small" color="error" onClick={() => handleRemoveFile(i)}>
|
||||||
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
component="label"
|
||||||
|
startIcon={<AddPhotoAlternateOutlinedIcon />}
|
||||||
|
size="small"
|
||||||
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
|
>
|
||||||
|
{files.length === 0 ? 'Vorschaubild hinzufügen' : 'Weiteres Bild hinzufügen'}
|
||||||
|
<input type="file" hidden accept="image/*" multiple onChange={handleFileChange} />
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Push Notification */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" fontWeight={500} mb={1.5}>Push Notification</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
exclusive
|
||||||
|
value={form.pushNotification}
|
||||||
|
onChange={(_, val) => {
|
||||||
|
if (val === null) return;
|
||||||
|
setForm((prev) => ({ ...prev, pushNotification: val }));
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<ToggleButton value={false} sx={{ gap: 0.75, px: 2 }}>
|
||||||
|
<NotificationsOffOutlinedIcon fontSize="small" />
|
||||||
|
Aus
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value={true} sx={{ gap: 0.75, px: 2 }}>
|
||||||
|
<NotificationsActiveIcon fontSize="small" />
|
||||||
|
An
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
|
||||||
|
<Collapse in={form.pushNotification} orientation="horizontal" sx={{ flex: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Nachricht"
|
||||||
|
name="pushMessage"
|
||||||
|
value={form.pushMessage}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
placeholder="Kurze Info für die Notification..."
|
||||||
|
inputProps={{ maxLength: 100 }}
|
||||||
|
helperText={`${form.pushMessage.length}/100`}
|
||||||
|
/>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end', pt: 1 }}>
|
||||||
|
<Button onClick={() => navigate('/posts')} disabled={saving}>Abbrechen</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
disabled={saving || !isValid}
|
||||||
|
startIcon={saving ? <CircularProgress size={16} color="inherit" /> : null}
|
||||||
|
>
|
||||||
|
Veröffentlichen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,27 +1,33 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Box, Typography, Alert, Chip, IconButton, Tooltip,
|
Box, Typography, Alert, IconButton, Tooltip,
|
||||||
|
Dialog, DialogTitle, DialogContent, DialogActions,
|
||||||
|
Button, Stack,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { DataGrid } from '@mui/x-data-grid';
|
import { DataGrid } from '@mui/x-data-grid';
|
||||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import axiosInstance from '../api/axiosInstance';
|
import axiosInstance from '../api/axiosInstance';
|
||||||
|
|
||||||
export default function PostsPage() {
|
export default function PostsPage() {
|
||||||
const [rows, setRows] = useState([]);
|
const [rows, setRows] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [detailNews, setDetailNews] = useState(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
axiosInstance.get('/api/posts')
|
axiosInstance.get('/api/news')
|
||||||
.then(({ data }) => setRows(data))
|
.then(({ data }) => setRows(data))
|
||||||
.catch(() => setError('Fehler beim Laden der Posts'))
|
.catch(() => setError('Fehler beim Laden der News'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
const handleDelete = async (id) => {
|
||||||
if (!window.confirm('Post wirklich löschen?')) return;
|
if (!window.confirm('News wirklich löschen?')) return;
|
||||||
try {
|
try {
|
||||||
await axiosInstance.delete(`/api/posts/${id}`);
|
await axiosInstance.delete(`/api/news/${id}`);
|
||||||
setRows((prev) => prev.filter((r) => r.id !== id));
|
setRows((prev) => prev.filter((r) => r.id !== id));
|
||||||
} catch {
|
} catch {
|
||||||
alert('Löschen fehlgeschlagen');
|
alert('Löschen fehlgeschlagen');
|
||||||
@@ -29,25 +35,45 @@ export default function PostsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ field: 'id', headerName: 'ID', width: 80 },
|
{ field: 'id', headerName: 'ID', width: 70 },
|
||||||
{ field: 'title', headerName: 'Titel', flex: 2 },
|
|
||||||
{ field: 'author', headerName: 'Autor', flex: 1 },
|
|
||||||
{
|
{
|
||||||
field: 'status',
|
field: 'title', headerName: 'Titel', flex: 1.5,
|
||||||
headerName: 'Status',
|
renderCell: ({ row, value }) => (
|
||||||
width: 120,
|
<Tooltip title="Details anzeigen">
|
||||||
renderCell: ({ value }) => (
|
<Box
|
||||||
<Chip
|
onClick={() => setDetailNews(row)}
|
||||||
label={value ?? 'published'}
|
sx={{ cursor: 'pointer', color: 'primary.main', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||||
color={value === 'draft' ? 'warning' : 'success'}
|
>
|
||||||
size="small"
|
{value}
|
||||||
/>
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'createdAt',
|
field: 'description', headerName: 'Beschreibung', flex: 2,
|
||||||
headerName: 'Datum',
|
renderCell: ({ value }) => (
|
||||||
flex: 1,
|
<Box sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'text.secondary', fontSize: 13 }}>
|
||||||
|
{value}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ field: 'author', headerName: 'Autor', width: 130 },
|
||||||
|
{ field: 'category', headerName: 'Kategorie', width: 130 },
|
||||||
|
{
|
||||||
|
field: 'picture',
|
||||||
|
headerName: 'Bilder',
|
||||||
|
width: 80,
|
||||||
|
sortable: false,
|
||||||
|
renderCell: ({ value }) => (
|
||||||
|
<Box sx={{ color: 'text.secondary', fontSize: 13 }}>
|
||||||
|
{Array.isArray(value) ? `${value.length} Bild${value.length !== 1 ? 'er' : ''}` : '—'}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'releaseDate',
|
||||||
|
headerName: 'Veröffentlicht',
|
||||||
|
width: 140,
|
||||||
valueFormatter: (value) => value ? new Date(value).toLocaleDateString('de-DE') : '—',
|
valueFormatter: (value) => value ? new Date(value).toLocaleDateString('de-DE') : '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -67,8 +93,15 @@ export default function PostsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h5" fontWeight={600} mb={3}>Posts / Inhalte</Typography>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||||
|
<Typography variant="h5" fontWeight={600}>News</Typography>
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/posts/create')}>
|
||||||
|
News erstellen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||||
|
|
||||||
<Box sx={{ bgcolor: 'white', borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
<Box sx={{ bgcolor: 'white', borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={rows}
|
rows={rows}
|
||||||
@@ -81,6 +114,54 @@ export default function PostsPage() {
|
|||||||
sx={{ border: 'none' }}
|
sx={{ border: 'none' }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Detail Dialog */}
|
||||||
|
<Dialog open={!!detailNews} onClose={() => setDetailNews(null)} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle sx={{ fontWeight: 600 }}>{detailNews?.title}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">Beschreibung</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mt: 0.5 }}>{detailNews?.description}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 4 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">Autor</Typography>
|
||||||
|
<Typography variant="body2">{detailNews?.author}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">Kategorie</Typography>
|
||||||
|
<Typography variant="body2">{detailNews?.category}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">Veröffentlicht</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{detailNews?.releaseDate ? new Date(detailNews.releaseDate).toLocaleDateString('de-DE') : '—'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{Array.isArray(detailNews?.picture) && detailNews.picture.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">Bilder</Typography>
|
||||||
|
<Stack spacing={1} sx={{ mt: 0.5 }}>
|
||||||
|
{detailNews.picture.map((url, i) => (
|
||||||
|
<Box
|
||||||
|
key={i}
|
||||||
|
component="img"
|
||||||
|
src={url}
|
||||||
|
alt={`Bild ${i + 1}`}
|
||||||
|
sx={{ width: '100%', borderRadius: 1, maxHeight: 200, objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2.5 }}>
|
||||||
|
<Button onClick={() => setDetailNews(null)}>Schließen</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user