460 lines
13 KiB
TypeScript
460 lines
13 KiB
TypeScript
import {
|
|
AlertTriangle,
|
|
CheckCircle2,
|
|
Cookie,
|
|
Save,
|
|
Sailboat,
|
|
Trash2,
|
|
X
|
|
} from "lucide-react";
|
|
import { useEffect, useId, useState, type FormEvent } from "react";
|
|
import type { BoatCategory, BoatProfile } from "@watermaps/shared";
|
|
import {
|
|
BOAT_CATEGORY_OPTIONS,
|
|
validateBoatProfile
|
|
} from "../lib/boat-profile";
|
|
import "./BoatProfilePanel.css";
|
|
|
|
export type BoatProfilePanelProps = {
|
|
profile: BoatProfile;
|
|
stored: boolean;
|
|
hasActiveRoute?: boolean;
|
|
onSave: (profile: BoatProfile) => boolean;
|
|
onDelete: () => boolean;
|
|
};
|
|
|
|
type Feedback = {
|
|
kind: "success" | "error";
|
|
message: string;
|
|
};
|
|
|
|
type BoatProfileDraft = {
|
|
name: string;
|
|
category: BoatCategory;
|
|
lengthM: string;
|
|
beamM: string;
|
|
airDraftM: string;
|
|
draughtM: string;
|
|
safetyReserveM: string;
|
|
cruiseSpeedKn: string;
|
|
bowRollerHeightM: string;
|
|
};
|
|
|
|
type NumericDraftKey = Exclude<keyof BoatProfileDraft, "name" | "category">;
|
|
|
|
export function BoatProfilePanel({
|
|
profile,
|
|
stored,
|
|
hasActiveRoute = false,
|
|
onSave,
|
|
onDelete
|
|
}: BoatProfilePanelProps) {
|
|
const headingId = useId();
|
|
const categoryHelpId = useId();
|
|
const deleteConfirmationId = useId();
|
|
const [draft, setDraft] = useState<BoatProfileDraft>(() => toDraft(profile));
|
|
const [feedback, setFeedback] = useState<Feedback | null>(null);
|
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setDraft(toDraft(profile));
|
|
}, [profile]);
|
|
|
|
useEffect(() => {
|
|
if (!stored) {
|
|
setConfirmDelete(false);
|
|
}
|
|
}, [stored]);
|
|
|
|
const updateName = (name: string) => {
|
|
setDraft((current) => ({ ...current, name }));
|
|
setFeedback(null);
|
|
};
|
|
|
|
const updateCategory = (category: BoatCategory) => {
|
|
setDraft((current) => ({ ...current, category }));
|
|
setFeedback(null);
|
|
};
|
|
|
|
const updateNumber = (key: NumericDraftKey, value: string) => {
|
|
setDraft((current) => ({ ...current, [key]: value }));
|
|
setFeedback(null);
|
|
};
|
|
|
|
const save = (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
setConfirmDelete(false);
|
|
|
|
const validation = validateBoatProfile(toProfile(draft));
|
|
if (!validation.valid) {
|
|
setFeedback({ kind: "error", message: validation.error });
|
|
return;
|
|
}
|
|
|
|
if (!onSave(validation.profile)) {
|
|
setFeedback({
|
|
kind: "error",
|
|
message:
|
|
"Die Bootsdaten konnten nicht im Cookie gespeichert werden. Bitte prüfe die Cookie-Einstellungen dieses Browsers."
|
|
});
|
|
return;
|
|
}
|
|
|
|
setDraft(toDraft(validation.profile));
|
|
setFeedback({
|
|
kind: "success",
|
|
message: "Die Bootsdaten wurden in diesem Browser gespeichert."
|
|
});
|
|
};
|
|
|
|
const deleteProfile = () => {
|
|
if (!onDelete()) {
|
|
setFeedback({
|
|
kind: "error",
|
|
message:
|
|
"Die gespeicherten Bootsdaten konnten nicht gelöscht werden. Bitte prüfe die Cookie-Einstellungen dieses Browsers."
|
|
});
|
|
return;
|
|
}
|
|
|
|
setConfirmDelete(false);
|
|
setFeedback({
|
|
kind: "success",
|
|
message: "Die gespeicherten Bootsdaten wurden aus diesem Browser gelöscht."
|
|
});
|
|
};
|
|
|
|
return (
|
|
<section className="boat-profile-panel" aria-labelledby={headingId}>
|
|
<header className="boat-profile-panel-intro">
|
|
<span className="boat-profile-panel-icon" aria-hidden="true">
|
|
<Sailboat size={23} />
|
|
</span>
|
|
<div>
|
|
<div className="boat-profile-panel-title">
|
|
<h2 id={headingId}>Aktuelles Boot</h2>
|
|
<span data-stored={stored}>
|
|
{stored ? "Im Cookie gespeichert" : "Noch nicht gespeichert"}
|
|
</span>
|
|
</div>
|
|
<p>
|
|
Diese Werte gelten automatisch für neue Routen und die weiteren
|
|
Navigationswerkzeuge.
|
|
</p>
|
|
</div>
|
|
</header>
|
|
|
|
{hasActiveRoute && (
|
|
<div className="boat-profile-route-warning">
|
|
<AlertTriangle size={18} aria-hidden="true" />
|
|
<p>
|
|
<strong>Aktive Route:</strong> Beim Speichern wird sie verworfen,
|
|
weil sie noch mit den bisherigen Bootsdaten berechnet wurde.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<form className="boat-profile-form" onSubmit={save}>
|
|
<div className="boat-profile-identity">
|
|
<TextField
|
|
label="Name des Bootes"
|
|
help="Wird in der App als aktuelles Boot angezeigt."
|
|
value={draft.name}
|
|
onChange={updateName}
|
|
/>
|
|
|
|
<div className="boat-profile-field">
|
|
<label htmlFor={`${categoryHelpId}-input`}>Kategorie</label>
|
|
<select
|
|
id={`${categoryHelpId}-input`}
|
|
value={draft.category}
|
|
onChange={(event) =>
|
|
updateCategory(event.target.value as BoatCategory)
|
|
}
|
|
aria-describedby={categoryHelpId}
|
|
required
|
|
>
|
|
{BOAT_CATEGORY_OPTIONS.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<small id={categoryHelpId}>
|
|
Beschreibt den Bootstyp; beeinflusst das Routing derzeit nicht.
|
|
</small>
|
|
</div>
|
|
</div>
|
|
|
|
<fieldset className="boat-profile-dimensions">
|
|
<legend>Abmessungen und Fahrtwerte</legend>
|
|
<p>
|
|
Bitte die Werte für den normalen Fahrzustand eintragen. Sie
|
|
beeinflussen Durchfahrt, Tiefenprüfung und Fahrtdauer.
|
|
</p>
|
|
|
|
<div className="boat-profile-field-grid">
|
|
<NumberField
|
|
label="Länge über alles"
|
|
help="Größte feste Länge von Bug bis Heck."
|
|
unit="m"
|
|
min={0.01}
|
|
max={400}
|
|
value={draft.lengthM}
|
|
onChange={(value) => updateNumber("lengthM", value)}
|
|
/>
|
|
<NumberField
|
|
label="Breite"
|
|
help="Größte Breite des Bootes."
|
|
unit="m"
|
|
min={0.01}
|
|
max={80}
|
|
value={draft.beamM}
|
|
onChange={(value) => updateNumber("beamM", value)}
|
|
/>
|
|
<NumberField
|
|
label="Durchfahrtshöhe ab Wasserlinie"
|
|
help="Bis zum höchsten festen Punkt; wichtig für Brücken."
|
|
unit="m"
|
|
min={0.01}
|
|
max={80}
|
|
value={draft.airDraftM}
|
|
onChange={(value) => updateNumber("airDraftM", value)}
|
|
/>
|
|
<NumberField
|
|
label="Tiefgang"
|
|
help="Wasserlinie bis zum tiefsten Punkt."
|
|
unit="m"
|
|
min={0.01}
|
|
max={15}
|
|
value={draft.draughtM}
|
|
onChange={(value) => updateNumber("draughtM", value)}
|
|
/>
|
|
<NumberField
|
|
label="Sicherheitsreserve"
|
|
help="Zusätzlicher Abstand zum Grund beim Routing."
|
|
unit="m"
|
|
min={0}
|
|
max={10}
|
|
value={draft.safetyReserveM}
|
|
onChange={(value) => updateNumber("safetyReserveM", value)}
|
|
/>
|
|
<NumberField
|
|
label="Reisegeschwindigkeit"
|
|
help="Normale, dauerhaft fahrbare Geschwindigkeit."
|
|
unit="kn"
|
|
min={0.01}
|
|
max={80}
|
|
value={draft.cruiseSpeedKn}
|
|
onChange={(value) => updateNumber("cruiseSpeedKn", value)}
|
|
/>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset className="boat-profile-dimensions boat-profile-anchor-details">
|
|
<legend>Ankern</legend>
|
|
<p>
|
|
Dieser Wert ist von der Durchfahrtshöhe getrennt und wird für die
|
|
Ketten- und Trossenberechnung verwendet.
|
|
</p>
|
|
|
|
<div className="boat-profile-field-grid">
|
|
<NumberField
|
|
label="Bugrolle über Wasserlinie"
|
|
help="Senkrechter Abstand von der Wasserlinie bis zum Austritt der Ankerkette."
|
|
unit="m"
|
|
min={0}
|
|
max={20}
|
|
value={draft.bowRollerHeightM}
|
|
onChange={(value) => updateNumber("bowRollerHeightM", value)}
|
|
/>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<div className="boat-profile-cookie-note">
|
|
<Cookie size={18} aria-hidden="true" />
|
|
<p>
|
|
Die Bootsdaten werden in einem Cookie dieses Browsers gespeichert
|
|
und bei Anfragen an Watermaps mitgesendet. Keine persönlichen Daten
|
|
eintragen. Die Daten lassen sich hier jederzeit löschen.
|
|
</p>
|
|
</div>
|
|
|
|
{feedback && (
|
|
<p
|
|
className="boat-profile-feedback"
|
|
data-kind={feedback.kind}
|
|
role={feedback.kind === "error" ? "alert" : "status"}
|
|
aria-live="polite"
|
|
>
|
|
{feedback.kind === "success" ? (
|
|
<CheckCircle2 size={17} aria-hidden="true" />
|
|
) : (
|
|
<AlertTriangle size={17} aria-hidden="true" />
|
|
)}
|
|
{feedback.message}
|
|
</p>
|
|
)}
|
|
|
|
<div className="boat-profile-actions">
|
|
{stored && (
|
|
<button
|
|
className="boat-profile-delete"
|
|
type="button"
|
|
onClick={() => {
|
|
setConfirmDelete(true);
|
|
setFeedback(null);
|
|
}}
|
|
aria-expanded={confirmDelete}
|
|
aria-controls={deleteConfirmationId}
|
|
>
|
|
<Trash2 size={17} aria-hidden="true" />
|
|
Bootsdaten löschen
|
|
</button>
|
|
)}
|
|
<button className="boat-profile-save" type="submit">
|
|
<Save size={17} aria-hidden="true" />
|
|
Bootsdaten speichern
|
|
</button>
|
|
</div>
|
|
|
|
{stored && confirmDelete && (
|
|
<div
|
|
id={deleteConfirmationId}
|
|
className="boat-profile-delete-confirmation"
|
|
role="group"
|
|
aria-label="Löschen der Bootsdaten bestätigen"
|
|
>
|
|
<p>
|
|
Bootsdaten wirklich aus diesem Browser löschen? Neue Routen
|
|
verwenden danach bis zum erneuten Speichern wieder die
|
|
Standardwerte.
|
|
</p>
|
|
<div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirmDelete(false)}
|
|
>
|
|
<X size={16} aria-hidden="true" />
|
|
Abbrechen
|
|
</button>
|
|
<button type="button" onClick={deleteProfile}>
|
|
<Trash2 size={16} aria-hidden="true" />
|
|
Endgültig löschen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</form>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function TextField({
|
|
label,
|
|
help,
|
|
value,
|
|
onChange
|
|
}: {
|
|
label: string;
|
|
help: string;
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
}) {
|
|
const id = useId();
|
|
const helpId = `${id}-help`;
|
|
|
|
return (
|
|
<div className="boat-profile-field">
|
|
<label htmlFor={id}>{label}</label>
|
|
<input
|
|
id={id}
|
|
type="text"
|
|
value={value}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
aria-describedby={helpId}
|
|
autoComplete="off"
|
|
minLength={1}
|
|
maxLength={80}
|
|
required
|
|
/>
|
|
<small id={helpId}>{help}</small>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NumberField({
|
|
label,
|
|
help,
|
|
unit,
|
|
min,
|
|
max,
|
|
value,
|
|
onChange
|
|
}: {
|
|
label: string;
|
|
help: string;
|
|
unit: "m" | "kn";
|
|
min: number;
|
|
max: number;
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
}) {
|
|
const id = useId();
|
|
const helpId = `${id}-help`;
|
|
|
|
return (
|
|
<div className="boat-profile-field">
|
|
<label htmlFor={id}>{label}</label>
|
|
<span className="boat-profile-number-input">
|
|
<input
|
|
id={id}
|
|
type="number"
|
|
inputMode="decimal"
|
|
value={value}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
aria-describedby={helpId}
|
|
min={min}
|
|
max={max}
|
|
step="any"
|
|
required
|
|
/>
|
|
<span aria-hidden="true">{unit}</span>
|
|
</span>
|
|
<small id={helpId}>{help}</small>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function toDraft(profile: BoatProfile): BoatProfileDraft {
|
|
return {
|
|
name: profile.name,
|
|
category: profile.category,
|
|
lengthM: String(profile.lengthM),
|
|
beamM: String(profile.beamM),
|
|
airDraftM: String(profile.airDraftM),
|
|
draughtM: String(profile.draughtM),
|
|
safetyReserveM: String(profile.safetyReserveM),
|
|
cruiseSpeedKn: String(profile.cruiseSpeedKn),
|
|
bowRollerHeightM: String(profile.bowRollerHeightM)
|
|
};
|
|
}
|
|
|
|
function toProfile(draft: BoatProfileDraft): BoatProfile {
|
|
return {
|
|
name: draft.name,
|
|
category: draft.category,
|
|
lengthM: parseNumber(draft.lengthM),
|
|
beamM: parseNumber(draft.beamM),
|
|
airDraftM: parseNumber(draft.airDraftM),
|
|
draughtM: parseNumber(draft.draughtM),
|
|
safetyReserveM: parseNumber(draft.safetyReserveM),
|
|
cruiseSpeedKn: parseNumber(draft.cruiseSpeedKn),
|
|
bowRollerHeightM: parseNumber(draft.bowRollerHeightM)
|
|
};
|
|
}
|
|
|
|
function parseNumber(value: string): number {
|
|
return value.trim() ? Number(value) : Number.NaN;
|
|
}
|