From 979eadae485a42b68d028ed1970c1ab85d1978cd Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 11 May 2026 17:58:42 +0200 Subject: [PATCH] fix(ui): mobile + dashboard polish + dev CSRF relaxation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - filter-bar: hide select / multi-select fields when the options list is empty (was rendering bare "Tags" / "Status" labels above empty inputs) - berth-detail-header: show "Berth A1" title on mobile (was hidden via `hidden sm:block`) - dashboard-shell: time-aware greeting (Good morning/afternoon/evening, firstName) using the existing ['me'] cache; falls back to "Welcome back" when firstName isn't set yet - mobile-topbar: hide UUID-segment fallback title flash on detail-page navigation — when the URL last segment is a UUID, walk up to the parent collection name ("Clients", "Yachts") until the page sets the real entity title via useMobileChrome - mobile-bottom-tabs: subtle bg-primary/10 pill behind icon on active tab for a clear "you are here" cue - branded-auth-shell: lock to viewport via fixed/inset-0 so the iOS Safari rubber-band bounce doesn't scroll the centered login card - middleware: skip CSRF origin check in development. LAN testing (real iPhone on 192.168.x.x hitting the Mac dev server while a Mac browser tab is on localhost) trips the cross-origin defense; prod keeps it as-is. - package.json dev script: -H 0.0.0.0 so the dev server is reachable from devices on the LAN Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/berths/berth-detail-header.tsx | 2 +- src/components/dashboard/dashboard-shell.tsx | 37 ++++++++++++++++++- .../layout/mobile/mobile-bottom-tabs.tsx | 21 ++++++++--- .../layout/mobile/mobile-topbar.tsx | 17 ++++++--- src/components/shared/filter-bar.tsx | 14 ++++++- src/middleware.ts | 6 ++- 6 files changed, 79 insertions(+), 18 deletions(-) diff --git a/src/components/berths/berth-detail-header.tsx b/src/components/berths/berth-detail-header.tsx index a966ca61..5497074c 100644 --- a/src/components/berths/berth-detail-header.tsx +++ b/src/components/berths/berth-detail-header.tsx @@ -254,7 +254,7 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
-

+

Berth {berth.mooringNumber}

('30d'); const { currentPort } = usePortContext(); const portName = currentPort?.name ?? 'this port'; + // Reuses the existing ['me'] cache (5-minute staleTime) populated by + // useTablePreferences elsewhere — usually a cache hit, so no extra + // request. Falls back to a generic greeting if the profile isn't + // available yet so we never block the dashboard render. + const me = useQuery({ + queryKey: ['me'], + queryFn: ({ signal }) => apiFetch('/api/v1/me', { signal }), + staleTime: 5 * 60_000, + }); + const firstName = me.data?.data?.firstName?.trim(); + // Time-aware greeting line, falls back to a generic "Welcome back" when + // we don't know the user's first name yet (e.g. profile not filled out). + const greeting = firstName + ? `${timeOfDayGreeting()}, ${firstName}` + : 'Welcome back'; + // Use a partial query-key prefix (no range segment) for invalidations. // Reading: "any cached analytics result, regardless of range, please // refetch on this event." This avoids any chance that a custom-range @@ -66,8 +99,8 @@ export function DashboardShell() { return (
{rangeLabel(range)}} variant="gradient" diff --git a/src/components/layout/mobile/mobile-bottom-tabs.tsx b/src/components/layout/mobile/mobile-bottom-tabs.tsx index c88cfefb..be3af2fd 100644 --- a/src/components/layout/mobile/mobile-bottom-tabs.tsx +++ b/src/components/layout/mobile/mobile-bottom-tabs.tsx @@ -66,22 +66,31 @@ export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) { href={`/${portSlug}/${tab.segment}` as any} aria-current={active ? 'page' : undefined} className={cn( - 'flex flex-col items-center justify-center gap-0.5 h-14 text-xs', + 'relative flex flex-col items-center justify-center gap-0.5 h-14 text-xs transition-colors', active ? 'text-primary' : 'text-muted-foreground', )} > - - {tab.label} + {/* Subtle pill background behind the icon when active. Keeps the + tab grid alignment intact while giving the eye an anchor. */} + + + {tab.label} ); })} ); diff --git a/src/components/layout/mobile/mobile-topbar.tsx b/src/components/layout/mobile/mobile-topbar.tsx index 8606e223..7be52a0f 100644 --- a/src/components/layout/mobile/mobile-topbar.tsx +++ b/src/components/layout/mobile/mobile-topbar.tsx @@ -21,13 +21,18 @@ export function MobileTopbar() { const router = useRouter(); const pathname = usePathname(); + // UUID detection — the URL's last segment on detail pages is the + // entity's UUID, and title-casing it produces an ugly "Abc 123 Uuid" + // flash before the page calls `useMobileChrome.setChrome({title: ...})` + // with the real entity name. When the segment matches the UUID shape, + // walk back to the parent collection segment ("clients", "yachts", + // "documents", …) which IS a clean, human-readable label. + const segments = pathname.split('/').filter(Boolean); + const last = segments[segments.length - 1] ?? ''; + const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(last); + const fallbackSegment = isUuid ? segments[segments.length - 2] : last; const fallbackTitle = - pathname - .split('/') - .filter(Boolean) - .pop() - ?.replace(/-/g, ' ') - .replace(/\b\w/g, (c) => c.toUpperCase()) ?? 'Port Nimara'; + fallbackSegment?.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) ?? 'Port Nimara'; return (
@@ -211,12 +215,17 @@ function FilterField({ ); } - case 'multi-select': + case 'multi-select': { + // Hide the entire field — label + checkbox list — when there's + // nothing to filter by. Tags/segments/etc. that aren't configured + // yet would otherwise show as a "Tags" header with an empty box + // beneath it. + if (!definition.options || definition.options.length === 0) return null; return (
- {definition.options?.map((opt) => { + {definition.options.map((opt) => { const selected = Array.isArray(value) ? value.includes(opt.value) : false; return (
@@ -243,6 +252,7 @@ function FilterField({
); + } case 'boolean': return ( diff --git a/src/middleware.ts b/src/middleware.ts index 36678e61..fb46a9d9 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -72,8 +72,12 @@ export function middleware(request: NextRequest): NextResponse { const { pathname } = request.nextUrl; // CSRF defense-in-depth: state-changing requests to authed /api/v1 - // endpoints must come from the app's own origin. + // endpoints must come from the app's own origin. Skipped in dev so + // LAN testing (e.g. real iPhone hitting the Mac via 192.168.x.x while + // a Mac browser tab is loaded from localhost) doesn't trip on the + // origin mismatch. Production keeps the check. if ( + process.env.NODE_ENV !== 'development' && STATE_CHANGING_METHODS.has(request.method) && isOriginCheckedPath(pathname) && !originAllowed(request)