From e85beba2bcf91d5312532e61f1dadccd76f77679 Mon Sep 17 00:00:00 2001 From: elpatron Date: Thu, 28 May 2026 12:44:30 +0200 Subject: [PATCH] feat(sails): support configuring available sails list in VesselForm and interactive sails selector in LogEntryEditor --- client/src/App.css | 122 +++++++++++++++++++++++ client/src/components/LogEntryEditor.tsx | 70 ++++++++++++- client/src/components/VesselForm.tsx | 72 ++++++++++++- client/src/i18n/locales/de.json | 8 +- client/src/i18n/locales/en.json | 8 +- 5 files changed, 275 insertions(+), 5 deletions(-) diff --git a/client/src/App.css b/client/src/App.css index 7306929..3d5c242 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -1516,3 +1516,125 @@ body:has(.theme-cupertino) { gap: 16px; width: 100%; } + +/* Sails List UI */ +.sails-section { + grid-column: span 2; + border-top: 1px solid #1e293b; + padding-top: 20px; + margin-top: 10px; +} + +.sails-section h3 { + font-size: 16px; + color: #fbbf24; + margin: 0 0 8px 0; +} + +.sails-section p.help-text { + font-size: 13px; + color: #64748b; + margin: 0 0 12px 0; +} + +.sails-badges-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +} + +.sail-badge { + display: inline-flex; + align-items: center; + gap: 6px; + background: rgba(30, 41, 59, 0.7); + border: 1px solid #334155; + color: #e2e8f0; + padding: 6px 12px; + border-radius: 20px; + font-size: 13px; + font-weight: 500; +} + +.sail-badge button.remove-btn { + background: none; + border: none; + color: #94a3b8; + cursor: pointer; + display: flex; + align-items: center; + padding: 0; + margin: 0; + transition: color 0.2s ease; +} + +.sail-badge button.remove-btn:hover { + color: #ef4444; +} + +.no-sails-msg { + color: #64748b; + font-size: 14px; + font-style: italic; +} + +.add-sail-form { + display: flex; + gap: 10px; + max-width: 400px; +} + +/* Event Editor Interactive Sails Picker */ +.sails-picker-container { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; +} + +.sails-picker-pills { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.sail-pill { + background: rgba(30, 41, 59, 0.5); + border: 1px solid #334155; + color: #94a3b8; + padding: 4px 10px; + border-radius: 15px; + font-size: 12px; + cursor: pointer; + user-select: none; + transition: all 0.2s ease; +} + +.sail-pill:hover { + border-color: #fbbf24; + color: #fbbf24; +} + +.sail-pill.active { + background: rgba(251, 191, 36, 0.15); + border-color: #fbbf24; + color: #fbbf24; +} + +.sail-pill.motor-pill { + border-color: #38bdf8; + color: #38bdf8; +} + +.sail-pill.motor-pill:hover { + border-color: #7dd3fc; + color: #7dd3fc; +} + +.sail-pill.motor-pill.active { + background: rgba(56, 189, 248, 0.15); + border-color: #38bdf8; + color: #38bdf8; +} + diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index 39122ef..4b8cc8c 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -44,12 +44,13 @@ interface LogEvent { } export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryEditorProps) { - const { t } = useTranslation() + const { t, i18n } = useTranslation() const { showAlert } = useDialog() // General details state const [date, setDate] = useState('') const [dayOfTravel, setDayOfTravel] = useState('') + const [yachtSails, setYachtSails] = useState([]) const [departure, setDeparture] = useState('') const [destination, setDestination] = useState('') @@ -120,6 +121,27 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE setFuelConsumption(cons >= 0 ? String(cons) : '0') }, [fuelMorning, fuelRefilled, fuelEvening]) + // Load Yacht Sails + useEffect(() => { + async function loadYachtSails() { + try { + const masterKey = getActiveMasterKey() + if (!masterKey) return + + const yacht = await db.yachts.get(logbookId) + if (yacht) { + const decrypted = await decryptJson(yacht.encryptedData, yacht.iv, yacht.tag, masterKey) + if (decrypted && decrypted.sails && Array.isArray(decrypted.sails)) { + setYachtSails(decrypted.sails) + } + } + } catch (err) { + console.error('Failed to load yacht sails in editor:', err) + } + } + loadYachtSails() + }, [logbookId]) + // Load entry details useEffect(() => { async function loadEntry() { @@ -324,6 +346,33 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE } } + const defaultSails = i18n.language === 'de' + ? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker'] + : ['Mainsail', 'Genoa', 'Jib', 'Spinnaker', 'Gennaker'] + + const toggleSailOrMotor = (item: string) => { + let currentItems = evSailsOrMotor + .split(/\s*(?:\+|\bplus\b|,)\s*/i) + .map(s => s.trim()) + .filter(Boolean) + + if (currentItems.some(s => s.toLowerCase() === item.toLowerCase())) { + currentItems = currentItems.filter(s => s.toLowerCase() !== item.toLowerCase()) + } else { + currentItems.push(item) + } + + setEvSailsOrMotor(currentItems.join(' + ')) + } + + const isItemActive = (item: string) => { + const currentItems = evSailsOrMotor + .split(/\s*(?:\+|\bplus\b|,)\s*/i) + .map(s => s.trim().toLowerCase()) + .filter(Boolean) + return currentItems.includes(item.toLowerCase()) + } + const handleAddEvent = (e: React.FormEvent) => { e.preventDefault() if (!evTime) return @@ -912,6 +961,25 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE onChange={(e) => setEvSailsOrMotor(e.target.value)} disabled={saving} /> +
+
+ {(yachtSails.length > 0 ? yachtSails : defaultSails).map((sail) => ( + toggleSailOrMotor(sail)} + > + {sail} + + ))} + toggleSailOrMotor(t('logs.motor_propulsion'))} + > + {t('logs.motor_propulsion')} + +
+
diff --git a/client/src/components/VesselForm.tsx b/client/src/components/VesselForm.tsx index cb64166..0bb942e 100644 --- a/client/src/components/VesselForm.tsx +++ b/client/src/components/VesselForm.tsx @@ -4,7 +4,7 @@ import { db } from '../services/db.js' import { getActiveMasterKey } from '../services/auth.js' import { encryptJson, decryptJson } from '../services/crypto.js' import { syncLogbook } from '../services/sync.js' -import { Ship, Save, Check } from 'lucide-react' +import { Ship, Save, Check, Plus, X } from 'lucide-react' interface VesselFormProps { logbookId: string @@ -20,6 +20,8 @@ export default function VesselForm({ logbookId }: VesselFormProps) { const [callSign, setCallSign] = useState('') const [atis, setAtis] = useState('') const [mmsi, setMmsi] = useState('') + const [sails, setSails] = useState([]) + const [newSailName, setNewSailName] = useState('') const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) @@ -48,6 +50,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) { setCallSign(decrypted.callSign || '') setAtis(decrypted.atis || '') setMmsi(decrypted.mmsi || '') + setSails(decrypted.sails || []) } } } catch (err: any) { @@ -61,6 +64,18 @@ export default function VesselForm({ logbookId }: VesselFormProps) { loadVessel() }, [logbookId]) + const handleAddSail = () => { + const trimmed = newSailName.trim() + if (trimmed && !sails.includes(trimmed)) { + setSails([...sails, trimmed]) + } + setNewSailName('') + } + + const handleRemoveSail = (indexToRemove: number) => { + setSails(sails.filter((_, idx) => idx !== indexToRemove)) + } + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setSaving(true) @@ -79,7 +94,8 @@ export default function VesselForm({ logbookId }: VesselFormProps) { registrationNumber: registrationNumber.trim(), callSign: callSign.trim(), atis: atis.trim(), - mmsi: mmsi.trim() + mmsi: mmsi.trim(), + sails: sails } // E2E encrypt @@ -226,6 +242,58 @@ export default function VesselForm({ logbookId }: VesselFormProps) { disabled={saving} />
+ +
+

