Files
tessera-ctl/apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx
T
schalli 59b8cd43fc
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 1m24s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m56s
fix(reminders): Cursor springt beim Schreiben nicht mehr in den Titel
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>
2026-10-01 11:38:58 +02:00

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,
);
}