// app.jsx — root component (sidebar + main + cmd+k + tweaks) const { useState: useStateA, useEffect: useEffectA, useRef: useRefA } = React; /** * PageWrapper — сохраняет состояние страницы между переключениями вкладок. * * Раньше main-контейнер использовал `
` + условный рендер, * из-за чего React при каждом переключении полностью unmount-ил предыдущую * страницу. Терялись: выбранный чат, скролл, фильтры, набираемый текст, * in-flight запросы (без AbortController оставались висеть и писать в * размонтированный setState → React warnings). Это был главный root cause * ощущения «забывает где я был / нажал и ничего не сохранилось». * * Сейчас: * - Lazy mount: страница монтируется при первом visible=true. Если юзер * никогда не заходил на «Чеки» — компонент не запускается совсем. * - Keep alive: после первого монтирования компонент **остаётся** в DOM, * скрытый через display:none. Его state, polling-таймеры, in-flight * fetch и т.п. — продолжают жить. Юзер возвращается — всё на месте. * * Trade-off: polling всех ранее открытых страниц продолжает работать в фоне. * Для большинства случаев это нормально (запросы лёгкие). Если станет тяжело * — можно подключить Page Visibility API и паузить polling когда invisible. */ function PageWrapper({ visible, children }) { const [mounted, setMounted] = useStateA(visible); useEffectA(() => { if (visible && !mounted) setMounted(true); }, [visible, mounted]); if (!mounted) return null; return
{children}
; } const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "homeVariant": "B", "density": "regular", "accent": "indigo", "sidebar": "full" }/*EDITMODE-END*/; const ACCENT_MAP = { indigo: "#6366f1", emerald: "#10b981", rose: "#f43f5e", amber: "#f59e0b", fuchsia: "#d946ef", }; // Логотип сайдбара: реальный Telegram-аватар БОТА (/api/owners/bot/avatar). // Пока фото грузится или если его нет (404/ошибка) — прежний зелёный градиент // (тот же контейнер w-7 h-7 rounded-lg, размер/скругление не меняются). function BrandMark() { const [imgFailed, setImgFailed] = useStateA(false); return (
{!imgFailed && ( setImgFailed(true)} className="w-full h-full object-cover" /> )}
); } function App() { const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); const [active, setActive] = useStateA("home"); // Мобильное меню: на узких экранах (телефон) сайдбар прячется и выезжает // по кнопке-бургеру, чтобы контент занимал всю ширину, а не уезжал за край. const [mobileNavOpen, setMobileNavOpen] = useStateA(false); const [cmdkOpen, setCmdkOpen] = useStateA(false); const [hint, setHint] = useStateA(null); const [statusPills, setStatusPills] = useStateA(STATUS_PILLS); const gTimer = useRefA(null); const allItems = NAV.flatMap((g) => g.items); const current = allItems.find((n) => n.id === active); /* Polling статуса бота: первый запрос сразу + потом каждые 10 сек. Берёт реальные данные из /api/status. */ useEffectA(() => { let stopped = false; const refresh = async () => { const pills = await fetchStatus(); if (!stopped) setStatusPills(pills); }; refresh(); const id = setInterval(refresh, 10_000); return () => { stopped = true; clearInterval(id); }; }, []); // Density CSS var (could be expanded — for now just applied to spacing inline) const densityScale = t.density === "compact" ? 0.85 : t.density === "spacious" ? 1.15 : 1; // [бежевый редизайн] Хоткеи (⌘K + g-leader переключение разделов) убраны по просьбе. // Sidebar collapse const sidebarCollapsed = t.sidebar === "icons"; const accent = ACCENT_MAP[t.accent] || ACCENT_MAP.indigo; return (
{/* Затемнение под выехавшим меню — только на узких экранах, клик закрывает. */} {mobileNavOpen && (
setMobileNavOpen(false)} /> )}
{/* Кнопка-бургер — только на узких экранах, открывает боковое меню. */}
Zxckotik
{statusPills.map((s) => (
{s.label}: {s.value}
))}
{/* Каждая страница в PageWrapper'е → keep-alive между переключениями. При первом visit ленится монтироваться, дальше живёт скрытая. Это убирает «забывает где я был» — главный source ощущения, что админка ненадёжная. */}
setTweak("homeVariant", v)} /> setTweak("density", v)} /> { const k = Object.entries(ACCENT_MAP).find(([_, v]) => v.toLowerCase() === String(hex).toLowerCase()); setTweak("accent", k ? k[0] : "indigo"); }} /> setTweak("sidebar", v)} />
); } function Root() { // null = проверка ещё не завершена (показываем пустой экран чтобы не // мелькала login-форма до того как cookie проверится). const [authed, setAuthed] = useStateA(null); useEffectA(() => { let stopped = false; checkAuth().then((ok) => { if (!stopped) setAuthed(ok); }); // Если mutating endpoint вернёт 401 — слушаем глобальное событие и // показываем login-форму (cookie мог истечь). const onAuthRequired = () => setAuthed(false); window.addEventListener("auth-required", onAuthRequired); return () => { stopped = true; window.removeEventListener("auth-required", onAuthRequired); }; }, []); // монтируем рядом с любым экраном (login или App) чтобы // ошибки login/network тоже могли пушиться в toast. Toaster сам себя // прячет когда списка нет — нулевой overhead. const ToasterEl = typeof Toaster !== "undefined" ? : null; // Sprint β — Debug HUD overlay (passive). Активация: ?debug=1 в URL // или localStorage.debugHud=1. Без активации компонент возвращает null. // Можем монтировать безусловно — он сам решает показываться или нет. const DebugHudEl = typeof DebugHud !== "undefined" ? : null; if (authed === null) { return <>
{ToasterEl}{DebugHudEl}; } if (!authed) { return <> setAuthed(true)} />{ToasterEl}{DebugHudEl}; } return <>{ToasterEl}{DebugHudEl}; } ReactDOM.createRoot(document.getElementById("root")).render();