// 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)"),
}}
>
{item.species || "unknown"}
);
})}
)}
{reassignItem && (
setReassignItem(null)} onReassign={reassign} />
)}
);
}
(window.FlorioTabs = window.FlorioTabs || {}).labels = Labels;
})();