235 lines
9.4 KiB
JavaScript
235 lines
9.4 KiB
JavaScript
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.js';
|
||
|
||
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('/news', formData, {
|
||
headers: { 'Content-Type': 'multipart/form-data' },
|
||
});
|
||
navigate('/posts');
|
||
} catch (err) {
|
||
setError(err.response?.data?.error ?? '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">
|
||
{typeof error === 'string' ? error : JSON.stringify(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/jpeg,image/png,image/webp" multiple onChange={handleFileChange} />
|
||
</Button>
|
||
</Stack>
|
||
</Box>
|
||
|
||
<Divider />
|
||
|
||
{/* Push Notification */}
|
||
<Box>
|
||
<Typography variant="subtitle2" fontWeight={500} mb={1.5}>Push Notification</Typography>
|
||
|
||
<Alert
|
||
severity="info"
|
||
variant="outlined"
|
||
sx={{ py: 0.5, mb: 2, fontSize: 13 }}
|
||
>
|
||
Wird gebündelt nach ~30 min gesendet. Kein Versand bei Ruhezeiten (22–8 Uhr),
|
||
wenn in den letzten 90 min bereits gesendet wurde oder das Tageslimit (3) erreicht ist.
|
||
</Alert>
|
||
|
||
<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>
|
||
);
|
||
} |