Bootssymbol hinzugefügt
Test and publish container images / test (push) Successful in 2m20s
Test and publish container images / publish (push) Failing after 4s

This commit is contained in:
BuTzZ
2026-07-26 12:31:49 +02:00
parent c9a20aacd7
commit 55ce94066e
19 changed files with 1969 additions and 122 deletions
@@ -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;
}