feat(sails): support configuring available sails list in VesselForm and interactive sails selector in LogEntryEditor
This commit is contained in:
@@ -1516,3 +1516,125 @@ body:has(.theme-cupertino) {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
width: 100%;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,12 +44,13 @@ interface LogEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryEditorProps) {
|
export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryEditorProps) {
|
||||||
const { t } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const { showAlert } = useDialog()
|
const { showAlert } = useDialog()
|
||||||
|
|
||||||
// General details state
|
// General details state
|
||||||
const [date, setDate] = useState('')
|
const [date, setDate] = useState('')
|
||||||
const [dayOfTravel, setDayOfTravel] = useState('')
|
const [dayOfTravel, setDayOfTravel] = useState('')
|
||||||
|
const [yachtSails, setYachtSails] = useState<string[]>([])
|
||||||
const [departure, setDeparture] = useState('')
|
const [departure, setDeparture] = useState('')
|
||||||
const [destination, setDestination] = useState('')
|
const [destination, setDestination] = useState('')
|
||||||
|
|
||||||
@@ -120,6 +121,27 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
|||||||
setFuelConsumption(cons >= 0 ? String(cons) : '0')
|
setFuelConsumption(cons >= 0 ? String(cons) : '0')
|
||||||
}, [fuelMorning, fuelRefilled, fuelEvening])
|
}, [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
|
// Load entry details
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadEntry() {
|
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) => {
|
const handleAddEvent = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!evTime) return
|
if (!evTime) return
|
||||||
@@ -912,6 +961,25 @@ export default function LogEntryEditor({ entryId, logbookId, onBack }: LogEntryE
|
|||||||
onChange={(e) => setEvSailsOrMotor(e.target.value)}
|
onChange={(e) => setEvSailsOrMotor(e.target.value)}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
|
<div className="sails-picker-container">
|
||||||
|
<div className="sails-picker-pills">
|
||||||
|
{(yachtSails.length > 0 ? yachtSails : defaultSails).map((sail) => (
|
||||||
|
<span
|
||||||
|
key={sail}
|
||||||
|
className={`sail-pill ${isItemActive(sail) ? 'active' : ''}`}
|
||||||
|
onClick={() => toggleSailOrMotor(sail)}
|
||||||
|
>
|
||||||
|
{sail}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span
|
||||||
|
className={`sail-pill motor-pill ${isItemActive(t('logs.motor_propulsion')) ? 'active' : ''}`}
|
||||||
|
onClick={() => toggleSailOrMotor(t('logs.motor_propulsion'))}
|
||||||
|
>
|
||||||
|
{t('logs.motor_propulsion')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { db } from '../services/db.js'
|
|||||||
import { getActiveMasterKey } from '../services/auth.js'
|
import { getActiveMasterKey } from '../services/auth.js'
|
||||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||||
import { syncLogbook } from '../services/sync.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 {
|
interface VesselFormProps {
|
||||||
logbookId: string
|
logbookId: string
|
||||||
@@ -20,6 +20,8 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
|||||||
const [callSign, setCallSign] = useState('')
|
const [callSign, setCallSign] = useState('')
|
||||||
const [atis, setAtis] = useState('')
|
const [atis, setAtis] = useState('')
|
||||||
const [mmsi, setMmsi] = useState('')
|
const [mmsi, setMmsi] = useState('')
|
||||||
|
const [sails, setSails] = useState<string[]>([])
|
||||||
|
const [newSailName, setNewSailName] = useState('')
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
@@ -48,6 +50,7 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
|||||||
setCallSign(decrypted.callSign || '')
|
setCallSign(decrypted.callSign || '')
|
||||||
setAtis(decrypted.atis || '')
|
setAtis(decrypted.atis || '')
|
||||||
setMmsi(decrypted.mmsi || '')
|
setMmsi(decrypted.mmsi || '')
|
||||||
|
setSails(decrypted.sails || [])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -61,6 +64,18 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
|||||||
loadVessel()
|
loadVessel()
|
||||||
}, [logbookId])
|
}, [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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
@@ -79,7 +94,8 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
|||||||
registrationNumber: registrationNumber.trim(),
|
registrationNumber: registrationNumber.trim(),
|
||||||
callSign: callSign.trim(),
|
callSign: callSign.trim(),
|
||||||
atis: atis.trim(),
|
atis: atis.trim(),
|
||||||
mmsi: mmsi.trim()
|
mmsi: mmsi.trim(),
|
||||||
|
sails: sails
|
||||||
}
|
}
|
||||||
|
|
||||||
// E2E encrypt
|
// E2E encrypt
|
||||||
@@ -226,6 +242,58 @@ export default function VesselForm({ logbookId }: VesselFormProps) {
|
|||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="sails-section">
|
||||||
|
<h3>{t('vessel.sails_list')}</h3>
|
||||||
|
<p className="help-text">{t('vessel.sails_help')}</p>
|
||||||
|
|
||||||
|
<div className="sails-badges-grid">
|
||||||
|
{sails.length === 0 ? (
|
||||||
|
<span className="no-sails-msg">{t('vessel.no_sails')}</span>
|
||||||
|
) : (
|
||||||
|
sails.map((sail, idx) => (
|
||||||
|
<span key={idx} className="sail-badge">
|
||||||
|
{sail}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="remove-btn"
|
||||||
|
onClick={() => handleRemoveSail(idx)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="add-sail-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-text"
|
||||||
|
placeholder={t('vessel.sail_name_placeholder')}
|
||||||
|
value={newSailName}
|
||||||
|
onChange={(e) => setNewSailName(e.target.value)}
|
||||||
|
disabled={saving}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddSail();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={handleAddSail}
|
||||||
|
disabled={saving || !newSailName.trim()}
|
||||||
|
style={{ width: 'auto' }}
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
{t('vessel.add_sail')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
|
|||||||
@@ -41,7 +41,12 @@
|
|||||||
"save": "Schiffsdaten speichern",
|
"save": "Schiffsdaten speichern",
|
||||||
"saving": "Wird gespeichert...",
|
"saving": "Wird gespeichert...",
|
||||||
"saved": "Schiffsdaten erfolgreich 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": {
|
"logs": {
|
||||||
"title": "Logbuch-Journal",
|
"title": "Logbuch-Journal",
|
||||||
@@ -85,6 +90,7 @@
|
|||||||
"event_wind_pressure": "Luftdruck (hPa)",
|
"event_wind_pressure": "Luftdruck (hPa)",
|
||||||
"event_heel": "Krängung (°)",
|
"event_heel": "Krängung (°)",
|
||||||
"event_sails": "Segelführung / Motor",
|
"event_sails": "Segelführung / Motor",
|
||||||
|
"motor_propulsion": "Maschinenfahrt",
|
||||||
"event_distance": "Distanz (sm)",
|
"event_distance": "Distanz (sm)",
|
||||||
"export_csv": "CSV herunterladen",
|
"export_csv": "CSV herunterladen",
|
||||||
"share_csv": "CSV teilen",
|
"share_csv": "CSV teilen",
|
||||||
|
|||||||
@@ -41,7 +41,12 @@
|
|||||||
"save": "Save Vessel Data",
|
"save": "Save Vessel Data",
|
||||||
"saving": "Saving...",
|
"saving": "Saving...",
|
||||||
"saved": "Vessel details saved successfully!",
|
"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": {
|
"logs": {
|
||||||
"title": "Logbook Journal",
|
"title": "Logbook Journal",
|
||||||
@@ -85,6 +90,7 @@
|
|||||||
"event_wind_pressure": "Barometer (hPa)",
|
"event_wind_pressure": "Barometer (hPa)",
|
||||||
"event_heel": "Heel Angle (°)",
|
"event_heel": "Heel Angle (°)",
|
||||||
"event_sails": "Sails / Motor Status",
|
"event_sails": "Sails / Motor Status",
|
||||||
|
"motor_propulsion": "Engine Propulsion",
|
||||||
"event_distance": "Distance (nm)",
|
"event_distance": "Distance (nm)",
|
||||||
"export_csv": "Download CSV",
|
"export_csv": "Download CSV",
|
||||||
"share_csv": "Share CSV",
|
"share_csv": "Share CSV",
|
||||||
|
|||||||
Reference in New Issue
Block a user