Skip to content

Commit 1473863

Browse files
committed
Mobile layout for flow-shield
1 parent 6015dd6 commit 1473863

8 files changed

Lines changed: 296 additions & 86 deletions

File tree

demos/flow-shield/src/App.jsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
1-
import { useCallback, useRef, useState } from 'react'
1+
import { useCallback, useEffect, useRef, useState } from 'react'
22
import { Canvas } from '@react-three/fiber'
33
import { Leva, useControls } from 'leva'
44
import SceneContent from './components/playground/SceneContent'
55
import UIOverlay from './components/overlay/UIOverlay'
66
import OverlayButtons from './components/overlay/OverlayButtons'
77
import LoadingOverlay from './components/overlay/LoadingOverlay'
88
import { LEVA_THEME } from './components/theme/theme'
9+
import { useIsMobile } from './hooks/useIsMobile'
910

1011
export default function App() {
1112
const [showGrid, setShowGrid] = useState(true)
12-
const [hideLeva, setHideLeva] = useState(false)
13+
const isMobile = useIsMobile()
14+
const [hideLeva, setHideLeva] = useState(isMobile)
15+
16+
// auto-hide Leva when crossing down to mobile
17+
useEffect(() => { if (isMobile) setHideLeva(true) }, [isMobile])
1318
const [glbUrl, setGlbUrl] = useState(null)
1419
const [preset, setPreset] = useState('default')
1520
const [isLoadingModel, setIsLoadingModel] = useState(false)

demos/flow-shield/src/components/overlay/OverlayButtons.module.css

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
/* ── Desktop strip ──────────────────────────────────────────────── */
2+
13
.container {
24
position: fixed;
3-
bottom: 74px;
5+
bottom: 30px;
46
right: 24px;
57
z-index: 55;
68
display: flex;
9+
align-items: center;
710
gap: 8px;
811
pointer-events: none;
912
}
@@ -40,7 +43,6 @@
4043
opacity: 0.6;
4144
}
4245

43-
/* Import GLB — wider button with text + icon */
4446
.importBtn {
4547
composes: btn;
4648
gap: 6px;
@@ -61,7 +63,6 @@
6163
display: none;
6264
}
6365

64-
/* Preset selector buttons */
6566
.presetGroup {
6667
display: flex;
6768
gap: 4px;
@@ -83,3 +84,102 @@
8384
margin: 4px 0;
8485
pointer-events: none;
8586
}
87+
88+
/* ── Mobile: FAB + popover ──────────────────────────────────────── */
89+
90+
.mobileRoot {
91+
position: fixed;
92+
bottom: max(16px, calc(env(safe-area-inset-bottom) + 12px));
93+
right: max(16px, calc(env(safe-area-inset-right) + 12px));
94+
z-index: 55;
95+
display: flex;
96+
flex-direction: column;
97+
align-items: flex-end;
98+
gap: 10px;
99+
pointer-events: none;
100+
}
101+
102+
.fab {
103+
width: 48px;
104+
height: 48px;
105+
border-radius: 14px;
106+
border: 1px solid var(--overlay-border);
107+
background: rgba(26, 24, 22, 0.92);
108+
backdrop-filter: blur(12px);
109+
color: var(--overlay-text);
110+
cursor: pointer;
111+
display: flex;
112+
align-items: center;
113+
justify-content: center;
114+
pointer-events: auto;
115+
transition: border-color 0.2s, background 0.2s;
116+
}
117+
118+
.fab:hover,
119+
.fab:active {
120+
border-color: var(--overlay-accent);
121+
background: rgba(42, 40, 38, 0.95);
122+
}
123+
124+
.fabIcon {
125+
transition: transform 0.25s ease;
126+
}
127+
128+
.fabIconOpen {
129+
transform: rotate(90deg);
130+
}
131+
132+
/* Popover panel */
133+
.popover {
134+
display: flex;
135+
flex-direction: column;
136+
align-items: stretch;
137+
gap: 8px;
138+
padding: 12px;
139+
border: 1px solid var(--overlay-border);
140+
border-radius: 12px;
141+
background: rgba(26, 24, 22, 0.92);
142+
backdrop-filter: blur(14px);
143+
pointer-events: auto;
144+
min-width: 200px;
145+
146+
opacity: 0;
147+
transform: translateY(8px) scale(0.96);
148+
transform-origin: bottom right;
149+
pointer-events: none;
150+
visibility: hidden;
151+
transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s;
152+
}
153+
154+
.popoverOpen {
155+
opacity: 1;
156+
transform: translateY(0) scale(1);
157+
pointer-events: auto;
158+
visibility: visible;
159+
}
160+
161+
/* Inside the popover, preset buttons span full width */
162+
.popover .presetGroup {
163+
width: 100%;
164+
}
165+
166+
.popover .presetBtn {
167+
flex: 1 1 0;
168+
justify-content: center;
169+
}
170+
171+
.popover .separator {
172+
width: 100%;
173+
height: 1px;
174+
margin: 2px 0;
175+
}
176+
177+
.popover .importBtn {
178+
width: 100%;
179+
justify-content: center;
180+
}
181+
182+
.popover .btn {
183+
height: 40px;
184+
min-width: 40px;
185+
}

