3d2918e0fe
Extend dashboard search with ship name and crew name parts from local data. When leaving a dirty travel day, offer save, discard, or stay instead of only leave/cancel. Co-authored-by: Cursor <cursoragent@cursor.com>
562 lines
20 KiB
TypeScript
562 lines
20 KiB
TypeScript
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { cycleAppLanguage } from '../utils/i18nLanguages.js'
|
|
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
|
|
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
|
|
import { loadLogbookSearchFieldsBatch } from '../services/logbookSearchIndex.js'
|
|
import { logbookMatchesFilter, type LogbookSearchFields } from '../utils/logbookFilter.js'
|
|
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
|
|
import BetaBadge from './BetaBadge.tsx'
|
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
|
import { getErrorMessage } from '../utils/errors.js'
|
|
import { logoutUser } from '../services/auth.js'
|
|
import { useDialog } from './ModalDialog.tsx'
|
|
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
|
|
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
|
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
|
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
|
|
|
|
interface LogbookDashboardProps {
|
|
onSelectLogbook: (id: string, title: string) => void
|
|
onLogout: () => void
|
|
onOpenProfile: () => void
|
|
}
|
|
|
|
type LogbookSortKey = 'name' | 'date'
|
|
type LogbookSortDirection = 'asc' | 'desc'
|
|
|
|
function sortLogbooks(
|
|
items: DecryptedLogbook[],
|
|
sortBy: LogbookSortKey,
|
|
direction: LogbookSortDirection,
|
|
locale: string
|
|
): DecryptedLogbook[] {
|
|
const sorted = [...items]
|
|
sorted.sort((a, b) => {
|
|
const cmp =
|
|
sortBy === 'name'
|
|
? a.title.localeCompare(b.title, locale, { sensitivity: 'base' })
|
|
: new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
|
|
return direction === 'asc' ? cmp : -cmp
|
|
})
|
|
return sorted
|
|
}
|
|
|
|
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile }: LogbookDashboardProps) {
|
|
const { t, i18n } = useTranslation()
|
|
const { showConfirm } = useDialog()
|
|
const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([])
|
|
const [newTitle, setNewTitle] = useState('')
|
|
const [editingLogbookId, setEditingLogbookId] = useState<string | null>(null)
|
|
const [editingTitleDraft, setEditingTitleDraft] = useState('')
|
|
const titleInputRef = useRef<HTMLInputElement>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [refreshing, setRefreshing] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [filterQuery, setFilterQuery] = useState('')
|
|
const [searchFieldsByLogbookId, setSearchFieldsByLogbookId] = useState<Map<string, LogbookSearchFields>>(
|
|
() => new Map()
|
|
)
|
|
const [sortBy, setSortBy] = useState<LogbookSortKey>('date')
|
|
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
|
|
const filterInputRef = useRef<HTMLInputElement>(null)
|
|
const [online, setOnline] = useState(navigator.onLine)
|
|
|
|
const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
|
|
|
|
// Listen to connectivity changes
|
|
useEffect(() => {
|
|
const handleOnline = () => setOnline(true)
|
|
const handleOffline = () => setOnline(false)
|
|
window.addEventListener('online', handleOnline)
|
|
window.addEventListener('offline', handleOffline)
|
|
return () => {
|
|
window.removeEventListener('online', handleOnline)
|
|
window.removeEventListener('offline', handleOffline)
|
|
}
|
|
}, [])
|
|
|
|
// Load logbooks on mount
|
|
useEffect(() => {
|
|
loadLogbooks()
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const ids = logbooks.map((lb) => lb.id)
|
|
if (ids.length === 0) {
|
|
setSearchFieldsByLogbookId(new Map())
|
|
return
|
|
}
|
|
|
|
let cancelled = false
|
|
void loadLogbookSearchFieldsBatch(ids).then((index) => {
|
|
if (!cancelled) setSearchFieldsByLogbookId(index)
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [logbooks])
|
|
|
|
const loadLogbooks = async (isRefresh = false) => {
|
|
if (isRefresh) setRefreshing(true)
|
|
else setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const data = await fetchLogbooks()
|
|
setLogbooks(data)
|
|
} catch (err: unknown) {
|
|
setError(getErrorMessage(err, t('errors.load_failed')))
|
|
} finally {
|
|
setLoading(false)
|
|
setRefreshing(false)
|
|
}
|
|
}
|
|
|
|
const handleCreate = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!newTitle.trim()) return
|
|
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const created = await createLogbook(newTitle.trim())
|
|
setLogbooks((prev) => [created, ...prev])
|
|
setNewTitle('')
|
|
trackPlausibleEvent(PlausibleEvents.LOGBOOK_CREATED)
|
|
} catch (err: unknown) {
|
|
setError(getErrorMessage(err, t('errors.save_failed')))
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
|
e.stopPropagation() // Prevent selecting the logbook when clicking delete
|
|
|
|
if (await showConfirm(t('dashboard.delete_confirm'), t('dashboard.delete_btn'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
await deleteLogbook(id)
|
|
setLogbooks((prev) => prev.filter((lb) => lb.id !== id))
|
|
} catch (err: any) {
|
|
setError(getErrorMessage(err, t('errors.delete_failed')))
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (editingLogbookId) {
|
|
titleInputRef.current?.focus()
|
|
titleInputRef.current?.select()
|
|
}
|
|
}, [editingLogbookId])
|
|
|
|
const startTitleEdit = (lb: DecryptedLogbook, e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
setEditingLogbookId(lb.id)
|
|
setEditingTitleDraft(lb.title)
|
|
}
|
|
|
|
const cancelTitleEdit = () => {
|
|
setEditingLogbookId(null)
|
|
setEditingTitleDraft('')
|
|
}
|
|
|
|
const commitTitleEdit = async (id: string) => {
|
|
if (editingLogbookId !== id) return
|
|
|
|
const lb = logbooks.find((item) => item.id === id)
|
|
const trimmedTitle = editingTitleDraft.trim()
|
|
cancelTitleEdit()
|
|
|
|
if (!lb || !trimmedTitle || trimmedTitle === lb.title.trim()) return
|
|
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
await updateLogbookTitle(id, trimmedTitle)
|
|
setLogbooks((prev) =>
|
|
prev.map((item) =>
|
|
item.id === id ? { ...item, title: trimmedTitle, updatedAt: new Date().toISOString() } : item
|
|
)
|
|
)
|
|
} catch (err: any) {
|
|
setError(getErrorMessage(err, t('errors.save_failed')))
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleLogout = () => {
|
|
void logoutUser()
|
|
onLogout()
|
|
}
|
|
|
|
const toggleLanguage = () => {
|
|
cycleAppLanguage(i18n)
|
|
}
|
|
|
|
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
|
|
const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
|
|
|
|
const filterActive = filterQuery.trim().length > 0
|
|
const filteredOwnedLogbooks = useMemo(
|
|
() =>
|
|
ownedLogbooks.filter((lb) =>
|
|
logbookMatchesFilter(lb, filterQuery, i18n.language, searchFieldsByLogbookId.get(lb.id))
|
|
),
|
|
[ownedLogbooks, filterQuery, i18n.language, searchFieldsByLogbookId]
|
|
)
|
|
const filteredSharedLogbooks = useMemo(
|
|
() =>
|
|
sharedLogbooks.filter((lb) =>
|
|
logbookMatchesFilter(lb, filterQuery, i18n.language, searchFieldsByLogbookId.get(lb.id))
|
|
),
|
|
[sharedLogbooks, filterQuery, i18n.language, searchFieldsByLogbookId]
|
|
)
|
|
const sortedOwnedLogbooks = useMemo(
|
|
() => sortLogbooks(filteredOwnedLogbooks, sortBy, sortDirection, i18n.language),
|
|
[filteredOwnedLogbooks, sortBy, sortDirection, i18n.language]
|
|
)
|
|
const sortedSharedLogbooks = useMemo(
|
|
() => sortLogbooks(filteredSharedLogbooks, sortBy, sortDirection, i18n.language),
|
|
[filteredSharedLogbooks, sortBy, sortDirection, i18n.language]
|
|
)
|
|
const filteredLogbookCount = sortedOwnedLogbooks.length + sortedSharedLogbooks.length
|
|
|
|
const renderLogbookCard = (lb: DecryptedLogbook) => {
|
|
const isEditingTitle = editingLogbookId === lb.id
|
|
|
|
return (
|
|
<div
|
|
key={lb.id}
|
|
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}${isEditingTitle ? ' logbook-card--editing-title' : ''}`}
|
|
>
|
|
{!isEditingTitle && (
|
|
<button
|
|
type="button"
|
|
className="logbook-card-select"
|
|
onClick={() => onSelectLogbook(lb.id, lb.title)}
|
|
aria-label={t('dashboard.open_logbook', { title: lb.title })}
|
|
/>
|
|
)}
|
|
|
|
<div className="card-icon" aria-hidden>
|
|
<BookOpen size={24} />
|
|
</div>
|
|
|
|
<div className="card-info">
|
|
<div className="card-title-row">
|
|
{isEditingTitle ? (
|
|
<input
|
|
ref={titleInputRef}
|
|
type="text"
|
|
className="logbook-title-inline-edit input-text"
|
|
value={editingTitleDraft}
|
|
onChange={(e) => setEditingTitleDraft(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
void commitTitleEdit(lb.id)
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault()
|
|
cancelTitleEdit()
|
|
}
|
|
}}
|
|
onBlur={() => void commitTitleEdit(lb.id)}
|
|
disabled={loading}
|
|
aria-label={t('dashboard.edit_title')}
|
|
/>
|
|
) : (
|
|
<h3
|
|
className={lb.isShared ? undefined : 'logbook-title-editable'}
|
|
onClick={lb.isShared ? undefined : (e) => startTitleEdit(lb, e)}
|
|
title={lb.isShared ? undefined : t('dashboard.edit_title')}
|
|
>
|
|
{lb.title}
|
|
</h3>
|
|
)}
|
|
<LogbookRoleBadge role={lb.accessRole} />
|
|
</div>
|
|
<div className="card-meta">
|
|
<span className={`sync-badge ${lb.isSynced ? 'synced' : 'local'}`}>
|
|
{lb.isSynced ? t('dashboard.status_synced') : t('dashboard.status_local')}
|
|
</span>
|
|
{lb.isDemo && (
|
|
<span className="demo-badge">{t('demo.badge')}</span>
|
|
)}
|
|
<span className="date-badge">
|
|
{new Date(lb.updatedAt).toLocaleDateString(i18n.language, {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric'
|
|
})}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{!lb.isShared && (
|
|
<div className="logbook-card-actions">
|
|
<button
|
|
type="button"
|
|
className="btn-delete"
|
|
onClick={(e) => handleDelete(lb.id, e)}
|
|
title={t('dashboard.delete_btn')}
|
|
aria-label={t('dashboard.delete_btn')}
|
|
>
|
|
<Trash2 size={18} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const renderLogbookSection = (
|
|
title: string,
|
|
items: DecryptedLogbook[],
|
|
hint?: string
|
|
) => (
|
|
<div className="logbook-section">
|
|
<div className="logbook-section-header">
|
|
<h3>{title}</h3>
|
|
{hint && <p className="logbook-section-hint">{hint}</p>}
|
|
</div>
|
|
<div className="logbooks-grid">
|
|
{items.map(renderLogbookCard)}
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<div className="dashboard-container">
|
|
{/* Premium Dashboard Header */}
|
|
<header className="dashboard-header">
|
|
<div className="header-brand">
|
|
<Ship className="header-logo" size={32} />
|
|
<div>
|
|
<div className="header-brand-title-row">
|
|
<h1>{t('app.name')}</h1>
|
|
<BetaBadge />
|
|
</div>
|
|
<p className="subtitle">{t('app.tagline')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="header-actions">
|
|
{/* Connection Indicator */}
|
|
<div
|
|
className={connStatusClassName(online)}
|
|
title={
|
|
online
|
|
? showSpinner
|
|
? 'Syncing'
|
|
: pendingCount > 0
|
|
? 'Pending Sync'
|
|
: 'Synced'
|
|
: 'Offline'
|
|
}
|
|
>
|
|
{online ? (
|
|
showSpinner ? (
|
|
<>
|
|
<RefreshCw size={18} className="spin" />
|
|
<span>{t('sync.status_syncing')}</span>
|
|
</>
|
|
) : showPendingWarning ? (
|
|
<>
|
|
<RefreshCw size={18} />
|
|
<span>{t('sync.status_unsynced')} ({pendingCount})</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Wifi size={18} />
|
|
<span>{t('sync.status_synced')}</span>
|
|
</>
|
|
)
|
|
) : (
|
|
<>
|
|
<WifiOff size={18} />
|
|
<span>{t('sync.status_offline')}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<ProfileHeaderButton onClick={onOpenProfile} />
|
|
|
|
{/* Lang toggle */}
|
|
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
|
<Languages size={18} />
|
|
</button>
|
|
|
|
<DisclaimerHeaderButton />
|
|
|
|
<FeedbackHeaderButton />
|
|
|
|
{/* Logout */}
|
|
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
|
|
<LogOut size={18} />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Main Dashboard Layout */}
|
|
<main className="dashboard-main">
|
|
{/* Left Side: Create form */}
|
|
<section className="create-section glass">
|
|
<h2>{t('dashboard.create_btn')}</h2>
|
|
<form onSubmit={handleCreate} className="dashboard-form">
|
|
<div className="input-group">
|
|
<input
|
|
type="text"
|
|
className="input-text"
|
|
placeholder={t('dashboard.new_logbook_placeholder')}
|
|
value={newTitle}
|
|
onChange={(e) => setNewTitle(e.target.value)}
|
|
disabled={loading}
|
|
required
|
|
/>
|
|
</div>
|
|
<button type="submit" className="btn primary" disabled={loading || !newTitle.trim()}>
|
|
<Plus size={18} />
|
|
{t('dashboard.create_btn')}
|
|
</button>
|
|
</form>
|
|
|
|
{error && <div className="auth-error mt-4">{error}</div>}
|
|
</section>
|
|
|
|
{/* Right Side: Logbooks list */}
|
|
<section className="list-section">
|
|
<div className="section-title-bar">
|
|
<h2>{t('dashboard.title')}</h2>
|
|
<button className="btn-refresh" onClick={() => loadLogbooks(true)} disabled={loading || refreshing}>
|
|
<RefreshCw size={16} className={refreshing ? 'spin' : ''} />
|
|
</button>
|
|
</div>
|
|
|
|
{loading && !refreshing ? (
|
|
<div className="dashboard-status-msg">{t('dashboard.loading')}</div>
|
|
) : logbooks.length === 0 ? (
|
|
<div className="dashboard-status-msg glass">{t('dashboard.no_logbooks')}</div>
|
|
) : (
|
|
<>
|
|
<div className="dashboard-list-controls">
|
|
<div className="dashboard-filter-bar">
|
|
<label className="dashboard-filter-label" htmlFor="logbook-list-filter">
|
|
{t('dashboard.filter_label')}
|
|
</label>
|
|
<div className="dashboard-filter-input-wrap">
|
|
<Search size={18} className="dashboard-filter-icon" aria-hidden="true" />
|
|
<input
|
|
ref={filterInputRef}
|
|
id="logbook-list-filter"
|
|
type="search"
|
|
className="input-text dashboard-filter-input"
|
|
placeholder={t('dashboard.filter_placeholder')}
|
|
value={filterQuery}
|
|
onChange={(e) => setFilterQuery(e.target.value)}
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
aria-describedby={filterActive ? 'logbook-filter-status' : undefined}
|
|
/>
|
|
{filterActive && (
|
|
<button
|
|
type="button"
|
|
className="dashboard-filter-clear"
|
|
onClick={() => {
|
|
setFilterQuery('')
|
|
filterInputRef.current?.focus()
|
|
}}
|
|
title={t('dashboard.filter_clear')}
|
|
aria-label={t('dashboard.filter_clear')}
|
|
>
|
|
<X size={16} aria-hidden="true" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{filterActive && (
|
|
<p id="logbook-filter-status" className="dashboard-filter-meta" role="status">
|
|
{t('dashboard.filter_results', { count: filteredLogbookCount })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="dashboard-sort-bar">
|
|
<span className="dashboard-sort-label">{t('dashboard.sort_label')}</span>
|
|
<div className="dashboard-sort-row">
|
|
<div className="dashboard-sort-group" role="group" aria-label={t('dashboard.sort_by_label')}>
|
|
<button
|
|
type="button"
|
|
className={`dashboard-sort-btn${sortBy === 'name' ? ' is-active' : ''}`}
|
|
onClick={() => setSortBy('name')}
|
|
aria-pressed={sortBy === 'name'}
|
|
aria-label={t('dashboard.sort_by_name')}
|
|
title={t('dashboard.sort_by_name')}
|
|
>
|
|
<CaseSensitive size={16} aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`dashboard-sort-btn${sortBy === 'date' ? ' is-active' : ''}`}
|
|
onClick={() => setSortBy('date')}
|
|
aria-pressed={sortBy === 'date'}
|
|
aria-label={t('dashboard.sort_by_date')}
|
|
title={t('dashboard.sort_by_date')}
|
|
>
|
|
<CalendarDays size={16} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
<div className="dashboard-sort-group" role="group" aria-label={t('dashboard.sort_dir_label')}>
|
|
<button
|
|
type="button"
|
|
className={`dashboard-sort-btn${sortDirection === 'asc' ? ' is-active' : ''}`}
|
|
onClick={() => setSortDirection('asc')}
|
|
aria-pressed={sortDirection === 'asc'}
|
|
aria-label={sortBy === 'name' ? t('dashboard.sort_name_asc') : t('dashboard.sort_date_asc')}
|
|
title={sortBy === 'name' ? t('dashboard.sort_name_asc') : t('dashboard.sort_date_asc')}
|
|
>
|
|
<ArrowUp size={16} aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`dashboard-sort-btn${sortDirection === 'desc' ? ' is-active' : ''}`}
|
|
onClick={() => setSortDirection('desc')}
|
|
aria-pressed={sortDirection === 'desc'}
|
|
aria-label={sortBy === 'name' ? t('dashboard.sort_name_desc') : t('dashboard.sort_date_desc')}
|
|
title={sortBy === 'name' ? t('dashboard.sort_name_desc') : t('dashboard.sort_date_desc')}
|
|
>
|
|
<ArrowDown size={16} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{filterActive && filteredLogbookCount === 0 ? (
|
|
<div className="dashboard-status-msg glass">{t('dashboard.filter_no_results')}</div>
|
|
) : (
|
|
<div className="logbook-sections">
|
|
{sortedOwnedLogbooks.length > 0 && renderLogbookSection(
|
|
sortedSharedLogbooks.length > 0 ? t('dashboard.section_owned') : t('dashboard.title'),
|
|
sortedOwnedLogbooks
|
|
)}
|
|
{sortedSharedLogbooks.length > 0 && renderLogbookSection(
|
|
t('dashboard.section_shared'),
|
|
sortedSharedLogbooks,
|
|
t('dashboard.section_shared_hint')
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</section>
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|