hvac/frontend/src/components/DevicePhotoUpload.tsx
jtricerolph 276c04f8c6 Scaffold Phase 1 hvac app — NewBook-driven room TRV heating scheduler
Ports the state machine, retry/backoff, and guest-override detection from
the retired homeassistant-newbook-heating-component, without depending on
Home Assistant. Backend (Fastify/pg) + frontend (React/Vite/TS) following
standard stack conventions; LXC 128 (127 was already taken by utilities).

MHI/Midea/Daikin/boiler drivers and the shared MQTT broker (LXC 104) are
later phases/infra, not included here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 18:00:42 +00:00

93 lines
3.4 KiB
TypeScript

import { useRef, useState } from 'react'
import { Camera, Image as ImageIcon, Loader2, X } from 'lucide-react'
import type { DevicePhoto, PhotoType } from '../types'
import { uploadDevicePhoto, deleteDevicePhoto, photoUrl } from '../api'
// Camera/library capture split: one input with capture="environment" (opens the
// device camera directly on mobile), one plain file input (opens the photo
// library/file picker) — same split used elsewhere in the stack for mobile uploads.
export default function DevicePhotoUpload({ deviceId, photoType, photos, onChanged, canDelete }: {
deviceId: number
photoType: PhotoType
photos: DevicePhoto[]
onChanged: () => void
canDelete: boolean
}) {
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
const [lightbox, setLightbox] = useState<string | null>(null)
const cameraInput = useRef<HTMLInputElement>(null)
const libraryInput = useRef<HTMLInputElement>(null)
const slotPhotos = photos.filter(p => p.photo_type === photoType)
async function handleFile(file: File | undefined) {
if (!file) return
setUploading(true); setError('')
try {
await uploadDevicePhoto(deviceId, file, photoType)
onChanged()
} catch (e) {
setError(e instanceof Error ? e.message : 'Upload failed')
} finally {
setUploading(false)
}
}
async function remove(id: number) {
await deleteDevicePhoto(id).catch(() => {})
onChanged()
}
return (
<div style={{ marginBottom: 10 }}>
<div className="photo-grid">
{slotPhotos.map(p => (
<div key={p.id} className="photo-thumb-wrap">
<img className="photo-thumb" src={photoUrl(p.file_path)} onClick={() => setLightbox(photoUrl(p.file_path))} />
{canDelete && (
<button className="photo-del" onClick={() => remove(p.id)} title="Delete photo">
<X size={12} strokeWidth={2} />
</button>
)}
</div>
))}
{uploading && (
<div className="photo-thumb-wrap">
<div className="photo-thumb photo-thumb-uploading">
<Loader2 size={20} strokeWidth={1.75} className="spin" />
</div>
</div>
)}
</div>
{error && <div className="error-banner">{error}</div>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="btn btn-sm" onClick={() => cameraInput.current?.click()}>
<Camera size={14} strokeWidth={1.75} /> Take photo
</button>
<input
ref={cameraInput}
type="file" accept="image/*" capture="environment" style={{ display: 'none' }}
onChange={e => { handleFile(e.target.files?.[0]); e.target.value = '' }}
/>
<button type="button" className="btn btn-sm" onClick={() => libraryInput.current?.click()}>
<ImageIcon size={14} strokeWidth={1.75} /> Choose from library
</button>
<input
ref={libraryInput}
type="file" accept="image/*" style={{ display: 'none' }}
onChange={e => { handleFile(e.target.files?.[0]); e.target.value = '' }}
/>
</div>
{lightbox && (
<div className="lightbox-overlay" onClick={() => setLightbox(null)}>
<img className="lightbox-img" src={lightbox} />
<button className="lightbox-close" onClick={() => setLightbox(null)}><X size={20} strokeWidth={1.75} /></button>
</div>
)}
</div>
)
}