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>
93 lines
3.4 KiB
TypeScript
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>
|
|
)
|
|
}
|