dockerfile, fixes, organization edit page
This commit is contained in:
29
Dockerfile
Normal file
29
Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# 1. Build-Stage
|
||||||
|
FROM node:18-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# package files kopieren
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# dependencies installieren
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# restlichen Code kopieren
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# React App bauen
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 2. Serve-Stage (Nginx)
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# build output in nginx kopieren
|
||||||
|
COPY --from=build /app/build /usr/share/nginx/html
|
||||||
|
|
||||||
|
# optional: eigene nginx config
|
||||||
|
# COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
11
src/App.jsx
11
src/App.jsx
@@ -10,6 +10,7 @@ import PostsPage from './pages/PostsPage';
|
|||||||
import CreateNewsPage from './pages/CreateNewsPage';
|
import CreateNewsPage from './pages/CreateNewsPage';
|
||||||
import OrganizationPage from './pages/OrganizationPage';
|
import OrganizationPage from './pages/OrganizationPage';
|
||||||
import PushPage from './pages/PushPage';
|
import PushPage from './pages/PushPage';
|
||||||
|
import EditOrganizationPage from "./pages/EditOrganizationPage.jsx";
|
||||||
|
|
||||||
const theme = createTheme({
|
const theme = createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
@@ -69,10 +70,18 @@ export default function App() {
|
|||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="organizations/:id/edit"
|
||||||
|
element={
|
||||||
|
<PrivateRoute allowedRoles={['ADMIN', 'SITE_OWNER']}>
|
||||||
|
<EditOrganizationPage/>
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="organizations"
|
path="organizations"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute allowedRoles={['ADMIN', 'REPORTER']}>
|
<PrivateRoute allowedRoles={['ADMIN', 'SITE_OWNER']}>
|
||||||
<OrganizationPage />
|
<OrganizationPage />
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
|
|||||||
227
src/pages/EditOrganizationPage.jsx
Normal file
227
src/pages/EditOrganizationPage.jsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import {useNavigate} from "react-router-dom";
|
||||||
|
import {useState} from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box, Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Checkbox, Chip, CircularProgress, Divider,
|
||||||
|
FormControlLabel,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography
|
||||||
|
} from "@mui/material";
|
||||||
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||||
|
import StarIcon from "@mui/icons-material/Star";
|
||||||
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||||
|
import AddPhotoAlternateOutlinedIcon from "@mui/icons-material/AddPhotoAlternateOutlined";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import axiosInstance from "../api/axiosInstance.js";
|
||||||
|
|
||||||
|
const EMPTY_FORM = {
|
||||||
|
description: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
address: '',
|
||||||
|
website: '',
|
||||||
|
contactPerson: '',
|
||||||
|
active: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditOrganizationPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [form, setForm] = useState(EMPTY_FORM);
|
||||||
|
const [files, setFiles] = useState([]);
|
||||||
|
const [previews, setPreviews] = useState([]);
|
||||||
|
const [existingImages, setExistingImages] = useState([]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const { id } = useParams();
|
||||||
|
const [data, setData] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get('/api/organizations/' + id);
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
setData(data);
|
||||||
|
|
||||||
|
setForm({
|
||||||
|
description: data.description || '',
|
||||||
|
phone: data.phone || '',
|
||||||
|
email: data.email || '',
|
||||||
|
address: data.address || '',
|
||||||
|
website: data.website || '',
|
||||||
|
contactPerson: data.contactPerson || '',
|
||||||
|
active: data.active || false,
|
||||||
|
});
|
||||||
|
|
||||||
|
setExistingImages(data.images || []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data || 'Fehler beim Laden');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
const { name, value, type, checked } = e.target;
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name]: type === 'checkbox' ? checked : value
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveExisting = (index) => {
|
||||||
|
setExistingImages((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
/*
|
||||||
|
Drauf achten alte und neue Bilder zu verwenden
|
||||||
|
Vielleicht extra Feld für Anzeigebild, anstatt erstes zu nehmen
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||||
|
<IconButton onClick={() => navigate('/organizations')} size="small">
|
||||||
|
<ArrowBackIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Typography variant="h5" fontWeight={600}>Organisation bearbeiten</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="Beschreibung" name="description" value={form.description} onChange={handleChange} required fullWidth autoFocus multiline rows={6}/>
|
||||||
|
<TextField label="Telefonnummber" name="phone" value={form.phone} onChange={handleChange} required fullWidth/>
|
||||||
|
<TextField label="Email Adresse" name="email" value={form.email} onChange={handleChange} required fullWidth/>
|
||||||
|
<TextField label="Adresse" name="address" value={form.address} onChange={handleChange} required fullWidth/>
|
||||||
|
<TextField label="Webseite" name="website" value={form.website} onChange={handleChange} required fullWidth/>
|
||||||
|
<TextField label="Kontaktperson" name="contactPerson" value={form.contactPerson} onChange={handleChange} required fullWidth/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
name="active"
|
||||||
|
checked={form.active}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({ ...prev, active: e.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Aktiv"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{existingImages.map((src, i) => (
|
||||||
|
<Box key={`existing-${i}`}>
|
||||||
|
<img src={src} width={64} height={64} />
|
||||||
|
<IconButton onClick={() => handleRemoveExisting(i)}>
|
||||||
|
<DeleteOutlineIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end', pt: 1 }}>
|
||||||
|
<Button onClick={() => navigate('/organizations')} disabled={saving}>Abbrechen</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
startIcon={saving ? <CircularProgress size={16} color="inherit" /> : null}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
|||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import { Chip } from '@mui/material';
|
import { Chip } from '@mui/material';
|
||||||
import axiosInstance from '../api/axiosInstance';
|
import axiosInstance from '../api/axiosInstance';
|
||||||
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||||
|
import {useNavigate} from "react-router-dom";
|
||||||
|
|
||||||
export default function OrganizationPage() {
|
export default function OrganizationPage() {
|
||||||
const [rows, setRows] = useState([]);
|
const [rows, setRows] = useState([]);
|
||||||
@@ -18,6 +20,7 @@ export default function OrganizationPage() {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [creatingError, setCreatingError] = useState('');
|
const [creatingError, setCreatingError] = useState('');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [createForm, setCreateForm] = useState({
|
const [createForm, setCreateForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -83,6 +86,10 @@ export default function OrganizationPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEdit = (id) => {
|
||||||
|
navigate(`/organizations/${id}/edit`);
|
||||||
|
}
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ field: 'id', headerName: 'ID', width: 70 },
|
{ field: 'id', headerName: 'ID', width: 70 },
|
||||||
{ field: 'name', headerName: 'Name', flex: 1.5 },
|
{ field: 'name', headerName: 'Name', flex: 1.5 },
|
||||||
@@ -112,13 +119,16 @@ export default function OrganizationPage() {
|
|||||||
{
|
{
|
||||||
field: 'actions',
|
field: 'actions',
|
||||||
headerName: '',
|
headerName: '',
|
||||||
width: 60,
|
width: 120,
|
||||||
sortable: false,
|
sortable: false,
|
||||||
renderCell: ({ row }) => (
|
renderCell: ({ row }) => (
|
||||||
<Tooltip title="Löschen">
|
<Tooltip title="Löschen">
|
||||||
<IconButton size="small" color="error" onClick={() => handleDelete(row.id)}>
|
<IconButton size="small" color="error" onClick={() => handleDelete(row.id)}>
|
||||||
<DeleteOutlineIcon fontSize="small" />
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
<IconButton size="small" color="grey" onClick={() => handleEdit(row.id)}>
|
||||||
|
<EditOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ export default function UsersPage() {
|
|||||||
<MenuItem value="ADMIN">ADMIN</MenuItem>
|
<MenuItem value="ADMIN">ADMIN</MenuItem>
|
||||||
<MenuItem value="REPORTER">REPORTER</MenuItem>
|
<MenuItem value="REPORTER">REPORTER</MenuItem>
|
||||||
<MenuItem value="USER">USER</MenuItem>
|
<MenuItem value="USER">USER</MenuItem>
|
||||||
|
<MenuItem value="SITE_OWNER">SITE_OWNER</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
Reference in New Issue
Block a user