Bootssymbol hinzugefügt
This commit is contained in:
@@ -0,0 +1,459 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user