59b8cd43fc
Der Fokus auf das Titelfeld lag im selben Effekt wie der Escape-Listener mit Abhaengigkeit onClose. Die Kachel uebergibt onClose inline und zeichnet alle 10 s neu (NOW_TICK_MS) - der Effekt lief jedes Mal erneut und holte den Cursor aus der Beschreibung zurueck in den Titel. Fokus jetzt nur beim Oeffnen, Escape ueber eine Ref. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
263 lines
10 KiB
TypeScript
263 lines
10 KiB
TypeScript
'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<string | null>(null);
|
|
const titleRef = useRef<HTMLInputElement>(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(
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="reminder-form-title"
|
|
className="w-full max-w-md rounded-lg bg-card p-6 shadow-lg dark:border dark:border-border"
|
|
>
|
|
<h2 id="reminder-form-title" className="mb-4 text-lg font-semibold text-foreground">
|
|
{reminder ? t('reminder.editTitle') : t('reminder.formTitle')}
|
|
</h2>
|
|
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
|
<div className="flex gap-3">
|
|
<div className="flex-1 space-y-2">
|
|
<label htmlFor="reminder-date" className="text-sm font-medium text-foreground">
|
|
{t('reminder.dateLabel')}
|
|
</label>
|
|
<input
|
|
id="reminder-date"
|
|
type="date"
|
|
value={date}
|
|
onChange={(e) => setDate(e.target.value)}
|
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="flex-1 space-y-2">
|
|
<label htmlFor="reminder-time" className="text-sm font-medium text-foreground">
|
|
{t('reminder.timeLabel')}
|
|
</label>
|
|
<input
|
|
id="reminder-time"
|
|
type="time"
|
|
value={time}
|
|
onChange={(e) => setTime(e.target.value)}
|
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label htmlFor="reminder-title" className="text-sm font-medium text-foreground">
|
|
{t('reminder.titleLabel')}
|
|
</label>
|
|
<input
|
|
id="reminder-title"
|
|
ref={titleRef}
|
|
type="text"
|
|
maxLength={TITLE_MAX}
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label htmlFor="reminder-description" className="text-sm font-medium text-foreground">
|
|
{t('reminder.descriptionLabel')}
|
|
</label>
|
|
<textarea
|
|
id="reminder-description"
|
|
rows={3}
|
|
maxLength={DESCRIPTION_MAX}
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<label className="flex items-center gap-2 text-sm text-foreground">
|
|
<input
|
|
type="checkbox"
|
|
checked={emailEnabled}
|
|
disabled={!emailAvailable && !emailEnabled}
|
|
onChange={(e) => setEmailEnabled(e.target.checked)}
|
|
aria-describedby={emailHint ? 'reminder-email-hint' : undefined}
|
|
/>
|
|
{t('reminder.emailLabel')}
|
|
</label>
|
|
{emailHint && (
|
|
<p id="reminder-email-hint" className="text-xs text-muted-foreground">
|
|
{emailHint}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{error && (
|
|
<div
|
|
role="alert"
|
|
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
|
|
>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end gap-3 pt-2">
|
|
<button type="button" onClick={onClose} className="btn btn-secondary">
|
|
{t('reminder.cancel')}
|
|
</button>
|
|
<button type="submit" disabled={saving} className="btn btn-primary">
|
|
{t('reminder.save')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|