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:
parent
ad7d43f720
commit
82348bc834
3 changed files with 137 additions and 49 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal 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
|
||||||
|
|
@ -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,49 +46,80 @@ export function Layout({ user, children }: Props) {
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sidebar = (
|
||||||
|
<nav style={{
|
||||||
|
width: '220px', flexShrink: 0, background: 'var(--navy)',
|
||||||
|
display: 'flex', flexDirection: 'column', padding: '1rem 0',
|
||||||
|
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={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<Banknote size={20} strokeWidth={1.75} color="var(--gold)" />
|
||||||
|
<span style={{ color: 'var(--gold)', fontWeight: 700, fontSize: '1rem' }}>Cash Up</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.75rem', marginTop: '0.25rem' }}>{user.name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="nav-scroll" style={{ flex: 1, padding: '0.5rem 0', overflowY: 'auto' }}>
|
||||||
|
{navItems.filter(item => !item.cap || can(user, item.cap)).map(({ to, label, icon: Icon }) => (
|
||||||
|
<NavLink key={to} to={to} style={({ isActive }) => ({
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.625rem',
|
||||||
|
padding: '0.625rem 1rem', textDecoration: 'none',
|
||||||
|
color: isActive ? 'var(--gold)' : 'var(--text)',
|
||||||
|
background: isActive ? 'var(--surface)' : 'transparent',
|
||||||
|
borderLeft: isActive ? '2px solid var(--gold)' : '2px solid transparent',
|
||||||
|
fontSize: '0.875rem', transition: 'background 0.15s',
|
||||||
|
})}>
|
||||||
|
<Icon size={15} strokeWidth={1.75} />
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '0.75rem 1rem', borderTop: '1px solid var(--surface-2)' }}>
|
||||||
|
<button onClick={logout} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||||
|
background: 'none', border: 'none', color: 'var(--text-muted)',
|
||||||
|
fontSize: '0.875rem', padding: '0.375rem 0', width: '100%',
|
||||||
|
}}>
|
||||||
|
<LogOut size={14} strokeWidth={1.75} />
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', height: '100dvh', overflow: 'hidden' }}>
|
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', height: '100dvh', overflow: 'hidden' }}>
|
||||||
{/* Sidebar */}
|
{isMobile && (
|
||||||
<nav style={{
|
<div style={{
|
||||||
width: '220px', flexShrink: 0, background: 'var(--navy)',
|
background: 'var(--navy)', display: 'flex', alignItems: 'center',
|
||||||
display: 'flex', flexDirection: 'column', padding: '1rem 0',
|
padding: '0.75rem 1rem', gap: '0.75rem', flexShrink: 0,
|
||||||
borderRight: '1px solid var(--surface-2)',
|
borderBottom: '1px solid var(--surface-2)',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ padding: '0 1rem 1rem', borderBottom: '1px solid var(--surface-2)' }}>
|
<button onClick={() => setMenuOpen(o => !o)} style={{
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
background: 'none', border: 'none', color: 'var(--text)', padding: '0.25rem',
|
||||||
<Banknote size={20} strokeWidth={1.75} color="var(--gold)" />
|
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
|
||||||
<span style={{ color: 'var(--gold)', fontWeight: 700, fontSize: '1rem' }}>Cash Up</span>
|
|
||||||
</div>
|
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.75rem', marginTop: '0.25rem' }}>{user.name}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="nav-scroll" style={{ flex: 1, padding: '0.5rem 0', overflowY: 'auto' }}>
|
|
||||||
{navItems.filter(item => !item.cap || can(user, item.cap)).map(({ to, label, icon: Icon }) => (
|
|
||||||
<NavLink key={to} to={to} style={({ isActive }) => ({
|
|
||||||
display: 'flex', alignItems: 'center', gap: '0.625rem',
|
|
||||||
padding: '0.625rem 1rem', textDecoration: 'none',
|
|
||||||
color: isActive ? 'var(--gold)' : 'var(--text)',
|
|
||||||
background: isActive ? 'var(--surface)' : 'transparent',
|
|
||||||
borderLeft: isActive ? '2px solid var(--gold)' : '2px solid transparent',
|
|
||||||
fontSize: '0.875rem', transition: 'background 0.15s',
|
|
||||||
})}>
|
|
||||||
<Icon size={15} strokeWidth={1.75} />
|
|
||||||
{label}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ padding: '0.75rem 1rem', borderTop: '1px solid var(--surface-2)' }}>
|
|
||||||
<button onClick={logout} style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
|
||||||
background: 'none', border: 'none', color: 'var(--text-muted)',
|
|
||||||
fontSize: '0.875rem', padding: '0.375rem 0', width: '100%',
|
|
||||||
}}>
|
}}>
|
||||||
<LogOut size={14} strokeWidth={1.75} />
|
<Menu size={22} strokeWidth={1.75} />
|
||||||
Sign out
|
|
||||||
</button>
|
</button>
|
||||||
|
<Banknote size={18} strokeWidth={1.75} color="var(--gold)" />
|
||||||
|
<span style={{ color: 'var(--gold)', fontWeight: 700, fontSize: '1rem' }}>Cash Up</span>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
)}
|
||||||
|
|
||||||
|
{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)' }}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue