fix(ui): mobile + dashboard polish + dev CSRF relaxation
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -254,7 +254,7 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
|
|||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<h1 className="hidden sm:block text-2xl font-bold text-foreground">
|
<h1 className="text-xl sm:text-2xl font-bold text-foreground">
|
||||||
Berth {berth.mooringNumber}
|
Berth {berth.mooringNumber}
|
||||||
</h1>
|
</h1>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
import { usePortContext } from '@/providers/port-provider';
|
import { usePortContext } from '@/providers/port-provider';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
import { ActivityFeed } from './activity-feed';
|
import { ActivityFeed } from './activity-feed';
|
||||||
import { DateRangePicker } from './date-range-picker';
|
import { DateRangePicker } from './date-range-picker';
|
||||||
@@ -39,11 +41,42 @@ function rangeLabel(range: DateRange): string {
|
|||||||
return PRESET_LABELS[range];
|
return PRESET_LABELS[range];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MeData {
|
||||||
|
data?: {
|
||||||
|
firstName?: string | null;
|
||||||
|
displayName?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeOfDayGreeting(): string {
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
if (hour < 5) return 'Up late';
|
||||||
|
if (hour < 12) return 'Good morning';
|
||||||
|
if (hour < 18) return 'Good afternoon';
|
||||||
|
return 'Good evening';
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardShell() {
|
export function DashboardShell() {
|
||||||
const [range, setRange] = useState<DateRange>('30d');
|
const [range, setRange] = useState<DateRange>('30d');
|
||||||
const { currentPort } = usePortContext();
|
const { currentPort } = usePortContext();
|
||||||
const portName = currentPort?.name ?? 'this port';
|
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<MeData>({
|
||||||
|
queryKey: ['me'],
|
||||||
|
queryFn: ({ signal }) => apiFetch<MeData>('/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.
|
// Use a partial query-key prefix (no range segment) for invalidations.
|
||||||
// Reading: "any cached analytics result, regardless of range, please
|
// Reading: "any cached analytics result, regardless of range, please
|
||||||
// refetch on this event." This avoids any chance that a custom-range
|
// refetch on this event." This avoids any chance that a custom-range
|
||||||
@@ -66,8 +99,8 @@ export function DashboardShell() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Dashboard"
|
title={greeting}
|
||||||
eyebrow="Overview"
|
eyebrow="Dashboard"
|
||||||
description={`Live snapshot of ${portName} activity`}
|
description={`Live snapshot of ${portName} activity`}
|
||||||
kpiLine={<span>{rangeLabel(range)}</span>}
|
kpiLine={<span>{rangeLabel(range)}</span>}
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
|
|||||||
@@ -66,22 +66,31 @@ export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) {
|
|||||||
href={`/${portSlug}/${tab.segment}` as any}
|
href={`/${portSlug}/${tab.segment}` as any}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
className={cn(
|
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',
|
active ? 'text-primary' : 'text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="size-5" aria-hidden />
|
{/* Subtle pill background behind the icon when active. Keeps the
|
||||||
<span className="font-medium">{tab.label}</span>
|
tab grid alignment intact while giving the eye an anchor. */}
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
'absolute top-1.5 h-7 w-12 rounded-full transition-all',
|
||||||
|
active ? 'bg-primary/10' : 'bg-transparent',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Icon className="relative size-5" aria-hidden />
|
||||||
|
<span className="relative font-medium">{tab.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onMoreClick}
|
onClick={onMoreClick}
|
||||||
className="flex flex-col items-center justify-center gap-0.5 h-14 text-xs text-muted-foreground"
|
className="relative flex flex-col items-center justify-center gap-0.5 h-14 text-xs text-muted-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<Menu className="size-5" aria-hidden />
|
<Menu className="relative size-5" aria-hidden />
|
||||||
<span className="font-medium">More</span>
|
<span className="relative font-medium">More</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,13 +21,18 @@ export function MobileTopbar() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
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 =
|
const fallbackTitle =
|
||||||
pathname
|
fallbackSegment?.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) ?? 'Port Nimara';
|
||||||
.split('/')
|
|
||||||
.filter(Boolean)
|
|
||||||
.pop()
|
|
||||||
?.replace(/-/g, ' ')
|
|
||||||
.replace(/\b\w/g, (c) => c.toUpperCase()) ?? 'Port Nimara';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
|
|||||||
@@ -188,6 +188,10 @@ function FilterField({
|
|||||||
// Radix Select forbids empty-string item values (throws at render
|
// Radix Select forbids empty-string item values (throws at render
|
||||||
// time, crashes the page). Use a sentinel and translate.
|
// time, crashes the page). Use a sentinel and translate.
|
||||||
const ANY = '__any__';
|
const ANY = '__any__';
|
||||||
|
// Hide the field entirely when there's nothing to pick — avoids a
|
||||||
|
// bare "Status" / "Stage" label sitting above an empty dropdown
|
||||||
|
// before the parent has loaded options.
|
||||||
|
if (!definition.options || definition.options.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">{definition.label}</Label>
|
<Label className="text-xs">{definition.label}</Label>
|
||||||
@@ -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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs">{definition.label}</Label>
|
<Label className="text-xs">{definition.label}</Label>
|
||||||
<div className="max-h-32 overflow-y-auto space-y-1">
|
<div className="max-h-32 overflow-y-auto space-y-1">
|
||||||
{definition.options?.map((opt) => {
|
{definition.options.map((opt) => {
|
||||||
const selected = Array.isArray(value) ? value.includes(opt.value) : false;
|
const selected = Array.isArray(value) ? value.includes(opt.value) : false;
|
||||||
return (
|
return (
|
||||||
<div key={opt.value} className="flex items-center gap-2">
|
<div key={opt.value} className="flex items-center gap-2">
|
||||||
@@ -243,6 +252,7 @@ function FilterField({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -72,8 +72,12 @@ export function middleware(request: NextRequest): NextResponse {
|
|||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
// CSRF defense-in-depth: state-changing requests to authed /api/v1
|
// 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 (
|
if (
|
||||||
|
process.env.NODE_ENV !== 'development' &&
|
||||||
STATE_CHANGING_METHODS.has(request.method) &&
|
STATE_CHANGING_METHODS.has(request.method) &&
|
||||||
isOriginCheckedPath(pathname) &&
|
isOriginCheckedPath(pathname) &&
|
||||||
!originAllowed(request)
|
!originAllowed(request)
|
||||||
|
|||||||
Reference in New Issue
Block a user