feat(dashboard): per-device widget visibility with layout reflow

Dashboard widgets (currency, timezones, upcoming reservations, atlas and the stat tiles) can be shown or hidden independently on desktop and mobile from the appearance settings. The stat grid spreads its visible tiles to full width, and disabling the right sidebar collapses the layout to a single centered column.
This commit is contained in:
Maurice
2026-06-29 11:00:04 +02:00
committed by Maurice
parent a7334a9060
commit 200108b76a
2 changed files with 91 additions and 47 deletions
+86 -45
View File
@@ -21,6 +21,7 @@ import {
import { formatTime, splitReservationDateTime } from '../utils/formatters'
import { convertDistance, getDistanceUnitLabel } from '../utils/units'
import { useSettingsStore } from '../store/settingsStore'
import { normalizeAppearance } from '@trek/shared'
import '../styles/dashboard.css'
const GRADIENTS = [
@@ -92,6 +93,17 @@ export default function DashboardPage(): React.ReactElement {
handleCreate, handleUpdate, confirmDelete, handleArchive, handleUnarchive, confirmCopy,
} = useDashboard()
// Per-device dashboard widget visibility (from the appearance config).
const isMobile = useIsMobile()
const appearanceCfg = useSettingsStore(s => s.settings.appearance)
const dashCfg = normalizeAppearance(appearanceCfg).dashboard
const sideWidgets = isMobile ? dashCfg.mobile : dashCfg.desktop
const showCurrency = sideWidgets.currency
const showTimezones = sideWidgets.timezones
const showUpcoming = sideWidgets.upcomingReservations
// Desktop has a master toggle for the whole right column; off → centered layout.
const sidebarVisible = (isMobile || dashCfg.desktop.sidebar) && (showCurrency || showTimezones || showUpcoming)
return (
<>
{/* Navbar lives outside .trek-dash so it keeps the app-wide font + button
@@ -102,7 +114,7 @@ export default function DashboardPage(): React.ReactElement {
{demoMode && <DemoBanner />}
<div className="trek-dash-scroll">
<MobileTopBar />
<main className="page">
<main className="page" data-no-sidebar={sidebarVisible ? undefined : 'true'}>
<div className="page-main">
{loadError && (
<div className="dash-error" role="alert">
@@ -176,11 +188,13 @@ export default function DashboardPage(): React.ReactElement {
</section>
</div>
<aside className="page-sidebar">
<CurrencyTool />
<TimezoneTool locale={locale} />
<UpcomingTool items={upcoming} locale={locale} onOpen={(tripId) => navigate(`/trips/${tripId}`)} />
</aside>
{sidebarVisible && (
<aside className="page-sidebar">
{showCurrency && <CurrencyTool />}
{showTimezones && <TimezoneTool locale={locale} />}
{showUpcoming && <UpcomingTool items={upcoming} locale={locale} onOpen={(tripId) => navigate(`/trips/${tripId}`)} />}
</aside>
)}
</main>
</div>
@@ -370,9 +384,28 @@ function formatCompactDistance(value: number): string {
return String(rounded)
}
function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElement {
function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElement | null {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) || 'metric'
const appearance = useSettingsStore(s => s.settings.appearance)
const isMobile = useIsMobile()
const dash = normalizeAppearance(appearance).dashboard
// Per-device widget visibility. Atlas + distance are desktop-only tiles.
const showAtlas = !isMobile && dash.desktop.atlas
const showTrips = isMobile ? dash.mobile.tripsTotal : dash.desktop.tripsTotal
const showDays = isMobile ? dash.mobile.daysTraveled : dash.desktop.daysTraveled
const showDistance = !isMobile && dash.desktop.distanceFlown
if (!showAtlas && !showTrips && !showDays && !showDistance) return null
// Reflow: the grid spreads the visible tiles to full width (the passport stays
// proportionally wider). Set as CSS vars so the responsive media queries still win.
const atlasTemplate =
[dash.desktop.atlas && '1.5fr', dash.desktop.tripsTotal && '1fr', dash.desktop.daysTraveled && '1fr', dash.desktop.distanceFlown && '1fr']
.filter(Boolean).join(' ') || '1fr'
const atlasTemplateM =
[dash.mobile.tripsTotal && '1fr', dash.mobile.daysTraveled && '1fr'].filter(Boolean).join(' ') || '1fr'
const countries = stats?.countries || []
const distanceKm = stats?.totalDistanceKm || 0
const distance = convertDistance(distanceKm, distanceUnit)
@@ -382,48 +415,56 @@ function AtlasStats({ stats }: { stats: TravelStats | null }): React.ReactElemen
const distanceLabel = getDistanceUnitLabel(distanceUnit)
return (
<section className="atlas">
<div className="atlas-card passport">
<div className="label">{t('dashboard.atlas.countriesVisited')}</div>
<div className="value mono">{countries.length} <span className="unit text-[oklch(1_0_0_/_.55)]">{t('dashboard.atlas.ofTotal', { total: 195 })}</span></div>
<div className="passport-flags">
{countries.slice(0, 5).map((c, i) => (
<span key={i} className="flag" title={c}>
<img src={`https://flagcdn.com/w40/${c.toLowerCase()}.png`} alt={c} loading="lazy" />
</span>
))}
{countries.length > 5 && <span className="flag more">+{countries.length - 5}</span>}
<section className="atlas" style={{ '--atlas-template': atlasTemplate, '--atlas-template-m': atlasTemplateM } as React.CSSProperties}>
{showAtlas && (
<div className="atlas-card passport">
<div className="label">{t('dashboard.atlas.countriesVisited')}</div>
<div className="value mono">{countries.length} <span className="unit text-[oklch(1_0_0_/_.55)]">{t('dashboard.atlas.ofTotal', { total: 195 })}</span></div>
<div className="passport-flags">
{countries.slice(0, 5).map((c, i) => (
<span key={i} className="flag" title={c}>
<img src={`https://flagcdn.com/w40/${c.toLowerCase()}.png`} alt={c} loading="lazy" />
</span>
))}
{countries.length > 5 && <span className="flag more">+{countries.length - 5}</span>}
</div>
<div className="delta" />
</div>
<div className="delta" />
</div>
)}
<div className="atlas-card">
<div className="label">{t('dashboard.atlas.tripsTotal')}</div>
<div className="value mono">{stats?.totalTrips ?? 0}</div>
<div className="delta">{t('dashboard.atlas.placesMapped', { count: stats?.totalPlaces ?? 0 })}</div>
<svg className="spark" width="80" height="36" viewBox="0 0 80 36">
<polyline points="0,30 12,26 22,28 32,18 44,22 56,10 68,14 80,4" fill="none" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
{showTrips && (
<div className="atlas-card">
<div className="label">{t('dashboard.atlas.tripsTotal')}</div>
<div className="value mono">{stats?.totalTrips ?? 0}</div>
<div className="delta">{t('dashboard.atlas.placesMapped', { count: stats?.totalPlaces ?? 0 })}</div>
<svg className="spark" width="80" height="36" viewBox="0 0 80 36">
<polyline points="0,30 12,26 22,28 32,18 44,22 56,10 68,14 80,4" fill="none" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
)}
<div className="atlas-card">
<div className="label">{t('dashboard.atlas.daysTraveled')}</div>
<div className="value mono">{stats?.totalDays ?? 0} <span className="unit">{t('dashboard.atlas.daysUnit')}</span></div>
<div className="delta">{t('dashboard.atlas.acrossAllTrips')}</div>
<svg className="spark" width="80" height="36" viewBox="0 0 80 36">
<path d="M0 30 Q10 24 20 26 T40 20 T60 14 T80 10" fill="none" strokeWidth="2" strokeLinecap="round" />
</svg>
</div>
{showDays && (
<div className="atlas-card">
<div className="label">{t('dashboard.atlas.daysTraveled')}</div>
<div className="value mono">{stats?.totalDays ?? 0} <span className="unit">{t('dashboard.atlas.daysUnit')}</span></div>
<div className="delta">{t('dashboard.atlas.acrossAllTrips')}</div>
<svg className="spark" width="80" height="36" viewBox="0 0 80 36">
<path d="M0 30 Q10 24 20 26 T40 20 T60 14 T80 10" fill="none" strokeWidth="2" strokeLinecap="round" />
</svg>
</div>
)}
<div className="atlas-card">
<div className="label">{t('dashboard.atlas.distanceFlown')}</div>
<div className="value mono">{distanceText} <span className="unit">{distanceLabel}</span></div>
<div className="delta">{t('dashboard.atlas.aroundEquator', { count: equatorTimes })}</div>
<svg className="spark" width="80" height="36" viewBox="0 0 80 36">
<circle cx="40" cy="18" r="14" fill="none" stroke="oklch(0.88 0.01 70)" strokeWidth="2" />
<circle cx="40" cy="18" r="14" fill="none" strokeWidth="2" strokeDasharray="58 88" strokeLinecap="round" transform="rotate(-90 40 18)" />
</svg>
</div>
{showDistance && (
<div className="atlas-card">
<div className="label">{t('dashboard.atlas.distanceFlown')}</div>
<div className="value mono">{distanceText} <span className="unit">{distanceLabel}</span></div>
<div className="delta">{t('dashboard.atlas.aroundEquator', { count: equatorTimes })}</div>
<svg className="spark" width="80" height="36" viewBox="0 0 80 36">
<circle cx="40" cy="18" r="14" fill="none" stroke="oklch(0.88 0.01 70)" strokeWidth="2" />
<circle cx="40" cy="18" r="14" fill="none" strokeWidth="2" strokeDasharray="58 88" strokeLinecap="round" transform="rotate(-90 40 18)" />
</svg>
</div>
)}
</section>
)
}
+5 -2
View File
@@ -103,6 +103,9 @@
align-items: start;
}
.trek-dash .page-main { min-width: 0; }
/* Right sidebar disabled → single centered column. */
.trek-dash .page[data-no-sidebar="true"] { grid-template-columns: 1fr; }
.trek-dash .page[data-no-sidebar="true"] .page-main { max-width: 1080px; margin-inline: auto; width: 100%; }
.trek-dash .page-sidebar {
position: sticky;
top: 24px;
@@ -318,7 +321,7 @@
.trek-dash .pass-cell.countdown { gap: 6px; }
/* ----------------- atlas / stats ----------------- */
.trek-dash .atlas { display: grid; grid-template-columns: 1.5fr 1fr 1fr 1fr; gap: 16px; margin-bottom: 56px; }
.trek-dash .atlas { display: grid; grid-template-columns: var(--atlas-template, 1.5fr 1fr 1fr 1fr); gap: 16px; margin-bottom: 56px; }
.trek-dash .atlas-card {
background: var(--glass-bg); border-radius: var(--r-lg); padding: 24px 26px;
border: 1px solid var(--glass-border);
@@ -599,7 +602,7 @@
/* Atlas → single row of stat cards. Passport (countries) and distance are
hidden on mobile; only Trips total + Days traveled remain. */
.trek-dash .atlas { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 0 0 26px; }
.trek-dash .atlas { display: grid; grid-template-columns: var(--atlas-template-m, 1fr 1fr); gap: 10px; margin: 0 0 26px; }
.trek-dash .atlas-card.passport,
.trek-dash .atlas-card:last-child { display: none; }
.trek-dash .atlas .spark { display: none; }