feat(live): make Live Journal the primary mobile-first logging UX.
Users mostly log via Live mode, so default to it with large sticky actions, in-stream event editing, day chips, and an overflow menu while keeping the day list as a secondary view. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+309
-8
@@ -3728,10 +3728,315 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.live-log-layout {
|
.live-log-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-log-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-log-header-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-log-header-main h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-log-overflow-trigger {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-day-chips {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-day-chips::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-day-chip {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 14px;
|
||||||
|
min-height: 40px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--app-border-muted);
|
||||||
|
background: var(--app-surface);
|
||||||
|
color: var(--app-text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-day-chip.is-active {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
border-color: rgba(59, 130, 246, 0.45);
|
||||||
|
color: var(--app-accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-day-chip-all {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-action-toolbar-sticky {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 5;
|
||||||
|
background: var(--app-bg, transparent);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-action-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-action-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(148px, 200px) 1fr;
|
gap: 8px;
|
||||||
gap: 20px;
|
}
|
||||||
align-items: start;
|
|
||||||
|
.live-action-grid-primary {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-action-grid-secondary {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-log-action-btn-compact {
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 10px 8px;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-log-action-btn-compact span {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-log-action-btn-compact.has-gps {
|
||||||
|
border-color: rgba(34, 197, 94, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-log-action-btn-compact.has-context {
|
||||||
|
border-color: rgba(59, 130, 246, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-log-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-log-entry-tap {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-log-entry-tap:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-log-entry-edit {
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-log-entry:hover .live-log-entry-edit,
|
||||||
|
.live-log-entry-edit:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-bottom-sheet-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10055;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-bottom-sheet {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: min(85vh, 640px);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--app-surface-alt);
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
border: 1px solid var(--app-border-muted);
|
||||||
|
border-bottom: none;
|
||||||
|
padding: 12px 16px calc(16px + env(safe-area-inset-bottom));
|
||||||
|
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-bottom-sheet-handle {
|
||||||
|
width: 36px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--app-border-muted);
|
||||||
|
margin: 0 auto 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-bottom-sheet-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-bottom-sheet-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-bottom-sheet-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-bottom-sheet-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-event-sheet-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-event-sheet-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-overflow-menu-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-overflow-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--app-radius-btn, 10px);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--app-text);
|
||||||
|
font-size: 15px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-overflow-menu-item:hover:not(:disabled) {
|
||||||
|
background: rgba(59, 130, 246, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-overflow-menu-item:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-overflow-menu-item-danger {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-text {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 481px) {
|
||||||
|
.live-action-grid-primary {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-action-grid-secondary {
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.live-action-grid-primary {
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-action-grid-secondary {
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-bottom-sheet-backdrop {
|
||||||
|
align-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-bottom-sheet {
|
||||||
|
border-radius: 16px;
|
||||||
|
border-bottom: 1px solid var(--app-border-muted);
|
||||||
|
max-height: min(80vh, 560px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-bottom-sheet-handle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.live-log-undo-bar {
|
||||||
|
bottom: calc(56px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-log-actions {
|
.live-log-actions {
|
||||||
@@ -4046,16 +4351,12 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.live-log-layout {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-log-actions {
|
.live-log-actions {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-log-action-btn {
|
.live-log-action-btn:not(.live-log-action-btn-compact) {
|
||||||
width: auto;
|
width: auto;
|
||||||
flex: 1 1 calc(50% - 4px);
|
flex: 1 1 calc(50% - 4px);
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
Anchor,
|
||||||
|
Camera,
|
||||||
|
CloudSun,
|
||||||
|
Compass,
|
||||||
|
Droplets,
|
||||||
|
Fuel,
|
||||||
|
Gauge,
|
||||||
|
MapPin,
|
||||||
|
MessageSquare,
|
||||||
|
Mic,
|
||||||
|
MoreHorizontal,
|
||||||
|
Sailboat,
|
||||||
|
Waves,
|
||||||
|
X,
|
||||||
|
Zap
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
export interface LiveActionToolbarProps {
|
||||||
|
motorRunning: boolean
|
||||||
|
hasLoggedPosition: boolean
|
||||||
|
lastSailsLabel?: string
|
||||||
|
busy: boolean
|
||||||
|
photoSaving: boolean
|
||||||
|
voiceSaving: boolean
|
||||||
|
tidesLoading: boolean
|
||||||
|
weatherOwmLoading: boolean
|
||||||
|
onMotorToggle: () => void
|
||||||
|
onCastOff: () => void
|
||||||
|
onMoor: () => void
|
||||||
|
onOpenSails: () => void
|
||||||
|
onOpenCourse: () => void
|
||||||
|
onOpenSog: () => void
|
||||||
|
onOpenStw: () => void
|
||||||
|
onOpenFuel: () => void
|
||||||
|
onOpenWater: () => void
|
||||||
|
onOpenPosition: () => void
|
||||||
|
onFetchTides: () => void
|
||||||
|
onOpenComment: () => void
|
||||||
|
onOpenPhoto: () => void
|
||||||
|
onOpenVoice: () => void
|
||||||
|
onFetchOwmWeather: () => void
|
||||||
|
onOpenWind: () => void
|
||||||
|
onOpenTemp: () => void
|
||||||
|
onOpenPressure: () => void
|
||||||
|
onOpenPrecip: () => void
|
||||||
|
onOpenSeaState: () => void
|
||||||
|
onOpenVisibility: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LiveActionToolbar({
|
||||||
|
motorRunning,
|
||||||
|
hasLoggedPosition,
|
||||||
|
lastSailsLabel,
|
||||||
|
busy,
|
||||||
|
photoSaving,
|
||||||
|
voiceSaving,
|
||||||
|
tidesLoading,
|
||||||
|
weatherOwmLoading,
|
||||||
|
onMotorToggle,
|
||||||
|
onCastOff,
|
||||||
|
onMoor,
|
||||||
|
onOpenSails,
|
||||||
|
onOpenCourse,
|
||||||
|
onOpenSog,
|
||||||
|
onOpenStw,
|
||||||
|
onOpenFuel,
|
||||||
|
onOpenWater,
|
||||||
|
onOpenPosition,
|
||||||
|
onFetchTides,
|
||||||
|
onOpenComment,
|
||||||
|
onOpenPhoto,
|
||||||
|
onOpenVoice,
|
||||||
|
onFetchOwmWeather,
|
||||||
|
onOpenWind,
|
||||||
|
onOpenTemp,
|
||||||
|
onOpenPressure,
|
||||||
|
onOpenPrecip,
|
||||||
|
onOpenSeaState,
|
||||||
|
onOpenVisibility
|
||||||
|
}: LiveActionToolbarProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [weatherSheetOpen, setWeatherSheetOpen] = useState(false)
|
||||||
|
const [moreSheetOpen, setMoreSheetOpen] = useState(false)
|
||||||
|
|
||||||
|
const closeSheets = () => {
|
||||||
|
setWeatherSheetOpen(false)
|
||||||
|
setMoreSheetOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const runWeather = (fn: () => void) => {
|
||||||
|
fn()
|
||||||
|
closeSheets()
|
||||||
|
}
|
||||||
|
|
||||||
|
const runMore = (fn: () => void) => {
|
||||||
|
fn()
|
||||||
|
closeSheets()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="live-action-toolbar-sticky">
|
||||||
|
<div className="live-action-toolbar" aria-label={t('logs.live_actions_label')}>
|
||||||
|
<div className="live-action-grid live-action-grid-primary" data-tour="live-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`live-log-action-btn live-log-action-btn-compact ${motorRunning ? 'is-active' : ''}`}
|
||||||
|
onClick={onMotorToggle}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<Zap size={20} />
|
||||||
|
<span>{motorRunning ? t('logs.live_motor_stop') : t('logs.live_motor_start')}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="live-log-action-btn live-log-action-btn-compact" onClick={onCastOff} disabled={busy}>
|
||||||
|
<Anchor size={20} />
|
||||||
|
<span>{t('logs.live_cast_off')}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="live-log-action-btn live-log-action-btn-compact" onClick={onMoor} disabled={busy}>
|
||||||
|
<Anchor size={20} style={{ transform: 'scaleX(-1)' }} />
|
||||||
|
<span>{t('logs.live_moor')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`live-log-action-btn live-log-action-btn-compact ${hasLoggedPosition ? 'has-gps' : ''}`}
|
||||||
|
onClick={onOpenPosition}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<MapPin size={20} />
|
||||||
|
<span>{t('logs.live_position')}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="live-log-action-btn live-log-action-btn-compact" onClick={onOpenPhoto} disabled={busy || photoSaving}>
|
||||||
|
<Camera size={20} />
|
||||||
|
<span>{t('logs.live_photo_btn')}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="live-log-action-btn live-log-action-btn-compact" onClick={onOpenVoice} disabled={busy || voiceSaving}>
|
||||||
|
<Mic size={20} />
|
||||||
|
<span>{t('logs.live_voice_btn')}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="live-log-action-btn live-log-action-btn-compact" onClick={onOpenComment} disabled={busy}>
|
||||||
|
<MessageSquare size={20} />
|
||||||
|
<span>{t('logs.live_comment_btn')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="live-action-grid live-action-grid-secondary">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`live-log-action-btn live-log-action-btn-compact ${lastSailsLabel ? 'has-context' : ''}`}
|
||||||
|
onClick={onOpenSails}
|
||||||
|
disabled={busy}
|
||||||
|
title={lastSailsLabel}
|
||||||
|
>
|
||||||
|
<Sailboat size={18} />
|
||||||
|
<span>{t('logs.live_sails_btn')}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="live-log-action-btn live-log-action-btn-compact" onClick={onOpenCourse} disabled={busy}>
|
||||||
|
<Compass size={18} />
|
||||||
|
<span>{t('logs.live_course_btn')}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="live-log-action-btn live-log-action-btn-compact" onClick={onOpenSog} disabled={busy}>
|
||||||
|
<Gauge size={18} />
|
||||||
|
<span>{t('logs.live_sog_btn')}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="live-log-action-btn live-log-action-btn-compact" onClick={onOpenStw} disabled={busy}>
|
||||||
|
<Gauge size={18} style={{ transform: 'scaleX(-1)' }} />
|
||||||
|
<span>{t('logs.live_stw_btn')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="live-log-action-btn live-log-action-btn-compact"
|
||||||
|
onClick={() => { setMoreSheetOpen(false); setWeatherSheetOpen(true) }}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<CloudSun size={18} />
|
||||||
|
<span>{t('logs.live_weather_btn')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="live-log-action-btn live-log-action-btn-compact"
|
||||||
|
onClick={() => { setWeatherSheetOpen(false); setMoreSheetOpen(true) }}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={18} />
|
||||||
|
<span>{t('logs.live_more_btn')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{weatherSheetOpen && (
|
||||||
|
<div
|
||||||
|
className="live-bottom-sheet-backdrop"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) closeSheets() }}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div className="live-bottom-sheet" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="live-bottom-sheet-handle" aria-hidden />
|
||||||
|
<div className="live-bottom-sheet-header">
|
||||||
|
<h3>{t('logs.live_weather_btn')}</h3>
|
||||||
|
<button type="button" className="btn-icon" onClick={closeSheets} aria-label={t('logs.live_cancel')}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="live-overflow-menu-list">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="live-overflow-menu-item live-log-subaction-btn-owm"
|
||||||
|
onClick={() => runWeather(onFetchOwmWeather)}
|
||||||
|
disabled={busy || weatherOwmLoading}
|
||||||
|
>
|
||||||
|
{weatherOwmLoading ? t('logs.live_weather_owm_loading') : t('logs.live_weather_owm_btn')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="live-overflow-menu-item" onClick={() => runWeather(onOpenWind)} disabled={busy}>
|
||||||
|
{t('logs.live_wind_btn')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="live-overflow-menu-item" onClick={() => runWeather(onOpenTemp)} disabled={busy}>
|
||||||
|
{t('logs.live_temp_btn')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="live-overflow-menu-item" onClick={() => runWeather(onOpenPressure)} disabled={busy}>
|
||||||
|
{t('logs.live_pressure_btn')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="live-overflow-menu-item" onClick={() => runWeather(onOpenPrecip)} disabled={busy}>
|
||||||
|
{t('logs.live_precip_btn')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="live-overflow-menu-item" onClick={() => runWeather(onOpenSeaState)} disabled={busy}>
|
||||||
|
{t('logs.live_sea_state_btn')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="live-overflow-menu-item" onClick={() => runWeather(onOpenVisibility)} disabled={busy}>
|
||||||
|
{t('logs.live_visibility_btn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{moreSheetOpen && (
|
||||||
|
<div
|
||||||
|
className="live-bottom-sheet-backdrop"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) closeSheets() }}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div className="live-bottom-sheet" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="live-bottom-sheet-handle" aria-hidden />
|
||||||
|
<div className="live-bottom-sheet-header">
|
||||||
|
<h3>{t('logs.live_more_btn')}</h3>
|
||||||
|
<button type="button" className="btn-icon" onClick={closeSheets} aria-label={t('logs.live_cancel')}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="live-overflow-menu-list">
|
||||||
|
<button type="button" className="live-overflow-menu-item" onClick={() => runMore(onOpenFuel)} disabled={busy}>
|
||||||
|
<Fuel size={18} />
|
||||||
|
{t('logs.live_fuel_btn')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="live-overflow-menu-item" onClick={() => runMore(onOpenWater)} disabled={busy}>
|
||||||
|
<Droplets size={18} />
|
||||||
|
{t('logs.live_water_btn')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="live-overflow-menu-item" onClick={() => runMore(onFetchTides)} disabled={busy || tidesLoading}>
|
||||||
|
<Waves size={18} />
|
||||||
|
{tidesLoading ? t('logs.tide_fetch_loading') : t('logs.tides')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Pencil, Trash2, X } from 'lucide-react'
|
||||||
|
import type { LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
|
import { isValidTimeHHMM } from '../utils/logEntryPayload.js'
|
||||||
|
|
||||||
|
interface LiveEventSheetProps {
|
||||||
|
open: boolean
|
||||||
|
event: LogEventPayload | null
|
||||||
|
eventIndex: number | null
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (index: number, patch: Partial<LogEventPayload>) => Promise<void>
|
||||||
|
onDelete: (index: number) => Promise<void>
|
||||||
|
onOpenEditor?: () => void
|
||||||
|
busy?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LiveEventSheet({
|
||||||
|
open,
|
||||||
|
event,
|
||||||
|
eventIndex,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
onDelete,
|
||||||
|
onOpenEditor,
|
||||||
|
busy = false
|
||||||
|
}: LiveEventSheetProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const timeRef = useRef<HTMLInputElement>(null)
|
||||||
|
const remarksRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !event) return
|
||||||
|
if (timeRef.current) timeRef.current.value = event.time || ''
|
||||||
|
if (remarksRef.current) remarksRef.current.value = event.remarks || ''
|
||||||
|
}, [open, event])
|
||||||
|
|
||||||
|
if (!open || !event || eventIndex === null) return null
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const time = timeRef.current?.value.trim() ?? ''
|
||||||
|
const remarks = remarksRef.current?.value ?? ''
|
||||||
|
if (!isValidTimeHHMM(time)) return
|
||||||
|
await onSave(eventIndex, { time, remarks })
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
await onDelete(eventIndex)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="live-bottom-sheet-backdrop"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="live-bottom-sheet"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="live-event-sheet-title"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="live-bottom-sheet-handle" aria-hidden />
|
||||||
|
<div className="live-bottom-sheet-header">
|
||||||
|
<h3 id="live-event-sheet-title">{t('logs.live_edit_event')}</h3>
|
||||||
|
<button type="button" className="btn-icon" onClick={onClose} aria-label={t('logs.live_cancel')}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="live-event-sheet-form">
|
||||||
|
<label className="live-event-sheet-field">
|
||||||
|
<span>{t('logs.event_time')}</span>
|
||||||
|
<input
|
||||||
|
ref={timeRef}
|
||||||
|
type="text"
|
||||||
|
className="input-text"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="HH:MM"
|
||||||
|
maxLength={5}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="live-event-sheet-field">
|
||||||
|
<span>{t('logs.event_remarks')}</span>
|
||||||
|
<textarea
|
||||||
|
ref={remarksRef}
|
||||||
|
className="input-text"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="live-bottom-sheet-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary"
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
{t('logs.save')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary danger-text"
|
||||||
|
onClick={() => void handleDelete()}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
{t('logs.live_delete_event')}
|
||||||
|
</button>
|
||||||
|
{onOpenEditor && (
|
||||||
|
<button type="button" className="btn secondary" onClick={onOpenEditor} disabled={busy}>
|
||||||
|
{t('logs.live_open_editor')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,25 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
Anchor,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronUp,
|
|
||||||
CloudSun,
|
|
||||||
Compass,
|
|
||||||
Droplets,
|
|
||||||
FileText,
|
|
||||||
Fuel,
|
|
||||||
Gauge,
|
|
||||||
MapPin,
|
MapPin,
|
||||||
MessageSquare,
|
MoreVertical,
|
||||||
Camera,
|
Pencil,
|
||||||
Mic,
|
|
||||||
Radio,
|
Radio,
|
||||||
Sailboat,
|
Undo2
|
||||||
Undo2,
|
|
||||||
Waves,
|
|
||||||
Zap
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import { getAiAuthorized } from '../services/userPreferences.js'
|
import { getAiAuthorized } from '../services/userPreferences.js'
|
||||||
@@ -50,7 +36,8 @@ import {
|
|||||||
liveSogRemark,
|
liveSogRemark,
|
||||||
liveStwRemark,
|
liveStwRemark,
|
||||||
liveTempRemark,
|
liveTempRemark,
|
||||||
liveWaterRemark
|
liveWaterRemark,
|
||||||
|
parseLiveSailsRemark
|
||||||
} from '../utils/liveEventCodes.js'
|
} from '../utils/liveEventCodes.js'
|
||||||
import { formatAppDecimal, formatTankLiters, parseAppDecimal } from '../utils/numberFormat.js'
|
import { formatAppDecimal, formatTankLiters, parseAppDecimal } from '../utils/numberFormat.js'
|
||||||
|
|
||||||
@@ -104,11 +91,35 @@ import { saveEntryVoiceMemo, deleteEntryVoiceMemo } from '../services/voiceAttac
|
|||||||
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
||||||
import { blobToAudioDataUrl } from '../utils/audioBlob.js'
|
import { blobToAudioDataUrl } from '../utils/audioBlob.js'
|
||||||
import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js'
|
import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js'
|
||||||
|
import { useEventEditor } from '../hooks/useEventEditor.js'
|
||||||
|
import LiveActionToolbar from './LiveActionToolbar.tsx'
|
||||||
|
import LiveEventSheet from './LiveEventSheet.tsx'
|
||||||
|
import LiveOverflowMenu from './LiveOverflowMenu.tsx'
|
||||||
|
|
||||||
|
export interface LiveEntrySummary {
|
||||||
|
id: string
|
||||||
|
date: string
|
||||||
|
dayOfTravel: string
|
||||||
|
departure: string
|
||||||
|
destination: string
|
||||||
|
}
|
||||||
|
|
||||||
interface LiveLogViewProps {
|
interface LiveLogViewProps {
|
||||||
logbookId: string
|
logbookId: string
|
||||||
|
selectedEntryId?: string | null
|
||||||
|
entrySummaries?: LiveEntrySummary[]
|
||||||
|
todayEntryId?: string | null
|
||||||
|
onEntryChange?: (entryId: string) => void
|
||||||
onOpenEditor: (entryId: string) => void
|
onOpenEditor: (entryId: string) => void
|
||||||
onSwitchToList: () => void
|
onOpenAllDays: () => void
|
||||||
|
onDeleteEntry?: (entryId: string) => void
|
||||||
|
onDownloadCsv?: () => void
|
||||||
|
onShareCsv?: () => void
|
||||||
|
onDownloadPhotosZip?: () => void
|
||||||
|
onDownloadPdf?: (entryId: string, date: string) => void
|
||||||
|
exporting?: boolean
|
||||||
|
canExportPhotosZip?: boolean
|
||||||
|
hasLogbookEntries?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type LiveModal =
|
type LiveModal =
|
||||||
@@ -174,6 +185,14 @@ function lastWindDirectionFromEvents(events: LogEventPayload[]): string {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lastSailsFromEvents(events: LogEventPayload[]): string {
|
||||||
|
for (let i = events.length - 1; i >= 0; i--) {
|
||||||
|
const sails = parseLiveSailsRemark(events[i].remarks?.trim() ?? '')
|
||||||
|
if (sails) return sails
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
function gpsFailureAlertBody(
|
function gpsFailureAlertBody(
|
||||||
t: (key: string) => string,
|
t: (key: string) => string,
|
||||||
reason: GeolocationErrorReason
|
reason: GeolocationErrorReason
|
||||||
@@ -201,8 +220,20 @@ function findActiveCreatorId(
|
|||||||
|
|
||||||
export default function LiveLogView({
|
export default function LiveLogView({
|
||||||
logbookId,
|
logbookId,
|
||||||
|
selectedEntryId: selectedEntryIdProp,
|
||||||
|
entrySummaries = [],
|
||||||
|
todayEntryId,
|
||||||
|
onEntryChange,
|
||||||
onOpenEditor,
|
onOpenEditor,
|
||||||
onSwitchToList
|
onOpenAllDays,
|
||||||
|
onDeleteEntry,
|
||||||
|
onDownloadCsv,
|
||||||
|
onShareCsv,
|
||||||
|
onDownloadPhotosZip,
|
||||||
|
onDownloadPdf,
|
||||||
|
exporting = false,
|
||||||
|
canExportPhotosZip = false,
|
||||||
|
hasLogbookEntries = true
|
||||||
}: LiveLogViewProps) {
|
}: LiveLogViewProps) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const { showAlert, showConfirm } = useDialog()
|
const { showAlert, showConfirm } = useDialog()
|
||||||
@@ -221,7 +252,9 @@ export default function LiveLogView({
|
|||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [modal, setModal] = useState<LiveModal>('none')
|
const [modal, setModal] = useState<LiveModal>('none')
|
||||||
const [weatherExpanded, setWeatherExpanded] = useState(false)
|
const [overflowOpen, setOverflowOpen] = useState(false)
|
||||||
|
const [editingEventIndex, setEditingEventIndex] = useState<number | null>(null)
|
||||||
|
const [signSkipper, setSignSkipper] = useState('')
|
||||||
const [weatherOwmLoading, setWeatherOwmLoading] = useState(false)
|
const [weatherOwmLoading, setWeatherOwmLoading] = useState(false)
|
||||||
const [tidesLoading, setTidesLoading] = useState(false)
|
const [tidesLoading, setTidesLoading] = useState(false)
|
||||||
const [tidePreview, setTidePreview] = useState<{
|
const [tidePreview, setTidePreview] = useState<{
|
||||||
@@ -323,18 +356,33 @@ export default function LiveLogView({
|
|||||||
)
|
)
|
||||||
const motorRunning = isMotorRunningFromEvents(events)
|
const motorRunning = isMotorRunningFromEvents(events)
|
||||||
const motorLabel = t('logs.motor_propulsion')
|
const motorLabel = t('logs.motor_propulsion')
|
||||||
|
const lastSailsLabel = lastSailsFromEvents(events)
|
||||||
const hasLoggedPosition = useMemo(
|
const hasLoggedPosition = useMemo(
|
||||||
() => (date ? getLatestLoggedPosition(events, date) != null : false),
|
() => (date ? getLatestLoggedPosition(events, date) != null : false),
|
||||||
[events, date]
|
[events, date]
|
||||||
)
|
)
|
||||||
const voiceMemoLookup = useEntryVoiceMemos(logbookId, entryId)
|
const voiceMemoLookup = useEntryVoiceMemos(logbookId, entryId)
|
||||||
|
|
||||||
|
const { updateEvent, deleteEvent } = useEventEditor({
|
||||||
|
logbookId,
|
||||||
|
entryId,
|
||||||
|
events,
|
||||||
|
onEventsChange: setEvents,
|
||||||
|
hasSkipperSignature: !!signSkipper
|
||||||
|
})
|
||||||
|
|
||||||
|
const dayChips = useMemo(() => {
|
||||||
|
const max = 5
|
||||||
|
return entrySummaries.slice(0, max)
|
||||||
|
}, [entrySummaries])
|
||||||
|
|
||||||
const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
|
const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
|
||||||
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
|
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
|
||||||
setDayOfTravel(String(loaded.data.dayOfTravel || ''))
|
setDayOfTravel(String(loaded.data.dayOfTravel || ''))
|
||||||
setDate(String(loaded.data.date || ''))
|
setDate(String(loaded.data.date || ''))
|
||||||
setDeparture(String(loaded.data.departure || ''))
|
setDeparture(String(loaded.data.departure || ''))
|
||||||
setDestination(String(loaded.data.destination || ''))
|
setDestination(String(loaded.data.destination || ''))
|
||||||
|
setSignSkipper(typeof loaded.data.signSkipper === 'string' ? loaded.data.signSkipper : '')
|
||||||
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
|
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
|
||||||
setCrewSnapshotsById((loaded.data.crewSnapshotsById as Record<string, any>) || {})
|
setCrewSnapshotsById((loaded.data.crewSnapshotsById as Record<string, any>) || {})
|
||||||
setSelectedSkipperId(typeof loaded.data.selectedSkipperId === 'string' ? loaded.data.selectedSkipperId : null)
|
setSelectedSkipperId(typeof loaded.data.selectedSkipperId === 'string' ? loaded.data.selectedSkipperId : null)
|
||||||
@@ -358,7 +406,7 @@ export default function LiveLogView({
|
|||||||
}, UNDO_TIMEOUT_MS)
|
}, UNDO_TIMEOUT_MS)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const runInit = useCallback(async () => {
|
const runInit = useCallback(async (targetEntryId?: string | null) => {
|
||||||
const seq = ++initSeqRef.current
|
const seq = ++initSeqRef.current
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -373,11 +421,13 @@ export default function LiveLogView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const id = await withTimeout(
|
const id = targetEntryId
|
||||||
findOrCreateTodayEntry(logbookId),
|
? targetEntryId
|
||||||
LIVE_LOG_INIT_TIMEOUT_MS,
|
: await withTimeout(
|
||||||
t('logs.live_load_error')
|
findOrCreateTodayEntry(logbookId),
|
||||||
)
|
LIVE_LOG_INIT_TIMEOUT_MS,
|
||||||
|
t('logs.live_load_error')
|
||||||
|
)
|
||||||
if (seq !== initSeqRef.current) return
|
if (seq !== initSeqRef.current) return
|
||||||
setEntryId(id)
|
setEntryId(id)
|
||||||
|
|
||||||
@@ -409,6 +459,14 @@ export default function LiveLogView({
|
|||||||
}
|
}
|
||||||
}, [logbookId, applyLoadedEntry, t])
|
}, [logbookId, applyLoadedEntry, t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void runInit(selectedEntryIdProp ?? null)
|
||||||
|
return () => {
|
||||||
|
initSeqRef.current += 1
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- runInit
|
||||||
|
}, [logbookId, selectedEntryIdProp])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOnline = () => setIsOnline(true)
|
const handleOnline = () => setIsOnline(true)
|
||||||
const handleOffline = () => setIsOnline(false)
|
const handleOffline = () => setIsOnline(false)
|
||||||
@@ -420,15 +478,6 @@ export default function LiveLogView({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void runInit()
|
|
||||||
return () => {
|
|
||||||
initSeqRef.current += 1
|
|
||||||
}
|
|
||||||
// Only re-init when the logbook changes — not when i18n `t` identity changes.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- runInit
|
|
||||||
}, [logbookId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && entryId) {
|
if (!loading && entryId) {
|
||||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_OPENED)
|
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_OPENED)
|
||||||
@@ -1307,32 +1356,60 @@ export default function LiveLogView({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="live-log-card">
|
<div className="live-log-card">
|
||||||
<div className="section-title-bar mb-4">
|
<div className="live-log-header">
|
||||||
<div className="form-header" style={{ margin: 0 }}>
|
<div className="live-log-header-main">
|
||||||
<Radio size={24} className="form-icon" />
|
<Radio size={24} className="form-icon" aria-hidden />
|
||||||
<div>
|
<div>
|
||||||
<h2>{t('logs.live_title')}</h2>
|
<h2>{t('logs.live_title')}</h2>
|
||||||
{date && (
|
{date && (
|
||||||
<p className="live-log-subtitle">
|
<p className="live-log-subtitle">
|
||||||
{t('logs.travel_day_number', { number: dayOfTravel })} · {new Date(date).toLocaleDateString()}
|
{t('logs.travel_day_number', { number: dayOfTravel })} · {new Date(date).toLocaleDateString()}
|
||||||
|
{departure && destination && (
|
||||||
|
<> · {departure} → {destination}</>
|
||||||
|
)}
|
||||||
|
{departure && !destination && (
|
||||||
|
<> · {departure}</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="section-toolbar">
|
<button
|
||||||
<button type="button" className="btn secondary" onClick={onSwitchToList} style={{ width: 'auto', padding: '8px 16px' }}>
|
type="button"
|
||||||
<ChevronLeft size={16} />
|
className="btn-icon live-log-overflow-trigger"
|
||||||
<span className="hide-mobile">{t('logs.view_list')}</span>
|
onClick={() => setOverflowOpen(true)}
|
||||||
</button>
|
aria-label={t('logs.more_actions')}
|
||||||
{entryId && (
|
>
|
||||||
<button type="button" className="btn secondary" onClick={() => onOpenEditor(entryId)} style={{ width: 'auto', padding: '8px 16px' }}>
|
<MoreVertical size={20} />
|
||||||
<FileText size={16} />
|
</button>
|
||||||
<span className="hide-mobile">{t('logs.live_open_editor')}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{dayChips.length > 0 && (
|
||||||
|
<div className="live-day-chips" role="tablist" aria-label={t('logs.live_switch_day')}>
|
||||||
|
{dayChips.map((chip) => {
|
||||||
|
const isToday = todayEntryId ? chip.id === todayEntryId : false
|
||||||
|
const isActive = chip.id === entryId
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={chip.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
className={`live-day-chip ${isActive ? 'is-active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (chip.id !== entryId) onEntryChange?.(chip.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isToday ? t('logs.live_day_today') : t('logs.travel_day_number', { number: chip.dayOfTravel })}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<button type="button" className="live-day-chip live-day-chip-all" onClick={onOpenAllDays}>
|
||||||
|
{t('logs.all_days')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <div className="auth-error mb-4">{error}</div>}
|
{error && <div className="auth-error mb-4">{error}</div>}
|
||||||
|
|
||||||
{!hasLoggedPosition && (
|
{!hasLoggedPosition && (
|
||||||
@@ -1343,120 +1420,56 @@ export default function LiveLogView({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="live-log-layout">
|
<div className="live-log-layout">
|
||||||
<aside className="live-log-actions" aria-label={t('logs.live_actions_label')}>
|
<LiveActionToolbar
|
||||||
<button type="button" className={`live-log-action-btn ${motorRunning ? 'is-active' : ''}`} onClick={handleMotorToggle} disabled={busy}>
|
motorRunning={motorRunning}
|
||||||
<Zap size={18} />
|
hasLoggedPosition={hasLoggedPosition}
|
||||||
{motorRunning ? t('logs.live_motor_stop') : t('logs.live_motor_start')}
|
lastSailsLabel={lastSailsLabel || undefined}
|
||||||
</button>
|
busy={busy}
|
||||||
<button type="button" className="live-log-action-btn" onClick={handleCastOff} disabled={busy}>
|
photoSaving={photoSaving}
|
||||||
<Anchor size={18} />
|
voiceSaving={voiceSaving}
|
||||||
{t('logs.live_cast_off')}
|
tidesLoading={tidesLoading}
|
||||||
</button>
|
weatherOwmLoading={weatherOwmLoading}
|
||||||
<button type="button" className="live-log-action-btn" onClick={handleMoor} disabled={busy}>
|
onMotorToggle={handleMotorToggle}
|
||||||
<Anchor size={18} style={{ transform: 'scaleX(-1)' }} />
|
onCastOff={handleCastOff}
|
||||||
{t('logs.live_moor')}
|
onMoor={handleMoor}
|
||||||
</button>
|
onOpenSails={() => { setSelectedSails([]); setModal('sails') }}
|
||||||
<button type="button" className="live-log-action-btn" onClick={() => { setSelectedSails([]); setModal('sails') }} disabled={busy}>
|
onOpenCourse={() => openValueModal('course', lastCourseFromEvents(events))}
|
||||||
<Sailboat size={18} />
|
onOpenSog={() => void openSogModal()}
|
||||||
{t('logs.live_sails_btn')}
|
onOpenStw={() => openValueModal('stw')}
|
||||||
</button>
|
onOpenFuel={() => openValueModal('fuel')}
|
||||||
<button type="button" className="live-log-action-btn" onClick={() => openValueModal('course', lastCourseFromEvents(events))} disabled={busy}>
|
onOpenWater={() => openValueModal('water')}
|
||||||
<Compass size={18} />
|
onOpenPosition={() => void openPositionModal()}
|
||||||
{t('logs.live_course_btn')}
|
onFetchTides={handleFetchTides}
|
||||||
</button>
|
onOpenComment={() => { setCommentText(''); setModal('comment') }}
|
||||||
<button type="button" className="live-log-action-btn" onClick={() => void openSogModal()} disabled={busy}>
|
onOpenPhoto={openPhotoModal}
|
||||||
<Gauge size={18} />
|
onOpenVoice={openVoiceModal}
|
||||||
{t('logs.live_sog_btn')}
|
onFetchOwmWeather={handleFetchOwmWeather}
|
||||||
</button>
|
onOpenWind={() => openValueModal('wind', lastWindDirectionFromEvents(events))}
|
||||||
<button type="button" className="live-log-action-btn" onClick={() => openValueModal('stw')} disabled={busy}>
|
onOpenTemp={() => openValueModal('temp')}
|
||||||
<Gauge size={18} style={{ transform: 'scaleX(-1)' }} />
|
onOpenPressure={() => openValueModal('pressure')}
|
||||||
{t('logs.live_stw_btn')}
|
onOpenPrecip={() => openValueModal('precip')}
|
||||||
</button>
|
onOpenSeaState={() => openValueModal('sea_state')}
|
||||||
<button type="button" className="live-log-action-btn" onClick={() => openValueModal('fuel')} disabled={busy}>
|
onOpenVisibility={() => openValueModal('visibility')}
|
||||||
<Fuel size={18} />
|
/>
|
||||||
{t('logs.live_fuel_btn')}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="live-log-action-btn" onClick={() => openValueModal('water')} disabled={busy}>
|
|
||||||
<Droplets size={18} />
|
|
||||||
{t('logs.live_water_btn')}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="live-log-weather-group">
|
<section className="live-log-stream-panel" aria-label={t('logs.live_stream_label')} data-tour="entry-list">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`live-log-action-btn live-log-weather-toggle ${weatherExpanded ? 'is-expanded' : ''}`}
|
|
||||||
onClick={() => setWeatherExpanded((prev) => !prev)}
|
|
||||||
disabled={busy}
|
|
||||||
aria-expanded={weatherExpanded}
|
|
||||||
>
|
|
||||||
<CloudSun size={18} />
|
|
||||||
{t('logs.live_weather_btn')}
|
|
||||||
{weatherExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
|
||||||
</button>
|
|
||||||
{weatherExpanded && (
|
|
||||||
<div className="live-log-weather-submenu">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="live-log-subaction-btn live-log-subaction-btn-owm"
|
|
||||||
onClick={handleFetchOwmWeather}
|
|
||||||
disabled={busy || weatherOwmLoading}
|
|
||||||
aria-busy={busy || weatherOwmLoading}
|
|
||||||
>
|
|
||||||
{weatherOwmLoading ? t('logs.live_weather_owm_loading') : t('logs.live_weather_owm_btn')}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('wind', lastWindDirectionFromEvents(events))} disabled={busy}>
|
|
||||||
{t('logs.live_wind_btn')}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('temp')} disabled={busy}>
|
|
||||||
{t('logs.live_temp_btn')}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('pressure')} disabled={busy}>
|
|
||||||
{t('logs.live_pressure_btn')}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('precip')} disabled={busy}>
|
|
||||||
{t('logs.live_precip_btn')}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('sea_state')} disabled={busy}>
|
|
||||||
{t('logs.live_sea_state_btn')}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('visibility')} disabled={busy}>
|
|
||||||
{t('logs.live_visibility_btn')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="button" className="live-log-action-btn" onClick={() => void openPositionModal()} disabled={busy}>
|
|
||||||
<MapPin size={18} />
|
|
||||||
{t('logs.live_position')}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="live-log-action-btn" onClick={handleFetchTides} disabled={busy || tidesLoading}>
|
|
||||||
<Waves size={18} />
|
|
||||||
{tidesLoading ? t('logs.tide_fetch_loading') : t('logs.tides')}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="live-log-action-btn" onClick={() => { setCommentText(''); setModal('comment') }} disabled={busy}>
|
|
||||||
<MessageSquare size={18} />
|
|
||||||
{t('logs.live_comment_btn')}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="live-log-action-btn" onClick={openPhotoModal} disabled={busy || photoSaving}>
|
|
||||||
<Camera size={18} />
|
|
||||||
{t('logs.live_photo_btn')}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="live-log-action-btn" onClick={openVoiceModal} disabled={busy || voiceSaving}>
|
|
||||||
<Mic size={18} />
|
|
||||||
{t('logs.live_voice_btn')}
|
|
||||||
</button>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section className="live-log-stream-panel" aria-label={t('logs.live_stream_label')}>
|
|
||||||
<h3 className="live-log-stream-title">{t('logs.live_stream_title')}</h3>
|
<h3 className="live-log-stream-title">{t('logs.live_stream_title')}</h3>
|
||||||
{events.length === 0 ? (
|
{events.length === 0 ? (
|
||||||
<p className="live-log-empty">{t('logs.live_no_events')}</p>
|
<p className="live-log-empty">{t('logs.live_no_events')}</p>
|
||||||
) : (
|
) : (
|
||||||
<ol className="live-log-stream">
|
<ol className="live-log-stream">
|
||||||
{events.map((event, index) => {
|
{events.map((event, index) => (
|
||||||
return (
|
<li
|
||||||
<li key={`${event.time}-${index}`} className="live-log-entry">
|
key={`${event.time}-${index}`}
|
||||||
|
className="live-log-entry"
|
||||||
|
data-tour={index === 0 ? 'entry-first' : undefined}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="live-log-entry-tap"
|
||||||
|
onClick={() => setEditingEventIndex(index)}
|
||||||
|
aria-label={t('logs.live_edit_event')}
|
||||||
|
>
|
||||||
<time className="live-log-time">{event.time}</time>
|
<time className="live-log-time">{event.time}</time>
|
||||||
<CreatorAvatar
|
<CreatorAvatar
|
||||||
creatorId={event.creatorId}
|
creatorId={event.creatorId}
|
||||||
@@ -1471,9 +1484,18 @@ export default function LiveLogView({
|
|||||||
readOnly={false}
|
readOnly={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</button>
|
||||||
)
|
<button
|
||||||
})}
|
type="button"
|
||||||
|
className="btn-icon live-log-entry-edit"
|
||||||
|
onClick={() => setEditingEventIndex(index)}
|
||||||
|
title={t('logs.live_edit_event')}
|
||||||
|
aria-label={t('logs.live_edit_event')}
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
<div ref={streamEndRef} />
|
<div ref={streamEndRef} />
|
||||||
</ol>
|
</ol>
|
||||||
)}
|
)}
|
||||||
@@ -1481,6 +1503,33 @@ export default function LiveLogView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<LiveOverflowMenu
|
||||||
|
open={overflowOpen}
|
||||||
|
onClose={() => setOverflowOpen(false)}
|
||||||
|
onOpenAllDays={onOpenAllDays}
|
||||||
|
onOpenEditor={() => entryId && onOpenEditor(entryId)}
|
||||||
|
onDownloadCsv={() => onDownloadCsv?.()}
|
||||||
|
onShareCsv={() => onShareCsv?.()}
|
||||||
|
onDownloadPhotosZip={onDownloadPhotosZip ? () => onDownloadPhotosZip() : undefined}
|
||||||
|
onDownloadPdf={() => entryId && date && onDownloadPdf?.(entryId, date)}
|
||||||
|
onDeleteEntry={onDeleteEntry && entryId ? () => onDeleteEntry(entryId) : undefined}
|
||||||
|
exporting={exporting}
|
||||||
|
canExportPhotosZip={canExportPhotosZip}
|
||||||
|
hasEntries={hasLogbookEntries}
|
||||||
|
entryId={entryId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LiveEventSheet
|
||||||
|
open={editingEventIndex !== null}
|
||||||
|
event={editingEventIndex !== null ? events[editingEventIndex] ?? null : null}
|
||||||
|
eventIndex={editingEventIndex}
|
||||||
|
onClose={() => setEditingEventIndex(null)}
|
||||||
|
onSave={updateEvent}
|
||||||
|
onDelete={deleteEvent}
|
||||||
|
onOpenEditor={entryId ? () => onOpenEditor(entryId) : undefined}
|
||||||
|
busy={busy}
|
||||||
|
/>
|
||||||
|
|
||||||
{((undoVisible && events.length > 0) || modal !== 'none') && createPortal(
|
{((undoVisible && events.length > 0) || modal !== 'none') && createPortal(
|
||||||
<>
|
<>
|
||||||
{undoVisible && events.length > 0 && (
|
{undoVisible && events.length > 0 && (
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
List,
|
||||||
|
Share2,
|
||||||
|
Trash2,
|
||||||
|
X
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
interface LiveOverflowMenuProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onOpenAllDays: () => void
|
||||||
|
onOpenEditor: () => void
|
||||||
|
onDownloadCsv: () => void
|
||||||
|
onShareCsv: () => void
|
||||||
|
onDownloadPhotosZip?: () => void
|
||||||
|
onDownloadPdf: () => void
|
||||||
|
onDeleteEntry?: () => void
|
||||||
|
exporting?: boolean
|
||||||
|
canExportPhotosZip?: boolean
|
||||||
|
hasEntries?: boolean
|
||||||
|
entryId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LiveOverflowMenu({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onOpenAllDays,
|
||||||
|
onOpenEditor,
|
||||||
|
onDownloadCsv,
|
||||||
|
onShareCsv,
|
||||||
|
onDownloadPhotosZip,
|
||||||
|
onDownloadPdf,
|
||||||
|
onDeleteEntry,
|
||||||
|
exporting = false,
|
||||||
|
canExportPhotosZip = false,
|
||||||
|
hasEntries = true,
|
||||||
|
entryId
|
||||||
|
}: LiveOverflowMenuProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const firstBtnRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) firstBtnRef.current?.focus()
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
const disabled = exporting || !hasEntries
|
||||||
|
|
||||||
|
const run = (fn: () => void) => {
|
||||||
|
fn()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="live-bottom-sheet-backdrop"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="live-bottom-sheet live-overflow-sheet"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="live-overflow-title"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="live-bottom-sheet-handle" aria-hidden />
|
||||||
|
<div className="live-bottom-sheet-header">
|
||||||
|
<h3 id="live-overflow-title">{t('logs.more_actions')}</h3>
|
||||||
|
<button type="button" className="btn-icon" onClick={onClose} aria-label={t('logs.live_cancel')}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="live-overflow-menu-list">
|
||||||
|
<button
|
||||||
|
ref={firstBtnRef}
|
||||||
|
type="button"
|
||||||
|
className="live-overflow-menu-item"
|
||||||
|
onClick={() => run(onOpenAllDays)}
|
||||||
|
>
|
||||||
|
<List size={18} />
|
||||||
|
{t('logs.all_days')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{entryId && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="live-overflow-menu-item"
|
||||||
|
onClick={() => run(onOpenEditor)}
|
||||||
|
>
|
||||||
|
<FileText size={18} />
|
||||||
|
{t('logs.live_open_editor')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="live-overflow-menu-item"
|
||||||
|
onClick={() => run(onDownloadCsv)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Download size={18} />
|
||||||
|
{exporting ? t('logs.exporting') : t('logs.export_csv')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="live-overflow-menu-item"
|
||||||
|
onClick={() => run(onShareCsv)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Share2 size={18} />
|
||||||
|
{t('logs.share_csv')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{canExportPhotosZip && onDownloadPhotosZip && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="live-overflow-menu-item"
|
||||||
|
onClick={() => run(onDownloadPhotosZip)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Download size={18} />
|
||||||
|
{exporting ? t('logs.exporting_photos_zip') : t('logs.export_photos_zip')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entryId && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="live-overflow-menu-item"
|
||||||
|
onClick={() => run(onDownloadPdf)}
|
||||||
|
disabled={exporting}
|
||||||
|
>
|
||||||
|
<Calendar size={18} />
|
||||||
|
{t('logs.export_pdf')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onDeleteEntry && entryId && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="live-overflow-menu-item live-overflow-menu-item-danger"
|
||||||
|
onClick={() => run(onDeleteEntry)}
|
||||||
|
disabled={exporting}
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
{t('logs.delete_entry')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,19 +1,22 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { db } from '../services/db.js'
|
import { db } from '../services/db.js'
|
||||||
import { getActiveMasterKey, hasUnlockedLocalCrypto } from '../services/auth.js'
|
import { getActiveMasterKey } from '../services/auth.js'
|
||||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
import { encryptJson } from '../services/crypto.js'
|
||||||
import { syncLogbook } from '../services/sync.js'
|
import { syncLogbook } from '../services/sync.js'
|
||||||
import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
|
||||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
|
||||||
import { buildZipArchive } from '../services/logbookBackup/zipArchive.js'
|
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import { getErrorMessage } from '../utils/errors.js'
|
import { getErrorMessage } from '../utils/errors.js'
|
||||||
import { findTodayEntryId, pruneEmptyTodayDuplicates, tryDecryptEntryPayload } from '../services/quickEventLog.js'
|
import { findTodayEntryId, pruneEmptyTodayDuplicates, tryDecryptEntryPayload } from '../services/quickEventLog.js'
|
||||||
import { localDateString } from '../utils/logEntryPayload.js'
|
import { localDateString } from '../utils/logEntryPayload.js'
|
||||||
|
import {
|
||||||
|
getLogsViewModePreference,
|
||||||
|
setLogsViewModePreference,
|
||||||
|
getActiveUserId
|
||||||
|
} from '../services/userPreferences.js'
|
||||||
|
import { useLogExport } from '../hooks/useLogExport.js'
|
||||||
import LogEntryEditor from './LogEntryEditor.tsx'
|
import LogEntryEditor from './LogEntryEditor.tsx'
|
||||||
import LiveLogView from './LiveLogView.tsx'
|
import LiveLogView, { type LiveEntrySummary } from './LiveLogView.tsx'
|
||||||
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
|
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
|
||||||
@@ -23,7 +26,7 @@ import {
|
|||||||
putEntryRecord
|
putEntryRecord
|
||||||
} from '../utils/entryListCache.js'
|
} from '../utils/entryListCache.js'
|
||||||
import { forEachInBatches } from '../utils/yieldToMain.js'
|
import { forEachInBatches } from '../utils/yieldToMain.js'
|
||||||
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react'
|
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, ChevronLeft } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
carryOverFromPreviousDay,
|
carryOverFromPreviousDay,
|
||||||
compareTravelDaysChronological,
|
compareTravelDaysChronological,
|
||||||
@@ -60,42 +63,6 @@ interface DecryptedEntryItem {
|
|||||||
skipperSignStatus: SkipperSignStatus
|
skipperSignStatus: SkipperSignStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to convert data URL to Uint8Array for zip packaging
|
|
||||||
function dataUrlToUint8Array(dataUrl: string): { data: Uint8Array; ext: string } {
|
|
||||||
const parts = dataUrl.split(',')
|
|
||||||
if (parts.length < 2) {
|
|
||||||
throw new Error('Invalid data URL')
|
|
||||||
}
|
|
||||||
const meta = parts[0]
|
|
||||||
const base64Data = parts[1]
|
|
||||||
|
|
||||||
let ext = 'jpg'
|
|
||||||
const mimeMatch = meta.match(/data:([^;]+)/)
|
|
||||||
if (mimeMatch) {
|
|
||||||
const mime = mimeMatch[1]
|
|
||||||
if (mime === 'image/png') ext = 'png'
|
|
||||||
else if (mime === 'image/gif') ext = 'gif'
|
|
||||||
else if (mime === 'image/webp') ext = 'webp'
|
|
||||||
else if (mime === 'image/heic') ext = 'heic'
|
|
||||||
else if (mime === 'image/heif') ext = 'heif'
|
|
||||||
}
|
|
||||||
|
|
||||||
const binaryString = atob(base64Data)
|
|
||||||
const bytes = new Uint8Array(binaryString.length)
|
|
||||||
for (let i = 0; i < binaryString.length; i++) {
|
|
||||||
bytes[i] = binaryString.charCodeAt(i)
|
|
||||||
}
|
|
||||||
return { data: bytes, ext }
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeFilename(str: string): string {
|
|
||||||
return str
|
|
||||||
.replace(/[^\w\s-]/gi, '')
|
|
||||||
.trim()
|
|
||||||
.replace(/\s+/g, '_')
|
|
||||||
.slice(0, 30)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LogEntriesList({
|
export default function LogEntriesList({
|
||||||
logbookId,
|
logbookId,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
@@ -124,12 +91,39 @@ export default function LogEntriesList({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [exporting, setExporting] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [viewMode, setViewMode] = useState<LogsViewMode>('list')
|
const [viewMode, setViewModeState] = useState<LogsViewMode>(() =>
|
||||||
|
readOnly ? 'list' : getLogsViewModePreference(getActiveUserId())
|
||||||
|
)
|
||||||
|
const [liveSelectedEntryId, setLiveSelectedEntryId] = useState<string | null>(null)
|
||||||
|
const [todayEntryId, setTodayEntryId] = useState<string | null>(null)
|
||||||
const [returnToLiveAfterEditor, setReturnToLiveAfterEditor] = useState(false)
|
const [returnToLiveAfterEditor, setReturnToLiveAfterEditor] = useState(false)
|
||||||
const prevSelectedEntryIdRef = useRef<string | null | undefined>(undefined)
|
const prevSelectedEntryIdRef = useRef<string | null | undefined>(undefined)
|
||||||
|
|
||||||
|
const setViewMode = useCallback((mode: LogsViewMode) => {
|
||||||
|
setViewModeState(mode)
|
||||||
|
if (!readOnly) {
|
||||||
|
const userId = getActiveUserId()
|
||||||
|
if (userId) setLogsViewModePreference(userId, mode)
|
||||||
|
}
|
||||||
|
}, [readOnly])
|
||||||
|
|
||||||
|
const {
|
||||||
|
exporting: exportBusy,
|
||||||
|
error: exportError,
|
||||||
|
handleDownloadCsv,
|
||||||
|
handleShareCsv,
|
||||||
|
handleDownloadPdf,
|
||||||
|
handleDownloadPhotosZip,
|
||||||
|
canExportPhotosZip
|
||||||
|
} = useLogExport({
|
||||||
|
logbookId,
|
||||||
|
entries,
|
||||||
|
readOnly,
|
||||||
|
preloadedYacht,
|
||||||
|
preloadedEntries
|
||||||
|
})
|
||||||
|
|
||||||
const loadEntries = useCallback(async () => {
|
const loadEntries = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -161,9 +155,10 @@ export default function LogEntriesList({
|
|||||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||||
|
|
||||||
const todayEntryId = await findTodayEntryId(logbookId)
|
const todayId = await findTodayEntryId(logbookId)
|
||||||
if (todayEntryId) {
|
setTodayEntryId(todayId)
|
||||||
await pruneEmptyTodayDuplicates(logbookId, todayEntryId)
|
if (todayId) {
|
||||||
|
await pruneEmptyTodayDuplicates(logbookId, todayId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const local = await db.entries.where({ logbookId }).toArray()
|
const local = await db.entries.where({ logbookId }).toArray()
|
||||||
@@ -212,9 +207,8 @@ export default function LogEntriesList({
|
|||||||
}, [logbookId, readOnly, preloadedEntries])
|
}, [logbookId, readOnly, preloadedEntries])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewMode === 'live') return
|
|
||||||
loadEntries()
|
loadEntries()
|
||||||
}, [loadEntries, viewMode])
|
}, [loadEntries])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewMode === 'live') return
|
if (viewMode === 'live') return
|
||||||
@@ -226,158 +220,6 @@ export default function LogEntriesList({
|
|||||||
}
|
}
|
||||||
}, [selectedEntryId, loadEntries, viewMode])
|
}, [selectedEntryId, loadEntries, viewMode])
|
||||||
|
|
||||||
const handleDownloadCsv = async () => {
|
|
||||||
setExporting(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const title = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
|
|
||||||
if (readOnly && preloadedEntries && preloadedYacht) {
|
|
||||||
await downloadCsv(logbookId, title, { yacht: preloadedYacht, entries: preloadedEntries })
|
|
||||||
} else {
|
|
||||||
await downloadCsv(logbookId, title)
|
|
||||||
}
|
|
||||||
trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED)
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Failed to download CSV:', err)
|
|
||||||
setError(getErrorMessage(err, t('errors.export_failed')))
|
|
||||||
} finally {
|
|
||||||
setExporting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleShareCsv = async () => {
|
|
||||||
setExporting(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const title = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
|
|
||||||
if (readOnly && preloadedEntries && preloadedYacht) {
|
|
||||||
await shareCsv(logbookId, title, { yacht: preloadedYacht, entries: preloadedEntries })
|
|
||||||
} else {
|
|
||||||
await shareCsv(logbookId, title)
|
|
||||||
}
|
|
||||||
trackPlausibleEvent(PlausibleEvents.CSV_SHARED)
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err.message === 'share_unsupported') {
|
|
||||||
const title = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
|
|
||||||
if (readOnly && preloadedEntries && preloadedYacht) {
|
|
||||||
await downloadCsv(logbookId, title, { yacht: preloadedYacht, entries: preloadedEntries })
|
|
||||||
} else {
|
|
||||||
await downloadCsv(logbookId, title)
|
|
||||||
}
|
|
||||||
setError(t('logs.share_unsupported'))
|
|
||||||
} else {
|
|
||||||
console.error('Failed to share CSV:', err)
|
|
||||||
setError(getErrorMessage(err, t('errors.export_failed')))
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setExporting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDownloadPdf = async (entryId: string, date: string, e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setExporting(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
if (readOnly && preloadedEntries && preloadedYacht) {
|
|
||||||
const fullEntry = preloadedEntries.find(entry => (entry.payloadId || entry.id) === entryId)
|
|
||||||
await downloadLogbookPagePdf(logbookId, entryId, date, { yacht: preloadedYacht, entry: fullEntry })
|
|
||||||
} else {
|
|
||||||
await downloadLogbookPagePdf(logbookId, entryId, date)
|
|
||||||
}
|
|
||||||
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Failed to download PDF:', err)
|
|
||||||
setError(getErrorMessage(err, t('errors.export_failed')))
|
|
||||||
} finally {
|
|
||||||
setExporting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDownloadPhotosZip = async () => {
|
|
||||||
setExporting(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
|
||||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
|
||||||
|
|
||||||
// Fetch all photos for this logbook from IndexedDB
|
|
||||||
const localPhotos = await db.photos.where({ logbookId }).toArray()
|
|
||||||
if (localPhotos.length === 0) {
|
|
||||||
setError(t('logs.no_photos_to_download'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a map of entry ID to entry info for filename lookup
|
|
||||||
const entryMap = new Map<string, DecryptedEntryItem>()
|
|
||||||
entries.forEach((e) => entryMap.set(e.id, e))
|
|
||||||
|
|
||||||
const files: Record<string, Uint8Array> = {}
|
|
||||||
const usedNames = new Set<string>()
|
|
||||||
|
|
||||||
for (const photo of localPhotos) {
|
|
||||||
// Decrypt photo payload (contains base64 image data and caption)
|
|
||||||
const decrypted = await decryptJson(photo.encryptedData, photo.iv, photo.tag, masterKey)
|
|
||||||
if (!decrypted || !decrypted.image) continue
|
|
||||||
|
|
||||||
const { data, ext } = dataUrlToUint8Array(decrypted.image)
|
|
||||||
|
|
||||||
// Construct unique, friendly filename
|
|
||||||
let fileBase = `photo_${photo.payloadId}`
|
|
||||||
const entry = entryMap.get(photo.entryId)
|
|
||||||
if (entry) {
|
|
||||||
const dateStr = entry.date || 'unknown-date'
|
|
||||||
const travelDay = entry.dayOfTravel ? `day-${entry.dayOfTravel}` : ''
|
|
||||||
const sanitizedCaption = decrypted.caption ? sanitizeFilename(decrypted.caption) : ''
|
|
||||||
|
|
||||||
const parts = [dateStr]
|
|
||||||
if (travelDay) parts.push(travelDay)
|
|
||||||
if (sanitizedCaption) parts.push(sanitizedCaption)
|
|
||||||
|
|
||||||
fileBase = parts.join('_')
|
|
||||||
} else if (decrypted.caption) {
|
|
||||||
fileBase = `photo_${sanitizeFilename(decrypted.caption)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// De-duplicate name
|
|
||||||
let candidate = `${fileBase}.${ext}`
|
|
||||||
let counter = 1
|
|
||||||
while (usedNames.has(candidate.toLowerCase())) {
|
|
||||||
candidate = `${fileBase}_${counter}.${ext}`
|
|
||||||
counter++
|
|
||||||
}
|
|
||||||
usedNames.add(candidate.toLowerCase())
|
|
||||||
|
|
||||||
files[candidate] = data
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(files).length === 0) {
|
|
||||||
setError(t('logs.no_photos_to_download'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const zipBytes = buildZipArchive(files)
|
|
||||||
const blob = new Blob([zipBytes as any], { type: 'application/zip' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
|
|
||||||
const yachtName = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
|
|
||||||
const safeTitle = yachtName.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
|
|
||||||
const datePart = new Date().toISOString().slice(0, 10)
|
|
||||||
const filename = `${safeTitle}-photos-${datePart}.zip`
|
|
||||||
|
|
||||||
const anchor = document.createElement('a')
|
|
||||||
anchor.href = url
|
|
||||||
anchor.download = filename
|
|
||||||
anchor.click()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Failed to download photos ZIP:', err)
|
|
||||||
setError(getErrorMessage(err, t('errors.export_failed')))
|
|
||||||
} finally {
|
|
||||||
setExporting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (readOnly) return
|
if (readOnly) return
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -488,17 +330,13 @@ export default function LogEntriesList({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (entryId: string, e: React.MouseEvent) => {
|
const handleDeleteEntry = async (entryId: string) => {
|
||||||
e.stopPropagation()
|
|
||||||
if (readOnly) return
|
if (readOnly) return
|
||||||
|
|
||||||
if (await showConfirm(t('logs.delete_confirm'), t('logs.delete_entry'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
|
if (await showConfirm(t('logs.delete_confirm'), t('logs.delete_entry'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
await db.entries.delete(entryId)
|
await db.entries.delete(entryId)
|
||||||
|
|
||||||
await db.syncQueue.put({
|
await db.syncQueue.put({
|
||||||
action: 'delete',
|
action: 'delete',
|
||||||
type: 'entry',
|
type: 'entry',
|
||||||
@@ -507,16 +345,33 @@ export default function LogEntriesList({
|
|||||||
data: '',
|
data: '',
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
})
|
})
|
||||||
|
|
||||||
setEntries((prev) => prev.filter((item) => item.id !== entryId))
|
setEntries((prev) => prev.filter((item) => item.id !== entryId))
|
||||||
|
if (liveSelectedEntryId === entryId) {
|
||||||
|
setLiveSelectedEntryId(null)
|
||||||
|
}
|
||||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to delete log entry:', err)
|
console.error('Failed to delete log entry:', err)
|
||||||
setError(getErrorMessage(err, t('errors.delete_failed')))
|
setError(getErrorMessage(err, t('errors.delete_failed')))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (entryId: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
await handleDeleteEntry(entryId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const entrySummaries: LiveEntrySummary[] = entries.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
date: e.date,
|
||||||
|
dayOfTravel: e.dayOfTravel,
|
||||||
|
departure: e.departure,
|
||||||
|
destination: e.destination
|
||||||
|
}))
|
||||||
|
|
||||||
|
const combinedError = error || exportError
|
||||||
|
|
||||||
if (selectedEntryId) {
|
if (selectedEntryId) {
|
||||||
return (
|
return (
|
||||||
<LogEntryEditor
|
<LogEntryEditor
|
||||||
@@ -540,17 +395,32 @@ export default function LogEntriesList({
|
|||||||
|
|
||||||
if (viewMode === 'live' && !readOnly) {
|
if (viewMode === 'live' && !readOnly) {
|
||||||
return (
|
return (
|
||||||
<LiveLogView
|
<>
|
||||||
logbookId={logbookId}
|
{combinedError && <div className="auth-error mb-4" style={{ margin: '0 0 12px' }}>{combinedError}</div>}
|
||||||
onOpenEditor={(entryId) => {
|
<LiveLogView
|
||||||
setReturnToLiveAfterEditor(true)
|
logbookId={logbookId}
|
||||||
setSelectedEntryId(entryId)
|
selectedEntryId={liveSelectedEntryId}
|
||||||
}}
|
entrySummaries={entrySummaries}
|
||||||
onSwitchToList={() => {
|
todayEntryId={todayEntryId}
|
||||||
setViewMode('list')
|
onEntryChange={setLiveSelectedEntryId}
|
||||||
void loadEntries()
|
onOpenEditor={(entryId) => {
|
||||||
}}
|
setReturnToLiveAfterEditor(true)
|
||||||
/>
|
setSelectedEntryId(entryId)
|
||||||
|
}}
|
||||||
|
onOpenAllDays={() => {
|
||||||
|
setViewMode('list')
|
||||||
|
void loadEntries()
|
||||||
|
}}
|
||||||
|
onDeleteEntry={handleDeleteEntry}
|
||||||
|
onDownloadCsv={() => void handleDownloadCsv()}
|
||||||
|
onShareCsv={() => void handleShareCsv()}
|
||||||
|
onDownloadPhotosZip={() => void handleDownloadPhotosZip()}
|
||||||
|
onDownloadPdf={(entryId, date) => void handleDownloadPdf(entryId, date)}
|
||||||
|
exporting={exportBusy}
|
||||||
|
canExportPhotosZip={canExportPhotosZip}
|
||||||
|
hasLogbookEntries={entries.length > 0}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,55 +447,46 @@ export default function LogEntriesList({
|
|||||||
</div>
|
</div>
|
||||||
<div className="section-toolbar">
|
<div className="section-toolbar">
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<div className="logs-view-toggle" role="group" aria-label={t('logs.view_mode_label')}>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
className="btn secondary"
|
||||||
className={`btn secondary logs-view-toggle-btn ${viewMode === 'list' ? 'is-active' : ''}`}
|
onClick={() => setViewMode('live')}
|
||||||
onClick={() => setViewMode('list')}
|
style={{ width: 'auto', padding: '8px 16px' }}
|
||||||
title={t('logs.view_list')}
|
title={t('logs.live_mode')}
|
||||||
>
|
>
|
||||||
<List size={16} />
|
<ChevronLeft size={16} />
|
||||||
<span className="hide-mobile">{t('logs.view_list')}</span>
|
<Radio size={16} />
|
||||||
</button>
|
<span className="hide-mobile">{t('logs.back_to_live')}</span>
|
||||||
<button
|
</button>
|
||||||
type="button"
|
|
||||||
className={`btn secondary logs-view-toggle-btn ${viewMode === 'live' ? 'is-active' : ''}`}
|
|
||||||
onClick={() => setViewMode('live')}
|
|
||||||
title={t('logs.live_mode')}
|
|
||||||
>
|
|
||||||
<Radio size={16} />
|
|
||||||
<span className="hide-mobile">{t('logs.live_mode')}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button className="btn secondary" onClick={handleDownloadCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.export_csv')}>
|
<button className="btn secondary" onClick={() => void handleDownloadCsv()} disabled={loading || exportBusy || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.export_csv')}>
|
||||||
<Download size={16} />
|
<Download size={16} />
|
||||||
<span className="hide-mobile">{exporting ? t('logs.exporting') : t('logs.export_csv')}</span>
|
<span className="hide-mobile">{exportBusy ? t('logs.exporting') : t('logs.export_csv')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button className="btn secondary" onClick={handleShareCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.share_csv')}>
|
<button className="btn secondary" onClick={() => void handleShareCsv()} disabled={loading || exportBusy || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.share_csv')}>
|
||||||
<Share2 size={16} />
|
<Share2 size={16} />
|
||||||
<span className="hide-mobile">{t('logs.share_csv')}</span>
|
<span className="hide-mobile">{t('logs.share_csv')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{hasUnlockedLocalCrypto() && (
|
{canExportPhotosZip && (
|
||||||
<button
|
<button
|
||||||
className="btn secondary"
|
className="btn secondary"
|
||||||
onClick={handleDownloadPhotosZip}
|
onClick={() => void handleDownloadPhotosZip()}
|
||||||
disabled={loading || exporting || entries.length === 0}
|
disabled={loading || exportBusy || entries.length === 0}
|
||||||
style={{ width: 'auto', padding: '8px 16px' }}
|
style={{ width: 'auto', padding: '8px 16px' }}
|
||||||
title={t('logs.export_photos_zip')}
|
title={t('logs.export_photos_zip')}
|
||||||
>
|
>
|
||||||
<Download size={16} />
|
<Download size={16} />
|
||||||
<span className="hide-mobile">
|
<span className="hide-mobile">
|
||||||
{exporting ? t('logs.exporting_photos_zip') : t('logs.export_photos_zip')}
|
{exportBusy ? t('logs.exporting_photos_zip') : t('logs.export_photos_zip')}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.new_entry')}>
|
<button className="btn primary" onClick={handleCreate} disabled={loading || exportBusy} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.new_entry')}>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
<span className="hide-mobile">{t('logs.new_entry')}</span>
|
<span className="hide-mobile">{t('logs.new_entry')}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -633,7 +494,7 @@ export default function LogEntriesList({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="auth-error mb-4">{error}</div>}
|
{combinedError && <div className="auth-error mb-4">{combinedError}</div>}
|
||||||
|
|
||||||
{entries.length === 0 ? (
|
{entries.length === 0 ? (
|
||||||
<div className="dashboard-status-msg">{t('logs.no_entries')}</div>
|
<div className="dashboard-status-msg">{t('logs.no_entries')}</div>
|
||||||
@@ -648,7 +509,14 @@ export default function LogEntriesList({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="logbook-card-select"
|
className="logbook-card-select"
|
||||||
onClick={() => setSelectedEntryId(item.id)}
|
onClick={() => {
|
||||||
|
if (readOnly) {
|
||||||
|
setSelectedEntryId(item.id)
|
||||||
|
} else {
|
||||||
|
setLiveSelectedEntryId(item.id)
|
||||||
|
setViewMode('live')
|
||||||
|
}
|
||||||
|
}}
|
||||||
aria-label={
|
aria-label={
|
||||||
item.departure && item.destination
|
item.departure && item.destination
|
||||||
? `${item.departure} → ${item.destination}, ${t('logs.travel_day_number', { number: item.dayOfTravel })}`
|
? `${item.departure} → ${item.destination}, ${t('logs.travel_day_number', { number: item.dayOfTravel })}`
|
||||||
@@ -678,7 +546,7 @@ export default function LogEntriesList({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="logbook-card-right-group">
|
<div className="logbook-card-right-group">
|
||||||
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
|
<button className="btn-pdf" onClick={(e) => { e.stopPropagation(); void handleDownloadPdf(item.id, item.date) }} title={t('logs.export_pdf')} disabled={exportBusy}>
|
||||||
<Download size={18} />
|
<Download size={18} />
|
||||||
</button>
|
</button>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { patchEntryEvents } from '../services/quickEventLog.js'
|
||||||
|
import { deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
|
||||||
|
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
|
||||||
|
import { normalizeLogEvent, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
|
import { useDialog } from '../components/ModalDialog.tsx'
|
||||||
|
|
||||||
|
interface UseEventEditorOptions {
|
||||||
|
logbookId: string
|
||||||
|
entryId: string | null
|
||||||
|
events: LogEventPayload[]
|
||||||
|
onEventsChange: (events: LogEventPayload[]) => void
|
||||||
|
hasSkipperSignature?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEventEditor({
|
||||||
|
logbookId,
|
||||||
|
entryId,
|
||||||
|
events,
|
||||||
|
onEventsChange,
|
||||||
|
hasSkipperSignature = false
|
||||||
|
}: UseEventEditorOptions) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { showAlert, showConfirm } = useDialog()
|
||||||
|
|
||||||
|
const persistEvents = useCallback(async (nextEvents: LogEventPayload[]) => {
|
||||||
|
if (!entryId) throw new Error('No entry selected')
|
||||||
|
const { hadSignature } = await patchEntryEvents(logbookId, entryId, nextEvents)
|
||||||
|
onEventsChange(nextEvents)
|
||||||
|
if (hadSignature || hasSkipperSignature) {
|
||||||
|
void showAlert(
|
||||||
|
t('logs.sign_cleared_skipper_re_sign'),
|
||||||
|
t('logs.sign_cleared_skipper_re_sign_title')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [entryId, hasSkipperSignature, logbookId, onEventsChange, showAlert, t])
|
||||||
|
|
||||||
|
const updateEvent = useCallback(async (index: number, patch: Partial<LogEventPayload>) => {
|
||||||
|
if (!entryId) return
|
||||||
|
const current = events[index]
|
||||||
|
if (!current) return
|
||||||
|
const updated = normalizeLogEvent({ ...current, ...patch })
|
||||||
|
const nextEvents = events.map((ev, idx) => (idx === index ? updated : ev))
|
||||||
|
await persistEvents(nextEvents)
|
||||||
|
}, [entryId, events, persistEvents])
|
||||||
|
|
||||||
|
const deleteEvent = useCallback(async (index: number) => {
|
||||||
|
if (!entryId) return
|
||||||
|
const confirmed = await showConfirm(
|
||||||
|
t('logs.delete_event_confirm'),
|
||||||
|
t('logs.delete_event'),
|
||||||
|
t('logs.confirm_yes'),
|
||||||
|
t('logs.confirm_no')
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
const voiceId = parseLiveVoiceRemark(events[index]?.remarks?.trim() ?? '')
|
||||||
|
const nextEvents = events.filter((_, idx) => idx !== index)
|
||||||
|
if (voiceId) {
|
||||||
|
await deleteEntryVoiceMemo(logbookId, voiceId)
|
||||||
|
}
|
||||||
|
await persistEvents(nextEvents)
|
||||||
|
}, [entryId, events, logbookId, persistEvents, showConfirm, t])
|
||||||
|
|
||||||
|
return { updateEvent, deleteEvent }
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { db } from '../services/db.js'
|
||||||
|
import { getActiveMasterKey, hasUnlockedLocalCrypto } from '../services/auth.js'
|
||||||
|
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||||
|
import { decryptJson } from '../services/crypto.js'
|
||||||
|
import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
||||||
|
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||||
|
import { buildZipArchive } from '../services/logbookBackup/zipArchive.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
import { getErrorMessage } from '../utils/errors.js'
|
||||||
|
|
||||||
|
export interface LogExportEntryItem {
|
||||||
|
id: string
|
||||||
|
date: string
|
||||||
|
dayOfTravel: string
|
||||||
|
departure: string
|
||||||
|
destination: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseLogExportOptions {
|
||||||
|
logbookId: string
|
||||||
|
entries: LogExportEntryItem[]
|
||||||
|
readOnly?: boolean
|
||||||
|
preloadedYacht?: { name?: string } | null
|
||||||
|
preloadedEntries?: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataUrlToUint8Array(dataUrl: string): { data: Uint8Array; ext: string } {
|
||||||
|
const parts = dataUrl.split(',')
|
||||||
|
if (parts.length < 2) throw new Error('Invalid data URL')
|
||||||
|
const meta = parts[0]
|
||||||
|
const base64Data = parts[1]
|
||||||
|
let ext = 'jpg'
|
||||||
|
const mimeMatch = meta.match(/data:([^;]+)/)
|
||||||
|
if (mimeMatch) {
|
||||||
|
const mime = mimeMatch[1]
|
||||||
|
if (mime === 'image/png') ext = 'png'
|
||||||
|
else if (mime === 'image/gif') ext = 'gif'
|
||||||
|
else if (mime === 'image/webp') ext = 'webp'
|
||||||
|
else if (mime === 'image/heic') ext = 'heic'
|
||||||
|
else if (mime === 'image/heif') ext = 'heif'
|
||||||
|
}
|
||||||
|
const binaryString = atob(base64Data)
|
||||||
|
const bytes = new Uint8Array(binaryString.length)
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i)
|
||||||
|
}
|
||||||
|
return { data: bytes, ext }
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFilename(str: string): string {
|
||||||
|
return str.replace(/[^\w\s-]/gi, '').trim().replace(/\s+/g, '_').slice(0, 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogExport({
|
||||||
|
logbookId,
|
||||||
|
entries,
|
||||||
|
readOnly = false,
|
||||||
|
preloadedYacht,
|
||||||
|
preloadedEntries
|
||||||
|
}: UseLogExportOptions) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [exporting, setExporting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const logbookTitle =
|
||||||
|
preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
|
||||||
|
|
||||||
|
const handleDownloadCsv = useCallback(async () => {
|
||||||
|
setExporting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
if (readOnly && preloadedEntries && preloadedYacht) {
|
||||||
|
await downloadCsv(logbookId, logbookTitle, { yacht: preloadedYacht, entries: preloadedEntries })
|
||||||
|
} else {
|
||||||
|
await downloadCsv(logbookId, logbookTitle)
|
||||||
|
}
|
||||||
|
trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('Failed to download CSV:', err)
|
||||||
|
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||||
|
} finally {
|
||||||
|
setExporting(false)
|
||||||
|
}
|
||||||
|
}, [logbookId, logbookTitle, preloadedEntries, preloadedYacht, readOnly, t])
|
||||||
|
|
||||||
|
const handleShareCsv = useCallback(async () => {
|
||||||
|
setExporting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
if (readOnly && preloadedEntries && preloadedYacht) {
|
||||||
|
await shareCsv(logbookId, logbookTitle, { yacht: preloadedYacht, entries: preloadedEntries })
|
||||||
|
} else {
|
||||||
|
await shareCsv(logbookId, logbookTitle)
|
||||||
|
}
|
||||||
|
trackPlausibleEvent(PlausibleEvents.CSV_SHARED)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof Error && err.message === 'share_unsupported') {
|
||||||
|
if (readOnly && preloadedEntries && preloadedYacht) {
|
||||||
|
await downloadCsv(logbookId, logbookTitle, { yacht: preloadedYacht, entries: preloadedEntries })
|
||||||
|
} else {
|
||||||
|
await downloadCsv(logbookId, logbookTitle)
|
||||||
|
}
|
||||||
|
setError(t('logs.share_unsupported'))
|
||||||
|
} else {
|
||||||
|
console.error('Failed to share CSV:', err)
|
||||||
|
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setExporting(false)
|
||||||
|
}
|
||||||
|
}, [logbookId, logbookTitle, preloadedEntries, preloadedYacht, readOnly, t])
|
||||||
|
|
||||||
|
const handleDownloadPdf = useCallback(async (entryId: string, date: string) => {
|
||||||
|
setExporting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
if (readOnly && preloadedEntries && preloadedYacht) {
|
||||||
|
const fullEntry = preloadedEntries.find((entry) => (entry.payloadId || entry.id) === entryId)
|
||||||
|
await downloadLogbookPagePdf(logbookId, entryId, date, { yacht: preloadedYacht, entry: fullEntry })
|
||||||
|
} else {
|
||||||
|
await downloadLogbookPagePdf(logbookId, entryId, date)
|
||||||
|
}
|
||||||
|
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('Failed to download PDF:', err)
|
||||||
|
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||||
|
} finally {
|
||||||
|
setExporting(false)
|
||||||
|
}
|
||||||
|
}, [logbookId, preloadedEntries, preloadedYacht, readOnly, t])
|
||||||
|
|
||||||
|
const handleDownloadPhotosZip = useCallback(async () => {
|
||||||
|
if (!hasUnlockedLocalCrypto()) return
|
||||||
|
setExporting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const masterKey = (await getLogbookKey(logbookId)) || getActiveMasterKey()
|
||||||
|
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||||
|
|
||||||
|
const localPhotos = await db.photos.where({ logbookId }).toArray()
|
||||||
|
if (localPhotos.length === 0) {
|
||||||
|
setError(t('logs.no_photos_to_download'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryMap = new Map(entries.map((e) => [e.id, e]))
|
||||||
|
const files: Record<string, Uint8Array> = {}
|
||||||
|
const usedNames = new Set<string>()
|
||||||
|
|
||||||
|
for (const photo of localPhotos) {
|
||||||
|
const decrypted = await decryptJson(photo.encryptedData, photo.iv, photo.tag, masterKey)
|
||||||
|
if (!decrypted || !decrypted.image) continue
|
||||||
|
|
||||||
|
const { data, ext } = dataUrlToUint8Array(decrypted.image)
|
||||||
|
let fileBase = `photo_${photo.payloadId}`
|
||||||
|
const entry = entryMap.get(photo.entryId)
|
||||||
|
if (entry) {
|
||||||
|
const parts = [entry.date || 'unknown-date']
|
||||||
|
if (entry.dayOfTravel) parts.push(`day-${entry.dayOfTravel}`)
|
||||||
|
const sanitizedCaption = decrypted.caption ? sanitizeFilename(decrypted.caption) : ''
|
||||||
|
if (sanitizedCaption) parts.push(sanitizedCaption)
|
||||||
|
fileBase = parts.join('_')
|
||||||
|
} else if (decrypted.caption) {
|
||||||
|
fileBase = `photo_${sanitizeFilename(decrypted.caption)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate = `${fileBase}.${ext}`
|
||||||
|
let counter = 1
|
||||||
|
while (usedNames.has(candidate.toLowerCase())) {
|
||||||
|
candidate = `${fileBase}_${counter}.${ext}`
|
||||||
|
counter++
|
||||||
|
}
|
||||||
|
usedNames.add(candidate.toLowerCase())
|
||||||
|
files[candidate] = data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(files).length === 0) {
|
||||||
|
setError(t('logs.no_photos_to_download'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipBytes = buildZipArchive(files)
|
||||||
|
const blob = new Blob([zipBytes as BlobPart], { type: 'application/zip' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const safeTitle = logbookTitle.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
|
||||||
|
const filename = `${safeTitle}-photos-${new Date().toISOString().slice(0, 10)}.zip`
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = url
|
||||||
|
anchor.download = filename
|
||||||
|
anchor.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('Failed to download photos ZIP:', err)
|
||||||
|
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||||
|
} finally {
|
||||||
|
setExporting(false)
|
||||||
|
}
|
||||||
|
}, [entries, logbookId, logbookTitle, t])
|
||||||
|
|
||||||
|
return {
|
||||||
|
exporting,
|
||||||
|
error,
|
||||||
|
setError,
|
||||||
|
handleDownloadCsv,
|
||||||
|
handleShareCsv,
|
||||||
|
handleDownloadPdf,
|
||||||
|
handleDownloadPhotosZip,
|
||||||
|
canExportPhotosZip: hasUnlockedLocalCrypto()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -267,6 +267,16 @@
|
|||||||
"view_mode_label": "Visning",
|
"view_mode_label": "Visning",
|
||||||
"view_list": "Liste",
|
"view_list": "Liste",
|
||||||
"live_mode": "Live",
|
"live_mode": "Live",
|
||||||
|
"all_days": "Alle dage",
|
||||||
|
"back_to_live": "Tilbage til Live",
|
||||||
|
"more_actions": "Flere handlinger",
|
||||||
|
"live_edit_event": "Rediger begivenhed",
|
||||||
|
"live_delete_event": "Slet begivenhed",
|
||||||
|
"delete_event_confirm": "Slet denne begivenhed?",
|
||||||
|
"live_day_today": "I dag",
|
||||||
|
"live_switch_day": "Vælg rejsedag",
|
||||||
|
"live_more_btn": "Mere",
|
||||||
|
"live_primary_actions": "Hurtighandlinger",
|
||||||
"live_title": "Live-Journal",
|
"live_title": "Live-Journal",
|
||||||
"live_loading": "Live-Journal indlæses...",
|
"live_loading": "Live-Journal indlæses...",
|
||||||
"live_retry": "Prøve igen",
|
"live_retry": "Prøve igen",
|
||||||
|
|||||||
@@ -273,6 +273,16 @@
|
|||||||
"view_mode_label": "Ansicht",
|
"view_mode_label": "Ansicht",
|
||||||
"view_list": "Liste",
|
"view_list": "Liste",
|
||||||
"live_mode": "Live",
|
"live_mode": "Live",
|
||||||
|
"all_days": "Alle Tage",
|
||||||
|
"back_to_live": "Zurück zu Live",
|
||||||
|
"more_actions": "Weitere Aktionen",
|
||||||
|
"live_edit_event": "Ereignis bearbeiten",
|
||||||
|
"live_delete_event": "Ereignis löschen",
|
||||||
|
"delete_event_confirm": "Dieses Ereignis wirklich löschen?",
|
||||||
|
"live_day_today": "Heute",
|
||||||
|
"live_switch_day": "Reisetag wählen",
|
||||||
|
"live_more_btn": "Mehr",
|
||||||
|
"live_primary_actions": "Schnellaktionen",
|
||||||
"live_title": "Live-Journal",
|
"live_title": "Live-Journal",
|
||||||
"live_loading": "Live-Journal wird geladen...",
|
"live_loading": "Live-Journal wird geladen...",
|
||||||
"live_retry": "Erneut versuchen",
|
"live_retry": "Erneut versuchen",
|
||||||
|
|||||||
@@ -273,6 +273,16 @@
|
|||||||
"view_mode_label": "View",
|
"view_mode_label": "View",
|
||||||
"view_list": "List",
|
"view_list": "List",
|
||||||
"live_mode": "Live",
|
"live_mode": "Live",
|
||||||
|
"all_days": "All days",
|
||||||
|
"back_to_live": "Back to Live",
|
||||||
|
"more_actions": "More actions",
|
||||||
|
"live_edit_event": "Edit event",
|
||||||
|
"live_delete_event": "Delete event",
|
||||||
|
"delete_event_confirm": "Delete this event?",
|
||||||
|
"live_day_today": "Today",
|
||||||
|
"live_switch_day": "Select travel day",
|
||||||
|
"live_more_btn": "More",
|
||||||
|
"live_primary_actions": "Quick actions",
|
||||||
"live_title": "Live Journal",
|
"live_title": "Live Journal",
|
||||||
"live_loading": "Loading live journal...",
|
"live_loading": "Loading live journal...",
|
||||||
"live_retry": "Try again",
|
"live_retry": "Try again",
|
||||||
|
|||||||
@@ -267,6 +267,16 @@
|
|||||||
"view_mode_label": "Vista",
|
"view_mode_label": "Vista",
|
||||||
"view_list": "Lista",
|
"view_list": "Lista",
|
||||||
"live_mode": "En directo",
|
"live_mode": "En directo",
|
||||||
|
"all_days": "Todos los días",
|
||||||
|
"back_to_live": "Volver a en directo",
|
||||||
|
"more_actions": "Más acciones",
|
||||||
|
"live_edit_event": "Editar evento",
|
||||||
|
"live_delete_event": "Eliminar evento",
|
||||||
|
"delete_event_confirm": "¿Eliminar este evento?",
|
||||||
|
"live_day_today": "Hoy",
|
||||||
|
"live_switch_day": "Elegir día",
|
||||||
|
"live_more_btn": "Más",
|
||||||
|
"live_primary_actions": "Acciones rápidas",
|
||||||
"live_title": "Diario en vivo",
|
"live_title": "Diario en vivo",
|
||||||
"live_loading": "Se está cargando Live-Journal...",
|
"live_loading": "Se está cargando Live-Journal...",
|
||||||
"live_retry": "Intentarlo de nuevo",
|
"live_retry": "Intentarlo de nuevo",
|
||||||
|
|||||||
@@ -267,6 +267,16 @@
|
|||||||
"view_mode_label": "Vue",
|
"view_mode_label": "Vue",
|
||||||
"view_list": "Liste",
|
"view_list": "Liste",
|
||||||
"live_mode": "En direct",
|
"live_mode": "En direct",
|
||||||
|
"all_days": "Tous les jours",
|
||||||
|
"back_to_live": "Retour au direct",
|
||||||
|
"more_actions": "Plus d'actions",
|
||||||
|
"live_edit_event": "Modifier l'événement",
|
||||||
|
"live_delete_event": "Supprimer l'événement",
|
||||||
|
"delete_event_confirm": "Supprimer cet événement ?",
|
||||||
|
"live_day_today": "Aujourd'hui",
|
||||||
|
"live_switch_day": "Choisir le jour",
|
||||||
|
"live_more_btn": "Plus",
|
||||||
|
"live_primary_actions": "Actions rapides",
|
||||||
"live_title": "Journal en direct",
|
"live_title": "Journal en direct",
|
||||||
"live_loading": "LiveJournal est en cours de chargement...",
|
"live_loading": "LiveJournal est en cours de chargement...",
|
||||||
"live_retry": "Réessayer",
|
"live_retry": "Réessayer",
|
||||||
|
|||||||
@@ -267,6 +267,16 @@
|
|||||||
"view_mode_label": "Visning",
|
"view_mode_label": "Visning",
|
||||||
"view_list": "Liste",
|
"view_list": "Liste",
|
||||||
"live_mode": "Direkte",
|
"live_mode": "Direkte",
|
||||||
|
"all_days": "Alle dager",
|
||||||
|
"back_to_live": "Tilbake til Live",
|
||||||
|
"more_actions": "Flere handlinger",
|
||||||
|
"live_edit_event": "Rediger hendelse",
|
||||||
|
"live_delete_event": "Slett hendelse",
|
||||||
|
"delete_event_confirm": "Slette denne hendelsen?",
|
||||||
|
"live_day_today": "I dag",
|
||||||
|
"live_switch_day": "Velg reisedag",
|
||||||
|
"live_more_btn": "Mer",
|
||||||
|
"live_primary_actions": "Hurtighandlinger",
|
||||||
"live_title": "Live-Journal",
|
"live_title": "Live-Journal",
|
||||||
"live_loading": "Live-Journal lastes inn...",
|
"live_loading": "Live-Journal lastes inn...",
|
||||||
"live_retry": "Prøv på nytt",
|
"live_retry": "Prøv på nytt",
|
||||||
|
|||||||
@@ -267,6 +267,16 @@
|
|||||||
"view_mode_label": "Vy",
|
"view_mode_label": "Vy",
|
||||||
"view_list": "Lista",
|
"view_list": "Lista",
|
||||||
"live_mode": "Live",
|
"live_mode": "Live",
|
||||||
|
"all_days": "Alla dagar",
|
||||||
|
"back_to_live": "Tillbaka till Live",
|
||||||
|
"more_actions": "Fler åtgärder",
|
||||||
|
"live_edit_event": "Redigera händelse",
|
||||||
|
"live_delete_event": "Ta bort händelse",
|
||||||
|
"delete_event_confirm": "Ta bort denna händelse?",
|
||||||
|
"live_day_today": "Idag",
|
||||||
|
"live_switch_day": "Välj resedag",
|
||||||
|
"live_more_btn": "Mer",
|
||||||
|
"live_primary_actions": "Snabbåtgärder",
|
||||||
"live_title": "Live-Journal",
|
"live_title": "Live-Journal",
|
||||||
"live_loading": "Live-Journal laddas...",
|
"live_loading": "Live-Journal laddas...",
|
||||||
"live_retry": "Försök igen",
|
"live_retry": "Försök igen",
|
||||||
|
|||||||
@@ -469,6 +469,26 @@ async function persistEntry(
|
|||||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function patchEntryEvents(
|
||||||
|
logbookId: string,
|
||||||
|
entryId: string,
|
||||||
|
events: LogEventPayload[],
|
||||||
|
options?: { clearSignatures?: boolean }
|
||||||
|
): Promise<{ hadSignature: boolean }> {
|
||||||
|
const loaded = await loadEntry(logbookId, entryId)
|
||||||
|
if (!loaded) throw new Error('Entry not found')
|
||||||
|
|
||||||
|
const hadSignature = !!(loaded.data.signSkipper || loaded.data.signCrew)
|
||||||
|
const nextEvents = sortLogEventsByTime(events.map((e) => normalizeLogEvent({ ...e })))
|
||||||
|
|
||||||
|
await persistEntry(logbookId, entryId, loaded.data, {
|
||||||
|
events: nextEvents,
|
||||||
|
clearSignatures: options?.clearSignatures ?? hadSignature
|
||||||
|
})
|
||||||
|
|
||||||
|
return { hadSignature }
|
||||||
|
}
|
||||||
|
|
||||||
export async function removeLastEvent(
|
export async function removeLastEvent(
|
||||||
logbookId: string,
|
logbookId: string,
|
||||||
entryId: string
|
entryId: string
|
||||||
|
|||||||
@@ -90,6 +90,25 @@ export function setOwmApiKey(userId: string, value: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logsViewModeKey(userId: string): string {
|
||||||
|
return `user_pref_logs_view_mode_${userId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LogsViewModePreference = 'list' | 'live'
|
||||||
|
|
||||||
|
export function getLogsViewModePreference(userId?: string | null): LogsViewModePreference {
|
||||||
|
const id = resolveUserId(userId)
|
||||||
|
if (id) {
|
||||||
|
const value = localStorage.getItem(logsViewModeKey(id))
|
||||||
|
if (value === 'list' || value === 'live') return value
|
||||||
|
}
|
||||||
|
return 'live'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLogsViewModePreference(userId: string, value: LogsViewModePreference): void {
|
||||||
|
localStorage.setItem(logsViewModeKey(userId), value)
|
||||||
|
}
|
||||||
|
|
||||||
function aiAuthorizedKey(userId: string): string {
|
function aiAuthorizedKey(userId: string): string {
|
||||||
return `user_pref_ai_authorized_${userId}`
|
return `user_pref_ai_authorized_${userId}`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user