// ════════════════════════════════════════════════════════════ // Caulis — screens + bottom navigation // ════════════════════════════════════════════════════════════ const PRINT_SIZES = [['S', 30], ['M', 40], ['L', 55]]; // device-local persistence for view prefs (never synced to a garden node) const GS = { get: (k, f) => { try { const v = localStorage.getItem(k); return v != null ? JSON.parse(v) : f; } catch(e) { return f; } }, set: (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch(e) {} }, }; // collapsible settings category — module-level so children keep identity (no remount) function SettingsSection({ title, open, onToggle, children }) { return (
{title}
{children}
); } // pointer-based reorder via a drag handle — nearest-center targeting works for // both vertical lists and grids. Reorders by array position only; ids untouched. function useReorder(onReorder) { const containerRef = useRef(null); const dragRef = useRef(null), overRef = useRef(null); const [dragIdx, setDragIdx] = useState(null); const [overIdx, setOverIdx] = useState(null); const start = (i) => (e) => { e.stopPropagation(); try { e.currentTarget.setPointerCapture(e.pointerId); } catch(_) {} dragRef.current = i; overRef.current = i; setDragIdx(i); setOverIdx(i); }; const move = (e) => { if (dragRef.current == null) return; const cont = containerRef.current; if (!cont) return; let target = dragRef.current, best = Infinity; [...cont.children].forEach((el, k) => { const r = el.getBoundingClientRect(); const dx = e.clientX - (r.left + r.width/2), dy = e.clientY - (r.top + r.height/2); const d = dx*dx + dy*dy; if (d < best) { best = d; target = k; } }); overRef.current = target; setOverIdx(target); }; const end = () => { const from = dragRef.current, to = overRef.current; dragRef.current = null; overRef.current = null; setDragIdx(null); setOverIdx(null); if (from != null && to != null && from !== to) onReorder(from, to); }; const grip = (i) => ({ onPointerDown: start(i), onPointerMove: move, onPointerUp: end, onPointerCancel: end }); return { containerRef, dragIdx, overIdx, grip }; } function GripIcon({ c = C.brown }) { return ( ); } // ── Plant card (Garden grid) ────────────────────────────── function PlantCard({ plant, tint, onOpen, onLongPress, czechMode, grip, dragging, over, selectable, selected, onToggleSelect, compact }) { const [press, setPress] = useState(false); const timer = useRef(null); const longed = useRef(false); const status = statusOf(plant.days, plant.every); const start = () => { if (selectable) return; setPress(true); longed.current = false; timer.current = setTimeout(() => { longed.current = true; setPress(false); onLongPress && onLongPress(plant); }, 480); }; const end = () => { setPress(false); if (timer.current) clearTimeout(timer.current); }; const click = () => { if (selectable) { onToggleSelect(plant.id); return; } if (longed.current) { longed.current = false; return; } onOpen(plant.id); }; return (
{grip && (
e.stopPropagation()} style={{ position:'absolute', top:9, left:9, width:24, height:24, borderRadius:999, background:C.panel, display:'flex', alignItems:'center', justifyContent:'center', boxShadow:'0 1px 2px rgba(43,42,38,0.12)', cursor:'grab', touchAction:'none' }}>
)} {selectable && (
{selected && }
)}
{czechMode && plant.czech ? plant.czech : plant.name}
{plant.location}
{compact ? (plant.days <= 0 ? 'Today' : plant.days === 1 ? '1 day' : `${plant.days} days`) : agoLabel(plant.days)}
); } // ── Shared screen header ────────────────────────────────── function ScreenHead({ eyebrow, title, isDesktop }) { return (
{!isDesktop && (
Caulis
)}
{eyebrow}
{title}
); } // ════════════════════════════════════════════════════════════ // GARDEN // ════════════════════════════════════════════════════════════ function GardenFilterBar({ sort, setSort, sidePad = 22 }) { const filters = [['all','All'],['urgent','Needs water'],['location','Location']]; return (
{filters.map(([key,label]) => { const on = sort === key; return (
setSort(key)} style={{ flexShrink:0, cursor:'pointer', whiteSpace:'nowrap', borderRadius:999, padding:'8px 15px', background: on ? C.forest : C.panel, border: on ? '1px solid '+C.forest : '0.5px solid rgba(45,80,22,0.14)', color: on ? '#fff' : C.ink, fontFamily:FONT_SANS, fontSize:12.5, fontWeight:on?600:500, letterSpacing:0.1, boxShadow: on ? '0 3px 10px rgba(45,80,22,0.18)' : 'none', transition:'all 160ms ease', }}>{label}
); })}
); } function RoomHeader({ room, count }) { return (
{room}
{count}
); } function ContextMenu({ plant, onClose, onEdit, onMove, onRemove, isDesktop }) { const Item = ({ icon, label, danger, onClick }) => (
{icon}
{label}
); return (
e.stopPropagation()} style={{ margin:'0 12px 12px', background:C.bg, borderRadius:24, overflow:'hidden', animation:'slideUp 260ms cubic-bezier(.2,.8,.2,1)', boxShadow:'0 -4px 30px rgba(0,0,0,0.12)' }}>
{plant.name} {plant.location}
} label="Move to another room" onClick={()=>{ onClose(); onMove(plant); }}/> } label="Edit plant" onClick={()=>{ onClose(); onEdit(plant); }}/> } label="Remove plant" onClick={()=>{ onClose(); onRemove(plant.id); }}/>
Cancel
); } function EmptyGarden({ onAdd }) { return (
Your garden is empty
Add your first plant to start tracking watering, light and care.
Add your first plant
); } function GardenScreen({ plants, onOpen, onAdd, onLongPress, onReorder, isDesktop, czechMode, density, gridCols: gridColsPref, hideHealthy, onBulkWater, onBulkQueue, onBulkMove, onBulkRemove, onHaptic }) { const [sort, setSort] = useState(() => GS.get('caulis_g_sort', 'all')); const [q, setQ] = useState(''); const [fStatus, setFStatus] = useState(() => GS.get('caulis_g_status', 'all')); const [fLoc, setFLoc] = useState(() => GS.get('caulis_g_loc', null)); useEffect(() => { GS.set('caulis_g_sort', sort); }, [sort]); useEffect(() => { GS.set('caulis_g_status', fStatus); }, [fStatus]); useEffect(() => { GS.set('caulis_g_loc', fLoc); }, [fLoc]); const [selMode, setSelMode] = useState(false); const [sel, setSel] = useState(() => new Set()); const exitSel = () => { setSelMode(false); setSel(new Set()); }; const toggleSel = (id) => { onHaptic && onHaptic(); setSel(s => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); }; const runBulk = (fn) => { const ids = [...sel]; if (ids.length) fn(ids); exitSel(); }; const re = useReorder(onReorder); const needs = plants.filter(p => statusOf(p.days,p.every) !== 'ok').length; const tintFor = id => TINTS[(id-1)%TINTS.length]; const empty = plants.length === 0; const rooms = [...new Set(plants.map(p => p.location).filter(Boolean))].sort(); const sidePad = isDesktop ? 28 : 18; const topPad = isDesktop ? 32 : 56; const cols = gridColsPref || (density === 'compact' ? 3 : 2); const gridCols = isDesktop ? 'repeat(auto-fill, minmax(185px, 1fr))' : `repeat(${cols}, minmax(0, 1fr))`; const compact = !isDesktop && cols >= 3; const gridGap = compact ? 10 : 14; const nq = q.trim().toLowerCase(); const matched = plants.filter(p => { if (nq && ![p.name, p.czech, p.latin, p.location, p.care, p.fact].some(v => (v||'').toLowerCase().includes(nq))) return false; if (hideHealthy && statusOf(p.days, p.every) === 'ok') return false; if (fStatus !== 'all' && statusOf(p.days, p.every) !== fStatus) return false; if (fLoc && p.location !== fLoc) return false; return true; }); let groups = null, flat = null; if (sort === 'location') { const byRoom = {}; matched.forEach(p => { (byRoom[p.location] = byRoom[p.location] || []).push(p); }); groups = Object.keys(byRoom).sort().map(room => ({ room, items: byRoom[room] })); } else if (sort === 'urgent') { flat = [...matched].sort((a,b) => (b.days/b.every) - (a.days/a.every)); } else { flat = [...matched]; } const cardProps = { onOpen, onLongPress, czechMode, selectable: selMode, onToggleSelect: toggleSel, compact }; return (
{!isDesktop && (
Caulis
)}
{todayGreeting()}
{empty ? <>Welcome to Caulis. : needs > 0 ? <>{needs} plants would love a drink. : <>Everything looks happy today.}
{!empty && (
{ if (selMode) exitSel(); else setSelMode(true); }} style={{ width:38, height:38, borderRadius:999, background: selMode?C.forest:C.panel, border:C.hair, boxShadow:'0 2px 8px rgba(45,80,22,0.06)', display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer' }}>
)}
{empty && } {!empty && (
setQ(e.target.value)} placeholder="Search plants…" style={{ flex:1, border:'none', background:'transparent', outline:'none', fontFamily:FONT_SANS, fontSize:14, color:C.ink }}/> {q &&
setQ('')} style={{ cursor:'pointer', opacity:0.5 }}>
}
)} {!empty && } {!empty && (
{[['all','All'],['needs','Needs'],['soon','Soon'],['ok','Healthy']].map(([k,l]) => { const on = fStatus === k; const col = k === 'all' ? C.forest : STATUS[k].dot; return (
setFStatus(k)} style={{ flexShrink:0, cursor:'pointer', whiteSpace:'nowrap', borderRadius:999, padding:'6px 13px', background: on ? (k==='all' ? C.forest : STATUS[k].soft) : C.panel, border: on ? `1px solid ${col}` : '0.5px solid rgba(45,80,22,0.14)', color: on ? (k==='all' ? '#fff' : col) : C.ink, fontFamily:FONT_SANS, fontSize:12, fontWeight: on?600:500, transition:'all 140ms ease', }}>{l}
); })} {rooms.length > 0 &&
} {rooms.map(r => { const on = fLoc === r; return (
setFLoc(on ? null : r)} style={{ flexShrink:0, cursor:'pointer', whiteSpace:'nowrap', display:'inline-flex', alignItems:'center', gap:5, borderRadius:999, padding:'6px 12px', 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)', color: on ? C.forest : C.ink, fontFamily:FONT_SANS, fontSize:12, fontWeight: on?600:500, transition:'all 140ms ease', }}> {r}
); })}
)} {!empty && matched.length === 0 && (
No matches
{nq ? `Nothing matches "${q}".` : 'No plants match these filters.'}
)} {!empty && sort === 'location' && (
{groups.map(g => (
{g.items.map(p => )}
))}
)} {!empty && sort !== 'location' && (() => { const dragEnabled = sort === 'all' && !nq && fStatus === 'all' && !fLoc && !hideHealthy && !selMode; return (
{flat.map((p,i) => )}
); })()} {selMode && (
{sel.size} {[['Water', onBulkWater], ['Queue', onBulkQueue], ['Move', onBulkMove]].map(([label, fn]) => (
runBulk(fn) : undefined} style={{ cursor: sel.size?'pointer':'default', opacity: sel.size?1:0.4, padding:'8px 11px', borderRadius:999, fontFamily:FONT_SANS, fontSize:13, fontWeight:600, color:C.forest, whiteSpace:'nowrap' }}>{label}
))}
runBulk(onBulkRemove) : undefined} style={{ cursor: sel.size?'pointer':'default', opacity: sel.size?1:0.4, padding:'8px 11px', borderRadius:999, fontFamily:FONT_SANS, fontSize:13, fontWeight:600, color:'#B4472E' }}>Delete
)}
); } // ════════════════════════════════════════════════════════════ // NEEDS WATER // ════════════════════════════════════════════════════════════ function NeedsRow({ plant, tint, onOpen, onLongPress, onSnooze, czechMode }) { const [press, setPress] = useState(false); const [dx, setDx] = useState(0); const timer = useRef(null); const longed = useRef(false); const startX = useRef(0), startY = useRef(0); const swiping = useRef(false), openRef = useRef(false), dxRef = useRef(0); const status = statusOf(plant.days, plant.every); const OPEN = -84; const setX = (v) => { dxRef.current = v; setDx(v); }; const start = (e) => { setPress(true); longed.current = false; swiping.current = false; startX.current = e.clientX; startY.current = e.clientY; try { e.currentTarget.setPointerCapture(e.pointerId); } catch(_) {} timer.current = setTimeout(() => { longed.current = true; setPress(false); onLongPress && onLongPress(plant); }, 480); }; const move = (e) => { const mx = e.clientX - startX.current, my = e.clientY - startY.current; if (!swiping.current && Math.abs(mx) > 8 && Math.abs(mx) > Math.abs(my)) { swiping.current = true; if (timer.current) clearTimeout(timer.current); setPress(false); } if (swiping.current) setX(Math.max(OPEN, Math.min(0, (openRef.current ? OPEN : 0) + mx))); }; const end = () => { setPress(false); if (timer.current) clearTimeout(timer.current); if (swiping.current) { const open = dxRef.current < OPEN/2; openRef.current = open; setX(open ? OPEN : 0); swiping.current = false; } }; const click = () => { if (longed.current) { longed.current = false; return; } if (openRef.current || dxRef.current !== 0) { openRef.current = false; setX(0); return; } onOpen(plant.id); }; const doSnooze = (e) => { e.stopPropagation(); onSnooze && onSnooze(plant.id, 2); openRef.current = false; setX(0); }; return (
+2d
{czechMode && plant.czech ? plant.czech : plant.name}
{agoLabel(plant.days)} · {plant.location}
); } function NeedsWaterScreen({ plants, onOpen, onLongPress, onSnooze, onWaterAll, confirmDelete, isDesktop, czechMode }) { const order = { needs:0, soon:1 }; const list = plants.filter(p=>statusOf(p.days,p.every)!=='ok') .sort((a,b)=> order[statusOf(a.days,a.every)] - order[statusOf(b.days,b.every)]); const sp = isDesktop ? 28 : 18; const [confirming, setConfirming] = useState(false); const doWaterAll = () => { if (confirming) { onWaterAll && onWaterAll(); setConfirming(false); } else { setConfirming(true); setTimeout(()=>setConfirming(false), 3200); } }; return (
{plants.length > 0 && (
{confirming ? `Water all ${plants.length}?` : 'Water all'}
)}
{list.length === 0 && (
Nothing to water
Every plant is happily hydrated.
)} {list.map((p,i)=> )}
); } // ════════════════════════════════════════════════════════════ // QR SCANNER (primary action) // ════════════════════════════════════════════════════════════ function Viewfinder() { return (
{[['top','left'],['top','right'],['bottom','left'],['bottom','right']].map(([v,h],i)=>(
))}
); } function ScannerScreen({ plants, onScan, isDesktop, paused }) { const [camError, setCamError] = useState(null); const [scanning, setScanning] = useState(false); const scannedRef = useRef(false); const scannerRef = useRef(null); useEffect(() => { if (isDesktop) return; const s = scannerRef.current; if (!s) return; try { paused ? s.pause(true) : s.resume(); } catch(e) {} if (!paused) scannedRef.current = false; }, [paused]); useEffect(() => { if (isDesktop) return; if (typeof Html5Qrcode === 'undefined') { setCamError('QR scanner script was blocked by the browser. Please check ad/tracker blockers.'); return; } scannedRef.current = false; const scanner = new Html5Qrcode('caulis-qr-reader'); scannerRef.current = scanner; scanner.start( { facingMode: 'environment' }, { fps: 10, qrbox: { width: 220, height: 220 } }, (text) => { if (scannedRef.current) return; const m = text.match(/[?&]plant=(\d+)/); const gm = text.match(/[?&]g=([^&\s]+)/); if (m) { scannedRef.current = true; onScan(parseInt(m[1], 10), gm ? decodeURIComponent(gm[1]) : null); } }, () => {} ).then(() => setScanning(true)).catch(() => setCamError('Camera access denied')); return () => { scannerRef.current = null; scanner.stop().then(() => scanner.clear()).catch(() => {}); }; }, [isDesktop]); return (
Scan a plant tag
{camError || (scanning ? 'Point at a Caulis QR code' : 'Starting camera…')}
); } // ════════════════════════════════════════════════════════════ // PRINT QUEUE // ════════════════════════════════════════════════════════════ function QueueRow({ plant, onOpen, onRemove, sizeMm, globalMm, onSetSize, czechMode, grip, dragging, over }) { return (
onOpen(plant.id)} style={{ flex:1, minWidth:0, cursor:'pointer' }}>
{czechMode && plant.czech ? plant.czech : plant.name}
{plant.latin}
{PRINT_SIZES.map(([label, mm]) => { const isOverride = sizeMm === mm; const isGlobal = !sizeMm && mm === globalMm; return (
onSetSize(plant.id, isOverride ? null : mm)} style={{ cursor:'pointer', width:26, height:22, borderRadius:6, background: isOverride ? C.forest : isGlobal ? 'rgba(45,80,22,0.18)' : 'transparent', color: isOverride ? '#fff' : C.ink, display:'flex', alignItems:'center', justifyContent:'center', fontFamily:FONT_SANS, fontSize:10, fontWeight:600, opacity: isOverride || isGlobal ? 1 : 0.32, transition:'all 120ms ease', }}>{label}
); })}
onRemove(plant.id)} style={{ cursor:'pointer', width:30, height:30, borderRadius:999, display:'flex', alignItems:'center', justifyContent:'center', color:C.brown, opacity:0.5, flexShrink:0 }}>
); } function PrintQueueScreen({ queue, plants, onOpen, onRemove, onPrintAll, printed, isDesktop, globalPrintSize, onSetGlobalSize, queueSizes, onSetSize, onReorder, monochromePrint, onToggleMono, czechMode }) { const items = queue.map(id => plants.find(p=>p.id===id)).filter(Boolean); const re = useReorder(onReorder); const sp = isDesktop ? 28 : 22; const tp = isDesktop ? 32 : 56; return (
Print queue
{items.length} {items.length===1?'tag':'tags'} ready
{items.length>0 && (
{printed ? : } {printed?'Sent':'Print all'}
)}
{items.length>0 && (
Size
{PRINT_SIZES.map(([label, mm]) => { const on = globalPrintSize === mm; return (
onSetGlobalSize(mm)} style={{ cursor:'pointer', width:32, height:26, borderRadius:6, background: on ? C.forest : 'transparent', color: on ? '#fff' : C.ink, display:'flex', alignItems:'center', justifyContent:'center', fontFamily:FONT_SANS, fontSize:11.5, fontWeight:600, opacity: on ? 1 : 0.45, transition:'all 140ms ease', }}>{label}
); })}
{globalPrintSize}mm
Mono
)} {items.length===0 && (
Queue is empty
Open a plant and tap "Add to print queue" to label it.
)}
{items.map((p,i) => )}
); } // ════════════════════════════════════════════════════════════ // SETTINGS // ════════════════════════════════════════════════════════════ function SettingsScreen({ plants, isDesktop, gardenKey, gardenHistory, onRemoveHistory, onSetGardenKey, onRenameGardenKey, installPrompt, onInstall, darkMode, onToggleDark, gardenPassword, onSavePassword, perenualKey, onSavePerenualKey, housePlantsKey, onSaveHousePlantsKey, anthropicKey, onSaveAnthropicKey, onRecheckAI, aiRecheck, plantIdKey, onSavePlantIdKey, identifyLang, onSetIdentifyLang, defaultEvery, onSetDefaultEvery, globalPrintSize, onSetGlobalSize, monochromePrint, onToggleMono, googleClientId, onSaveGoogleClientId, googleToken, onConnectGoogle, onSyncCalendar, onDisconnectGoogle, googleSyncMode, onSetGoogleSyncMode, reminderTime, onSetReminderTime, onUpdateApp, onExport, onImport, cardDensity, onSetDensity, hideHealthy, onToggleHideHealthy, reduceMotion, onToggleReduceMotion, confirmDelete, onToggleConfirmDelete, haptics, onToggleHaptics, defaultTab, onSetDefaultTab, swipeNav, onToggleSwipeNav, onWaterAll, onDevOffsetDays, onDevSetDays, onDevResyncFromHistory, onAdminListGardens, onAdminLoadGarden, onAdminSaveGarden, onAdminRemoveGarden, navConfig, onSetNavConfig, navLabels, onToggleNavLabels, gridCols, onSetGridCols, sidebar, onSetSidebar, palette, onSetPalette, doctorModel, onSetDoctorModel }) { const [openSecs, setOpenSecs] = useState(() => GS.get('caulis_set_open', {})); const isOpen = (id) => openSecs[id] !== false; const toggleSec = (id) => setOpenSecs(s => { const n = { ...s, [id]: s[id] === false }; GS.set('caulis_set_open', n); return n; }); const [key, setKey] = useState(''); const [saved, setSaved] = useState(false); const [housePlantsInput, setHousePlantsInput] = useState(''); const [housePlantsSaved, setHousePlantsSaved] = useState(false); const [plantIdInput, setPlantIdInput] = useState(''); const [plantIdSaved, setPlantIdSaved] = useState(false); const [anthropicInput, setAnthropicInput] = useState(''); const [anthropicSaved, setAnthropicSaved] = useState(false); const [gcalInput, setGcalInput] = useState(''); const [gcalSaved, setGcalSaved] = useState(false); const [gcalSyncing, setGcalSyncing] = useState(false); const [updating, setUpdating] = useState(false); const [importData, setImportData] = useState(null); const [importErr, setImportErr] = useState(false); const [imported, setImported] = useState(false); const importRef = useRef(null); const onImportFile = (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; if (!f) return; setImportErr(false); const reader = new FileReader(); reader.onload = ev => { try { const d = JSON.parse(ev.target.result); if (!d || !Array.isArray(d.plants)) throw 0; setImportData(d); } catch(_) { setImportErr(true); } }; reader.readAsText(f); }; const doImport = (mode) => { if (onImport(importData, mode)) { setImportData(null); setImported(true); setTimeout(()=>setImported(false), 1800); } }; const handleGcalSync = async () => { setGcalSyncing(true); await onSyncCalendar(); setGcalSyncing(false); }; const sp = isDesktop ? 28 : 18; const [renaming, setRenaming] = useState(false); const [renameKey, setRenameKey] = useState(''); const [renameStatus, setRenameStatus] = useState('idle'); const [joining, setJoining] = useState(false); const [joinKey, setJoinKey] = useState(''); const [copied, setCopied] = useState(false); const [settingPassword, setSettingPassword] = useState(false); const [newPassword, setNewPassword] = useState(''); const [joinPassword, setJoinPassword] = useState(''); const [joinStatus, setJoinStatus] = useState('idle'); // 'idle' | 'checking' | 'notFound' const [devRevealed, setDevRevealed] = useState(() => { try { return localStorage.getItem('caulis_dev_revealed') === '1'; } catch(e) { return false; } }); const [verTaps, setVerTaps] = useState(0); const verTapTimer = useRef(null); const tapVersion = () => { if (devRevealed) return; if (verTapTimer.current) clearTimeout(verTapTimer.current); verTapTimer.current = setTimeout(() => setVerTaps(0), 1500); setVerTaps(t => { const n = t + 1; if (n >= 7) { try { localStorage.setItem('caulis_dev_revealed', '1'); } catch(e) {} setDevRevealed(true); } return n; }); }; const [devAuthed, setDevAuthed] = useState(false); const [pinInput, setPinInput] = useState(''); const [pinErr, setPinErr] = useState(false); const submitPin = () => { let stored = ''; try { stored = localStorage.getItem('caulis_dev_pin') || ''; } catch(e) {} if (!stored) { try { localStorage.setItem('caulis_dev_pin', pinInput); } catch(e) {} setDevAuthed(true); setPinInput(''); return; } if (pinInput === stored) { setDevAuthed(true); setPinInput(''); setPinErr(false); } else { setPinErr(true); } }; const lockDev = () => { try { localStorage.removeItem('caulis_dev_revealed'); } catch(e) {} setDevRevealed(false); setDevAuthed(false); setVerTaps(0); }; const [devOffsetN, setDevOffsetN] = useState(1); const [resyncMsg, setResyncMsg] = useState(null); const resyncFromHistory = () => { const fixed = onDevResyncFromHistory(); setResyncMsg(fixed ? `Fixed ${fixed} plant${fixed===1?'':'s'} from the watering log` : 'Already matched the watering log'); setTimeout(() => setResyncMsg(null), 3000); }; const [adminSecret, setAdminSecretState] = useState(() => { try { return localStorage.getItem('caulis_admin_secret') || ''; } catch(e) { return ''; } }); const setAdminSecret = (v) => { setAdminSecretState(v); try { localStorage.setItem('caulis_admin_secret', v); } catch(e) {} }; const [adminGardens, setAdminGardens] = useState(null); const [adminListStatus, setAdminListStatus] = useState('idle'); // idle|loading|error const [adminLoaded, setAdminLoaded] = useState(null); // { key, data, plants } const [adminStatus, setAdminStatus] = useState('idle'); // idle|loading|loaded|empty|error|pushing|pushed const [adminOffsetN, setAdminOffsetN] = useState(1); const listAdminGardens = async () => { setAdminListStatus('loading'); const gardens = await onAdminListGardens(adminSecret); if (!gardens) { setAdminListStatus('error'); return; } setAdminGardens(gardens); setAdminListStatus('idle'); }; const loadAdminGarden = async (key) => { setAdminStatus('loading'); const { data } = await onAdminLoadGarden(adminSecret, key); if (!data || !Array.isArray(data.plants)) { setAdminLoaded({ key, data: data || {}, plants: [] }); setAdminStatus('empty'); return; } setAdminLoaded({ key, data, plants: data.plants.map(p => ({ ...p })) }); setAdminStatus('loaded'); }; const adminShiftAll = (n) => setAdminLoaded(nl => nl && ({ ...nl, plants: nl.plants.map(p => { const wa = (typeof p.wateredAt === 'number' ? p.wateredAt : todayMidnight()) - n * 86400000; return { ...p, wateredAt: wa, wv: WATER_SCHEMA, days: daysSinceMidnight(wa) }; }) })); const adminWaterAll = () => setAdminLoaded(nl => nl && ({ ...nl, plants: nl.plants.map(p => { const wa = todayMidnight(); return { ...p, wateredAt: wa, wv: WATER_SCHEMA, days: 0, history: [...(p.history||[]), fmtLocalDate(new Date())].slice(-60) }; }) })); const adminSetDays = (id, d) => setAdminLoaded(nl => nl && ({ ...nl, plants: nl.plants.map(p => { if (p.id !== id) return p; const dd = Math.max(0, d | 0); const wa = todayMidnight() - dd * 86400000; return { ...p, wateredAt: wa, wv: WATER_SCHEMA, days: dd }; }) })); const pushAdminGarden = async () => { if (!adminLoaded) return; setAdminStatus('pushing'); const clean = adminLoaded.plants.map(({ photos, userImage, ...rest }) => rest); await onAdminSaveGarden(adminSecret, adminLoaded.key, { ...adminLoaded.data, plants: clean }); setAdminStatus('pushed'); setTimeout(() => setAdminStatus('loaded'), 1800); }; const deleteAdminGarden = async () => { if (!adminLoaded) return; await onAdminRemoveGarden(adminSecret, adminLoaded.key); setAdminLoaded(null); setAdminStatus('idle'); listAdminGardens(); }; const copyKey = () => { navigator.clipboard.writeText(gardenKey).catch(()=>{}); setCopied(true); setTimeout(() => setCopied(false), 1500); }; const checkRename = async () => { const k = renameKey.trim(); if (!k || k === gardenKey) return; setRenameStatus('checking'); const exists = await gardenExists(k); setRenameStatus(exists ? 'taken' : 'available'); }; const doRename = async () => { const k = renameKey.trim(); if (!k || k === gardenKey) return; setRenameStatus('saving'); const ok = await onRenameGardenKey(k); setRenameStatus(ok ? 'done' : 'error'); if (ok) setTimeout(() => { setRenaming(false); setRenameKey(''); setRenameStatus('idle'); }, 1200); }; const resetJoin = () => { setJoining(false); setJoinKey(''); setJoinPassword(''); setJoinStatus('idle'); }; // single step: key + (optional) password derive the node. If nothing is // stored there, the key/password pair is wrong or the garden is empty. const submitJoin = async (force = false) => { const k = joinKey.trim(); if (!k) return; if (SYNC_READY && !force) { setJoinStatus('checking'); const node = await gardenNodeId(k, joinPassword); const data = await fetchGardenOnce(node); if (!data) { setJoinStatus('notFound'); return; } } onSetGardenKey(k, joinPassword); resetJoin(); }; const Row = ({ label, value, last }) => (
{label} {value}
); const Toggle = ({ on }) => (
); return (
toggleSec('appearance')}>
Dark mode
Botanical night theme
Accent color
Theme color for buttons, icons & highlights
{PALETTE_ORDER.map(key => { const p = PALETTES[key]; const on = palette === key; return (
onSetPalette(key)} style={{ display:'flex', flexDirection:'column', alignItems:'center', gap:5, cursor:'pointer' }}>
{p.label}
); })}
Reduce motion
Disable swipe & transition animations
Card density
Garden grid layout
{[['comfy','Comfy'],['compact','Compact']].map(([val,label]) => { const on = cardDensity === val; return (
onSetDensity(val)} style={{ cursor:'pointer', padding:'5px 12px', borderRadius:6, background:on?C.forest:'transparent', color:on?'#fff':C.ink, fontFamily:FONT_SANS, fontSize:11.5, fontWeight:600, opacity:on?1:0.5, transition:'all 140ms ease' }}>{label}
); })}
{!isDesktop && (
Garden columns
Override the grid width
{[[0,'Auto'],[2,'2'],[3,'3'],[4,'4']].map(([val,label]) => { const on = (gridCols || 0) === val; return (
onSetGridCols(val)} style={{ cursor:'pointer', padding:'5px 11px', borderRadius:6, background:on?C.forest:'transparent', color:on?'#fff':C.ink, fontFamily:FONT_SANS, fontSize:11.5, fontWeight:600, opacity:on?1:0.5, transition:'all 140ms ease' }}>{label}
); })}
)}
toggleSec('garden')}>
p.location)).size)}/>
Default watering
For new plants without species data
onSetDefaultEvery(Math.max(1, defaultEvery - 1))} style={{ width:28, height:28, borderRadius:8, background:'rgba(45,80,22,0.08)', display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', fontFamily:FONT_SANS, fontSize:18, color:C.forest, fontWeight:500, userSelect:'none', WebkitUserSelect:'none' }}>−
{defaultEvery}d
onSetDefaultEvery(Math.min(365, defaultEvery + 1))} style={{ width:28, height:28, borderRadius:8, background:'rgba(45,80,22,0.08)', display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', fontFamily:FONT_SANS, fontSize:18, color:C.forest, fontWeight:500, userSelect:'none', WebkitUserSelect:'none' }}>+
Hide healthy plants
Garden shows only soon & thirsty plants
Opens on launch
Tab shown when you start Caulis
{(() => { const seen = new Set(); const tabs = normalizeNav(navConfig).filter(s => s.action !== 'empty' && NAV_ACTIONS[s.action] && NAV_ACTIONS[s.action].tab && (seen.has(s.action) ? false : seen.add(s.action))); return tabs.map(s => { const on = defaultTab === s.action; return
onSetDefaultTab(s.action)} style={{ flexShrink:0, cursor:'pointer', padding:'6px 13px', borderRadius:999, background:on?C.forest:C.input, color:on?'#fff':C.ink, fontFamily:FONT_SANS, fontSize:12, fontWeight:on?600:500, transition:'all 140ms ease' }}>{navLabel(s)}
; }); })()}
toggleSec('behavior')}>
Confirm before delete
Ask before removing a plant
Swipe between tabs
Horizontal swipe switches screens (mobile)
Haptics
Vibrate on water, snooze & delete (mobile)
toggleSec('notif')}>
Watering reminders
Weekly garden digest
toggleSec('printing')}>
Label size
{globalPrintSize} × {globalPrintSize} mm default
{PRINT_SIZES.map(([label, mm]) => { const on = globalPrintSize === mm; return (
onSetGlobalSize(mm)} style={{ cursor:'pointer', width:32, height:26, borderRadius:6, background: on ? C.forest : 'transparent', color: on ? '#fff' : C.ink, display:'flex', alignItems:'center', justifyContent:'center', fontFamily:FONT_SANS, fontSize:11.5, fontWeight:600, opacity: on ? 1 : 0.45, transition:'all 140ms ease', }}>{label}
); })}
Monochrome
Black & white output
toggleSec('data')}>
Perenual — species photos & care data
setKey(e.target.value)} placeholder="API key" style={{ flex:1, boxSizing:'border-box', height:42, borderRadius:11, border:'1px solid rgba(45,80,22,0.14)', background:C.input, padding:'0 13px', fontFamily:'ui-monospace, Menlo, monospace', fontSize:12.5, color:C.ink, outline:'none' }}/>
{ onSavePerenualKey(key.trim()); setSaved(true); setTimeout(()=>setSaved(false),1800); }} style={{ flexShrink:0, padding:'0 14px', height:42, borderRadius:11, background: saved?C.sage:C.forest, color:'#fff', display:'flex', alignItems:'center', gap:6, cursor:'pointer', transition:'background 200ms' }}> {saved && } {saved?'Saved':'Save'}
{perenualKey ? 'Live mode' : 'Using built-in library'}
House Plants API — fallback data
RapidAPI key for FreeWebApi House Plants. Used when Perenual hits rate limits.
setHousePlantsInput(e.target.value)} placeholder="RapidAPI key" style={{ flex:1, boxSizing:'border-box', height:42, borderRadius:11, border:'1px solid rgba(45,80,22,0.14)', background:C.input, padding:'0 13px', fontFamily:'ui-monospace, Menlo, monospace', fontSize:12.5, color:C.ink, outline:'none' }}/>
{ onSaveHousePlantsKey(housePlantsInput.trim()); setHousePlantsSaved(true); setTimeout(()=>setHousePlantsSaved(false),1800); }} style={{ flexShrink:0, padding:'0 14px', height:42, borderRadius:11, background: housePlantsSaved?C.sage:C.forest, color:'#fff', display:'flex', alignItems:'center', gap:6, cursor:'pointer', transition:'background 200ms' }}> {housePlantsSaved && } {housePlantsSaved?'Saved':'Save'}
{housePlantsKey ? 'Fallback active' : 'Not configured — Wikipedia images used as last resort'}
PlantNet — photo identification
setPlantIdInput(e.target.value)} placeholder="API key" style={{ flex:1, boxSizing:'border-box', height:42, borderRadius:11, border:'1px solid rgba(45,80,22,0.14)', background:C.input, padding:'0 13px', fontFamily:'ui-monospace, Menlo, monospace', fontSize:12.5, color:C.ink, outline:'none' }}/>
{ onSavePlantIdKey(plantIdInput.trim()); setPlantIdSaved(true); setTimeout(()=>setPlantIdSaved(false),1800); }} style={{ flexShrink:0, padding:'0 14px', height:42, borderRadius:11, background: plantIdSaved?C.sage:C.forest, color:'#fff', display:'flex', alignItems:'center', gap:6, cursor:'pointer', transition:'background 200ms' }}> {plantIdSaved && } {plantIdSaved?'Saved':'Save'}
{plantIdKey ? 'Identification active' : 'No key — using demo mode'}
Claude — AI care review
Anthropic API key. Claude reviews & corrects species care data on identify — filling gaps and fixing wrong watering intervals. Key is stored on this device; expose only in a prototype.
setAnthropicInput(e.target.value)} placeholder="sk-ant-…" style={{ flex:1, boxSizing:'border-box', height:42, borderRadius:11, border:'1px solid rgba(45,80,22,0.14)', background:C.input, padding:'0 13px', fontFamily:'ui-monospace, Menlo, monospace', fontSize:12.5, color:C.ink, outline:'none' }}/>
{ onSaveAnthropicKey(anthropicInput.trim()); setAnthropicSaved(true); setTimeout(()=>setAnthropicSaved(false),1800); }} style={{ flexShrink:0, padding:'0 14px', height:42, borderRadius:11, background: anthropicSaved?C.sage:C.forest, color:'#fff', display:'flex', alignItems:'center', gap:6, cursor:'pointer', transition:'background 200ms' }}> {anthropicSaved && } {anthropicSaved?'Saved':'Save'}
{anthropicKey ? 'AI review active' : 'No key — using raw source data'}
{anthropicKey && (plants || []).some(p => !p.aiV) && (() => { const pending = (plants || []).filter(p => !p.aiV).length; const busy = aiRecheck && aiRecheck.busy; return (
{busy ? `Reviewing… ${aiRecheck.done}/${aiRecheck.total}` : `Recheck ${pending} older plant${pending===1?'':'s'} with AI`}
); })()}
Doctor model
Haiku is cheapest; Sonnet reads photos more carefully.
{[['claude-haiku-4-5','Haiku'],['claude-sonnet-4-6','Sonnet']].map(([id, label]) => { const on = (doctorModel || 'claude-haiku-4-5') === id; return (
onSetDoctorModel(id)} style={{ cursor:'pointer', padding:'5px 16px', borderRadius:6, background: on ? C.forest : 'transparent', fontFamily:FONT_SANS, fontSize:12.5, fontWeight:600, color: on ? '#fff' : C.brown, transition:'background 180ms' }}>{label}
); })}
Name language
{[['en','English'],['cs','Česky']].map(([code, label]) => { const on = identifyLang === code; return (
onSetIdentifyLang(code)} style={{ cursor:'pointer', padding:'5px 16px', borderRadius:6, background: on ? C.forest : 'transparent', color: on ? '#fff' : C.ink, fontFamily:FONT_SANS, fontSize:13, fontWeight:600, opacity: on ? 1 : 0.45, transition:'all 140ms ease', }}>{label}
); })}
{identifyLang === 'cs' ? 'Identified names filled in Czech. Care data stays in English.' : 'Identified names filled in English.'}
{(installPrompt || /iphone|ipad|ipod/i.test(navigator.userAgent)) && ( toggleSec('app')}>
{installPrompt ? (
Install Caulis
Add to your home screen
Install
) : (
Add to Home Screen
Tap ShareAdd to Home Screen in Safari to install Caulis.
)}
)} toggleSec('google')}>
Sync to
{[['tasks','Tasks'],['calendar','Calendar']].map(([val,label]) => { const on = googleSyncMode === val; return (
onSetGoogleSyncMode(val)} style={{ flex:1, height:32, borderRadius:7, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', background: on?C.forest:'transparent', color: on?'#fff':C.ink, opacity: on?1:0.5, fontFamily:FONT_SANS, fontSize:12.5, fontWeight:600, transition:'all 140ms ease' }}>{label}
); })}
{googleSyncMode === 'calendar' ? `Own togglable "Caulis Plants" calendar, recurring reminders at ${reminderTime}.` : 'Checkable tasks in a "Caulis Plants" list. Tick them off in Google.'}
{googleSyncMode === 'calendar' && (() => { const [h, m] = (reminderTime || '09:00').split(':').map(Number); const step = (dir) => { let t = h*60 + m + dir*30; t = Math.max(0, Math.min(1410, t)); onSetReminderTime(`${String(Math.floor(t/60)).padStart(2,'0')}:${String(t%60).padStart(2,'0')}`); }; const Btn = ({label, on}) =>
{label}
; return (
Reminder time
step(-1)}/> {reminderTime} step(1)}/>
); })()}
{!googleToken ? (<>
Paste your OAuth 2.0 web client ID from Google Cloud Console.
setGcalInput(e.target.value)} placeholder="OAuth client ID" style={{ flex:1, boxSizing:'border-box', height:42, borderRadius:11, border:C.hair, background:C.input, padding:'0 12px', fontFamily:'ui-monospace,Menlo,monospace', fontSize:11.5, color:C.ink, outline:'none' }}/>
{ onSaveGoogleClientId(gcalInput.trim()); setGcalSaved(true); setTimeout(()=>setGcalSaved(false),1800); }} style={{ flexShrink:0, padding:'0 14px', height:42, borderRadius:11, background: gcalSaved?C.sage:C.forest, color:'#fff', display:'flex', alignItems:'center', gap:6, cursor:'pointer', transition:'background 200ms' }}> {gcalSaved && } {gcalSaved?'Saved':'Save'}
{googleClientId && (
Connect Google {googleSyncMode === 'calendar' ? 'Calendar' : 'Tasks'}
)}
{googleClientId ? 'Client ID saved — tap Connect' : 'Not configured'}
) : (<>
Connected
Disconnect
{gcalSyncing ?
: } {gcalSyncing ? 'Syncing…' : (googleSyncMode === 'calendar' ? 'Sync all reminders' : 'Sync all tasks')}
{googleSyncMode === 'calendar' ? `Recurring reminders update when you mark a plant as watered, at ${reminderTime} on the optimal day.` : 'Tasks update when you mark a plant as watered, due on the optimal day. Switching mode re-syncs everything.'}
)}
toggleSec('cloud')}> {!SYNC_READY && (
Firebase not configured. Fill in FIREBASE_CONFIG in caulis-firebase.jsx.
)} {SYNC_READY && (
Garden key
{gardenKey}
{copied ? : }
Password
{gardenPassword ? 'Protected' : 'None'}
{!settingPassword && (
{ setSettingPassword(true); setNewPassword(''); }} style={{ flexShrink:0, padding:'6px 14px', borderRadius:10, background:'rgba(45,80,22,0.08)', cursor:'pointer', fontFamily:FONT_SANS, fontSize:12.5, fontWeight:600, color:C.forest }}> {gardenPassword ? 'Change' : 'Set'}
)}
{settingPassword && (
{gardenPassword ? 'New password. Leave empty to remove protection.' : 'Prevent others from joining without a password.'}
setNewPassword(e.target.value)} placeholder="Password…" style={{ boxSizing:'border-box', height:42, borderRadius:10, border:C.hair, background:C.input, padding:'0 12px', fontFamily:FONT_SANS, fontSize:14, color:C.ink, outline:'none' }}/>
{ setSettingPassword(false); setNewPassword(''); }} style={{ flex:1, height:36, borderRadius:10, border:C.hair, color:C.brown, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', fontFamily:FONT_SANS, fontSize:13 }}>Cancel
{ onSavePassword(newPassword.trim()); setSettingPassword(false); setNewPassword(''); }} style={{ flex:2, height:36, borderRadius:10, background:C.forest, color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', fontFamily:FONT_SANS, fontSize:13, fontWeight:600 }}>Save
)}
{!renaming && !joining && (
{ setRenaming(true); setRenameKey(''); setRenameStatus('idle'); }} style={{ flex:1, height:38, borderRadius:12, background:'rgba(45,80,22,0.08)', color:C.forest, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', fontFamily:FONT_SANS, fontSize:13, fontWeight:600 }}>Rename
{ setJoining(true); setJoinKey(''); setJoinPassword(''); setJoinStatus('idle'); }} style={{ flex:1, height:38, borderRadius:12, background:'rgba(45,80,22,0.08)', color:C.forest, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', fontFamily:FONT_SANS, fontSize:13, fontWeight:600 }}>Join garden
)} {renaming && (
Rename keeps your current plants under a new key.
{ setRenameKey(e.target.value); setRenameStatus('idle'); }} onKeyDown={e=>{ if(e.key==='Enter') checkRename(); }} placeholder="new-garden-name" style={{ flex:1, boxSizing:'border-box', height:42, borderRadius:10, border:C.hair, background:C.input, padding:'0 12px', fontFamily:'ui-monospace,Menlo,monospace', fontSize:12.5, color:C.ink, outline:'none' }}/>
Check
{renameStatus==='checking' &&
Checking…
} {renameStatus==='available' &&
✓ Available
} {renameStatus==='taken' &&
⚠ Key already taken — renaming will overwrite it.
} {renameStatus==='error' &&
Something went wrong. Try again.
} {renameStatus==='done' &&
✓ Renamed
}
{ setRenaming(false); setRenameKey(''); setRenameStatus('idle'); }} style={{ flex:1, height:38, borderRadius:10, border:C.hair, color:C.brown, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', fontFamily:FONT_SANS, fontSize:13 }}>Cancel
{(renameStatus==='available'||renameStatus==='taken') && (
{renameStatus==='saving'?'Saving…':renameStatus==='taken'?'Overwrite & rename':'Rename'}
)}
)} {joining && (
{joinStatus==='notFound' ? "No garden found. Create it now?" : "Enter the garden key and its password (if any). Your current plants will be replaced."}
{ setJoinKey(e.target.value); setJoinStatus('idle'); }} placeholder="garden-key" style={{ boxSizing:'border-box', height:42, borderRadius:10, border:C.hair, background:C.input, padding:'0 12px', fontFamily:'ui-monospace,Menlo,monospace', fontSize:12.5, color:C.ink, outline:'none' }}/> { setJoinPassword(e.target.value); setJoinStatus('idle'); }} onKeyDown={e=>{ if(e.key==='Enter') submitJoin(); }} placeholder="Password (leave empty if none)" style={{ boxSizing:'border-box', height:42, borderRadius:10, border:C.hair, background:C.input, padding:'0 12px', fontFamily:FONT_SANS, fontSize:14, color:C.ink, outline:'none' }}/> {joinStatus==='checking' &&
Checking…
}
Cancel
submitJoin(joinStatus==='notFound')} style={{ flex:2, height:38, borderRadius:10, background:joinStatus==='notFound'?'#C98A2B':C.forest, color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', fontFamily:FONT_SANS, fontSize:13, fontWeight:600 }}> {joinStatus==='notFound'?'Create Garden':'Join'}
)} {gardenHistory && gardenHistory.length > 1 && !joining && !renaming && (
Previous Gardens
{gardenHistory.map(h => (
onSetGardenKey(h.key, h.password)} style={{ flex:1, height:34, padding:'0 12px', borderRadius:8, background:'rgba(45,80,22,0.05)', display:'flex', alignItems:'center', justifyContent:'space-between', cursor:'pointer' }}> {h.key} {h.key === gardenKey && }
{h.key !== gardenKey && (
onRemoveHistory(h.key)} style={{ width:34, height:34, borderRadius:8, background:'rgba(180,71,46,0.06)', display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer' }}> ×
)}
))}
)}
Syncing — {gardenKey}
)}
toggleSec('backup')}>
Export your whole garden (plants, photos, queue) to a JSON file, or restore from one.
Export
{ setImportErr(false); importRef.current && importRef.current.click(); }} style={{ flex:1, height:42, borderRadius:12, background: imported?C.sage:'rgba(45,80,22,0.08)', color: imported?'#fff':C.forest, display:'flex', alignItems:'center', justifyContent:'center', gap:8, cursor:'pointer', transition:'background 200ms' }}> {imported ? : } {imported?'Imported':'Import'}
{importErr &&
Not a valid Caulis export file.
} {importData && (
{importData.plants.length} plants in file. Merge keeps your current plants; Replace overwrites them.
setImportData(null)} style={{ flex:1, height:38, borderRadius:10, border:C.hair, color:C.brown, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', fontFamily:FONT_SANS, fontSize:13 }}>Cancel
doImport('merge')} style={{ flex:1, height:38, borderRadius:10, background:C.forest, color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', fontFamily:FONT_SANS, fontSize:13, fontWeight:600 }}>Merge
doImport('replace')} style={{ flex:1, height:38, borderRadius:10, background:'#B4472E', color:'#fff', display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', fontFamily:FONT_SANS, fontSize:13, fontWeight:600 }}>Replace
)} View format & API docs ↗
{(() => { const nav = normalizeNav(navConfig); const opts = [...NAV_ORDER, 'empty']; const cycleAction = (i) => { const idx = opts.indexOf(nav[i].action); const next = opts[(idx + 1) % opts.length]; onSetNavConfig(nav.map((s, j) => j === i ? { ...s, action: next } : s)); }; const setCenter = (i) => onSetNavConfig(nav.map((s, j) => ({ ...s, center: j === i }))); const swap = (i, j) => { if (j < 0 || j >= nav.length) return; const out = nav.map(s => ({ ...s })); const t = out[i]; out[i] = out[j]; out[j] = t; onSetNavConfig(out); }; const removeSlot = (i) => { if (nav.length <= 1) return; onSetNavConfig(nav.filter((_, j) => j !== i)); }; const addSlot = () => { if (nav.length >= NAV_MAX) return; const used = nav.map(s => s.action); const pick = NAV_ORDER.find(a => !used.includes(a)) || 'garden'; onSetNavConfig([...nav, { action: pick }]); }; const setLabel = (i, v) => onSetNavConfig(nav.map((s, j) => { if (j !== i) return s; const o = { ...s }; if (v.trim()) o.label = v; else delete o.label; return o; })); const setColor = (i, c) => onSetNavConfig(nav.map((s, j) => { if (j !== i) return s; const o = { ...s }; if (c) o.color = c; else delete o.color; return o; })); const SLOT_COLORS = ['#2D5016','#15605A','#5A2456','#8A3A1E','#6E9A3E','#C98A2B','#B4472E']; const arrow = (dir, enabled, onClick) => (
{dir}
); return ( toggleSec('nav')}>
Tap a slot to change its button, reorder with the arrows{isDesktop ? '' : ', pick which one is raised in the center'}, and add up to {NAV_MAX}. The “More” button opens everything not on the bar — so nothing is ever out of reach.
{nav.map((s, i) => { const meta = NAV_ACTIONS[s.action]; const isEmpty = s.action === 'empty'; return (
{arrow('▲', i > 0, ()=>swap(i, i-1))} {arrow('▼', i < nav.length-1, ()=>swap(i, i+1))}
cycleAction(i)} style={{ flex:1, display:'flex', alignItems:'center', gap:10, cursor:'pointer' }}> {meta ? :
} {meta ? meta.label : 'Empty'}
{!isDesktop && (
!isEmpty && setCenter(i)} style={{ display:'flex', alignItems:'center', gap:6, cursor: isEmpty?'default':'pointer', opacity: isEmpty?0.3:1 }}> Center
{s.center &&
}
)}
removeSlot(i)} style={{ cursor: nav.length>1?'pointer':'default', opacity: nav.length>1?0.5:0.2, color:C.brown, fontSize:18, lineHeight:1, padding:'0 2px' }}>×
{!isEmpty && (
setLabel(i, e.target.value)} placeholder={meta.label} maxLength={18} style={{ flex:1, minWidth:0, boxSizing:'border-box', height:32, borderRadius:9, border:`1px solid ${C.line}`, background:C.panel, padding:'0 10px', fontFamily:FONT_SANS, fontSize:12.5, color:C.ink, outline:'none' }}/>
setColor(i, null)} title="Default" style={{ width:18, height:18, borderRadius:999, border:`1.5px solid ${!s.color?C.forest:C.line}`, display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer' }}> {!s.color &&
}
{SLOT_COLORS.map(c => (
setColor(i, c)} style={{ width:18, height:18, borderRadius:999, background:c, cursor:'pointer', boxShadow: s.color===c ? `0 0 0 1.5px ${C.bg}, 0 0 0 3px ${c}` : 'none' }}/> ))}
)}
); })}
{nav.length < NAV_MAX && (
Add button
)}
onSetNavConfig(DEFAULT_NAV)} style={{ fontFamily:FONT_SANS, fontSize:12.5, fontWeight:600, color:C.brown, opacity:0.7, cursor:'pointer', padding:'4px 2px' }}>Reset to default
Show labels
Text under each {isDesktop ? 'sidebar' : 'bar'} icon
Desktop sidebar
Position
{[['left','Left'],['right','Right']].map(([val,label]) => { const on = (sidebar.side || 'left') === val; return
onSetSidebar({ side: val })} style={{ cursor:'pointer', padding:'5px 12px', borderRadius:6, background:on?C.forest:'transparent', color:on?'#fff':C.ink, fontFamily:FONT_SANS, fontSize:11.5, fontWeight:600, opacity:on?1:0.5, transition:'all 140ms ease' }}>{label}
; })}
onSetSidebar({ collapsed: !sidebar.collapsed })} style={{ display:'flex', alignItems:'center', justifyContent:'space-between', cursor:'pointer' }}>
Collapse to icons
{!sidebar.collapsed && (
Width
onSetSidebar({ width: Math.max(180, (sidebar.width||220) - 10) })} style={{ width:28, height:28, borderRadius:8, background:'rgba(45,80,22,0.08)', display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', fontFamily:FONT_SANS, fontSize:18, color:C.forest, fontWeight:500, userSelect:'none' }}>−
{sidebar.width||220}px
onSetSidebar({ width: Math.min(300, (sidebar.width||220) + 10) })} style={{ width:28, height:28, borderRadius:8, background:'rgba(45,80,22,0.08)', display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', fontFamily:FONT_SANS, fontSize:18, color:C.forest, fontWeight:500, userSelect:'none' }}>+
)} {!sidebar.collapsed && (
Footer text
onSetSidebar({ footer: e.target.value.slice(0, 40) })} placeholder="grown with care" style={{ width:'100%', boxSizing:'border-box', height:38, borderRadius:10, border:`1px solid ${C.line}`, background:C.bg, padding:'0 12px', fontFamily:FONT_SERIF, fontStyle:'italic', fontSize:14, color:C.brown, outline:'none' }}/>
)}
); })()} {devRevealed && (() => { let pinIsSet = false; try { pinIsSet = !!localStorage.getItem('caulis_dev_pin'); } catch(e) {} const dInput = { width:'100%', padding:'11px 13px', borderRadius:12, border:`1px solid ${C.line}`, background:C.bg, fontFamily:FONT_SANS, fontSize:14, color:C.ink, outline:'none', boxSizing:'border-box' }; const dBtn = (filled) => ({ display:'inline-flex', alignItems:'center', justifyContent:'center', gap:7, padding:'10px 16px', borderRadius:12, cursor:'pointer', fontFamily:FONT_SANS, fontSize:13.5, fontWeight:600, border:`1px solid ${C.forest}`, background: filled?C.forest:'transparent', color: filled?'#fff':C.forest, userSelect:'none' }); const grpLabel = { fontFamily:FONT_SANS, fontSize:11, fontWeight:600, color:C.brown, opacity:0.6, letterSpacing:0.6, textTransform:'uppercase', marginBottom:2 }; const stepper = (val, set) => (
set(Math.max(1, val-1))} style={{ ...dBtn(false), padding:'6px 12px', fontSize:16 }}>−
{val}d
set(val+1)} style={{ ...dBtn(false), padding:'6px 12px', fontSize:16 }}>+
); const plantEditor = (rows, onSet) => (
{rows.map(p => (
{p.name}
onSet(p.id, Math.max(0,(p.days||0)-1))} style={{ ...dBtn(false), padding:'4px 11px', fontSize:15 }}>−
onSet(p.id, parseInt(e.target.value)||0)} style={{ ...dInput, width:52, padding:'6px 4px', textAlign:'center' }}/>
onSet(p.id,(p.days||0)+1)} style={{ ...dBtn(false), padding:'4px 11px', fontSize:15 }}>+
))}
); return ( toggleSec('dev')}>
{!devAuthed ? (
{pinIsSet ? 'Enter developer PIN' : 'Set a developer PIN to protect these tools'}
{ setPinInput(e.target.value); setPinErr(false); }} onKeyDown={e=>{ if(e.key==='Enter') submitPin(); }} placeholder="PIN" style={dInput}/> {pinErr &&
Wrong PIN
}
{pinIsSet ? 'Unlock' : 'Set PIN'}
Hide panel
) : ( <>
This garden · {plants.length} plants
Water all to today
Resync days from watering log
{resyncMsg &&
{resyncMsg}
}
Shift every plant {stepper(devOffsetN, setDevOffsetN)}
onDevOffsetDays(-devOffsetN)} style={{ ...dBtn(false), flex:1 }}>− {devOffsetN}d fresher
onDevOffsetDays(devOffsetN)} style={{ ...dBtn(false), flex:1 }}>+ {devOffsetN}d older
Per plant · days since watered
{plantEditor(plants, onDevSetDays)}
Admin · manage any garden
setAdminSecret(e.target.value)} placeholder="Admin secret" style={dInput}/>
{adminListStatus==='loading' ? 'Loading…' : 'List all gardens'}
{adminListStatus==='error' && Wrong secret or request failed}
{adminGardens && (
{adminGardens.map(g => (
loadAdminGarden(g.key)} style={{ display:'flex', alignItems:'center', justifyContent:'space-between', padding:'10px 12px', background:C.panel, borderBottom:C.hair, cursor:'pointer' }}> {g.key}{g.unclaimed ? ' · unclaimed' : ''} {g.plant_count} plants
))} {adminGardens.length === 0 &&
No gardens yet
}
)} {adminStatus==='empty' && No plants in that garden} {adminLoaded && (
{adminLoaded.key} · {adminLoaded.plants.length} plants loaded · edits stay local until pushed
{adminLoaded.plants.length > 0 && <>
Water all to today
Shift every plant {stepper(adminOffsetN, setAdminOffsetN)}
adminShiftAll(-adminOffsetN)} style={{ ...dBtn(false), flex:1 }}>− {adminOffsetN}d
adminShiftAll(adminOffsetN)} style={{ ...dBtn(false), flex:1 }}>+ {adminOffsetN}d
{plantEditor(adminLoaded.plants, adminSetDays)}
{adminStatus==='pushed' ? 'Pushed ✓' : adminStatus==='pushing' ? 'Pushing…' : 'Push to garden'}
}
Delete this garden
)}
schema v{WATER_SCHEMA} · app v{APP_VERSION}
Lock & hide
)}
); })()} toggleSec('about')}>
Version {`v${APP_VERSION}`}{!devRevealed && verTaps >= 3 && verTaps < 7 ? ` · ${7-verTaps} more` : ''}
{ setUpdating(true); await onUpdateApp(); }} style={{ display:'flex', alignItems:'center', justifyContent:'space-between', padding:'12px 16px', cursor: updating?'default':'pointer' }}>
Check for updates
Clear cache & reload latest version
{updating ?
: } {updating?'Updating…':'Update'}
Caulis · grown with care
); } // ════════════════════════════════════════════════════════════ // BOTTOM NAVIGATION // ════════════════════════════════════════════════════════════ function BottomNav({ tab, setTab, onAction, navConfig, showLabels = true }) { const slots = normalizeNav(navConfig).filter(s => s.action !== 'empty'); const fire = (action) => { const a = NAV_ACTIONS[action]; if (!a) return; if (a.tab) setTab(action); else onAction && onAction(action); }; return (
{slots.map((s, i) => { const meta = NAV_ACTIONS[s.action]; const active = meta.tab && tab === s.action; const accent = navColor(s); const label = navLabel(s); if (s.center) { return (
fire(s.action)} style={{ flex:1, display:'flex', flexDirection:'column', alignItems:'center', gap:4, cursor:'pointer' }}>
{showLabels && {label}}
); } const col = active ? accent : C.brown; return (
fire(s.action)} style={{ flex:1, display:'flex', flexDirection:'column', alignItems:'center', gap:5, cursor:'pointer', paddingBottom:2 }}> {showLabels && {label}}
); })}
); } // ════════════════════════════════════════════════════════════ // MOVE SHEET (reassign room) // ════════════════════════════════════════════════════════════ function MoveSheet({ plant, ids, locations, onClose, onPick, onAddLocation, isDesktop }) { const [typed, setTyped] = useState(''); const bulk = Array.isArray(ids) && ids.length > 0; const targets = bulk ? ids : (plant ? [plant.id] : []); const addNew = () => { const v = typed.trim(); if (!v) return; if (!locations.some(l=>l.toLowerCase()===v.toLowerCase())) onAddLocation(v); targets.forEach(id => onPick(id, v)); onClose(); }; return (
e.stopPropagation()} style={{ background:C.bg, borderTopLeftRadius:26, borderTopRightRadius:26, padding:'10px 18px 30px', animation:'slideUp 260ms cubic-bezier(.2,.8,.2,1)', maxHeight:'80%', overflowY:'auto' }}>
{bulk ? `Move ${ids.length} plants` : `Move ${plant.name}`}
{bulk ? 'Choose a room' : `Currently in ${plant.location}`}
{locations.map(l => { const on = !bulk && l === plant.location; return (
{ targets.forEach(id => onPick(id, l)); onClose(); }} style={{ display:'flex', alignItems:'center', gap:11, padding:'13px 14px', cursor:'pointer', background:C.panel, borderRadius:14, border: on ? '1px solid rgba(110,154,62,0.5)' : '0.5px solid rgba(45,80,22,0.12)', }}> {l} {on && }
); })}
setTyped(e.target.value)} onKeyDown={e=>{ if(e.key==='Enter'){ e.preventDefault(); addNew(); } }} placeholder="New room…" style={{ flex:1, boxSizing:'border-box', height:46, borderRadius:14, border:'1px solid rgba(45,80,22,0.14)', background:C.input, padding:'0 15px', fontFamily:FONT_SANS, fontSize:14, color:C.ink, outline:'none' }}/>
); } // ════════════════════════════════════════════════════════════ // ERROR SCREENS // ════════════════════════════════════════════════════════════ function PlantNotFoundScreen({ onBack }) { return (
Plant not found
This QR code points to a plant that doesn't exist in your garden.
Back to garden
); } // ════════════════════════════════════════════════════════════ // DESKTOP SIDEBAR // ════════════════════════════════════════════════════════════ function DesktopSidebar({ tab, setTab, onAction, navConfig, showLabels = true, sidebar = {} }) { const slots = normalizeNav(navConfig).filter(s => s.action !== 'empty'); const fire = (action) => { const a = NAV_ACTIONS[action]; if (!a) return; if (a.tab) setTab(action); else onAction && onAction(action); }; const collapsed = !!sidebar.collapsed; const labels = showLabels && !collapsed; const width = collapsed ? 72 : (sidebar.width || 220); const side = sidebar.side === 'right' ? 'right' : 'left'; const footer = sidebar.footer != null ? sidebar.footer : 'grown with care'; return (
{!collapsed && Caulis}
{!collapsed && footer && (
{footer}
)}
); } // ════════════════════════════════════════════════════════════ // DESKTOP MODAL WRAPPER // ════════════════════════════════════════════════════════════ function DesktopModal({ onClose, children, maxWidth = 520, noBackdropClose = false }) { return (
{ if (window._filePickerOpen) return; onClose(); })} style={{ position:'fixed', inset:0, zIndex:100, background:'rgba(42,42,38,0.38)', display:'flex', alignItems:'center', justifyContent:'center', padding:'32px 20px', animation:'fade 160ms ease', }}>
e.stopPropagation()} style={{ width:'100%', maxWidth, height:'min(88vh, 840px)', position:'relative', borderRadius:28, overflow:'hidden', background:C.bg, boxShadow:'0 24px 60px rgba(0,0,0,0.28), 0 0 0 0.5px rgba(45,80,22,0.1)', animation:'slideUp 300ms cubic-bezier(.2,.8,.2,1)', }}> {children}
); } Object.assign(window, { PlantCard, ScreenHead, GardenScreen, NeedsWaterScreen, ScannerScreen, PrintQueueScreen, SettingsScreen, BottomNav, MoveSheet, ContextMenu, DesktopSidebar, DesktopModal, PlantNotFoundScreen, });