// ════════════════════════════════════════════════════════════ // Caulis — Plant detail + Add/Edit (overlays) // ════════════════════════════════════════════════════════════ // ── info tile ───────────────────────────────────────────── function InfoTile({ icon, label, children, accent = C.forest }) { return (
{icon}
{label}
{children}
); } const offsetLabel = (n) => n === 0 ? 'Today' : n === 1 ? 'Yesterday' : `${n} days ago`; // downscale + jpeg-compress so photos are light enough to sync to Firebase function compressImage(dataUrl, max = 1024, q = 0.72) { return new Promise(resolve => { const img = new Image(); img.onload = () => { let w = img.width, h = img.height; if (w > h && w > max) { h = Math.round(h * max / w); w = max; } else if (h >= w && h > max) { w = Math.round(w * max / h); h = max; } try { const c = document.createElement('canvas'); c.width = w; c.height = h; c.getContext('2d').drawImage(img, 0, 0, w, h); resolve(c.toDataURL('image/jpeg', q)); } catch (e) { resolve(dataUrl); } }; img.onerror = () => resolve(dataUrl); img.src = dataUrl; }); } // photos of a plant, in display order: user photos first, then preset/wiki function plantGallery(plant) { const user = plant.photos || (plant.userImage ? [plant.userImage] : []); const all = [...user]; if (plant.image && !all.includes(plant.image)) all.push(plant.image); return all; } function firstPhoto(plant) { return (plant.photos && plant.photos[0]) || plant.userImage || plant.image || null; } // ── swipeable photo carousel ────────────────────────────── function PhotoCarousel({ images, tint, height = 196, radius = 22 }) { const [slide, setSlide] = useState(0); const ref = useRef(null); const imgs = images.length ? images : [null]; const onScroll = () => { const el = ref.current; if (!el) return; setSlide(Math.round(el.scrollLeft / el.clientWidth)); }; return (
{imgs.map((img, i) => (
))}
{imgs.length > 1 && (
{imgs.map((_, i) => ( ))}
)}
); } // ════════════════════════════════════════════════════════════ // PLANT DETAIL // ════════════════════════════════════════════════════════════ function PlantDetail({ plant, tint, fromScan, inQueue, onBack, onWater, onUndoWater, onToggleQueue, onGoQueue, onEdit, onAskDoctor, isDesktop, readonly = false, czechMode = false }) { const [justWatered, setJustWatered] = useState(false); const [waterOffset, setWaterOffset] = useState(0); // days ago const prevRef = useRef(null); const status = statusOf(plant.days, plant.every); const gallery = plantGallery(plant); const water = () => { prevRef.current = { days: plant.days }; onWater(plant.id, waterOffset); setJustWatered(true); }; const undo = () => { if (prevRef.current) onUndoWater(plant.id, prevRef.current.days); setJustWatered(false); }; return (
{/* scrollable body */}
{/* top bar */}
{fromScan && (
{readonly ? 'Guest view' : 'Scanned'}
)} {!readonly && (
onEdit(plant)} style={{ cursor:'pointer', width:38, height:38, borderRadius:999, background:C.panel, border:C.hair, boxShadow:'0 2px 8px rgba(45,80,22,0.06)', display:'flex', alignItems:'center', justifyContent:'center' }}>
)}
{/* hero */} {/* name block */}
{czechMode && plant.czech ? plant.czech : plant.name}
{czechMode && plant.czech && (
{plant.name}
)}
{plant.latin}
{agoLabel(plant.days)}
{/* water button */} {readonly ? (
View only — not your garden
) : (<>
{justWatered ? : } {justWatered ? 'Watered' + (waterOffset ? ` ${offsetLabel(waterOffset)}` : ' today') : (waterOffset ? `Mark watered ${offsetLabel(waterOffset)}` : 'Mark as watered')}
{!justWatered && (
When?
setWaterOffset(o=>Math.max(0, o-1))} style={{ cursor:'pointer', width:30, height:30, borderRadius:9, background:'rgba(45,80,22,0.08)', color:C.forest, display:'flex', alignItems:'center', justifyContent:'center', fontFamily:FONT_SANS, fontSize:17, fontWeight:600 }}>−
{offsetLabel(waterOffset)}
setWaterOffset(o=>Math.min(60, o+1))} style={{ cursor:'pointer', width:30, height:30, borderRadius:9, background:'rgba(45,80,22,0.08)', color:C.forest, display:'flex', alignItems:'center', justifyContent:'center', fontFamily:FONT_SANS, fontSize:17, fontWeight:600 }}>+
)} )} {onAskDoctor && (
onAskDoctor(plant)} style={{ marginTop:10, display:'flex', alignItems:'center', justifyContent:'center', gap:8, height:46, borderRadius:16, border:`1px solid ${C.forest}`, cursor:'pointer' }}> Ask the doctor
)} {/* info tiles */}
} label="Light">{plant.light || '—'} } label="Watering"> {plant.watering || 'Average'} · every {plant.benchmark || plant.every + ' days'}
{plant.care && } label="Care">{plant.care}} {plant.fact && i} label="Fun fact">{plant.fact}} {(() => { const st = wateringStats(plant.history); const fmt = s => { const [y,m,d] = s.split('-').map(Number); return new Date(y, m-1, d).toLocaleDateString('en-US', { month:'short', day:'numeric' }); }; const recent = [...(plant.history || [])].slice(-6).reverse(); return ( } label="Watering log"> {st.total ? (<>
{st.total} total {st.count30} in 30 days
{recent.length > 0 && (
{recent.map((s,i) => {fmt(s)})}
)} ) : No history yet — mark this plant watered to start logging.}
); })()}
Care data & photo via Perenual, House Plants & Wikipedia
{/* QR block */} {!readonly &&
Plant tag
QR code
Scan to open {czechMode && plant.czech ? plant.czech : plant.name}
onToggleQueue(plant.id)} style={{ marginTop:16, cursor:'pointer', width:'100%', display:'flex', alignItems:'center', justifyContent:'center', gap:8, height:48, borderRadius:14, background: inQueue ? 'rgba(122,158,78,0.14)' : 'rgba(45,80,22,0.06)', border: inQueue ? '1px solid rgba(110,154,62,0.4)' : '0.5px solid rgba(45,80,22,0.12)', color: inQueue ? C.sage : C.forest, transition:'all 200ms ease', }}> {inQueue ? : } {inQueue ? 'In print queue' : 'Add to print queue'}
{inQueue && (
View print queue →
)}
} {readonly &&
}
{/* floating Undo pill */} {justWatered && (
{plant.name} watered
Undo
)}
); } // ════════════════════════════════════════════════════════════ // ADD / EDIT PLANT // ════════════════════════════════════════════════════════════ function Field({ label, children }) { return (
{label}
{children}
); } const inputStyle = () => ({ width:'100%', boxSizing:'border-box', height:48, borderRadius:14, border:C.hair, background:C.input, padding:'0 15px', fontFamily:FONT_SANS, fontSize:15, color:C.ink, outline:'none', }); // the plant identifier returns a Perenual species record (see caulis-perenual.jsx) function CameraIcon({ s = 20, c = C.forest }) { return ( ); } function SparkIcon({ s = 20, c = C.forest }) { return ( ); } function AddPlant({ locations, editing, onBack, onSave, onAddLocation, isDesktop, czechMode }) { const [name, setName] = useState(editing ? editing.name : ''); const [czech, setCzech] = useState(editing ? (editing.czech || '') : ''); const [latin, setLatin] = useState(editing && editing.latin !== '\u2014' ? editing.latin : ''); const [loc, setLoc] = useState(editing ? editing.location : ''); const [typed, setTyped] = useState(''); const [sheet, setSheet] = useState(false); const [identifying, setIdentifying] = useState(false); const [identified, setIdentified] = useState(false); const [source, setSource] = useState(''); const [species, setSpecies] = useState(null); const [presetImage, setPresetImage] = useState(editing ? editing.image : null); const [photos, setPhotos] = useState(editing ? (editing.photos || (editing.userImage ? [editing.userImage] : [])) : []); const [every, setEvery] = useState(editing ? editing.every : 7); const [light, setLight] = useState(editing ? editing.light || '' : ''); const [care, setCare] = useState(editing ? editing.care || '' : ''); const [fact, setFact] = useState(editing ? editing.fact || '' : ''); const [lastWatered, setLastWatered] = useState(editing ? (editing.days || 0) : 0); const [suggestions, setSuggestions] = useState([]); const [loadingSpecies, setLoadingSpecies] = useState(false); const [candidates, setCandidates] = useState([]); const [rechecking, setRechecking] = useState(false); const fileRef = useRef(null); const photoModeRef = useRef('photo'); // fill the form from a resolved species record const applyIdentified = (sp) => { const c = speciesCare(sp); setName(sp.common_name || ''); setLatin(Array.isArray(sp.scientific_name) ? sp.scientific_name[0] : (sp.scientific_name || '')); setCzech((sp.czech_names && sp.czech_names[0]) || sp.czech || c.czech || ''); setSpecies(sp); setPresetImage(c.image); setEvery(c.every); setLight(c.light || ''); setCare(c.care || ''); setFact(c.fact || ''); setSource((sp._source || 'PlantNet') + (sp._score ? ` · ${Math.round(sp._score * 100)}%` : '')); setIdentified(true); }; const chooseCandidate = async (cand) => { setCandidates([]); setIdentifying(true); const full = await resolveSpecies(cand.scientificName, cand.commonName || cand.scientificName, cand.score); setIdentifying(false); applyIdentified(full); }; const runAiRecheck = async () => { if (rechecking || !latin) return; setRechecking(true); try { const record = species ? { ...species } : { scientific_name: latin, common_name: name, czech: czech, _care: care, _fact: fact }; const aiRecord = await aiReviewCare(record); if (!aiRecord) return; const c = speciesCare(aiRecord); setEvery(c.every); if (c.light) setLight(c.light); if (c.care) setCare(c.care); if (c.fact) setFact(c.fact); if (c.czech && !czech) setCzech(c.czech); setSource((source || 'Manual') + ' · AI Reviewed'); if (aiRecord.common_name && !name) setName(aiRecord.common_name); } catch(e) { console.error(e); } finally { setRechecking(false); } }; const fmtName = n => n.replace(/-/g, ' ').split(' ').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); const onNameChange = (val) => { setName(val); setSuggestions(searchLocalPlants(val)); }; const pickSuggestion = async (p) => { setSuggestions([]); setName(fmtName(p.name)); setLatin(p.latin); if (p.czech) setCzech(p.czech); if (!hasApiKey() && !p.isLibrary) return; setLoadingSpecies(true); try { const sp = await getSpeciesDetails(p.id, p.latin); if (sp) { const care = speciesCare(sp); setSpecies(sp); setPresetImage(care.image); setEvery(care.every); setLight(care.light || ''); setCare(care.care || ''); setFact(care.fact || ''); if (care.czech) setCzech(care.czech); setSource(sp._source || 'Perenual'); setIdentified(true); } } finally { setLoadingSpecies(false); } }; const formGallery = [...photos, ...(presetImage && !photos.includes(presetImage) ? [presetImage] : [])]; const hasPhoto = formGallery.length > 0; const commitTyped = () => { const v = typed.trim(); if (!v) return; if (!locations.some(l=>l.toLowerCase()===v.toLowerCase())) onAddLocation(v); setLoc(v); setTyped(''); }; const canSave = name.trim().length > 0; const processFile = async (f) => { window._filePickerOpen = false; const reader = new FileReader(); reader.onload = async (ev) => { const dataUrl = await compressImage(ev.target.result); setPhotos(prev => [...prev, dataUrl]); // keep the user's shot; never replaced if (photoModeRef.current === 'identify') { setIdentified(false); setCandidates([]); setIdentifying(true); const sp = await identifySpecies(dataUrl); setIdentifying(false); if (!sp) { setSource('failed'); return; } if (sp.candidates) { setCandidates(sp.candidates); return; } // uncertain — let user pick applyIdentified(sp); } }; reader.readAsDataURL(f); }; const removePhoto = (i) => setPhotos(prev => prev.filter((_, idx) => idx !== i)); const openPicker = async (mode = 'photo') => { photoModeRef.current = mode; window._filePickerOpen = true; const useCamera = !isDesktop && mode !== 'gallery'; if (!useCamera && window.showOpenFilePicker) { try { const [fh] = await window.showOpenFilePicker({ types:[{ description:'Images', accept:{'image/*':['.jpg','.jpeg','.png','.gif','.webp','.avif','.heic']} }], multiple:false }); const file = await fh.getFile(); await processFile(file); } catch(e) { window._filePickerOpen = false; } return; } const input = fileRef.current; if (!input) { window._filePickerOpen = false; return; } input.value = ''; if (useCamera) input.setAttribute('capture', 'environment'); else input.removeAttribute('capture'); let done = false; const finish = (f) => { if (done) return; done = true; clearInterval(poll); clearTimeout(giveUp); processFile(f); }; const poll = setInterval(() => { const f = input.files?.[0]; if (f) finish(f); }, 100); const giveUp = setTimeout(() => { done = true; clearInterval(poll); window._filePickerOpen = false; }, 60000); input.click(); }; const takePhoto = () => { setSheet(false); openPicker('photo'); }; const fromGallery = () => { setSheet(false); openPicker('gallery'); }; const identify = () => { setSheet(false); openPicker('identify'); }; return (
{editing ? 'Edit plant' : 'New plant'}
{/* photo area — input lives outside conditional so it never remounts */} {(() => { const addPhoto = () => { if (identifying) return; isDesktop ? openPicker('photo') : setSheet(true); }; return (<>
{hasPhoto ? ( ) : (
)} {!identifying && (
)} {identified && !identifying && (
{source === 'demo' ? 'Demo match' : `Identified via ${source || 'Perenual'}`}
)} {source === 'failed' && !identifying && (
Couldn't identify
)} {identifying && (
Identifying plant…
)}
{/* thumbnail strip — your photos, removable */} {photos.length > 0 && (
{photos.map((img, i) => (
i!==0 && setPhotos(prev => [prev[i], ...prev.filter((_,idx)=>idx!==i)])} style={{ position:'relative', flexShrink:0, cursor: i!==0 ? 'pointer' : 'default' }}> {i===0 && COVER}
{ e.stopPropagation(); removePhoto(i); }} style={{ position:'absolute', top:-5, right:-5, width:20, height:20, borderRadius:999, background:C.toast, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', boxShadow:'0 1px 3px rgba(0,0,0,0.3)' }}>
))}
)} ); })()} {candidates.length > 0 && (
Not sure — pick the closest:
{candidates.map((c, i) => (
chooseCandidate(c)} style={{ display:'flex', alignItems:'center', justifyContent:'space-between', gap:10, padding:'10px 12px', borderRadius:12, background:C.bg, border:'0.5px solid rgba(45,80,22,0.12)', cursor:'pointer' }}>
{c.commonName || c.scientificName}
{c.scientificName}
{Math.round(c.score*100)}%
))}
)} onNameChange(e.target.value)} placeholder="e.g. Monstera" style={inputStyle()}/> {suggestions.length > 0 && (
{suggestions.map(p => (
pickSuggestion(p)} style={{ display:'flex', alignItems:'center', justifyContent:'space-between', padding:'10px 14px', borderRadius:12, cursor:'pointer', background:C.panel, border:'0.5px solid rgba(45,80,22,0.12)', transition:'background 120ms ease', }}>
{fmtName(p.name)}
{p.latin}
{hasApiKey() ? Fill info : use name}
))}
)} {loadingSpecies && (
Loading care data…
)} setLatin(e.target.value)} placeholder="e.g. Monstera deliciosa" style={{ ...inputStyle(), fontFamily:FONT_SERIF, fontStyle:'italic', fontSize:16 }}/> setCzech(e.target.value)} placeholder="e.g. Monstera děravá" style={inputStyle()}/> {/* location tag input */}
setTyped(e.target.value)} onKeyDown={e=>{ if(e.key==='Enter'){ e.preventDefault(); commitTyped(); } }} placeholder={loc ? '' : 'Type a room or spot…'} style={{ ...inputStyle(), flex:1 }}/>
{/* selected */} {loc && (
Selected: {loc} setLoc('')} style={{ cursor:'pointer', marginLeft:2, opacity:0.8 }}>
)} {/* suggestions */}
Previously used
{locations.map(l => { const on = loc===l; return (
{ setLoc(l); setTyped(''); }} style={{ cursor:'pointer', display:'inline-flex', alignItems:'center', gap:5, borderRadius:999, padding:'7px 13px', background: on ? 'rgba(122,158,78,0.16)' : C.panel, border: on ? '1px solid rgba(110,154,62,0.5)' : '0.5px solid rgba(45,80,22,0.14)', fontFamily:FONT_SANS, fontSize:12.5, fontWeight:500, color: on?C.forest:C.ink, transition:'all 140ms ease', }}> {l}
); })}
Every { const v=e.target.value; if(v==='') return setEvery(''); const n=parseInt(v,10); if(!isNaN(n)) setEvery(Math.min(365, Math.max(1, n))); }} onBlur={()=>{ if(every==='' || every<1) setEvery(7); }} style={{ ...inputStyle(), width:88 }}/> days
setLight(e.target.value)} placeholder="e.g. Bright, indirect" style={inputStyle()}/>