Skip to content

Commit 1dcc6d8

Browse files
committed
feat(web): mobile bottom-sheet shell and improved mobile UI
Replace the fixed-height mobile panels with a single MobileBottomSheet that snaps between peek/medium/full and tracks its own height for overlay positioning. SidebarShell and DetailShell delegate to it on small viewports while keeping their existing desktop layouts. The map shell adapts in concert: - SearchBar takes over the viewport when focused, picks up an inline account avatar, and renders an empty state listing the user's saved places when the query is empty. - MapControls and MapFooter follow the bottom-sheet height up to a cap and fade out when the MapLibre attribution `details` is expanded (centralized via `useMapAttributionExpanded`). - LayerSelector hides under the same attribution-expanded signal, becomes a circular FAB on mobile, and renders into a non-modal popover. - TopRightControls becomes the desktop-only branch of a shared AccountAvatarButton and is hidden on mobile. - WeatherWidget skips its fetch entirely on small viewports. - MapCanvas no longer hides the attribution button; instead it lets MapLibre auto-collapse on narrow widths. Auth/review dialogs (AuthDialog, AccountSettingsDialog, Mangrove export/setup, WriteReviewDialog) go fullScreen on mobile through a shared `useFullScreenOnMobile` hook so keyboards can't squeeze the form into a sliver. Two new strings (`saved.emptyStateSignedOut`, `saved.emptyStateNoLabels`) back the mobile search empty state.
1 parent c082304 commit 1dcc6d8

27 files changed

Lines changed: 1142 additions & 296 deletions

apps/web/src/app/globals.css

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,6 @@ main {
5858
font-size: 10px;
5959
}
6060

