feat: implement collapsible accordions for event protocol list and form

This commit is contained in:
2026-06-06 21:02:35 +02:00
parent 7d6f381f55
commit a86da72b04
2 changed files with 310 additions and 214 deletions
+41
View File
@@ -6421,3 +6421,44 @@ body.app-tour-active .feedback-modal-overlay--tour .disclaimer-modal-panel {
color: #e2e8f0; color: #e2e8f0;
word-break: break-word; word-break: break-word;
} }
/* Accordion Styling */
.accordion-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
padding: 8px 12px;
margin: -8px -12px;
border-radius: 8px;
transition: background-color 0.2s ease, color 0.2s ease;
}
.accordion-header:hover {
background-color: var(--app-surface-hover, rgba(255, 255, 255, 0.03));
}
.accordion-header-title {
display: flex;
align-items: center;
gap: 8px;
}
.accordion-chevron {
color: var(--app-text-muted, #94a3b8);
transition: transform 0.2s ease;
}
/* Specific styling for nested member-editor-card header */
.member-editor-card .accordion-header {
margin: 0 0 16px 0;
padding: 8px 12px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.01);
border: 1px solid rgba(255, 255, 255, 0.03);
}
.member-editor-card .accordion-header:hover {
background: var(--app-surface-hover, rgba(255, 255, 255, 0.03));
}
+269 -214
View File
@@ -296,6 +296,9 @@ export default function LogEntryEditor({
const [evLocationName, setEvLocationName] = useState('') const [evLocationName, setEvLocationName] = useState('')
const [activeCourseTab, setActiveCourseTab] = useState<'mgk' | 'rwk'>('mgk') const [activeCourseTab, setActiveCourseTab] = useState<'mgk' | 'rwk'>('mgk')
const [eventsCollapsed, setEventsCollapsed] = useState(true)
const [addEventFormCollapsed, setAddEventFormCollapsed] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [exporting, setExporting] = useState(false) const [exporting, setExporting] = useState(false)
@@ -1407,6 +1410,7 @@ export default function LogEntryEditor({
if (!ev) return if (!ev) return
fillEventForm(ev) fillEventForm(ev)
setEditingEventIndex(index) setEditingEventIndex(index)
setAddEventFormCollapsed(false)
} }
const handleCancelEventEdit = () => { const handleCancelEventEdit = () => {
@@ -1853,78 +1857,153 @@ export default function LogEntryEditor({
{/* Section 3: Event Journal Entries */} {/* Section 3: Event Journal Entries */}
<div className="form-card"> <div className="form-card">
<div className="form-header mb-4"> <div
<Compass size={20} className="form-icon" /> className="form-header mb-4 accordion-header"
<h3>{t('logs.event_title')}</h3> onClick={() => setEventsCollapsed(!eventsCollapsed)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setEventsCollapsed(!eventsCollapsed)
}
}}
role="button"
aria-expanded={!eventsCollapsed}
tabIndex={0}
>
<div className="accordion-header-title">
<Compass size={20} className="form-icon" />
<h3>{t('logs.event_title')}</h3>
</div>
{eventsCollapsed ? (
<ChevronDown size={20} className="accordion-chevron" />
) : (
<ChevronUp size={20} className="accordion-chevron" />
)}
</div> </div>
{/* List existing events */} {/* List existing events */}
{events.length === 0 ? ( {!eventsCollapsed && (
<div className="dashboard-status-msg mb-6">{t('logs.no_events')}</div> events.length === 0 ? (
) : ( <div className="dashboard-status-msg mb-6">{t('logs.no_events')}</div>
<> ) : (
{/* Desktop view */} <>
<div className="events-scroll-container mb-6 events-desktop-only"> {/* Desktop view */}
<table className="events-table"> <div className="events-scroll-container mb-6 events-desktop-only">
<thead> <table className="events-table">
<tr> <thead>
<th>{t('logs.event_time')}</th> <tr>
<th>{t('logs.event_creator')}</th> <th>{t('logs.event_time')}</th>
<th>{t('logs.event_mgk')}</th> <th>{t('logs.event_creator')}</th>
<th>{t('logs.event_rwk')}</th> <th>{t('logs.event_mgk')}</th>
<th>{t('logs.event_wind_direction')}</th> <th>{t('logs.event_rwk')}</th>
<th>{t('logs.event_wind_strength')}</th> <th>{t('logs.event_wind_direction')}</th>
<th>{t('logs.event_sea_state')}</th> <th>{t('logs.event_wind_strength')}</th>
<th>{t('logs.event_weather')}</th> <th>{t('logs.event_sea_state')}</th>
<th>{t('logs.event_log')}</th> <th>{t('logs.event_weather')}</th>
<th>{t('logs.event_gps')}</th> <th>{t('logs.event_log')}</th>
<th>{t('logs.event_remarks')}</th> <th>{t('logs.event_gps')}</th>
{!readOnly && <th></th>} <th>{t('logs.event_remarks')}</th>
</tr> {!readOnly && <th></th>}
</thead> </tr>
<tbody> </thead>
{events.map((ev, idx) => ( <tbody>
<tr key={idx}> {events.map((ev, idx) => (
<td className="font-mono">{ev.time}</td> <tr key={idx}>
<td style={{ textAlign: 'center', width: '40px', verticalAlign: 'middle' }}> <td className="font-mono">{ev.time}</td>
<CreatorAvatar <td style={{ textAlign: 'center', width: '40px', verticalAlign: 'middle' }}>
creatorId={ev.creatorId} <CreatorAvatar
crewSnapshotsById={entryCrew.crewSnapshotsById} creatorId={ev.creatorId}
size={24} crewSnapshotsById={entryCrew.crewSnapshotsById}
/> size={24}
</td>
<td>{ev.mgk ? `${ev.mgk}°` : '—'}</td>
<td>{ev.rwk ? `${ev.rwk}°` : '—'}</td>
<td>{ev.windDirection || '—'}</td>
<td>{ev.windStrength || '—'}</td>
<td>{ev.seaState || '—'}</td>
<td>
{ev.weatherIcon ? (
<img
src={`https://openweathermap.org/img/wn/${ev.weatherIcon}.png`}
alt="Weather"
title="Weather Icon"
className="table-weather-img"
/> />
) : ( </td>
'—' <td>{ev.mgk ? `${ev.mgk}°` : '—'}</td>
<td>{ev.rwk ? `${ev.rwk}°` : '—'}</td>
<td>{ev.windDirection || '—'}</td>
<td>{ev.windStrength || '—'}</td>
<td>{ev.seaState || '—'}</td>
<td>
{ev.weatherIcon ? (
<img
src={`https://openweathermap.org/img/wn/${ev.weatherIcon}.png`}
alt="Weather"
title="Weather Icon"
className="table-weather-img"
/>
) : (
'—'
)}
</td>
<td>{ev.logReading ? `${ev.logReading} nm` : '—'}</td>
<td className="font-mono text-sm">
{ev.gpsLat && ev.gpsLng ? `${ev.gpsLat}, ${ev.gpsLng}` : '—'}
</td>
<td className="remarks-td">
<EventRemarksCell
event={ev}
logbookId={logbookId}
voiceMemoLookup={voiceMemoLookup}
readOnly={readOnly}
/>
</td>
{!readOnly && (
<td className="events-actions-td">
<div className="events-actions-cell">
<button
type="button"
className="btn-icon"
onClick={() => handleEditEvent(idx)}
title={t('logs.edit_event')}
disabled={editingEventIndex !== null && editingEventIndex !== idx}
>
<Pencil size={14} />
</button>
<button
type="button"
className="btn-icon danger"
onClick={() => handleDeleteEvent(idx)}
title={t('logs.delete_event')}
>
<Trash2 size={14} />
</button>
</div>
</td>
)} )}
</td> </tr>
<td>{ev.logReading ? `${ev.logReading} nm` : '—'}</td> ))}
<td className="font-mono text-sm"> </tbody>
{ev.gpsLat && ev.gpsLng ? `${ev.gpsLat}, ${ev.gpsLng}` : '—'} </table>
</td> </div>
<td className="remarks-td">
<EventRemarksCell {/* Mobile view */}
event={ev} <div className="events-mobile-only mb-6">
logbookId={logbookId} {events.map((ev, idx) => {
voiceMemoLookup={voiceMemoLookup} const hasCourse = ev.mgk || ev.rwk;
readOnly={readOnly} const hasWind = ev.windDirection || ev.windStrength || ev.windPressure;
/> const hasSeaState = ev.seaState;
</td> const hasWeather = ev.weatherIcon;
{!readOnly && ( const hasLog = ev.logReading;
<td className="events-actions-td"> const hasGps = ev.gpsLat && ev.gpsLng;
<div className="events-actions-cell"> const hasVisibility = ev.visibility;
const hasHeel = ev.heel;
const hasSailsOrMotor = ev.sailsOrMotor;
return (
<div className="event-mobile-card" key={idx}>
<div className="event-card-header">
<div className="event-card-meta">
<div className="event-card-time">
<Clock size={14} />
<span>{ev.time}</span>
</div>
<CreatorAvatar
creatorId={ev.creatorId}
crewSnapshotsById={entryCrew.crewSnapshotsById}
size={24}
/>
</div>
{!readOnly && (
<div className="event-card-actions">
<button <button
type="button" type="button"
className="btn-icon" className="btn-icon"
@@ -1943,164 +2022,138 @@ export default function LogEntryEditor({
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
</div> </div>
</td> )}
)} </div>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile view */} <hr className="event-card-divider" />
<div className="events-mobile-only mb-6">
{events.map((ev, idx) => {
const hasCourse = ev.mgk || ev.rwk;
const hasWind = ev.windDirection || ev.windStrength || ev.windPressure;
const hasSeaState = ev.seaState;
const hasWeather = ev.weatherIcon;
const hasLog = ev.logReading;
const hasGps = ev.gpsLat && ev.gpsLng;
const hasVisibility = ev.visibility;
const hasHeel = ev.heel;
const hasSailsOrMotor = ev.sailsOrMotor;
return ( <div className="event-card-grid">
<div className="event-mobile-card" key={idx}> {hasCourse && (
<div className="event-card-header"> <span className="event-card-chip" title={t('logs.event_course_section')}>
<div className="event-card-meta"> <Compass size={12} />
<div className="event-card-time"> <span>
<Clock size={14} /> {ev.mgk ? `MgK: ${ev.mgk}°` : ''}
<span>{ev.time}</span> {ev.mgk && ev.rwk ? ' / ' : ''}
</div> {ev.rwk ? `rwK: ${ev.rwk}°` : ''}
<CreatorAvatar </span>
creatorId={ev.creatorId} </span>
crewSnapshotsById={entryCrew.crewSnapshotsById} )}
size={24}
{hasWind && (
<span className="event-card-chip" title={t('logs.event_wind_direction')}>
<span>
Wind:{' '}
{[
ev.windDirection,
ev.windStrength ? `${ev.windStrength} Bft` : '',
ev.windPressure ? `${ev.windPressure} hPa` : ''
]
.filter(Boolean)
.join(' / ')}
</span>
</span>
)}
{hasSeaState && (
<span className="event-card-chip" title={t('logs.event_sea_state')}>
<span>{t('logs.event_sea_state')}: {ev.seaState}</span>
</span>
)}
{hasWeather && (
<span className="event-card-chip" title={t('logs.event_weather')}>
<img
src={`https://openweathermap.org/img/wn/${ev.weatherIcon}.png`}
alt="Weather"
className="event-card-weather-img"
/>
</span>
)}
{hasLog && (
<span className="event-card-chip" title={t('logs.event_log')}>
<span>Log: {ev.logReading} nm</span>
</span>
)}
{hasGps && (
<span className="event-card-chip" title={t('logs.event_gps')}>
<MapPin size={12} />
<span className="font-mono text-xs">{ev.gpsLat}, {ev.gpsLng}</span>
</span>
)}
{hasVisibility && (
<span className="event-card-chip" title={t('logs.event_visibility')}>
<span>{t('logs.event_visibility')}: {ev.visibility}</span>
</span>
)}
{hasHeel && (
<span className="event-card-chip" title={t('logs.event_heel')}>
<span>{t('logs.event_heel')}: {ev.heel}°</span>
</span>
)}
{hasSailsOrMotor && (
<span className="event-card-chip" title={t('logs.event_sails')}>
<span>{ev.sailsOrMotor}</span>
</span>
)}
</div>
<div className="event-card-remarks">
<EventRemarksCell
event={ev}
logbookId={logbookId}
voiceMemoLookup={voiceMemoLookup}
readOnly={readOnly}
/> />
</div> </div>
{!readOnly && (
<div className="event-card-actions">
<button
type="button"
className="btn-icon"
onClick={() => handleEditEvent(idx)}
title={t('logs.edit_event')}
disabled={editingEventIndex !== null && editingEventIndex !== idx}
>
<Pencil size={14} />
</button>
<button
type="button"
className="btn-icon danger"
onClick={() => handleDeleteEvent(idx)}
title={t('logs.delete_event')}
>
<Trash2 size={14} />
</button>
</div>
)}
</div> </div>
);
<hr className="event-card-divider" /> })}
</div>
<div className="event-card-grid"> </>
{hasCourse && ( )
<span className="event-card-chip" title={t('logs.event_course_section')}>
<Compass size={12} />
<span>
{ev.mgk ? `MgK: ${ev.mgk}°` : ''}
{ev.mgk && ev.rwk ? ' / ' : ''}
{ev.rwk ? `rwK: ${ev.rwk}°` : ''}
</span>
</span>
)}
{hasWind && (
<span className="event-card-chip" title={t('logs.event_wind_direction')}>
<span>
Wind:{' '}
{[
ev.windDirection,
ev.windStrength ? `${ev.windStrength} Bft` : '',
ev.windPressure ? `${ev.windPressure} hPa` : ''
]
.filter(Boolean)
.join(' / ')}
</span>
</span>
)}
{hasSeaState && (
<span className="event-card-chip" title={t('logs.event_sea_state')}>
<span>{t('logs.event_sea_state')}: {ev.seaState}</span>
</span>
)}
{hasWeather && (
<span className="event-card-chip" title={t('logs.event_weather')}>
<img
src={`https://openweathermap.org/img/wn/${ev.weatherIcon}.png`}
alt="Weather"
className="event-card-weather-img"
/>
</span>
)}
{hasLog && (
<span className="event-card-chip" title={t('logs.event_log')}>
<span>Log: {ev.logReading} nm</span>
</span>
)}
{hasGps && (
<span className="event-card-chip" title={t('logs.event_gps')}>
<MapPin size={12} />
<span className="font-mono text-xs">{ev.gpsLat}, {ev.gpsLng}</span>
</span>
)}
{hasVisibility && (
<span className="event-card-chip" title={t('logs.event_visibility')}>
<span>{t('logs.event_visibility')}: {ev.visibility}</span>
</span>
)}
{hasHeel && (
<span className="event-card-chip" title={t('logs.event_heel')}>
<span>{t('logs.event_heel')}: {ev.heel}°</span>
</span>
)}
{hasSailsOrMotor && (
<span className="event-card-chip" title={t('logs.event_sails')}>
<span>{ev.sailsOrMotor}</span>
</span>
)}
</div>
<div className="event-card-remarks">
<EventRemarksCell
event={ev}
logbookId={logbookId}
voiceMemoLookup={voiceMemoLookup}
readOnly={readOnly}
/>
</div>
</div>
);
})}
</div>
</>
)} )}
{/* Add New Event Form Sub-Card */} {/* Add New Event Form Sub-Card */}
{!readOnly && ( {!readOnly && (
<div className="member-editor-card glass"> <div className="member-editor-card glass">
<h4 style={{ margin: '0 0 16px 0', color: '#fbbf24' }}> <div
{editingEventIndex !== null ? t('logs.edit_event') : t('logs.add_event')} className="accordion-header"
</h4> onClick={() => setAddEventFormCollapsed(!addEventFormCollapsed)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setAddEventFormCollapsed(!addEventFormCollapsed)
}
}}
role="button"
aria-expanded={!addEventFormCollapsed}
tabIndex={0}
style={{
margin: '0 0 16px 0',
padding: '8px 12px',
borderRadius: '6px',
background: 'rgba(255, 255, 255, 0.01)',
border: '1px solid rgba(255, 255, 255, 0.03)'
}}
>
<h4 style={{ margin: 0, color: '#fbbf24' }}>
{editingEventIndex !== null ? t('logs.edit_event') : t('logs.add_event')}
</h4>
{addEventFormCollapsed ? (
<ChevronDown size={18} style={{ color: '#fbbf24' }} className="accordion-chevron" />
) : (
<ChevronUp size={18} style={{ color: '#fbbf24' }} className="accordion-chevron" />
)}
</div>
<div className="form-grid mb-4"> {!addEventFormCollapsed && (
<>
<div className="form-grid mb-4">
<div className="input-group"> <div className="input-group">
<label> <label>
<Clock size={12} style={{ display: 'inline', marginRight: 4 }} /> <Clock size={12} style={{ display: 'inline', marginRight: 4 }} />
@@ -2445,6 +2498,8 @@ export default function LogEntryEditor({
{editingEventIndex !== null ? t('logs.save_event_btn') : t('logs.add_event_btn')} {editingEventIndex !== null ? t('logs.save_event_btn') : t('logs.add_event_btn')}
</button> </button>
</div> </div>
</>
)}
</div> </div>
)} )}
</div> </div>