// 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 && (
)}
{/* 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 (
);
}
// ── 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 (
);
}
// ── 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,
};