'use client'; import { useTranslations } from 'next-intl'; import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { requestBrowserPermissionOnce } from '@/lib/reminder-notify'; import { defaultNewReminderInputs, isoToLocalInputs, localInputsToIso } from '@/lib/reminder-time'; import { createReminder, type Reminder, type ReminderEmailStatus, ReminderRequestError, updateReminder, } from '@/lib/reminders-api'; interface ReminderFormModalProps { /** `null` = neue Erinnerung anlegen, sonst diese (noch nicht faellige) bearbeiten. */ reminder: Reminder | null; /** Ergebnis von `GET /reminders/email-status`; `null` = unbekannt oder fehlgeschlagen (gilt als nicht moeglich). */ emailStatus: ReminderEmailStatus | null; onClose: () => void; /** Nach erfolgreichem Speichern (die Kachel laedt neu und meldet die Aenderung). */ onSaved: () => void; /** Der Server meldet, dass die Erinnerung inzwischen faellig ist (409) — die Kachel laedt neu. */ onStale?: () => void; } const TITLE_MAX = 200; const DESCRIPTION_MAX = 2000; /** * Formular „Neue Erinnerung“ (quick-260929-if2). Datum und Uhrzeit sind * Ortszeit; erst hier wird daraus ein ISO-Zeitpunkt. * * Gerendert per `createPortal` in `document.body`: die Kachel liegt in einem * `react-grid-item` mit CSS-`transform`, und ein transformierter Vorfahr * macht sich fuer `position: fixed` zum Bezugsrahmen (Vorbild * `picture-frame-lightbox.tsx`). Dialog-Aufbau wie `custom-module-form-modal`. * * BEARBEITEN (`reminder` gesetzt): nur fuer eine noch nicht faellige Erinnerung; * eine inzwischen faellige lehnt der Server mit 409 ab (`alreadyDue`). * * ERLAUBNIS (D-04): `requestBrowserPermissionOnce()` laeuft beim ANLEGEN als * ERSTES im Absende-Handler, synchron und noch vor jedem `await` — so bleibt der Aufruf * eine Nutzerhandlung, die der Browser fuer die Frage akzeptiert. Nie beim * Laden der Seite. */ export function ReminderFormModal({ reminder, emailStatus, onClose, onSaved, onStale, }: ReminderFormModalProps) { const t = useTranslations('widgets'); const initial = useRef( reminder ? isoToLocalInputs(reminder.dueAt) : defaultNewReminderInputs(new Date()), ).current; const [date, setDate] = useState(initial.date); const [time, setTime] = useState(initial.time); const [title, setTitle] = useState(reminder?.title ?? ''); const [description, setDescription] = useState(reminder?.description ?? ''); const [emailEnabled, setEmailEnabled] = useState(reminder?.emailEnabled ?? false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const titleRef = useRef(null); // Fokus nur EINMAL beim Oeffnen (quick-261001-g68): `onClose` kommt aus der // Kachel als neue Funktion bei jedem Neuzeichnen — die Kachel zeichnet alle // 10 s neu (NOW_TICK_MS). Hing der Fokus mit am `onClose`-Effekt, sprang der // Cursor beim Schreiben der Beschreibung immer wieder in den Titel. useEffect(() => { titleRef.current?.focus(); }, []); const onCloseRef = useRef(onClose); onCloseRef.current = onClose; useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onCloseRef.current(); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); // Als Erstes und synchron: Nutzerhandlung fuer die Browser-Erlaubnis (D-04). if (!reminder) requestBrowserPermissionOnce(); setError(null); const trimmedTitle = title.trim(); if (trimmedTitle === '') { setError(t('reminder.titleRequired')); return; } // Beim Bearbeiten nur senden, wenn Datum oder Uhrzeit wirklich geaendert // wurden: das Formular kennt keine Sekunden, ein unveraendertes Feld wuerde // die Faelligkeit sonst um bis zu 59 s verschieben — und kurz vor der // Faelligkeit faelschlich „in der Vergangenheit“ melden. Ist sie inzwischen // faellig, antwortet der Server ohnehin mit 409 (`alreadyDue`). const dueChanged = !reminder || date !== initial.date || time !== initial.time; const dueAt = dueChanged ? localInputsToIso(date, time) : undefined; // Spiegel der Serverregel: die Faelligkeit muss in der Zukunft liegen. if (dueChanged && (!dueAt || new Date(dueAt).getTime() <= Date.now())) { setError(t('reminder.pastError')); return; } setSaving(true); try { // `emailEnabled` nur senden, wenn es sich aendert oder beim Anlegen `true` // ist: ein Server, der E-Mail nicht mehr anbietet, lehnt `true` mit 400 // ab — eine unveraenderte Alt-Einstellung darf das Speichern nicht kippen. const emailChanged = reminder ? emailEnabled !== reminder.emailEnabled : emailEnabled; const common = { title: trimmedTitle, description: description.trim(), ...(emailChanged ? { emailEnabled } : {}), }; if (reminder) { await updateReminder(reminder.id, { ...common, ...(dueAt ? { dueAt } : {}) }); } else if (dueAt) { await createReminder({ ...common, dueAt }); } onSaved(); onClose(); } catch (err) { if (err instanceof ReminderRequestError && err.status === 409) { // Anlegen: Grenze erreicht; Bearbeiten: inzwischen faellig. setError(reminder ? t('reminder.alreadyDue') : t('reminder.limitReached')); if (reminder) onStale?.(); } else { setError(t('reminder.saveError')); } } finally { setSaving(false); } }; // E-Mail moeglich: Versand eingerichtet UND Adresse im Konto. Unbekannt gilt // als nicht moeglich. Ein bereits angehaktes Feld bleibt bedienbar, damit man // es wieder abwaehlen kann. const emailAvailable = emailStatus?.smtpConfigured === true && emailStatus.hasEmail; const emailHint = emailStatus === null || emailAvailable ? null : !emailStatus.smtpConfigured ? t('reminder.emailNoSmtp') : t('reminder.emailNoAddress'); if (typeof document === 'undefined') return null; return createPortal(

{reminder ? t('reminder.editTitle') : t('reminder.formTitle')}

setDate(e.target.value)} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
setTime(e.target.value)} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
setTitle(e.target.value)} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />