/* app.jsx — Root. */ const { useState, useReducer, useEffect, useRef, useMemo } = React; function hexToRgb(hex) { const h = hex.replace("#", ""); const n = parseInt(h.length === 3 ? h.split("").map(c => c + c).join("") : h, 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } function shade(hex, amt) { let [r, g, b] = hexToRgb(hex); const f = (c) => Math.max(0, Math.min(255, Math.round(c + amt * 255))); return `rgb(${f(r)},${f(g)},${f(b)})`; } const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "accent": "#ff7a00", "glow": 1, "anim": 1 }/*EDITMODE-END*/; const ACCENTS = ["#ff7a00", "#f5b942", "#ff4d4d", "#8b5cf6", "#2dd4a7", "#3b82f6"]; function App() { const { Store, FX, Ic, Api } = window; const [t, setTweak] = window.useTweaks(TWEAK_DEFAULTS); const [state, dispatch] = useReducer(Store.reducer, undefined, Store.defaultState); const stateRef = useRef(state); stateRef.current = state; useEffect(() => { (async () => { try { const me = await Api.me(); dispatch({ type: "BOOT", user: me.user }); if (me.user) { const tk = await Api.tasks.list(); dispatch({ type: "SET_TASKS", tasks: tk.tasks }); try { const g = await Api.game.me(); dispatch({ type: "SET_GAME", game: g.game }); } catch (_) {} } } catch (e) { dispatch({ type: "BOOT", user: null }); } })(); }, []); useEffect(() => { const root = document.documentElement.style; root.setProperty("--accent", t.accent); root.setProperty("--accent-hi", shade(t.accent, 0.16)); root.setProperty("--accent-lo", shade(t.accent, -0.22)); root.setProperty("--accent-rgb", hexToRgb(t.accent).join(", ")); root.setProperty("--glow", String(t.glow)); root.setProperty("--anim", String(t.anim)); }, [t.accent, t.glow, t.anim]); const [muted, setMuted] = useState(() => localStorage.getItem("ds_muted") === "1"); useEffect(() => { FX.sfx.muted = muted; localStorage.setItem("ds_muted", muted ? "1" : "0"); }, [muted]); const [query, setQuery] = useState(""); const [catFilter, setCatFilter] = useState(null); const [prioFilter, setPrioFilter] = useState(null); const [openId, setOpenId] = useState(null); const [modal, setModal] = useState(null); const [popId, setPopId] = useState(null); const [justLeveled, setJustLeveled] = useState(false); const [toasts, setToasts] = useState([]); const [entering, setEntering] = useState(false); const [settings, setSettings] = useState(null); const [showAdmin, setShowAdmin] = useState(false); const [showApprovals, setShowApprovals] = useState(false); const [pendingCount, setPendingCount] = useState(0); const [viewer, setViewer] = useState(null); const openTask = state.tasks.find(x => x.id === openId) || null; const pushToast = (e, text) => { const id = Store.uid(); setToasts(ts => [...ts, { id, e, t: text }]); setTimeout(() => setToasts(ts => ts.map(x => x.id === id ? { ...x, out: true } : x)), 2200); setTimeout(() => setToasts(ts => ts.filter(x => x.id !== id)), 2600); }; const celebrate = (opts = {}) => { const x = innerWidth / 2, y = innerHeight * 0.32; FX.fireConfetti(x, y, { count: opts.big ? 160 : 95, spread: opts.big ? 1.3 : 1, up: true }); FX.sfx.play("complete"); if (!opts.silentToast) { const m = Store.REINFORCE[(Math.random() * Store.REINFORCE.length) | 0]; pushToast(m.e, opts.message || m.t); } }; const pop = (id) => { setPopId(id); setTimeout(() => setPopId(p => (p === id ? null : p)), 520); }; const flashLevel = () => { setJustLeveled(true); FX.sfx.play("levelup"); setTimeout(() => setJustLeveled(false), 1300); setTimeout(() => FX.fireConfetti(innerWidth / 2, innerHeight * 0.4, { count: 180, spread: 1.5, up: true, colors: ["#f5b942", t.accent, "#ffffff", "#ffd28a"] }), 250); pushToast("⭐", "Subiu de nível!"); }; // Refs para polling e fila de upload const lastTasksJsonRef = useRef(""); const pollInFlightRef = useRef(false); const uploadChainRef = useRef(Promise.resolve()); const uploadsInFlightRef = useRef(0); // ---- POLLING: sincroniza com a equipe a cada 5s ---- useEffect(() => { if (!state.loggedIn) return; const isEditing = () => { const el = document.activeElement; if (!el) return false; const tag = (el.tagName || "").toLowerCase(); if (tag === "input" || tag === "textarea" || tag === "select") return true; if (el.isContentEditable) return true; return false; }; const tick = async () => { if (pollInFlightRef.current) return; if (document.hidden) return; if (isEditing()) return; if (modal) return; if (uploadsInFlightRef.current > 0) return; if (document.querySelector(".card.ghost")) return; pollInFlightRef.current = true; try { const tk = await Api.tasks.list(); const next = JSON.stringify(tk.tasks); if (next !== lastTasksJsonRef.current) { lastTasksJsonRef.current = next; dispatch({ type: "SET_TASKS", tasks: tk.tasks }); } } catch (e) { if (e && e.status === 401) dispatch({ type: "LOGOUT" }); } finally { pollInFlightRef.current = false; } }; const t0 = setTimeout(tick, 1500); const id = setInterval(tick, 5000); const onVis = () => { if (!document.hidden) tick(); }; document.addEventListener("visibilitychange", onVis); return () => { clearTimeout(t0); clearInterval(id); document.removeEventListener("visibilitychange", onVis); }; }, [state.loggedIn, modal]); // ---- Polling do contador de pendentes (admin/gerente, a cada 30s) ---- const canApprove = state.user && (state.user.role === "admin" || state.user.role === "gerente"); useEffect(() => { if (!state.loggedIn || !canApprove) { setPendingCount(0); return; } let alive = true; const tick = async () => { try { const r = await Api.approvals.count(); if (alive) setPendingCount(r.count || 0); } catch (_) {} }; tick(); const id = setInterval(tick, 30000); return () => { alive = false; clearInterval(id); }; }, [state.loggedIn, canApprove]); const apiSafe = (fn) => async (...args) => { try { return await fn(...args); } catch (e) { if (e.status === 401) { pushToast("⚠️", "Sessão expirada"); setTimeout(() => location.reload(), 1200); return; } pushToast("⚠️", e.message || "Erro"); throw e; } }; const handleMove = apiSafe(async (id, toStatus, toIndex) => { const task = stateRef.current.tasks.find(x => x.id === id); if (!task) return; const prev = task.status; const r = await Api.tasks.move(id, toStatus, toIndex); dispatch({ type: "UPSERT_TASK", task: r.task }); if (toStatus === "done" && prev !== "done") { const xp = Store.xpForTask(task); const before = Store.levelInfo(stateRef.current.game.xp).level; dispatch({ type: "SET_GAME", game: { ...stateRef.current.game, xp: (stateRef.current.game.xp||0)+xp } }); const after = Store.levelInfo(stateRef.current.game.xp + xp).level; pop(id); celebrate(); if (after > before) setTimeout(flashLevel, 350); } else if (prev === "done" && toStatus !== "done") { const xp = Store.xpForTask(task); dispatch({ type: "SET_GAME", game: { ...stateRef.current.game, xp: Math.max(0,(stateRef.current.game.xp||0)-xp) } }); } else FX.sfx.play("move"); }); const moveStatus = (id, toStatus) => handleMove(id, toStatus, 999); const createTask = apiSafe(async (data) => { const payload = { title: data.title, description: data.desc || "", status: data.status, category: data.category, priority: data.priority, thumb: data.thumb || null, checklist: (data.checklist || []).map(c => ({ text: c.text, done: !!c.done })), }; const r = await Api.tasks.create(payload); dispatch({ type: "UPSERT_TASK", task: r.task }); setModal(null); FX.sfx.play("click"); pushToast("✅", "Tarefa criada!"); }); const updateTask = apiSafe(async (id, patch) => { const p = {}; if ("title" in patch) p.title = patch.title; if ("desc" in patch) p.description = patch.desc; if ("status" in patch) p.status = patch.status; if ("category" in patch) p.category = patch.category; if ("priority" in patch) p.priority = patch.priority; if ("progress" in patch) p.progress = patch.progress; if ("thumb" in patch) p.thumb = patch.thumb; const r = await Api.tasks.update(id, p); dispatch({ type: "UPSERT_TASK", task: r.task }); }); const deleteTask = apiSafe(async (id) => { await Api.tasks.remove(id); dispatch({ type: "REMOVE_TASK", id }); if (openId === id) setOpenId(null); pushToast("🗑️", "Tarefa removida"); }); // ---- DetailPanel dispatch: traduz ações em chamadas de API ---- const detailDispatch = apiSafe(async (action) => { const id = action.id; if (action.type === "TOGGLE_CHECK") { const task = stateRef.current.tasks.find(x => x.id === id); const item = task.checklist.find(c => c.id === action.cid); const r = await Api.tasks.chkUpd(id, action.cid, { done: !item.done }); dispatch({ type: "UPSERT_TASK", task: r.task }); const before = Store.taskProgress(task); const after = Store.taskProgress(Store.normalizeTask(r.task)); if (before < 100 && after === 100 && task.status !== "done") { setTimeout(() => { celebrate({ message: "Checklist completo! 🎯" }); pop(task.id); }, 80); } return; } if (action.type === "ADD_CHECK") { const r = await Api.tasks.chkAdd(id, action.text); dispatch({ type: "UPSERT_TASK", task: r.task }); return; } if (action.type === "DEL_CHECK") { const r = await Api.tasks.chkDel(id, action.cid); dispatch({ type: "UPSERT_TASK", task: r.task }); return; } if (action.type === "ADD_IMAGE") { // Upload sequencial: cada novo upload aguarda os anteriores. // Polling pausa enquanto uploadsInFlightRef > 0. uploadsInFlightRef.current++; const job = uploadChainRef.current.then(async () => { try { let file = action.file; if (!file && action.url && action.url.startsWith("data:")) { const blob = await (await fetch(action.url)).blob(); file = new File([blob], "image", { type: blob.type }); } if (!file) return; const r = await Api.tasks.imgAdd(id, file); dispatch({ type: "UPSERT_TASK", task: r.task }); } catch (e) { pushToast("⚠️", "Falha ao subir imagem: " + (e.message || "erro")); } finally { uploadsInFlightRef.current = Math.max(0, uploadsInFlightRef.current - 1); } }); uploadChainRef.current = job.catch(() => {}); return job; } if (action.type === "DEL_IMAGE") { const r = await Api.tasks.imgDel(id, action.index); dispatch({ type: "UPSERT_TASK", task: r.task }); return; } if (action.type === "REPLACE_IMAGE") { uploadsInFlightRef.current++; const job = uploadChainRef.current.then(async () => { try { await Api.tasks.imgDel(id, action.index); let file = action.file; if (!file && action.url && action.url.startsWith("data:")) { const blob = await (await fetch(action.url)).blob(); file = new File([blob], "image", { type: blob.type }); } if (file) { const r = await Api.tasks.imgAdd(id, file); dispatch({ type: "UPSERT_TASK", task: r.task }); } else { const r = await Api.tasks.list(); dispatch({ type: "SET_TASKS", tasks: r.tasks }); } } catch (e) { pushToast("⚠️", "Falha ao trocar imagem: " + (e.message || "erro")); } finally { uploadsInFlightRef.current = Math.max(0, uploadsInFlightRef.current - 1); } }); uploadChainRef.current = job.catch(() => {}); return job; } if (action.type === "ADD_VIDEO") { const r = await Api.tasks.videoAdd(id, action.video); dispatch({ type: "UPSERT_TASK", task: r.task }); return; } if (action.type === "DEL_VIDEO") { const r = await Api.tasks.videoDel(id, action.index); dispatch({ type: "UPSERT_TASK", task: r.task }); return; } if (action.type === "UPDATE") { return updateTask(action.id, action.patch); } }); // ---- login flow ---- const onLogin = async (user) => { dispatch({ type: "LOGIN", user }); setEntering(true); try { const tk = await Api.tasks.list(); dispatch({ type: "SET_TASKS", tasks: tk.tasks }); const g = await Api.game.me(); dispatch({ type: "SET_GAME", game: g.game }); } catch (_) {} setTimeout(() => setEntering(false), 700); }; const onLogout = async () => { try { await Api.logout(); } catch (_) {} dispatch({ type: "LOGOUT" }); setOpenId(null); }; const saveUser = (newUser) => dispatch({ type: "SET_USER", user: newUser }); useEffect(() => { const h = (e) => { if (e.key === "Escape") { setOpenId(null); setModal(null); setSettings(null); setShowAdmin(false); setShowApprovals(false); } }; addEventListener("keydown", h); return () => removeEventListener("keydown", h); }, []); if (state.loading) { return