docs: extract frontend design rules and add presentation persistence fix
Create FRONTEND_DESIGN_RULES.md as the single source of truth for all dashboard UI conventions, including component library (Syncfusion first), component defaults table, layout structure, buttons, dialogs, badges, toasts, form fields, tabs, statistics cards, warnings, color palette, CSS files, loading states, locale rules, and icon conventions (TentTree for skip-holidays events). Move embedded design rules from ACADEMIC_PERIODS_CRUD_BUILD_PLAN.md to the new file and replace with a reference link for maintainability. Update copilot-instructions.md to point to FRONTEND_DESIGN_RULES.md and remove redundant Syncfusion/Tailwind prose from the Theming section. Add reference blockquote to README.md under Frontend Features directing readers to FRONTEND_DESIGN_RULES.md. Bug fix: Presentation events now reliably persist page_progress and auto_progress flags across create, update, and detached occurrence flows so display settings survive round-trips to the API. Files changed: - Created: FRONTEND_DESIGN_RULES.md (15 sections, 340+ lines) - Modified: ACADEMIC_PERIODS_CRUD_BUILD_PLAN.md (extract rules, consolidate) - Modified: .github/copilot-instructions.md (link to new rules file) - Modified: README.md (reference blockquote)
This commit is contained in:
@@ -97,11 +97,6 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
const [media, setMedia] = React.useState<{ id: string; path: string; name: string } | null>(
|
||||
initialData.media ?? null
|
||||
);
|
||||
const [pendingMedia, setPendingMedia] = React.useState<{
|
||||
id: string;
|
||||
path: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
// General settings state for presentation
|
||||
// Removed unused generalLoaded and setGeneralLoaded
|
||||
// Removed unused generalLoaded/generalSlideshowInterval/generalPageProgress/generalAutoProgress
|
||||
@@ -124,6 +119,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
const [volume, setVolume] = React.useState<number>(initialData.volume ?? 0.8);
|
||||
const [muted, setMuted] = React.useState<boolean>(initialData.muted ?? false);
|
||||
const [videoDefaultsLoaded, setVideoDefaultsLoaded] = React.useState<boolean>(false);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
|
||||
const [mediaModalOpen, setMediaModalOpen] = React.useState(false);
|
||||
|
||||
@@ -203,14 +199,11 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
}
|
||||
}, [open, initialData, editMode]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!mediaModalOpen && pendingMedia) {
|
||||
setMedia(pendingMedia);
|
||||
setPendingMedia(null);
|
||||
}
|
||||
}, [mediaModalOpen, pendingMedia]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newErrors: { [key: string]: string } = {};
|
||||
if (!title.trim()) newErrors.title = 'Titel ist erforderlich';
|
||||
if (!startDate) newErrors.startDate = 'Startdatum ist erforderlich';
|
||||
@@ -253,6 +246,14 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
if (type === 'video') {
|
||||
if (!media) newErrors.media = 'Bitte ein Video auswählen';
|
||||
}
|
||||
|
||||
const parsedMediaId = media?.id ? Number(media.id) : null;
|
||||
if (
|
||||
(type === 'presentation' || type === 'video') &&
|
||||
(!Number.isFinite(parsedMediaId) || (parsedMediaId as number) <= 0)
|
||||
) {
|
||||
newErrors.media = 'Ausgewähltes Medium ist ungültig. Bitte Datei erneut auswählen.';
|
||||
}
|
||||
// Holiday blocking logic removed (blockHolidays, isHolidayRange no longer used)
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
@@ -260,6 +261,8 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
const group_id = typeof groupName === 'object' && groupName !== null ? groupName.id : groupName;
|
||||
|
||||
// Build recurrence rule if repeat is enabled
|
||||
@@ -323,7 +326,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
};
|
||||
|
||||
if (type === 'presentation') {
|
||||
payload.event_media_id = media?.id ? Number(media.id) : undefined;
|
||||
payload.event_media_id = parsedMediaId as number;
|
||||
payload.slideshow_interval = slideshowInterval;
|
||||
payload.page_progress = pageProgress;
|
||||
payload.auto_progress = autoProgress;
|
||||
@@ -334,7 +337,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
}
|
||||
|
||||
if (type === 'video') {
|
||||
payload.event_media_id = media?.id ? Number(media.id) : undefined;
|
||||
payload.event_media_id = parsedMediaId as number;
|
||||
payload.autoplay = autoplay;
|
||||
payload.loop = loop;
|
||||
payload.volume = volume;
|
||||
@@ -378,12 +381,29 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
}
|
||||
} else {
|
||||
// CREATE
|
||||
res = await fetch('/api/events', {
|
||||
const createResponse = await fetch('/api/events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
res = await res.json();
|
||||
|
||||
let createData: { success?: boolean; error?: string } = {};
|
||||
try {
|
||||
createData = await createResponse.json();
|
||||
} catch {
|
||||
createData = { error: `HTTP ${createResponse.status}` };
|
||||
}
|
||||
|
||||
if (!createResponse.ok) {
|
||||
setErrors({
|
||||
api:
|
||||
createData.error ||
|
||||
`Fehler beim Speichern (HTTP ${createResponse.status})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res = createData;
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
@@ -394,6 +414,8 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
}
|
||||
} catch {
|
||||
setErrors({ api: 'Netzwerkfehler beim Speichern' });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
|
||||
};
|
||||
@@ -454,14 +476,29 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
<button
|
||||
className="e-btn e-success"
|
||||
onClick={handleSave}
|
||||
disabled={shouldDisableButton} // <--- Button nur für Einzeltermine in Vergangenheit deaktivieren
|
||||
disabled={shouldDisableButton || isSaving} // <--- Button nur für Einzeltermine in Vergangenheit deaktivieren
|
||||
>
|
||||
Termin(e) speichern
|
||||
{isSaving ? 'Speichert...' : 'Termin(e) speichern'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div style={{ padding: '24px' }}>
|
||||
{errors.api && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
color: '#721c24',
|
||||
background: '#f8d7da',
|
||||
border: '1px solid #f5c6cb',
|
||||
borderRadius: 4,
|
||||
padding: '8px 12px',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{errors.api}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: 1, minWidth: 260 }}>
|
||||
{/* ...Titel, Beschreibung, Datum, Zeit... */}
|
||||
@@ -640,6 +677,10 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
<span style={{ color: '#888' }}>Kein Medium ausgewählt</span>
|
||||
)}
|
||||
</div>
|
||||
{errors.media && <div style={{ color: 'red', fontSize: 12 }}>{errors.media}</div>}
|
||||
{errors.slideshowInterval && (
|
||||
<div style={{ color: 'red', fontSize: 12 }}>{errors.slideshowInterval}</div>
|
||||
)}
|
||||
<TextBoxComponent
|
||||
placeholder="Slideshow-Intervall (Sekunden)"
|
||||
floatLabelType="Auto"
|
||||
@@ -692,6 +733,7 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
<span style={{ color: '#888' }}>Kein Video ausgewählt</span>
|
||||
)}
|
||||
</div>
|
||||
{errors.media && <div style={{ color: 'red', fontSize: 12 }}>{errors.media}</div>}
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<CheckBoxComponent
|
||||
label="Automatisch abspielen"
|
||||
@@ -737,7 +779,13 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
|
||||
open={mediaModalOpen}
|
||||
onClose={() => setMediaModalOpen(false)}
|
||||
onSelect={({ id, path, name }) => {
|
||||
setPendingMedia({ id, path, name });
|
||||
setMedia({ id, path, name });
|
||||
setErrors(prev => {
|
||||
if (!prev.media) return prev;
|
||||
const next = { ...prev };
|
||||
delete next.media;
|
||||
return next;
|
||||
});
|
||||
setMediaModalOpen(false);
|
||||
}}
|
||||
selectedFileId={null}
|
||||
|
||||
@@ -28,6 +28,7 @@ const CustomSelectUploadEventModal: React.FC<CustomSelectUploadEventModalProps>
|
||||
path: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const [selectionError, setSelectionError] = useState<string>('');
|
||||
|
||||
// Callback für Dateiauswahl
|
||||
interface FileSelectEventArgs {
|
||||
@@ -42,6 +43,7 @@ const CustomSelectUploadEventModal: React.FC<CustomSelectUploadEventModalProps>
|
||||
const handleFileSelect = async (args: FileSelectEventArgs) => {
|
||||
if (args.fileDetails.isFile && args.fileDetails.size > 0) {
|
||||
const filename = args.fileDetails.name;
|
||||
setSelectionError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
@@ -51,10 +53,13 @@ const CustomSelectUploadEventModal: React.FC<CustomSelectUploadEventModalProps>
|
||||
const data = await response.json();
|
||||
setSelectedFile({ id: data.id, path: data.file_path, name: filename });
|
||||
} else {
|
||||
setSelectedFile({ id: filename, path: filename, name: filename });
|
||||
setSelectedFile(null);
|
||||
setSelectionError('Datei ist noch nicht als Medium registriert. Bitte erneut hochladen oder Metadaten prüfen.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching file details:', e);
|
||||
setSelectedFile(null);
|
||||
setSelectionError('Medium-ID konnte nicht geladen werden. Bitte erneut versuchen.');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -135,6 +140,9 @@ const CustomSelectUploadEventModal: React.FC<CustomSelectUploadEventModalProps>
|
||||
>
|
||||
<Inject services={[NavigationPane, DetailsView, Toolbar]} />
|
||||
</FileManagerComponent>
|
||||
{selectionError && (
|
||||
<div style={{ marginTop: 10, color: '#b71c1c', fontSize: 13 }}>{selectionError}</div>
|
||||
)}
|
||||
</DialogComponent>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user