// app.jsx — root: auth, routing, shared state, toasts, API integration
const { useState, useEffect, useCallback } = React;
const ACCENTS = {
Blue: { c6: '#0a66ff', c7: '#0a52d6', c5: '#599bff', c1: '#d9e8ff', c0: '#eef5ff', rgb: '10,102,255' },
Indigo: { c6: '#4f46e5', c7: '#4338ca', c5: '#818cf8', c1: '#e0e7ff', c0: '#eef2ff', rgb: '79,70,229' },
Violet: { c6: '#7c3aed', c7: '#6d28d9', c5: '#a78bfa', c1: '#ede9fe', c0: '#f5f3ff', rgb: '124,58,237' },
Emerald: { c6: '#059669', c7: '#047857', c5: '#34d399', c1: '#d1fae5', c0: '#ecfdf5', rgb: '5,150,105' },
Coral: { c6: '#f4623a', c7: '#e14e29', c5: '#fb8a5c', c1: '#ffe2d5', c0: '#fff3ee', rgb: '244,98,58' },
};
const RADII = {
Sharp: { xs: '4px', sm: '6px', r: '8px', lg: '10px', xl: '13px' },
Default: { xs: '7px', sm: '10px', r: '13px', lg: '17px', xl: '22px' },
Round: { xs: '10px', sm: '14px', r: '18px', lg: '24px', xl: '30px' },
};
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"theme": "Light",
"accent": "Blue",
"radius": "Default",
"density": "Comfortable",
"railStyle": "Tinted"
}/*EDITMODE-END*/;
function Toasts({ items }) {
const Icon = window.Icon;
return (
{items.map(t => (
{t.msg}
))}
);
}
function App() {
const [t, setTweak] = window.useTweaks(TWEAK_DEFAULTS);
// ── Auth ─────────────────────────────────────────────────────
const [authUser, setAuthUser] = useState(null); // null=loading, false=logged out, obj=authed
const [authReady, setAuthReady] = useState(false);
useEffect(() => {
const token = localStorage.getItem('ims_token');
if (!token) { setAuthUser(false); setAuthReady(true); return; }
window.API.me()
.then(u => { setAuthUser(u); setBalance(u.balance); setAuthReady(true); })
.catch(() => { localStorage.removeItem('ims_token'); setAuthUser(false); setAuthReady(true); });
}, []);
const handleLogin = useCallback((user) => {
setAuthUser(user);
setBalance(user.balance || 1000);
window.API.getNotifs().then(ns => setNotifs(ns)).catch(() => {});
}, []);
const handleLogout = useCallback(() => {
localStorage.removeItem('ims_token');
setAuthUser(false);
setRoute('home');
}, []);
// ── Routing ──────────────────────────────────────────────────
const [route, setRoute] = useState('home');
const [collapsed, setCollapsed] = useState(false);
const [search, setSearch] = useState('');
const [activeIdea, setActiveIdea] = useState(null);
const [activeUser, setActiveUser] = useState(null);
const [prevRoute, setPrevRoute] = useState('home');
const [addOpen, setAddOpen] = useState(false);
const [notifOpen, setNotifOpen] = useState(false);
const [balance, setBalance] = useState(1000);
const [lang, setLang] = useState('en');
const toggleLang = () => {
setLang(l => {
const next = l === 'en' ? 'ar' : 'en';
const html = document.documentElement;
html.setAttribute('dir', next === 'ar' ? 'rtl' : 'ltr');
html.setAttribute('lang', next);
if (next === 'ar') {
html.style.fontFamily = "'Noto Sans Arabic', 'Segoe UI', sans-serif";
} else {
html.style.fontFamily = '';
}
return next;
});
};
// ── Notifications ─────────────────────────────────────────────
const [notifs, setNotifs] = useState([]);
useEffect(() => {
if (!authUser) return;
window.API.getNotifs().then(ns => setNotifs(ns)).catch(() => {});
const iv = setInterval(() => window.API.getNotifs().then(ns => setNotifs(ns)).catch(() => {}), 30000);
return () => clearInterval(iv);
}, [!!authUser]);
const unread = notifs.filter(n => n.unread).length;
const [toasts, setToasts] = useState([]);
const toast = useCallback((msg) => {
const id = Date.now() + Math.random();
setToasts(ts => [...ts, { id, msg }]);
setTimeout(() => setToasts(ts => ts.filter(x => x.id !== id)), 2600);
}, []);
// ── Votes (optimistic + API) ──────────────────────────────────
const [votes, setVotes] = useState({});
const onVote = useCallback(async (id, dir) => {
const prev = votes[id];
setVotes(v => ({ ...v, [id]: v[id] === dir ? undefined : dir }));
try {
const res = await window.API.voteIdea(id, dir);
setVotes(v => ({ ...v, [id]: res.vote || undefined }));
} catch (e) {
setVotes(v => ({ ...v, [id]: prev }));
toast('Vote failed: ' + e.message);
}
}, [votes, toast]);
const go = useCallback((r) => { setPrevRoute(route); setRoute(r); document.querySelector('.content')?.scrollTo(0, 0); }, [route]);
const openIdea = useCallback((idea) => { setActiveIdea(idea); setPrevRoute(route); setRoute('idea'); document.querySelector('.content')?.scrollTo(0, 0); }, [route]);
const openUser = useCallback((user) => { setActiveUser(user); setPrevRoute(route); setRoute('user'); document.querySelector('.content')?.scrollTo(0, 0); }, [route]);
const redeem = useCallback(async (award) => {
try {
const res = await window.API.redeem(award.id);
setBalance(res.balance);
toast(`Redeemed "${award.title}" · -${award.cost} pts`);
} catch (e) {
toast('Redemption failed: ' + e.message);
}
}, [toast]);
const submitIdea = useCallback(async ({ title, description, category, tags, coverImage, extraImage, video }) => {
try {
await window.API.createIdea({ title, description, category, tags, coverImage, extraImage, video });
setAddOpen(false);
window.API.me().then(u => setBalance(u.balance)).catch(() => {});
setRoute('myideas');
toast(`Idea "${title}" submitted · +150 pts`);
} catch (e) {
toast('Submit failed: ' + e.message);
}
}, [toast]);
const onSearchSubmit = useCallback((val, enter) => {
if (enter && val.trim()) { setRoute('search'); document.querySelector('.content')?.scrollTo(0, 0); }
else if (val.trim() && route !== 'search') { setRoute('search'); }
else if (!val.trim() && route === 'search') { setRoute('home'); }
}, [route]);
const readNotif = useCallback(async (id) => {
setNotifs(ns => ns.map(n => n.id === id ? { ...n, unread: false } : n));
try { await window.API.readNotif(id); } catch {}
}, []);
const readAllNotifs = useCallback(async () => {
setNotifs(ns => ns.map(n => ({ ...n, unread: false })));
try { await window.API.readAllNotifs(); } catch {}
}, []);
const saveProfile = useCallback(() => { go('profile'); toast('Profile saved'); }, [go, toast]);
// ⌘K focuses search
useEffect(() => {
const h = (e) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); document.querySelector('.search input')?.focus(); } };
window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h);
}, []);
// apply tweaks → CSS vars
useEffect(() => {
const r = document.documentElement.style;
document.body.classList.add('no-anim');
const a = ACCENTS[t.accent] || ACCENTS.Blue;
r.setProperty('--accent', a.c6); r.setProperty('--accent-600', a.c6); r.setProperty('--accent-700', a.c7);
r.setProperty('--accent-300', a.c5); r.setProperty('--accent-100', a.c1); r.setProperty('--accent-50', a.c0);
r.setProperty('--accent-rgb', a.rgb);
const rad = RADII[t.radius] || RADII.Default;
r.setProperty('--r-xs', rad.xs); r.setProperty('--r-sm', rad.sm); r.setProperty('--r', rad.r);
r.setProperty('--r-lg', rad.lg); r.setProperty('--r-xl', rad.xl);
r.setProperty('--density', t.density === 'Compact' ? '0.6' : '1');
const dark = t.theme === 'Dark';
document.body.classList.toggle('dark', dark);
r.setProperty('--accent-50', dark ? `rgba(${a.rgb},.16)` : a.c0);
r.setProperty('--accent-100', dark ? `rgba(${a.rgb},.26)` : a.c1);
r.setProperty('--accent-700', dark ? a.c5 : a.c7);
requestAnimationFrame(() => requestAnimationFrame(() => document.body.classList.remove('no-anim')));
}, [t.accent, t.radius, t.density, t.theme]);
const screen = () => {
const listProps = { votes, onVote, openIdea };
switch (route) {
case 'home': return ;
case 'search': return ;
case 'addIdea': return go(prevRoute === 'addIdea' ? 'home' : prevRoute)} onSubmit={submitIdea} />;
case 'dashboard': return ;
case 'myideas': return ;
case 'favorite': return ;
case 'profile': return go('editProfile')} openUser={openUser} />;
case 'editProfile': return go('profile')} onSave={saveProfile} />;
case 'settings': return ;
case 'trade': return go('balanceDetail')} />;
case 'balanceDetail': return go('trade')} />;
case 'user': return go(prevRoute)} openIdea={openIdea} votes={votes} onVote={onVote} />;
case 'idea': return go(prevRoute)} openUser={openUser} currentUser={authUser} />;
default: return ;
}
};
// nav highlight: map sub-routes back to their owning nav section
const navMap = { idea: prevRoute, user: prevRoute, editProfile: 'profile', balanceDetail: 'trade', search: 'home', addIdea: prevRoute };
const navRoute = navMap[route] || route;
// ── Auth gate ─────────────────────────────────────────────────
if (!authReady) return (
);
if (!authUser) return ;
return (
setCollapsed(c => !c)} search={search} setSearch={setSearch} balance={balance} onAdd={() => go('addIdea')} onNav={go} onSearchSubmit={onSearchSubmit} notifOpen={notifOpen} setNotifOpen={setNotifOpen} unread={unread} currentUser={authUser} lang={lang} toggleLang={toggleLang} />
{screen()}
setNotifOpen(false)} items={notifs} onRead={readNotif} onReadAll={readAllNotifs} />
setAddOpen(false)} onSubmit={submitIdea} />
setTweak('theme', v)} />
a.c6)}
onChange={(hex) => setTweak('accent', Object.keys(ACCENTS).find(k => ACCENTS[k].c6 === hex) || 'Blue')} />
setTweak('radius', v)} />
setTweak('density', v)} />
setTweak('railStyle', v)} />
);
}
// alias fix
window.Toasts = Toasts;
ReactDOM.createRoot(document.getElementById('root')).render();