demos/flow-shield/src/components/overlay/OverlayButtons.tsx

Lines changed: 80 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
'use client'
22

3-
import { useRef } from 'react'
3+
import { useCallback, useEffect, useRef, useState } from 'react'
44
import { COLORS } from '../theme/theme'
5+
import { useIsMobile } from '../../hooks/useIsMobile'
56
import styles from './OverlayButtons.module.css'
67

78
export type Preset = 'default' | 'droideka'
@@ -30,39 +31,53 @@ export default function OverlayButtons({
3031
onSetPreset
3132
}: OverlayButtonsProps) {
3233
const fileInputRef = useRef<HTMLInputElement>(null)
34+
const popoverRef = useRef<HTMLDivElement>(null)
35+
const isMobile = useIsMobile()
36+
const [open, setOpen] = useState(false)
3337

3438
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
3539
const file = e.target.files?.[0]
3640
if (file) onLoadGlb(file)
3741
e.target.value = ''
3842
}
3943

40-
return (
41-
<div
42-
className={styles.container}
43-
style={
44-
{
45-
'--overlay-bg': COLORS.bg,
46-
'--overlay-surface': COLORS.surface,
47-
'--overlay-border': COLORS.border,
48-
'--overlay-text': COLORS.text,
49-
'--overlay-accent': COLORS.accent
50-
} as React.CSSProperties
51-
}
52-
>
44+
const close = useCallback(() => setOpen(false), [])
45+
46+
// close popover on outside tap
47+
useEffect(() => {
48+
if (!open || !isMobile) return
49+
const handler = (e: PointerEvent) => {
50+
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) close()
51+
}
52+
document.addEventListener('pointerdown', handler, true)
53+
return () => document.removeEventListener('pointerdown', handler, true)
54+
}, [open, isMobile, close])
55+
56+
const cssVars = {
57+
'--overlay-bg': COLORS.bg,
58+
'--overlay-surface': COLORS.surface,
59+
'--overlay-border': COLORS.border,
60+
'--overlay-text': COLORS.text,
61+
'--overlay-accent': COLORS.accent
62+
} as React.CSSProperties
63+
64+
const fileInput = (
65+
<input ref={fileInputRef} type="file" accept=".glb,.gltf" className={styles.fileInput} onChange={handleFileChange} />
66+
)
67+
68+
const controls = (
69+
<>
5370
{/* Preset selector */}
5471
<div className={styles.presetGroup}>
5572
<button
5673
onClick={() => onSetPreset('default')}
5774
className={`${styles.presetBtn} ${preset === 'default' ? styles.active : styles.inactive}`}
58-
title="Default preset — sphere shield"
5975
>
6076
Default
6177
</button>
6278
<button
6379
onClick={() => onSetPreset('droideka')}
6480
className={`${styles.presetBtn} ${preset === 'droideka' ? styles.active : styles.inactive}`}
65-
title="Droideka preset — Star Wars droid"
6681
>
6782
Droideka
6883
</button>
@@ -71,11 +86,9 @@ export default function OverlayButtons({
7186
<div className={styles.separator} />
7287

7388
{/* Load GLB */}
74-
<input ref={fileInputRef} type="file" accept=".glb,.gltf" className={styles.fileInput} onChange={handleFileChange} />
7589
<button
7690
onClick={() => fileInputRef.current?.click()}
7791
className={`${styles.importBtn} ${hasGlb ? styles.active : ''}`}
78-
title="Load GLB model"
7992
>
8093
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.2">
8194
<path d="M8 2v8M5 7l3 3 3-3" />
@@ -86,7 +99,7 @@ export default function OverlayButtons({
8699

87100
{/* Clear GLB */}
88101
{hasGlb && (
89-
<button onClick={onClearGlb} className={styles.btn} title="Remove model (back to sphere)">
102+
<button onClick={onClearGlb} className={styles.btn} title="Remove model">
90103
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.2">
91104
<line x1="4" y1="4" x2="12" y2="12" />
92105
<line x1="12" y1="4" x2="4" y2="12" />
@@ -98,7 +111,6 @@ export default function OverlayButtons({
98111
<button
99112
onClick={onToggleGrid}
100113
className={`${styles.btn} ${showGrid ? styles.active : styles.inactive}`}
101-
title={showGrid ? 'Hide Grid' : 'Show Grid'}
102114
>
103115
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.2">
104116
<rect x="1" y="1" width="6" height="6" />
@@ -112,7 +124,6 @@ export default function OverlayButtons({
112124
<button
113125
onClick={onToggleLeva}
114126
className={`${styles.btn} ${!hideLeva ? styles.active : styles.inactive}`}
115-
title={hideLeva ? 'Show Controls' : 'Hide Controls'}
116127
>
117128
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.2">
118129
<line x1="2" y1="4" x2="14" y2="4" />
@@ -123,6 +134,54 @@ export default function OverlayButtons({
123134
<circle cx="9" cy="12" r="1.5" fill="currentColor" />
124135
</svg>
125136
</button>
137+
</>
138+
)
139+
140+
/* ── Desktop: inline strip (unchanged) ─────────────────────────── */
141+
if (!isMobile) {
142+
return (
143+
<div className={styles.container} style={cssVars}>
144+
{fileInput}
145+
{controls}
146+
</div>
147+
)
148+
}
149+
150+
/* ── Mobile: FAB + popover ─────────────────────────────────────── */
151+
return (
152+
<div ref={popoverRef} className={styles.mobileRoot} style={cssVars}>
153+
{fileInput}
154+
155+
{/* Popover panel */}
156+
<div className={`${styles.popover} ${open ? styles.popoverOpen : ''}`}>
157+
{controls}
158+
</div>
159+
160+
{/* FAB trigger */}
161+
<button className={styles.fab} onClick={() => setOpen((v) => !v)} aria-label="Options">
162+
<svg
163+
width="20"
164+
height="20"
165+
viewBox="0 0 20 20"
166+
fill="none"
167+
stroke="currentColor"
168+
strokeWidth="1.4"
169+
className={`${styles.fabIcon} ${open ? styles.fabIconOpen : ''}`}
170+
>
171+
{open ? (
172+
<>
173+
<line x1="5" y1="5" x2="15" y2="15" />
174+
<line x1="15" y1="5" x2="5" y2="15" />
175+
</>
176+
) : (
177+
<>
178+
<circle cx="10" cy="4" r="1.5" fill="currentColor" />
179+
<circle cx="10" cy="10" r="1.5" fill="currentColor" />
180+
<circle cx="10" cy="16" r="1.5" fill="currentColor" />
181+
</>
182+
)}
183+
</svg>
184+
</button>
126185
</div>
127186
)
128187
}

0 commit comments

Comments
 (0)