diff --git a/global/src/global-ui.jsx b/global/src/global-ui.jsx new file mode 100644 index 0000000..b397e81 --- /dev/null +++ b/global/src/global-ui.jsx @@ -0,0 +1,498 @@ +// 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, +};