61-
.maplibregl-ctrl-attrib-button {
62-
display: none;
63-
}
64-
6561
/* Shared MapLibre popup styling */
6662
@layer base {
6763
.omx-popup.maplibregl-popup .maplibregl-popup-content {
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"use client";
2+
3+
import PersonIcon from "@mui/icons-material/Person";
4+
import Avatar from "@mui/material/Avatar";
5+
import type { SxProps, Theme } from "@mui/material/styles";
6+
import Tooltip from "@mui/material/Tooltip";
7+
import { getInitials, useSession } from "@openmapx/core";
8+
import { useTranslations } from "next-intl";
9+
import { useRef, useState } from "react";
10+
import { AccountMenu } from "./AccountMenu";
11+
import { AccountSettingsDialog } from "./AccountSettingsDialog";
12+
import { AuthDialog } from "./AuthDialog";
13+
import { ResetPasswordDialog } from "./ResetPasswordDialog";
14+
15+
interface Props {
16+
/** Visual size of the avatar button. */
17+
size?: number;
18+
/** Optional sx merged onto the Avatar (e.g. omit boxShadow when inline). */
19+
sx?: SxProps<Theme>;
20+
}
21+
22+
/** Avatar button with auth flow: opens AuthDialog when signed-out, AccountMenu when signed-in. */
23+
export function AccountAvatarButton({ size = 36, sx }: Props) {
24+
const t = useTranslations("map");
25+
const { data: session, isPending } = useSession();
26+
const [authOpen, setAuthOpen] = useState(false);
27+
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>(null);
28+
const [settingsOpen, setSettingsOpen] = useState(false);
29+
const avatarRef = useRef<HTMLButtonElement>(null);
30+
31+
const user = session?.user ?? null;
32+
33+
const handleAvatarClick = () => {
34+
if (user) {
35+
setMenuAnchor(avatarRef.current);
36+
} else {
37+
setAuthOpen(true);
38+
}
39+
};
40+
41+
const initials = user ? getInitials(user.name, user.email) : null;
42+
43+
return (
44+
<>
45+
<Tooltip title={user ? (user.name ?? t("account")) : t("signIn")} placement="bottom">
46+
<Avatar
47+
ref={avatarRef}
48+
component="button"
49+
aria-label={t("account")}
50+
src={user?.image ?? undefined}
51+
onClick={handleAvatarClick}
52+
sx={[
53+
{
54+
width: size,
55+
height: size,
56+
bgcolor: user ? "primary.main" : "grey.400",
57+
fontSize: Math.round(size * 0.42),
58+
fontWeight: 500,
59+
cursor: "pointer",
60+
border: "none",
61+
fontFamily: "inherit",
62+
opacity: isPending ? 0.5 : 1,
63+
},
64+
...(Array.isArray(sx) ? sx : sx ? [sx] : []),
65+
]}
66+
>
67+
{initials ?? <PersonIcon sx={{ fontSize: Math.round(size * 0.55) }} />}
68+
</Avatar>
69+
</Tooltip>
70+
71+
<AuthDialog open={authOpen} onClose={() => setAuthOpen(false)} />
72+
<ResetPasswordDialog />
73+
74+
{user && (
75+
<AccountMenu
76+
anchorEl={menuAnchor}
77+
onClose={() => setMenuAnchor(null)}
78+
user={user}
79+
onOpenSettings={() => setSettingsOpen(true)}
80+
/>
81+
)}
82+
83+
{user && (
84+
<AccountSettingsDialog
85+
open={settingsOpen}
86+
onClose={() => setSettingsOpen(false)}
87+
user={user}
88+
/>
89+
)}
90+
</>
91+
);
92+
}

apps/web/src/components/auth/AccountSettingsDialog.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { authClient, getInitials, oauthProviders } from "@openmapx/core";
3434
import { useLocale, useTranslations } from "next-intl";
3535
import QRCode from "qrcode";
3636
import { useEffect, useState } from "react";
37+
import { mobileFullScreenDialogPaperSx, useFullScreenOnMobile } from "@/lib/useFullScreenOnMobile";
3738
import { MangroveAccountSection } from "./MangroveAccountSection";
3839

3940
interface AccountSettingsDialogProps {
@@ -46,6 +47,7 @@ export function AccountSettingsDialog({ open, onClose, user }: AccountSettingsDi
4647
const t = useTranslations("account");
4748
const tc = useTranslations("common");
4849
const locale = useLocale();
50+
const fullScreen = useFullScreenOnMobile();
4951
const [name, setName] = useState(user.name);
5052
const [saving, setSaving] = useState(false);
5153
const [message, setMessage] = useState<{
@@ -397,7 +399,8 @@ export function AccountSettingsDialog({ open, onClose, user }: AccountSettingsDi
397399
onClose={onClose}
398400
maxWidth="sm"
399401
fullWidth
400-
PaperProps={{ sx: { borderRadius: "12px" } }}
402+
fullScreen={fullScreen}
403+
PaperProps={{ sx: mobileFullScreenDialogPaperSx }}
401404
>
402405
<DialogTitle sx={{ display: "flex", alignItems: "center", gap: 1 }}>
403406
<PersonIcon />

apps/web/src/components/auth/AuthDialog.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import CloseIcon from "@mui/icons-material/Close";
34
import KeyIcon from "@mui/icons-material/Key";
45
import Visibility from "@mui/icons-material/Visibility";
56
import VisibilityOff from "@mui/icons-material/VisibilityOff";
@@ -18,6 +19,7 @@ import Typography from "@mui/material/Typography";
1819
import { authClient, oauthProviders } from "@openmapx/core";
1920
import { useTranslations } from "next-intl";
2021
import { useState } from "react";
22+
import { mobileFullScreenDialogPaperSx, useFullScreenOnMobile } from "@/lib/useFullScreenOnMobile";
2123

2224
type AuthMode = "sign-in" | "sign-up" | "2fa" | "forgot-password" | "reset-password";
2325

@@ -29,6 +31,7 @@ interface AuthDialogProps {
2931
export function AuthDialog({ open, onClose }: AuthDialogProps) {
3032
const t = useTranslations("auth");
3133
const tc = useTranslations("common");
34+
const fullScreen = useFullScreenOnMobile();
3235
const [mode, setMode] = useState<AuthMode>("sign-in");
3336
const [email, setEmail] = useState("");
3437
const [password, setPassword] = useState("");
@@ -228,13 +231,18 @@ export function AuthDialog({ open, onClose }: AuthDialogProps) {
228231
onClose={handleClose}
229232
maxWidth="xs"
230233
fullWidth
231-
PaperProps={{
232-
sx: {
233-
borderRadius: "12px",
234-
p: 0,
235-
},
236-
}}
234+
fullScreen={fullScreen}
235+
PaperProps={{ sx: [mobileFullScreenDialogPaperSx, { p: 0 }] }}
237236
>
237+
{fullScreen && (
238+
<IconButton
239+
onClick={handleClose}
240+
aria-label={tc("close")}
241+
sx={{ position: "absolute", top: 8, right: 8, zIndex: 1 }}
242+
>
243+
<CloseIcon />
244+
</IconButton>
245+
)}
238246
<DialogContent sx={{ px: 5, py: 4 }}>
239247
{/* Logo / Title */}
240248
<Box sx={{ textAlign: "center", mb: 3 }}>

apps/web/src/components/auth/MangroveExportDialog.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import Typography from "@mui/material/Typography";
1818
import { toMangroveExportJwk, useMangroveKeypairExport } from "@openmapx/core";
1919
import { useTranslations } from "next-intl";
2020
import { useState } from "react";
21+
import { mobileFullScreenDialogPaperSx, useFullScreenOnMobile } from "@/lib/useFullScreenOnMobile";
2122

2223
interface Props {
2324
open: boolean;
@@ -27,6 +28,7 @@ interface Props {
2728
export function MangroveExportDialog({ open, onClose }: Props) {
2829
const t = useTranslations("account");
2930
const tc = useTranslations("common");
31+
const fullScreen = useFullScreenOnMobile();
3032
const { privateJwk, reason } = useMangroveKeypairExport();
3133
const [revealed, setRevealed] = useState(false);
3234
const [copied, setCopied] = useState(false);
@@ -64,7 +66,14 @@ export function MangroveExportDialog({ open, onClose }: Props) {
6466
}
6567

6668
return (
67-
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
69+
<Dialog
70+
open={open}
71+
onClose={onClose}
72+
fullWidth
73+
maxWidth="sm"
74+
fullScreen={fullScreen}
75+
PaperProps={{ sx: mobileFullScreenDialogPaperSx }}
76+
>
6877
<DialogTitle>{t("mangroveExportTitle")}</DialogTitle>
6978
<DialogContent dividers>
7079
<Stack spacing={2}>

apps/web/src/components/auth/MangroveSetupWizard.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
} from "@openmapx/core";
3232
import { useTranslations } from "next-intl";
3333
import { useState } from "react";
34+
import { mobileFullScreenDialogPaperSx, useFullScreenOnMobile } from "@/lib/useFullScreenOnMobile";
3435

3536
type Mode = "unencrypted" | "passphrase" | "passphrase+webauthn";
3637
type Step = "chooseMode" | "importJwk" | "configure" | "confirmUnencrypted";
@@ -89,6 +90,7 @@ export function MangroveSetupWizard({
8990
}: Props) {
9091
const t = useTranslations("account");
9192
const tc = useTranslations("common");
93+
const fullScreen = useFullScreenOnMobile();
9294
const setup = useSetupKeypair();
9395

9496
const [step, setStep] = useState<Step>("chooseMode");
@@ -194,7 +196,14 @@ export function MangroveSetupWizard({
194196
}
195197

196198
return (
197-
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="sm">
199+
<Dialog
200+
open={open}
201+
onClose={handleClose}
202+
fullWidth
203+
maxWidth="sm"
204+
fullScreen={fullScreen}
205+
PaperProps={{ sx: mobileFullScreenDialogPaperSx }}
206+
>
198207
<DialogTitle>
199208
{step === "chooseMode"
200209
? t("mangroveSetupTitle")

apps/web/src/components/map/MapCanvas.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useEffect, useRef } from "react";
99
import { useEnv } from "@/lib/EnvProvider";
1010
import { useMap } from "@/lib/MapContext";
1111
import { loadOpenMapXStyle, maptilerStyleUrl } from "@/lib/map";
12+
import { useMapAttributionExpandedObserver } from "@/lib/mapAttributionExpanded";
1213

1314
export function MapCanvas() {
1415
const containerRef = useRef<HTMLDivElement>(null);
@@ -19,6 +20,7 @@ export function MapCanvas() {
1920
const resolvedMode = mode === "system" ? systemMode : mode;
2021
const mapStyle = resolvedMode === "dark" ? "streets-v2-dark" : "bright-v2";
2122
const { setCenter, setZoom, setBearing, setPitch, setUserLocation } = useMapStore();
23+
useMapAttributionExpandedObserver();
2224

2325
// biome-ignore lint/correctness/useExhaustiveDependencies: mapStyle intentionally excluded — style changes handled by the style-swap effect below
2426
useEffect(() => {
@@ -55,7 +57,15 @@ export function MapCanvas() {
5557
canvasContextAttributes: { antialias: true },
5658
});
5759

58-
map.addControl(new maplibregl.AttributionControl({ compact: false }), "bottom-right");
60+
// `compact` left undefined → MapLibre auto-collapses to an "i" button
61+
// below 640px viewport width, which keeps the attribution from wrapping
62+
// across the footer/legal links on mobile. The control's <details>
63+
// element renders open by default in compact mode, so force it closed
64+
// once after mounting.
65+
map.addControl(new maplibregl.AttributionControl(), "bottom-right");
66+
const attrib = map.getContainer().querySelector(".maplibregl-ctrl-attrib");
67+
if (attrib instanceof HTMLDetailsElement) attrib.open = false;
68+
attrib?.classList.remove("maplibregl-compact-show");
5969

6070
map.on("moveend", () => {
6171
const c = map.getCenter();

apps/web/src/components/map/MapControls.tsx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,31 +10,59 @@ import Paper from "@mui/material/Paper";
1010
import Tooltip from "@mui/material/Tooltip";
1111
import { useMapStore } from "@openmapx/core";
1212
import { useTranslations } from "next-intl";
13+
import { useEffect, useState } from "react";
1314
import { useMyLocation } from "@/components/command-palette/useMyLocation";
15+
import { MOBILE_SHEET_FOLLOW_CAP_FRACTION } from "@/components/panels/MobileBottomSheet";
1416
import { useMap } from "@/lib/MapContext";
17+
import { useMapAttributionExpanded } from "@/lib/mapAttributionExpanded";
18+
import { useMobilePanelMaxHeight } from "@/lib/mobilePanelHeight";
1519
import { Pegman } from "./Pegman";
1620

21+
const BASE_BOTTOM = 48;
22+
const PANEL_GAP = 12;
23+
1724
export function MapControls() {
1825
const t = useTranslations("map");
1926
const { zoomIn, zoomOut, resetBearing } = useMap();
2027
const bearing = useMapStore((s) => s.bearing);
2128
const pitch = useMapStore((s) => s.pitch);
2229
const handleMyLocation = useMyLocation();
30+
const mobilePanelHeight = useMobilePanelMaxHeight();
31+
const attributionExpanded = useMapAttributionExpanded();
32+
const [vh, setVh] = useState(0);
33+
useEffect(() => {
34+
const update = () => setVh(window.innerHeight);
35+
update();
36+
window.addEventListener("resize", update);
37+
return () => window.removeEventListener("resize", update);
38+
}, []);
39+
// Cap how far the controls follow the sheet — when the user drags above the
40+
// medium snap, the sheet covers the controls anyway, so freezing the offset
41+
// here keeps them in their last reachable position rather than scrolling
42+
// them off the top of the visible map area.
43+
const followHeight =
44+
vh > 0 ? Math.min(mobilePanelHeight, vh * MOBILE_SHEET_FOLLOW_CAP_FRACTION) : mobilePanelHeight;
2345

2446
return (
2547
<Box
2648
sx={{
2749
position: "absolute",
28-
bottom: 48,
50+
bottom: {
51+
xs: followHeight > 0 ? followHeight + PANEL_GAP : BASE_BOTTOM,
52+
sm: BASE_BOTTOM,
53+
},
2954
right: 12,
3055
display: "flex",
3156
flexDirection: "column",
3257
alignItems: "center",
3358
gap: 1,
3459
zIndex: 10,
60+
opacity: { xs: attributionExpanded ? 0 : 1, sm: 1 },
61+
pointerEvents: { xs: attributionExpanded ? "none" : "auto", sm: "auto" },
62+
transition: "bottom 0.25s ease, opacity 0.18s ease",
3563
}}
3664
>
37-
{/* My location — topmost, matches Google Maps order */}
65+
{/* My location */}
3866
<Tooltip title={t("myLocation")} placement="left">
3967
<Paper elevation={2} sx={{ borderRadius: "12px", overflow: "hidden" }}>
4068
<IconButton

apps/web/src/components/map/MapFooter.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ import { useSidebarStore } from "@openmapx/core";
66
import NextLink from "next/link";
77
import { useTranslations } from "next-intl";
88
import { PANEL_WIDTH } from "@/lib/layout";
9+
import { useMapAttributionExpanded } from "@/lib/mapAttributionExpanded";
910

1011
export function MapFooter() {
1112
const t = useTranslations("footer");
1213
const sidebarOpen = useSidebarStore((s) => s.activeSidebarId !== null);
1314
const collapsed = useSidebarStore((s) => s.collapsed);
1415
const shifted = sidebarOpen && !collapsed;
16+
const attributionExpanded = useMapAttributionExpanded();
1517
return (
1618
<Box
1719
component="footer"
@@ -22,11 +24,12 @@ export function MapFooter() {
2224
zIndex: 5,
2325
display: "flex",
2426
gap: "0.6em",
25-
pointerEvents: "auto",
2627
bgcolor: "color-mix(in srgb, var(--omx-overlay-bg) 50%, transparent)",
2728
px: "5px",
2829
font: '12px/20px "Helvetica Neue", Arial, Helvetica, sans-serif',
29-
transition: { sm: "left 0.25s ease" },
30+
opacity: { xs: attributionExpanded ? 0 : 1, sm: 1 },
31+
pointerEvents: { xs: attributionExpanded ? "none" : "auto", sm: "auto" },
32+
transition: "opacity 0.18s ease, left 0.25s ease",
3033
"& a": {
3134
color: "text.primary",
3235
textDecoration: "none",

0 commit comments

Comments
 (0)