Show upload progress — spinner placeholder in photo grid, status text on submit

TaskModal: grey placeholder thumb with spinning loader appears in the photo grid immediately on file pick, replaced by the real thumb once upload+resize completes.
NewTaskModal: submit button shows spinning icon with 'Creating task…' then 'Uploading photo N of M…' instead of just greying out.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-22 11:12:00 +00:00
parent b3f4adeb3e
commit 9f402bd2d9
3 changed files with 27 additions and 6 deletions

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { X, Camera } from 'lucide-react' import { X, Camera, Loader2 } from 'lucide-react'
import type { Category, Location, Task, Priority, AppConfig, Asset } from '../types' import type { Category, Location, Task, Priority, AppConfig, Asset } from '../types'
import { PRIORITIES, PRIORITY_LABELS } from '../types' import { PRIORITIES, PRIORITY_LABELS } from '../types'
import { createTask, fetchTasks, uploadTaskPhoto, fetchAssets } from '../api' import { createTask, fetchTasks, uploadTaskPhoto, fetchAssets } from '../api'
@ -25,6 +25,7 @@ export default function NewTaskModal({ categories, locations, config, onClose, o
const [assets, setAssets] = useState<Asset[]>([]) const [assets, setAssets] = useState<Asset[]>([])
const [existing, setExisting] = useState<Task[]>([]) const [existing, setExisting] = useState<Task[]>([])
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saveStatus, setSaveStatus] = useState('')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [assignment, setAssignment] = useState<Assignment>({ const [assignment, setAssignment] = useState<Assignment>({
@ -58,6 +59,7 @@ export default function NewTaskModal({ categories, locations, config, onClose, o
async function submit() { async function submit() {
if (!title.trim() || !locationId) { setError('Title and location are required'); return } if (!title.trim() || !locationId) { setError('Title and location are required'); return }
setSaving(true) setSaving(true)
setSaveStatus('Creating task…')
setError(null) setError(null)
try { try {
const task = await createTask({ const task = await createTask({
@ -74,8 +76,9 @@ export default function NewTaskModal({ categories, locations, config, onClose, o
contractor_id: assignment.contractor_id, contractor_id: assignment.contractor_id,
}) })
for (const file of files) { for (let i = 0; i < files.length; i++) {
await uploadTaskPhoto(task.id, file, 'report').catch(() => {}) setSaveStatus(`Uploading photo ${i + 1} of ${files.length}`)
await uploadTaskPhoto(task.id, files[i], 'report').catch(() => {})
} }
onCreated() onCreated()
@ -84,6 +87,7 @@ export default function NewTaskModal({ categories, locations, config, onClose, o
setError(err instanceof Error ? err.message : 'Failed to create task') setError(err instanceof Error ? err.message : 'Failed to create task')
} finally { } finally {
setSaving(false) setSaving(false)
setSaveStatus('')
} }
} }
@ -193,7 +197,7 @@ export default function NewTaskModal({ categories, locations, config, onClose, o
<div className="modal-actions"> <div className="modal-actions">
<button className="btn" onClick={onClose}>Cancel</button> <button className="btn" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={submit} disabled={saving}> <button className="btn btn-primary" onClick={submit} disabled={saving}>
{saving ? 'Saving…' : 'Submit'} {saving ? <><Loader2 size={14} strokeWidth={1.75} className="spin" /> {saveStatus}</> : 'Submit'}
</button> </button>
</div> </div>
</div> </div>

View file

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { import {
X, Camera, MessageSquare, ArrowRight, RotateCcw, PlusCircle, X, Camera, MessageSquare, ArrowRight, RotateCcw, PlusCircle,
CheckCircle2, Image as ImageIcon, PoundSterling, UserRound, Pencil, Trash2, CheckCircle2, Image as ImageIcon, PoundSterling, UserRound, Pencil, Trash2, Loader2,
} from 'lucide-react' } from 'lucide-react'
import type { TaskDetail, TaskStatus, TaskEvent, AuthUser, Priority } from '../types' import type { TaskDetail, TaskStatus, TaskEvent, AuthUser, Priority } from '../types'
import { STATUS_LABELS, TRANSITIONS, PRIORITIES, PRIORITY_LABELS, can } from '../types' import { STATUS_LABELS, TRANSITIONS, PRIORITIES, PRIORITY_LABELS, can } from '../types'
@ -52,6 +52,7 @@ export default function TaskModal({ taskId, onClose, onChanged }: {
const [assignment, setAssignment] = useState<Assignment | null>(null) const [assignment, setAssignment] = useState<Assignment | null>(null)
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null) const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
const [photoUploading, setPhotoUploading] = useState(false)
const [showEdit, setShowEdit] = useState(false) const [showEdit, setShowEdit] = useState(false)
const [editTitle, setEditTitle] = useState('') const [editTitle, setEditTitle] = useState('')
@ -276,6 +277,13 @@ export default function TaskModal({ taskId, onClose, onChanged }: {
)} )}
</div> </div>
))} ))}
{photoUploading && (
<div className="photo-thumb-wrap">
<div className="photo-thumb photo-thumb-uploading">
<Loader2 size={22} strokeWidth={1.5} className="spin" />
</div>
</div>
)}
{canReport && ( {canReport && (
<label className="btn btn-sm" style={{ cursor: 'pointer', alignSelf: 'center' }}> <label className="btn btn-sm" style={{ cursor: 'pointer', alignSelf: 'center' }}>
<Camera size={14} strokeWidth={1.75} /> Add <Camera size={14} strokeWidth={1.75} /> Add
@ -283,7 +291,10 @@ export default function TaskModal({ taskId, onClose, onChanged }: {
type="file" accept="image/*" capture="environment" style={{ display: 'none' }} type="file" accept="image/*" capture="environment" style={{ display: 'none' }}
onChange={e => { onChange={e => {
const f = e.target.files?.[0] const f = e.target.files?.[0]
if (f) run(() => uploadTaskPhoto(task.id, f, task.status === 'submitted' ? 'report' : 'progress')) if (!f) return
setPhotoUploading(true)
run(() => uploadTaskPhoto(task.id, f, task.status === 'submitted' ? 'report' : 'progress'))
.finally(() => setPhotoUploading(false))
}} }}
/> />
</label> </label>

View file

@ -374,6 +374,12 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; }
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
cursor: pointer; cursor: pointer;
} }
.photo-thumb-uploading {
display: flex; align-items: center; justify-content: center;
background: var(--card-border); color: var(--text-mid);
}
@keyframes spin { to { transform: rotate(360deg); } }
.spin { animation: spin .75s linear infinite; }
.photo-thumb-wrap { position: relative; } .photo-thumb-wrap { position: relative; }
.photo-del { .photo-del {
position: absolute; position: absolute;