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
|
|
@ -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 { api, uploadAttachment } from '../api'
|
||||
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> {
|
||||
if (!file.type.startsWith('image/')) return file
|
||||
return new Promise(resolve => {
|
||||
|
|
@ -714,13 +725,15 @@ function PhotoUploader({
|
|||
}) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const cameraRef = useRef<HTMLInputElement>(null)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const mine = attachments.filter(a =>
|
||||
a.attachment_type === attachmentType &&
|
||||
(label === null ? !a.label : a.label === label)
|
||||
)
|
||||
|
||||
async function handleFiles(files: FileList) {
|
||||
const handleFiles = useCallback(async (files: FileList, inputEl?: HTMLInputElement | null) => {
|
||||
setUploading(true)
|
||||
for (const file of Array.from(files)) {
|
||||
try {
|
||||
|
|
@ -732,14 +745,20 @@ function PhotoUploader({
|
|||
}
|
||||
}
|
||||
setUploading(false)
|
||||
if (inputRef.current) inputRef.current.value = ''
|
||||
}
|
||||
if (inputEl) inputEl.value = ''
|
||||
}, [cashUpId, attachmentType, label, onAdded])
|
||||
|
||||
async function remove(id: number) {
|
||||
await api.delete(`/attachments/${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 (
|
||||
<div style={{ display: 'flex', gap: '0.625rem', flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||||
{mine.map(a => (
|
||||
|
|
@ -766,14 +785,27 @@ function PhotoUploader({
|
|||
)}
|
||||
</div>
|
||||
))}
|
||||
{!disabled && (
|
||||
<button onClick={() => inputRef.current?.click()}
|
||||
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' }}>
|
||||
{!disabled && !isMobile && (
|
||||
<button onClick={() => inputRef.current?.click()} style={thumbBtn}>
|
||||
{uploading ? <Loader size={18} style={{ animation: 'spin 1s linear infinite' }} /> : <><Camera size={18} /><span>Add photo</span></>}
|
||||
</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
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue