// global-ui.jsx — Shared UI components for Teknav Global (English, LTR) import { useState, useEffect, useRef, useCallback, createContext, useContext } from 'react'; import { authors, categories, siteConfig } from '../global-data.js'; import { api } from './lib/api.js'; // ── Toast System ──────────────────────────────────────────────────────────── const ToastCtx = createContext(null); function ToastProvider({ children }) { const [toasts, setToasts] = useState([]); const add = useCallback((msg, type = 'success') => { const id = Date.now(); setToasts(t => [...t, { id, msg, type }]); setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 3200); }, []); return ( {children}
{toasts.map(t => (
{t.msg}
))}
); } const useToast = () => useContext(ToastCtx); // ── Navigation Context ────────────────────────────────────────────────────── const NavCtx = createContext(null); function getCurrentPage() { const pathname = window.location.pathname; const cleanPath = pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; const path = `${cleanPath}${window.location.search}`; return path === '' ? '/' : path; } function NavProvider({ children }) { const [page, setPage] = useState(getCurrentPage); const navigate = useCallback((to) => { if (`${window.location.pathname}${window.location.search}` !== to) { window.history.pushState(null, '', to); } setPage(to); window.scrollTo(0, 0); }, []); useEffect(() => { const handler = () => setPage(getCurrentPage()); window.addEventListener('popstate', handler); return () => window.removeEventListener('popstate', handler); }, []); return {children}; } const useNav = () => useContext(NavCtx); // ── Auth Context ──────────────────────────────────────────────────────────── const AuthCtx = createContext(null); function AuthProvider({ children }) { const [user, setUser] = useState(null); const [bootstrapping, setBootstrapping] = useState(true); useEffect(() => { let cancelled = false; api.get('/api/auth/me') .then((res) => { if (!cancelled) setUser(res?.user ?? null); }) .catch(() => {}) .finally(() => { if (!cancelled) setBootstrapping(false); }); return () => { cancelled = true; }; }, []); const logout = async () => { try { await api.post('/api/auth/logout', {}); } catch (e) { console.warn('[auth/logout]', e); } setUser(null); }; return {children}; } const useAuth = () => useContext(AuthCtx); // ── Badges ────────────────────────────────────────────────────────────────── const TYPE_COLORS = { 'Deep Analysis': { bg: '#0F6B73', text: '#fff' }, 'Technical Guide': { bg: '#D49A2A', text: '#fff' }, 'Data Viz': { bg: '#C76D4A', text: '#fff' }, 'Research': { bg: '#2F8F6B', text: '#fff' }, 'Fact-Checked': { bg: '#263238', text: '#fff' }, 'Breaking': { bg: '#C94C4C', text: '#fff' }, 'Analysis': { bg: '#5F6B6D', text: '#fff' }, 'Technical Analysis': { bg: '#0F6B73', text: '#fff' }, 'Guide': { bg: '#D49A2A', text: '#fff' }, 'default': { bg: '#E4DDD2', text: '#263238' }, }; function CategoryBadge({ name, color, small }) { return ( {name} ); } function TypeBadge({ type, small }) { const c = TYPE_COLORS[type] || TYPE_COLORS.default; return ( {type} ); } // ── Header ────────────────────────────────────────────────────────────────── const NAV_ITEMS = [ { label: 'Home', path: '/' }, { label: 'AI', path: '/category/ai' }, { label: 'Security', path: '/category/security' }, { label: 'Software', path: '/category/software' }, { label: 'Hardware', path: '/category/hardware' }, { label: 'Data', path: '/category/data' }, { label: 'Policy', path: '/category/policy' }, { label: 'Articles', path: '/articles' }, ]; function Header() { const { navigate, page } = useNav(); const { user, logout } = useAuth(); const [scrolled, setScrolled] = useState(false); const [mobileOpen, setMobileOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false); const [searchQ, setSearchQ] = useState(''); const searchRef = useRef(); useEffect(() => { const h = () => setScrolled(window.scrollY > 20); window.addEventListener('scroll', h); return () => window.removeEventListener('scroll', h); }, []); useEffect(() => { if (searchOpen) setTimeout(() => searchRef.current?.focus(), 100); }, [searchOpen]); const handleSearch = (e) => { e.preventDefault(); if (searchQ.trim()) { navigate('/articles?q=' + encodeURIComponent(searchQ)); setSearchOpen(false); setSearchQ(''); } }; return ( <>
{/* Logo */} {/* Nav */} {/* Actions */}
فارسی {user ? (
{user.username && ( @{user.username} )}
) : ( )}
{/* Search bar */} {searchOpen && (
setSearchQ(e.target.value)} placeholder="Search articles, authors, topics..." style={{ flex: 1, ...inputStyle, fontSize: 14 }} aria-label="Search query" />
)}
{/* Mobile Drawer */} {mobileOpen && (
setMobileOpen(false)} style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.4)' }} />
Teknav
{NAV_ITEMS.map(item => ( ))} فارسی (Persian)
)} ); } // ── Footer ────────────────────────────────────────────────────────────────── function Footer() { const { navigate } = useNav(); return ( ); } // ── Newsletter Form ───────────────────────────────────────────────────────── function NewsletterForm() { const [email, setEmail] = useState(''); const [state, setState] = useState('idle'); const toast = useToast(); const submit = async (e) => { e.preventDefault(); if (!email.includes('@') || !email.includes('.')) { toast('Please enter a valid email address', 'error'); return; } try { await api.post('/api/newsletter/subscribe', { email }); setState('done'); toast('You\'ve subscribed to Teknav!', 'success'); } catch { toast('Subscription error — please try again', 'error'); } }; if (state === 'done') return (
✓
You're subscribed!
You'll receive Teknav's latest analysis in your inbox.
); return (
setEmail(e.target.value)} type="email" placeholder="Your email address" style={{ flex: 1, minWidth: 200, ...inputStyle }} />
); } // ── Skeleton Loader ───────────────────────────────────────────────────────── function SkeletonLoader({ lines = 3, height = 16 }) { return (
{Array.from({ length: lines }).map((_, i) => (
))}
); } // ── Empty State ───────────────────────────────────────────────────────────── function EmptyState({ title = 'No results found', subtitle = 'Try different filters or search terms' }) { return (
◎
{title}
{subtitle}
); } // ── Reading Progress ──────────────────────────────────────────────────────── function ReadingProgress() { const [pct, setPct] = useState(0); useEffect(() => { const h = () => { const el = document.documentElement; const scroll = el.scrollTop || document.body.scrollTop; const total = el.scrollHeight - el.clientHeight; setPct(total > 0 ? (scroll / total) * 100 : 0); }; window.addEventListener('scroll', h); return () => window.removeEventListener('scroll', h); }, []); return (
); } // ── Author Avatar ─────────────────────────────────────────────────────────── function AuthorAvatar({ author, size = 40 }) { const a = typeof author === 'string' ? authors.find(x => x.id === author) : author; if (!a) return null; return (
{a.initials || a.name.slice(0, 2)}
); } // ── Push Notification Prompt ──────────────────────────────────────────────── function PushPrompt() { const [visible, setVisible] = useState(false); const toast = useToast(); useEffect(() => { if ( 'PushManager' in window && 'serviceWorker' in navigator && typeof Notification !== 'undefined' && Notification.permission === 'default' && !localStorage.getItem('push_prompt_dismissed_en') ) { const t = setTimeout(() => setVisible(true), 8000); return () => clearTimeout(t); } }, []); if (!visible) return null; const handleEnable = async () => { try { const perm = await Notification.requestPermission(); if (perm === 'granted') toast('Browser notifications enabled'); } catch { /* ignore */ } setVisible(false); }; const dismiss = () => { localStorage.setItem('push_prompt_dismissed_en', '1'); setVisible(false); }; return (
🔔
Browser notifications
Never miss a Teknav article
); } // ── Shared styles ─────────────────────────────────────────────────────────── const iconBtnStyle = { background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', width: 36, height: 36, borderRadius: 8, transition: 'background 0.15s', }; const pillBtn = { border: 'none', cursor: 'pointer', padding: '8px 18px', borderRadius: 8, fontSize: 13, fontFamily: 'Inter,sans-serif', fontWeight: 600, transition: 'all 0.15s', display: 'inline-flex', alignItems: 'center', gap: 6, }; const inputStyle = { border: '1px solid #E4DDD2', borderRadius: 8, padding: '9px 14px', fontSize: 13, fontFamily: 'Inter,sans-serif', background: '#fff', color: '#263238', outline: 'none', width: '100%', }; export { ToastProvider, useToast, NavProvider, useNav, AuthProvider, useAuth, CategoryBadge, TypeBadge, AuthorAvatar, Header, Footer, NewsletterForm, SkeletonLoader, EmptyState, ReadingProgress, PushPrompt, iconBtnStyle, pillBtn, inputStyle, };