fix(ui): Mobile-Layout für Tour, Header, Toolbars und Dialoge

Onboarding-Tooltip bleibt im Viewport; PWA-Banner während Tour aus.
Kopfzeilen, Listen-Toolbars, Link-Zeilen und Modals für iPhone optimiert.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-30 16:30:33 +02:00
parent caf6e395cd
commit 7e01106801
6 changed files with 331 additions and 19 deletions
+293
View File
@@ -905,6 +905,36 @@ html.scheme-dark .themed-select-option.is-selected {
color: var(--app-text-heading); color: var(--app-text-heading);
} }
.section-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.copy-link-row {
display: flex;
gap: 8px;
align-items: center;
width: 100%;
min-width: 0;
}
.copy-link-row .input-text {
flex: 1;
min-width: 0;
}
.form-actions--start {
justify-content: flex-start;
}
.table-responsive {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.btn-refresh { .btn-refresh {
background: none; background: none;
border: none; border: none;
@@ -1645,6 +1675,224 @@ html.scheme-dark .themed-select-option.is-selected {
.hide-mobile { .hide-mobile {
display: none !important; display: none !important;
} }
.dashboard-header,
.app-header {
flex-wrap: wrap;
align-items: flex-start;
gap: 12px;
}
.app-header-left {
flex: 1 1 100%;
min-width: 0;
align-items: flex-start;
gap: 10px;
}
.app-title-area {
min-width: 0;
flex: 1;
}
.app-title-area h2 {
font-size: 17px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.app-title-row {
gap: 6px;
}
.app-subtitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-brand {
flex: 1 1 auto;
min-width: 0;
}
.header-brand h1 {
font-size: 20px;
}
.header-actions {
flex: 1 1 100%;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.conn-status > span:not(.pulse-dot) {
display: none;
}
.skipper-badge__name {
display: none;
}
.btn-back {
padding: 8px 10px;
flex-shrink: 0;
}
.section-title-bar {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.section-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
width: 100%;
}
.section-toolbar .btn {
flex: 1 1 auto;
min-width: 0;
}
.section-toolbar .btn.primary {
flex: 1 1 100%;
}
.section-title-left {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.section-title-left .form-header h2 {
font-size: 16px;
white-space: normal;
word-break: break-word;
}
.logbooks-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.logbook-card {
flex-wrap: wrap;
padding: 16px;
gap: 12px;
}
.card-meta {
flex-wrap: wrap;
}
.card-info h3 {
white-space: normal;
word-break: break-word;
}
.editor-header {
flex-wrap: wrap;
gap: 10px;
}
.crew-grid {
grid-template-columns: 1fr;
}
.copy-link-row {
flex-direction: column;
align-items: stretch;
}
.copy-link-row .btn {
width: 100%;
}
.form-actions--start {
flex-direction: column;
align-items: stretch;
}
.form-actions--start .btn {
width: 100%;
}
.table-responsive {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.table-responsive table {
min-width: 480px;
}
.custom-dialog-overlay {
padding: max(16px, env(safe-area-inset-left)) max(16px, env(safe-area-inset-right));
align-items: flex-end;
}
.custom-dialog-card {
width: 100%;
max-width: none;
padding: 22px 18px;
margin-bottom: env(safe-area-inset-bottom, 0px);
}
.custom-dialog-actions {
flex-direction: column-reverse;
gap: 10px;
}
.custom-dialog-actions .btn {
width: 100%;
margin: 0 !important;
}
.disclaimer-modal-overlay {
padding: max(12px, env(safe-area-inset-left)) max(12px, env(safe-area-inset-right));
align-items: flex-end;
}
.disclaimer-modal-panel,
.registration-disclaimer--modal {
width: 100%;
max-width: none;
}
.auth-card {
padding: 28px 20px;
max-width: calc(100vw - 24px);
}
.app-layout,
.dashboard-container {
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px));
}
.track-info-left {
flex-wrap: wrap;
}
.track-actions {
width: 100%;
}
.track-actions .btn {
flex: 1 1 calc(50% - 4px);
justify-content: center;
}
#openseamap-container,
.track-map-container {
height: min(360px, 45svh);
}
} }
/* ========================================== */ /* ========================================== */
@@ -2267,6 +2515,12 @@ html.theme-cupertino .events-scroll-container {
color: #94a3b8; color: #94a3b8;
} }
.track-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.track-error-msg { .track-error-msg {
color: #ef4444; color: #ef4444;
background: rgba(239, 68, 68, 0.1); background: rgba(239, 68, 68, 0.1);
@@ -3352,7 +3606,9 @@ body.app-tour-active .app-tour-target-active {
.app-tour-tooltip { .app-tour-tooltip {
position: fixed; position: fixed;
z-index: 10002; z-index: 10002;
box-sizing: border-box;
width: min(420px, calc(100vw - 32px)); width: min(420px, calc(100vw - 32px));
max-width: calc(100vw - 32px);
padding: 20px 20px 16px; padding: 20px 20px 16px;
border-radius: 16px; border-radius: 16px;
background: #1e293b; background: #1e293b;
@@ -3361,10 +3617,19 @@ body.app-tour-active .app-tour-target-active {
pointer-events: auto; pointer-events: auto;
} }
.app-tour-tooltip:not(.centered) {
left: max(16px, env(safe-area-inset-left, 0px));
right: max(16px, env(safe-area-inset-right, 0px));
width: auto;
max-width: none;
}
.app-tour-tooltip.centered { .app-tour-tooltip.centered {
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
width: min(420px, calc(100vw - 32px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)));
max-width: calc(100vw - 32px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px));
} }
.app-tour-close { .app-tour-close {
@@ -3441,6 +3706,34 @@ body.app-tour-active .app-tour-target-active {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
flex-shrink: 0;
}
@media (max-width: 520px) {
.app-tour-tooltip {
padding: 18px 16px 14px;
}
.app-tour-actions {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.app-tour-nav {
margin-left: 0;
width: 100%;
}
.app-tour-nav-btn {
flex: 1;
justify-content: center;
min-width: 0;
}
}
body.app-tour-active .pwa-install-banner {
display: none !important;
} }
body.app-tour-active .disclaimer-modal-overlay.feedback-modal-overlay--tour { body.app-tour-active .disclaimer-modal-overlay.feedback-modal-overlay--tour {
+3 -3
View File
@@ -421,7 +421,7 @@ function App() {
) )
} }
const pwaInstallBanner = <PwaInstallPrompt variant="banner" /> const pwaInstallBanner = !isActive ? <PwaInstallPrompt variant="banner" /> : null
const logbookReadOnly = const logbookReadOnly =
activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ' activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ'
@@ -446,9 +446,9 @@ function App() {
{/* Active Logbook Header */} {/* Active Logbook Header */}
<header className="app-header"> <header className="app-header">
<div className="app-header-left"> <div className="app-header-left">
<button className="btn-back" onClick={handleBackToDashboard}> <button className="btn-back" onClick={handleBackToDashboard} title={t('nav.dashboard')}>
<ChevronLeft size={16} /> <ChevronLeft size={16} />
{t('nav.dashboard')} <span className="hide-mobile">{t('nav.dashboard')}</span>
</button> </button>
<div className="app-title-area"> <div className="app-title-area">
<div className="app-title-row"> <div className="app-title-row">
+23 -6
View File
@@ -15,12 +15,33 @@ interface SpotlightRect {
height: number height: number
} }
const TOOLTIP_EDGE_MARGIN = 16
const TOOLTIP_ESTIMATED_HEIGHT = 240
function buildCutoutClipPath(rect: SpotlightRect): string { function buildCutoutClipPath(rect: SpotlightRect): string {
const right = rect.left + rect.width const right = rect.left + rect.width
const bottom = rect.top + rect.height const bottom = rect.top + rect.height
return `polygon(evenodd, 0 0, 100vw 0, 100vw 100vh, 0 100vh, 0 0, ${rect.left}px ${rect.top}px, ${right}px ${rect.top}px, ${right}px ${bottom}px, ${rect.left}px ${bottom}px, ${rect.left}px ${rect.top}px)` return `polygon(evenodd, 0 0, 100vw 0, 100vw 100vh, 0 100vh, 0 0, ${rect.left}px ${rect.top}px, ${right}px ${rect.top}px, ${right}px ${bottom}px, ${rect.left}px ${bottom}px, ${rect.left}px ${rect.top}px)`
} }
function computeTooltipTop(spotlight: SpotlightRect): number {
const viewportBottom = window.innerHeight - TOOLTIP_EDGE_MARGIN
const below = spotlight.top + spotlight.height + 12
if (below + TOOLTIP_ESTIMATED_HEIGHT <= viewportBottom) {
return below
}
const above = spotlight.top - 12 - TOOLTIP_ESTIMATED_HEIGHT
if (above >= TOOLTIP_EDGE_MARGIN) {
return above
}
return Math.max(
TOOLTIP_EDGE_MARGIN,
Math.min(below, viewportBottom - TOOLTIP_ESTIMATED_HEIGHT)
)
}
export default function AppTourOverlay() { export default function AppTourOverlay() {
const { t } = useTranslation() const { t } = useTranslation()
const { const {
@@ -111,12 +132,8 @@ export default function AppTourOverlay() {
const tooltipStyle = centered const tooltipStyle = centered
? undefined ? undefined
: spotlight : spotlight
? { ? { top: computeTooltipTop(spotlight) }
top: Math.min(window.innerHeight - 220, spotlight.top + spotlight.height + 12), : { top: '20%' }
left: Math.min(window.innerWidth - 340, Math.max(16, spotlight.left)),
maxWidth: '420px'
}
: { top: '20%', left: '50%', transform: 'translateX(-50%)', maxWidth: '420px' }
const backdropStyle = spotlight && !centered const backdropStyle = spotlight && !centered
? { clipPath: buildCutoutClipPath(spotlight) } ? { clipPath: buildCutoutClipPath(spotlight) }
+3 -3
View File
@@ -372,7 +372,7 @@ export default function LogEntriesList({
<Calendar size={24} className="form-icon" /> <Calendar size={24} className="form-icon" />
<h2>{t('logs.title')}</h2> <h2>{t('logs.title')}</h2>
</div> </div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}> <div className="section-toolbar">
<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={handleDownloadCsv} disabled={loading || exporting || 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">{exporting ? t('logs.exporting') : t('logs.export_csv')}</span>
@@ -384,9 +384,9 @@ export default function LogEntriesList({
</button> </button>
{!readOnly && ( {!readOnly && (
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }}> <button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.new_entry')}>
<Plus size={16} /> <Plus size={16} />
{t('logs.new_entry')} <span className="hide-mobile">{t('logs.new_entry')}</span>
</button> </button>
)} )}
</div> </div>
+6 -4
View File
@@ -992,7 +992,7 @@ export default function LogEntryEditor({
style={{ width: 'auto', padding: '8px 16px' }} style={{ width: 'auto', padding: '8px 16px' }}
> >
<Download size={16} /> <Download size={16} />
<span>{exporting ? t('logs.exporting_pdf') : t('logs.export_pdf')}</span> <span className="hide-mobile">{exporting ? t('logs.exporting_pdf') : t('logs.export_pdf')}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -1567,15 +1567,16 @@ export default function LogEntryEditor({
)} )}
</span> </span>
</div> </div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}> <div className="track-actions">
<button <button
type="button" type="button"
className="btn secondary" className="btn secondary"
onClick={() => downloadTrackFile(savedTrack)} onClick={() => downloadTrackFile(savedTrack)}
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px' }} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px' }}
title={t('logs.gps_tracking_btn_gpx')}
> >
<Download size={14} /> <Download size={14} />
{t('logs.gps_tracking_btn_gpx')} <span className="hide-mobile">{t('logs.gps_tracking_btn_gpx')}</span>
</button> </button>
{!readOnly && ( {!readOnly && (
<button <button
@@ -1583,9 +1584,10 @@ export default function LogEntryEditor({
className="btn secondary" className="btn secondary"
onClick={handleDeleteTrack} onClick={handleDeleteTrack}
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px', background: 'rgba(239, 68, 68, 0.1)', color: '#ef4444', borderColor: 'rgba(239, 68, 68, 0.2)' }} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px', background: 'rgba(239, 68, 68, 0.1)', color: '#ef4444', borderColor: 'rgba(239, 68, 68, 0.2)' }}
title={t('logs.gps_track_delete')}
> >
<Trash2 size={14} /> <Trash2 size={14} />
{t('logs.gps_track_delete')} <span className="hide-mobile">{t('logs.gps_track_delete')}</span>
</button> </button>
)} )}
</div> </div>
+3 -3
View File
@@ -414,7 +414,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div> </div>
{shareEnabled && shareLink && ( {shareEnabled && shareLink && (
<div className="input-group mb-4" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}> <div className="input-group mb-4 copy-link-row">
<input <input
type="text" type="text"
readOnly readOnly
@@ -455,7 +455,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
{t('logs.invite_link_desc')} {t('logs.invite_link_desc')}
</p> </p>
<div className="form-actions" style={{ justifyContent: 'flex-start', gap: '12px', marginBottom: '20px' }}> <div className="form-actions form-actions--start" style={{ gap: '12px', marginBottom: '20px' }}>
<button <button
type="button" type="button"
className="btn primary" className="btn primary"
@@ -469,7 +469,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div> </div>
{inviteLink && ( {inviteLink && (
<div className="input-group mb-6" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}> <div className="input-group mb-6 copy-link-row">
<input <input
type="text" type="text"
readOnly readOnly