// Florio Analytics — Labels tab (ported from js/pages/labels.js). // Browse the labelled plant dataset (`avatar_labels`) and manage the pending // labeling queue (`labeling_queue`). Two views: // • "labels" — grid of labelled photos, filterable per avatar, each card can // be reassigned to a different avatar via a Rive-preview modal. // • "queue" — grid of pending queue items with checkbox multi-select, // single delete + batch delete. // All Firestore queries / writes are preserved 1:1 from the old vanilla page, // re-expressed as React + the new design kit. Real data only. (function () { const { useState, useEffect, useRef, useCallback } = React; // ─── Constants (identical to old page) ─────────────────────────────────────── const AVATAR_ORDER = [ "monstera", "fern", "peace", "snake", "cactus", "palm", "bushy", "hanging", "orchid", "aloevera", "succulent", "bonsai", "anthurium", "prayer", "dracaena", "ficus", "banana", "clover", "coin", ]; const DISPLAY_NAMES = { monstera: "Monstera", fern: "Fern", peace: "Peace Lily", snake: "Snake Plant", cactus: "Cactus", palm: "Palm", bushy: "Bushy", hanging: "Hanging", orchid: "Orchid", aloevera: "Aloe Vera", succulent: "Succulent", bonsai: "Bonsai", anthurium: "Anthurium", prayer: "Prayer Plant", dracaena: "Dracaena", ficus: "Ficus", banana: "Banana", clover: "Clover", coin: "Coin Plant", }; const AVATAR_COLORS = { monstera: "#2d9a4e", fern: "#5bb87a", peace: "#a8b5c8", snake: "#d4b83e", cactus: "#c0392b", palm: "#2980b9", bushy: "#8e44ad", hanging: "#16a085", orchid: "#c2185b", aloevera: "#7cb342", succulent: "#e67e22", bonsai: "#6d4c41", anthurium: "#e53935", prayer: "#00838f", dracaena: "#546e7a", ficus: "#388e3c", banana: "#f9a825", clover: "#00897b", coin: "#9e9d24", }; function fmt(n) { return (window.FlorioAPI && window.FlorioAPI.formatNumber) ? window.FlorioAPI.formatNumber(n) : String(n != null ? n : 0); } // ─── Rive previews (lazy import, cached at module scope) ───────────────────── let RiveLib = null; async function ensureRiveLib() { if (RiveLib) return RiveLib; try { const mod = await import("https://esm.sh/@rive-app/canvas@2.17.3"); const Rive = mod.Rive || (mod.default && mod.default.Rive); const Layout = mod.Layout || (mod.default && mod.default.Layout); const Fit = mod.Fit || (mod.default && mod.default.Fit); const Alignment = mod.Alignment || (mod.default && mod.default.Alignment); RiveLib = { Rive, Layout, Fit, Alignment }; } catch (err) { console.error("[Labels] Rive import failed:", err); RiveLib = { Rive: null }; } return RiveLib; } // Mounts a Rive canvas (or a letter placeholder) for one avatar. // Paths get one extra "../" vs the old page because this file lives in app/. function RivePreview({ id, size = 64 }) { const slotRef = useRef(null); useEffect(() => { let cancelled = false; let riveInstance = null; const name = DISPLAY_NAMES[id] || id; const placeholder = () => { if (slotRef.current) { slotRef.current.innerHTML = '
' + ((name && name[0]) || "?") + "
"; } }; (async () => { const lib = await ensureRiveLib(); if (cancelled || !slotRef.current) return; if (!lib.Rive) { placeholder(); return; } const canvas = document.createElement("canvas"); canvas.width = 160; canvas.height = 160; canvas.style.width = "100%"; canvas.style.height = "100%"; slotRef.current.innerHTML = ""; slotRef.current.appendChild(canvas); const src = "../rive/" + id + "/" + id + "_4.riv"; try { riveInstance = new lib.Rive({ src, canvas, autoplay: true, stateMachines: "State Machine 1", layout: new lib.Layout({ fit: lib.Fit.Contain, alignment: lib.Alignment.Center }), onLoadError: () => placeholder(), }); } catch (err) { console.error("[Labels] Rive constructor threw for", id, err); placeholder(); } })(); return () => { cancelled = true; try { if (riveInstance) riveInstance.cleanup(); } catch (err) { /* noop */ } }; }, [id]); return
; } // ─── Shared style helpers (match the other ported tabs) ────────────────────── const cardStyle = { background: "var(--surface-card)", border: "1px solid var(--border-subtle)", borderRadius: "var(--radius-2xl)", boxShadow: "var(--shadow-card)", padding: 22, }; const toggleBtn = (active) => ({ padding: "8px 16px", borderRadius: "var(--radius-md)", cursor: "pointer", border: "1px solid " + (active ? "var(--app-accent)" : "var(--border-default)"), background: active ? "color-mix(in srgb, var(--app-accent) 12%, transparent)" : "var(--surface-card)", color: active ? "var(--app-accent)" : "var(--text-secondary)", fontFamily: "var(--font-sans)", fontWeight: 700, fontSize: 13, whiteSpace: "nowrap", }); const secondaryBtn = (extra) => Object.assign({ padding: "8px 14px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-default)", background: "var(--surface-card)", color: "var(--text-secondary)", cursor: "pointer", fontFamily: "var(--font-sans)", fontWeight: 600, fontSize: 13, whiteSpace: "nowrap", }, extra || {}); // ─── Empty / loading helpers ───────────────────────────────────────────────── function Empty({ note }) { const I = window.HyperIcons; return (
{I && I.leaf ? I.leaf({ size: 26 }) : null} {note || "No data yet"}
); } // ─── Reassign modal ────────────────────────────────────────────────────────── function ReassignModal({ item, onClose, onReassign }) { const [savingId, setSavingId] = useState(null); if (!item) return null; return (
{ if (ev.target === ev.currentTarget) onClose(); }} style={{ position: "fixed", inset: 0, zIndex: 200, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(15,20,30,0.55)", padding: 24, }} >
Reassign avatar
Pick the closest match
{AVATAR_ORDER.map((id) => { const name = DISPLAY_NAMES[id] || id; const color = AVATAR_COLORS[id] || "#999"; const current = item.avatar === id; const saving = savingId === id; return ( ); })}
); } // ─── Main component ────────────────────────────────────────────────────────── function Labels({ range }) { // eslint-disable-line no-unused-vars const [loading, setLoading] = useState(true); const [labelDocs, setLabelDocs] = useState([]); const [queueDocs, setQueueDocs] = useState([]); const [view, setView] = useState("labels"); // "labels" | "queue" const [activeFilter, setActiveFilter] = useState("all"); const [selectedIds, setSelectedIds] = useState(() => new Set()); const [reassignItem, setReassignItem] = useState(null); const [batchBusy, setBatchBusy] = useState(false); const FB = () => window.FlorioDB; // ── Data loading (mirror of old loadData) ── const loadData = useCallback(async () => { const { db, collection, query, where, getDocs } = FB(); setLoading(true); try { const [labelsSnap, queueSnap] = await Promise.all([ getDocs(collection(db, "avatar_labels")), getDocs(query(collection(db, "labeling_queue"), where("status", "==", "pending"))), ]); const labels = []; labelsSnap.forEach((d) => { const data = d.data(); if (data.skipped || data.notHouseplant) return; if (!data.photoUrl) return; // Firestore field is `chosenAvatar`; normalize to `avatar` for display. const avatar = data.chosenAvatar || data.avatar || null; labels.push(Object.assign({ id: d.id }, data, { avatar })); }); labels.sort((a, b) => { const ta = (a.createdAt && a.createdAt.toMillis && a.createdAt.toMillis()) || 0; const tb = (b.createdAt && b.createdAt.toMillis && b.createdAt.toMillis()) || 0; return tb - ta; }); const queue = []; queueSnap.forEach((d) => { queue.push(Object.assign({ id: d.id }, d.data())); }); queue.sort((a, b) => { const ta = (a.createdAt && a.createdAt.toMillis && a.createdAt.toMillis()) || 0; const tb = (b.createdAt && b.createdAt.toMillis && b.createdAt.toMillis()) || 0; return tb - ta; }); setLabelDocs(labels); setQueueDocs(queue); } catch (err) { console.error("[Labels] loadData failed:", err); setLabelDocs([]); setQueueDocs([]); } finally { setLoading(false); } }, []); // ── Boot ── useEffect(() => { let alive = true; (async () => { await window.FlorioReady; if (!alive) return; ensureRiveLib(); // preload in background await loadData(); })(); return () => { alive = false; }; }, [loadData]); // Clear selection when leaving the queue view. useEffect(() => { if (view !== "queue") setSelectedIds(new Set()); }, [view]); // ── Reassign (avatar_labels.chosenAvatar) ── const reassign = useCallback(async (docId, newAvatar) => { const { db, doc, updateDoc } = FB(); try { await updateDoc(doc(db, "avatar_labels", docId), { chosenAvatar: newAvatar }); setLabelDocs((prev) => prev.map((d) => (d.id === docId ? Object.assign({}, d, { avatar: newAvatar, chosenAvatar: newAvatar }) : d))); } catch (err) { console.error("[Labels] reassign failed:", err); alert("Save failed: " + err.message); } finally { setReassignItem(null); } }, []); // ── Single queue delete ── const deleteSingle = useCallback(async (id) => { if (!confirm("Delete this item from the queue?")) return; const { db, doc, deleteDoc } = FB(); try { await deleteDoc(doc(db, "labeling_queue", id)); setQueueDocs((prev) => prev.filter((d) => d.id !== id)); setSelectedIds((prev) => { const n = new Set(prev); n.delete(id); return n; }); } catch (err) { console.error("[Labels] delete failed:", err); alert("Delete failed: " + err.message); } }, []); // ── Batch delete (chunks of 500, like old page) ── const batchDelete = useCallback(async () => { if (selectedIds.size === 0) return; if (!confirm("Delete " + selectedIds.size + " items from the queue?")) return; const { db, doc, writeBatch } = FB(); setBatchBusy(true); try { const ids = [...selectedIds]; for (let i = 0; i < ids.length; i += 500) { const batch = writeBatch(db); ids.slice(i, i + 500).forEach((id) => batch.delete(doc(db, "labeling_queue", id))); await batch.commit(); } const removed = new Set(selectedIds); setQueueDocs((prev) => prev.filter((d) => !removed.has(d.id))); setSelectedIds(new Set()); } catch (err) { console.error("[Labels] batch delete failed:", err); alert("Batch delete failed: " + err.message); } finally { setBatchBusy(false); } }, [selectedIds]); const toggleSelect = useCallback((id) => { setSelectedIds((prev) => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; }); }, []); // ── Loading state ── if (loading) { return (
); } const I = window.HyperIcons; // ── Filtered label items ── const labelItems = activeFilter === "all" ? labelDocs : labelDocs.filter((d) => d.avatar === activeFilter); // ── Per-avatar counts (for filter chips) ── const perAvatar = {}; for (const d of labelDocs) { if (d.avatar) perAvatar[d.avatar] = (perAvatar[d.avatar] || 0) + 1; } const gridStyle = { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 16, }; return (
{/* ── Header: view toggle + counts ── */}
{/* ── Filter chips (labels view only) ── */} {view === "labels" && (
{AVATAR_ORDER.map((id) => { const name = DISPLAY_NAMES[id] || id; const color = AVATAR_COLORS[id] || "#999"; const active = activeFilter === id; const count = perAvatar[id] || 0; return ( ); })}
)} {/* ── Batch toolbar (queue view only) ── */} {view === "queue" && (
{fmt(selectedIds.size)} selected
)} {/* ── Grid ── */} {view === "labels" ? (
{labelItems.length === 0 ? ( ) : labelItems.map((item) => { const name = DISPLAY_NAMES[item.avatar] || item.avatar || "?"; const color = AVATAR_COLORS[item.avatar] || "#999"; const hasEmbed = item.embeddingVersion === "v2"; return (
{hasEmbed && ( embedded )}
{name}
); })}
) : (
{queueDocs.length === 0 ? ( ) : queueDocs.map((item) => { const selected = selectedIds.has(item.id); return (
toggleSelect(item.id)} style={{ ...cardStyle, padding: 0, overflow: "hidden", display: "flex", flexDirection: "column", cursor: "pointer", border: "2px solid " + (selected ? "var(--app-accent)" : "var(--border-subtle)"), }} >
ev.stopPropagation()} onChange={() => toggleSelect(item.id)} style={{ width: 18, height: 18, cursor: "pointer", accentColor: "var(--app-accent)" }} />
{item.species || "unknown"}
); })}
)} {reassignItem && ( setReassignItem(null)} onReassign={reassign} /> )}
); } (window.FlorioTabs = window.FlorioTabs || {}).labels = Labels; })();