{t('vessel.sails_list')}

+

{t('vessel.sails_help')}

+ +
+ {sails.length === 0 ? ( + {t('vessel.no_sails')} + ) : ( + sails.map((sail, idx) => ( + + {sail} + + + )) + )} +
+ +
+ setNewSailName(e.target.value)} + disabled={saving} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddSail(); + } + }} + /> + +
+
diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 9c42e4e..75a0337 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -41,7 +41,12 @@ "save": "Schiffsdaten speichern", "saving": "Wird gespeichert...", "saved": "Schiffsdaten erfolgreich gespeichert!", - "loading": "Schiffsdaten werden geladen..." + "loading": "Schiffsdaten werden geladen...", + "sails_list": "Besegelung (vorhandene Segel)", + "sails_help": "Tragen Sie hier die Segel ein, die an Eurem Schiff zur Verfügung stehen (z. B. Großsegel, Genua, Fock).", + "add_sail": "Segel hinzufügen", + "sail_name_placeholder": "z. B. Großsegel", + "no_sails": "Keine Segel hinterlegt." }, "logs": { "title": "Logbuch-Journal", @@ -85,6 +90,7 @@ "event_wind_pressure": "Luftdruck (hPa)", "event_heel": "Krängung (°)", "event_sails": "Segelführung / Motor", + "motor_propulsion": "Maschinenfahrt", "event_distance": "Distanz (sm)", "export_csv": "CSV herunterladen", "share_csv": "CSV teilen", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 651f2f7..bf575ce 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -41,7 +41,12 @@ "save": "Save Vessel Data", "saving": "Saving...", "saved": "Vessel details saved successfully!", - "loading": "Loading vessel details..." + "loading": "Loading vessel details...", + "sails_list": "Sails (Available Sails)", + "sails_help": "List the sails available on your vessel (e.g. Mainsail, Genoa, Jib).", + "add_sail": "Add Sail", + "sail_name_placeholder": "e.g. Mainsail", + "no_sails": "No sails defined." }, "logs": { "title": "Logbook Journal", @@ -85,6 +90,7 @@ "event_wind_pressure": "Barometer (hPa)", "event_heel": "Heel Angle (°)", "event_sails": "Sails / Motor Status", + "motor_propulsion": "Engine Propulsion", "event_distance": "Distance (nm)", "export_csv": "Download CSV", "share_csv": "Share CSV",