feat(dashboard): Filter und Sortierung für die Logbuchliste

Hilft bei vielen Logbüchern: Suche nach Name/Jahr/Datum sowie Sortierung nach Name oder Datum in beide Richtungen.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 14:27:41 +02:00
parent 7cf04b3357
commit 2b5c5d4a36
4 changed files with 357 additions and 14 deletions
+150
View File
@@ -1371,6 +1371,148 @@ html.scheme-dark .themed-select-option.is-selected {
gap: 20px;
}
.dashboard-list-controls {
display: flex;
flex-direction: column;
gap: 16px;
}
.dashboard-filter-bar {
display: flex;
flex-direction: column;
gap: 8px;
}
.dashboard-sort-bar {
display: flex;
flex-direction: column;
gap: 8px;
}
.dashboard-sort-label {
font-size: 13px;
font-weight: 600;
color: var(--app-text-muted);
}
.dashboard-sort-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: stretch;
}
.dashboard-sort-group {
display: flex;
flex: 1 1 140px;
gap: 6px;
min-width: 0;
}
.dashboard-sort-btn {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 40px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--app-border-subtle);
background: var(--app-surface-alt);
color: var(--app-text-muted);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: border-color 0.2s, background-color 0.2s, color 0.2s;
}
.dashboard-sort-btn:hover {
border-color: var(--app-border);
color: var(--app-text-heading);
}
.dashboard-sort-btn.is-active {
border-color: var(--app-accent-border);
background: var(--app-accent-bg);
color: var(--app-accent-light);
}
.dashboard-sort-btn:focus-visible {
outline: 2px solid var(--app-accent-focus-ring);
outline-offset: 2px;
}
.dashboard-sort-btn span {
white-space: nowrap;
}
.dashboard-filter-label {
font-size: 13px;
font-weight: 600;
color: var(--app-text-muted);
margin: 0;
}
.dashboard-filter-input-wrap {
position: relative;
display: flex;
align-items: center;
width: 100%;
}
.dashboard-filter-icon {
position: absolute;
left: 14px;
color: var(--app-text-muted);
pointer-events: none;
flex-shrink: 0;
}
.dashboard-filter-input {
width: 100%;
padding-left: 42px;
padding-right: 42px;
min-height: 44px;
}
.dashboard-filter-input::-webkit-search-cancel-button {
display: none;
}
.dashboard-filter-clear {
position: absolute;
right: 6px;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--app-text-muted);
cursor: pointer;
transition: color 0.2s, background-color 0.2s;
}
.dashboard-filter-clear:hover {
color: var(--app-text-heading);
background: var(--app-accent-bg);
}
.dashboard-filter-clear:focus-visible {
outline: 2px solid var(--app-accent-focus-ring);
outline-offset: 2px;
}
.dashboard-filter-meta {
margin: 0;
font-size: 13px;
color: var(--app-text-muted);
}
.section-title-bar {
display: flex;
justify-content: space-between;
@@ -2322,6 +2464,14 @@ html.scheme-dark .themed-select-option.is-selected {
word-break: break-word;
}
.dashboard-sort-row {
flex-direction: column;
}
.dashboard-sort-group {
flex: 1 1 100%;
}
.logbooks-grid {
grid-template-columns: 1fr;
gap: 16px;
+173 -12
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react'
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
@@ -7,7 +7,7 @@ import BetaBadge from './BetaBadge.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { logoutUser } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
@@ -17,6 +17,46 @@ interface LogbookDashboardProps {
onOpenProfile: () => void
}
function logbookMatchesFilter(lb: DecryptedLogbook, query: string, locale: string): boolean {
const q = query.trim().toLowerCase()
if (!q) return true
if (lb.title.toLowerCase().includes(q)) return true
const updated = new Date(lb.updatedAt)
const year = updated.getFullYear().toString()
if (year.includes(q)) return true
const dateLabel = updated.toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric'
}).toLowerCase()
if (dateLabel.includes(q)) return true
return false
}
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()
@@ -28,6 +68,10 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
const [loading, setLoading] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [filterQuery, setFilterQuery] = useState('')
const [sortBy, setSortBy] = useState<LogbookSortKey>('date')
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
const filterInputRef = useRef<HTMLInputElement>(null)
const [online, setOnline] = useState(navigator.onLine)
const [username] = useState(localStorage.getItem('active_username') || 'Skipper')
@@ -156,6 +200,25 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
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)),
[ownedLogbooks, filterQuery, i18n.language]
)
const filteredSharedLogbooks = useMemo(
() => sharedLogbooks.filter((lb) => logbookMatchesFilter(lb, filterQuery, i18n.language)),
[sharedLogbooks, filterQuery, i18n.language]
)
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
@@ -376,17 +439,115 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
) : logbooks.length === 0 ? (
<div className="dashboard-status-msg glass">{t('dashboard.no_logbooks')}</div>
) : (
<div className="logbook-sections">
{ownedLogbooks.length > 0 && renderLogbookSection(
sharedLogbooks.length > 0 ? t('dashboard.section_owned') : t('dashboard.title'),
ownedLogbooks
<>
<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'}
title={t('dashboard.sort_by_name')}
>
<CaseSensitive size={16} aria-hidden="true" />
<span>{t('dashboard.sort_by_name')}</span>
</button>
<button
type="button"
className={`dashboard-sort-btn${sortBy === 'date' ? ' is-active' : ''}`}
onClick={() => setSortBy('date')}
aria-pressed={sortBy === 'date'}
title={t('dashboard.sort_by_date')}
>
<CalendarDays size={16} aria-hidden="true" />
<span>{t('dashboard.sort_by_date')}</span>
</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'}
title={sortBy === 'name' ? t('dashboard.sort_name_asc') : t('dashboard.sort_date_asc')}
>
<ArrowUp size={16} aria-hidden="true" />
<span>{t('dashboard.sort_asc')}</span>
</button>
<button
type="button"
className={`dashboard-sort-btn${sortDirection === 'desc' ? ' is-active' : ''}`}
onClick={() => setSortDirection('desc')}
aria-pressed={sortDirection === 'desc'}
title={sortBy === 'name' ? t('dashboard.sort_name_desc') : t('dashboard.sort_date_desc')}
>
<ArrowDown size={16} aria-hidden="true" />
<span>{t('dashboard.sort_desc')}</span>
</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>
)}
{sharedLogbooks.length > 0 && renderLogbookSection(
t('dashboard.section_shared'),
sharedLogbooks,
t('dashboard.section_shared_hint')
)}
</div>
</>
)}
</section>
</main>
+17 -1
View File
@@ -304,7 +304,23 @@
"edit_title": "Logbuch umbenennen",
"edit_placeholder": "Neuer Name des Logbuchs",
"edit_success": "Logbuch erfolgreich umbenannt",
"edit_btn": "Umbenennen"
"edit_btn": "Umbenennen",
"filter_label": "Logbücher filtern",
"filter_placeholder": "Name, Jahr oder Datum …",
"filter_clear": "Filter zurücksetzen",
"filter_results": "{{count}} Treffer",
"filter_no_results": "Keine Logbücher passen zu deiner Suche. Probiere einen anderen Namen oder ein anderes Jahr.",
"sort_label": "Sortieren",
"sort_by_label": "Sortieren nach",
"sort_by_name": "Name",
"sort_by_date": "Datum",
"sort_dir_label": "Reihenfolge",
"sort_asc": "Aufsteigend",
"sort_desc": "Absteigend",
"sort_name_asc": "Name A bis Z",
"sort_name_desc": "Name Z bis A",
"sort_date_asc": "Älteste zuerst",
"sort_date_desc": "Neueste zuerst"
},
"profile": {
"title": "Benutzerprofil",
+17 -1
View File
@@ -304,7 +304,23 @@
"edit_title": "Rename Logbook",
"edit_placeholder": "New name of the logbook",
"edit_success": "Logbook renamed successfully",
"edit_btn": "Rename"
"edit_btn": "Rename",
"filter_label": "Filter logbooks",
"filter_placeholder": "Name, year or date …",
"filter_clear": "Clear filter",
"filter_results": "{{count}} matches",
"filter_no_results": "No logbooks match your search. Try a different name or year.",
"sort_label": "Sort",
"sort_by_label": "Sort by",
"sort_by_name": "Name",
"sort_by_date": "Date",
"sort_dir_label": "Order",
"sort_asc": "Ascending",
"sort_desc": "Descending",
"sort_name_asc": "Name A to Z",
"sort_name_desc": "Name Z to A",
"sort_date_asc": "Oldest first",
"sort_date_desc": "Newest first"
},
"profile": {
"title": "User profile",