Mobile-responsive layout and camera upload improvements

- Hamburger menu with slide-in nav for viewports < 700px; auto-closes on route change
- PhotoUploader: separate camera trigger on mobile (capture="environment"), file input hidden on mobile; useCallback to fix stale-closure reset
- Add .gitignore to exclude compiled JS artefacts and package-lock

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-14 08:57:54 +00:00
parent ad7d43f720
commit 82348bc834
3 changed files with 137 additions and 49 deletions

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
node_modules/
dist/
frontend/dist/
*.js.map
# compiled JS artefacts next to TS sources
frontend/src/**/*.js
frontend/package-lock.json
.env

View file

@ -1,9 +1,21 @@
import { NavLink, useNavigate } from 'react-router-dom' import { useState, useEffect } from 'react'
import { NavLink, useNavigate, useLocation } from 'react-router-dom'
import { import {
Banknote, ClipboardList, BarChart2, Wallet, Vault, FileText, Settings, LogOut, Banknote, ClipboardList, BarChart2, Wallet, Vault, FileText, Settings, LogOut, Menu,
} from 'lucide-react' } from 'lucide-react'
import { can, type User, type CashupCap } from '../types' import { can, type User, type CashupCap } from '../types'
function useIsMobile() {
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 700)
useEffect(() => {
const mq = window.matchMedia('(max-width: 699px)')
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
return isMobile
}
interface Props { interface Props {
user: User user: User
children: React.ReactNode children: React.ReactNode
@ -22,6 +34,11 @@ const navItems: { to: string; label: string; icon: typeof Banknote; cap?: Cashup
export function Layout({ user, children }: Props) { export function Layout({ user, children }: Props) {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const isMobile = useIsMobile()
const [menuOpen, setMenuOpen] = useState(false)
useEffect(() => { setMenuOpen(false) }, [location.pathname])
async function logout() { async function logout() {
await fetch('/cashup/api/auth/logout', { method: 'POST', credentials: 'include' }) await fetch('/cashup/api/auth/logout', { method: 'POST', credentials: 'include' })
@ -29,13 +46,16 @@ export function Layout({ user, children }: Props) {
window.location.reload() window.location.reload()
} }
return ( const sidebar = (
<div style={{ display: 'flex', height: '100dvh', overflow: 'hidden' }}>
{/* Sidebar */}
<nav style={{ <nav style={{
width: '220px', flexShrink: 0, background: 'var(--navy)', width: '220px', flexShrink: 0, background: 'var(--navy)',
display: 'flex', flexDirection: 'column', padding: '1rem 0', display: 'flex', flexDirection: 'column', padding: '1rem 0',
borderRight: '1px solid var(--surface-2)', borderRight: '1px solid var(--surface-2)',
...(isMobile ? {
position: 'fixed', top: 0, left: 0, bottom: 0, zIndex: 200,
transform: menuOpen ? 'translateX(0)' : 'translateX(-220px)',
transition: 'transform 0.25s ease',
} : {}),
}}> }}>
<div style={{ padding: '0 1rem 1rem', borderBottom: '1px solid var(--surface-2)' }}> <div style={{ padding: '0 1rem 1rem', borderBottom: '1px solid var(--surface-2)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
@ -72,6 +92,34 @@ export function Layout({ user, children }: Props) {
</button> </button>
</div> </div>
</nav> </nav>
)
return (
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', height: '100dvh', overflow: 'hidden' }}>
{isMobile && (
<div style={{
background: 'var(--navy)', display: 'flex', alignItems: 'center',
padding: '0.75rem 1rem', gap: '0.75rem', flexShrink: 0,
borderBottom: '1px solid var(--surface-2)',
}}>
<button onClick={() => setMenuOpen(o => !o)} style={{
background: 'none', border: 'none', color: 'var(--text)', padding: '0.25rem',
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
}}>
<Menu size={22} strokeWidth={1.75} />
</button>
<Banknote size={18} strokeWidth={1.75} color="var(--gold)" />
<span style={{ color: 'var(--gold)', fontWeight: 700, fontSize: '1rem' }}>Cash Up</span>
</div>
)}
{isMobile && menuOpen && (
<div onClick={() => setMenuOpen(false)} style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 199,
}} />
)}
{sidebar}
{/* Content */} {/* Content */}
<main style={{ flex: 1, overflow: 'auto', background: 'var(--body-bg)' }}> <main style={{ flex: 1, overflow: 'auto', background: 'var(--body-bg)' }}>

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { RefreshCw, Save, CheckCircle, Loader, Camera, FileText, X } from 'lucide-react' import { RefreshCw, Save, CheckCircle, Loader, Camera, FileText, X } from 'lucide-react'
import { api, uploadAttachment } from '../api' import { api, uploadAttachment } from '../api'
import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout' import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout'
@ -677,6 +677,17 @@ function DenomGrid({ denoms, onChange, disabled, tabBase = 0 }: {
) )
} }
function useIsMobile() {
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 700)
useEffect(() => {
const mq = window.matchMedia('(max-width: 699px)')
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
return isMobile
}
async function compressImage(file: File, maxWidth = 1600, quality = 0.85): Promise<File> { async function compressImage(file: File, maxWidth = 1600, quality = 0.85): Promise<File> {
if (!file.type.startsWith('image/')) return file if (!file.type.startsWith('image/')) return file
return new Promise(resolve => { return new Promise(resolve => {
@ -714,13 +725,15 @@ function PhotoUploader({
}) { }) {
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const cameraRef = useRef<HTMLInputElement>(null)
const isMobile = useIsMobile()
const mine = attachments.filter(a => const mine = attachments.filter(a =>
a.attachment_type === attachmentType && a.attachment_type === attachmentType &&
(label === null ? !a.label : a.label === label) (label === null ? !a.label : a.label === label)
) )
async function handleFiles(files: FileList) { const handleFiles = useCallback(async (files: FileList, inputEl?: HTMLInputElement | null) => {
setUploading(true) setUploading(true)
for (const file of Array.from(files)) { for (const file of Array.from(files)) {
try { try {
@ -732,14 +745,20 @@ function PhotoUploader({
} }
} }
setUploading(false) setUploading(false)
if (inputRef.current) inputRef.current.value = '' if (inputEl) inputEl.value = ''
} }, [cashUpId, attachmentType, label, onAdded])
async function remove(id: number) { async function remove(id: number) {
await api.delete(`/attachments/${id}`) await api.delete(`/attachments/${id}`)
onRemoved(id) onRemoved(id)
} }
const thumbBtn: React.CSSProperties = {
width: '88px', height: '88px', border: '2px dashed var(--card-border)', borderRadius: '6px',
background: 'var(--body-bg)', cursor: 'pointer', display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: '4px', color: 'var(--text-mid)', fontSize: '0.7rem',
}
return ( return (
<div style={{ display: 'flex', gap: '0.625rem', flexWrap: 'wrap', alignItems: 'flex-start' }}> <div style={{ display: 'flex', gap: '0.625rem', flexWrap: 'wrap', alignItems: 'flex-start' }}>
{mine.map(a => ( {mine.map(a => (
@ -766,14 +785,27 @@ function PhotoUploader({
)} )}
</div> </div>
))} ))}
{!disabled && ( {!disabled && !isMobile && (
<button onClick={() => inputRef.current?.click()} <button onClick={() => inputRef.current?.click()} style={thumbBtn}>
style={{ width: '88px', height: '88px', border: '2px dashed var(--card-border)', borderRadius: '6px', background: 'var(--body-bg)', cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '4px', color: 'var(--text-mid)', fontSize: '0.7rem' }}>
{uploading ? <Loader size={18} style={{ animation: 'spin 1s linear infinite' }} /> : <><Camera size={18} /><span>Add photo</span></>} {uploading ? <Loader size={18} style={{ animation: 'spin 1s linear infinite' }} /> : <><Camera size={18} /><span>Add photo</span></>}
</button> </button>
)} )}
{!disabled && isMobile && (
<>
<button onClick={() => cameraRef.current?.click()} style={thumbBtn}>
{uploading ? <Loader size={18} style={{ animation: 'spin 1s linear infinite' }} /> : <><Camera size={18} /><span>Camera</span></>}
</button>
<button onClick={() => inputRef.current?.click()} style={thumbBtn}>
<FileText size={18} /><span>Library</span>
</button>
</>
)}
{/* Desktop / library picker — supports multiple files */}
<input ref={inputRef} type="file" accept="image/*" multiple hidden <input ref={inputRef} type="file" accept="image/*" multiple hidden
onChange={e => e.target.files?.length && handleFiles(e.target.files)} /> onChange={e => e.target.files?.length && handleFiles(e.target.files, inputRef.current)} />
{/* Mobile camera — single capture, goes straight to rear camera */}
<input ref={cameraRef} type="file" accept="image/*" capture="environment" hidden
onChange={e => e.target.files?.length && handleFiles(e.target.files, cameraRef.current)} />
</div> </div>
) )
} }