// teknav-ui.jsx — Shared UI components (ES module) import { useState, useEffect, useRef, useCallback, createContext, useContext } from 'react'; import Lenis from 'lenis'; import { api, ApiError } from './src/lib/api.js'; import { contentApi } from './src/lib/content-api.js'; import { engagementApi } from './src/lib/engagement-api.js'; import TeknavCAP from './src/lib/TeknavCAP.jsx'; // ── 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 hashPath = window.location.hash.startsWith('#/') ? window.location.hash.slice(1) : ''; if (hashPath) { window.history.replaceState(null, '', hashPath); return hashPath; } const pathname = window.location.pathname; // Strip trailing slash (except root "/") so /article/slug/ resolves same as /article/slug 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 lenisRef = useRef(null); // Boot Lenis once useEffect(() => { const lenis = new Lenis({ duration: 1.2, easing: t => Math.min(1, 1.001 - Math.pow(2, -10 * t)), smoothWheel: true, wheelMultiplier: 1, touchMultiplier: 1.5, infinite: false, }); lenisRef.current = lenis; let raf; function loop(time) { lenis.raf(time); raf = requestAnimationFrame(loop); } raf = requestAnimationFrame(loop); return () => { cancelAnimationFrame(raf); lenis.destroy(); lenisRef.current = null; }; }, []); const navigate = useCallback((to) => { if (`${window.location.pathname}${window.location.search}` !== to) { window.history.pushState(null, '', to); } setPage(to); // Instant jump to top on route change (no smooth scroll between pages) if (lenisRef.current) { lenisRef.current.scrollTo(0, { immediate: true }); } else { window.scrollTo(0, 0); } }, []); useEffect(() => { const handler = () => setPage(getCurrentPage()); window.addEventListener('popstate', handler); window.addEventListener('hashchange', handler); return () => { window.removeEventListener('popstate', handler); window.removeEventListener('hashchange', handler); }; }, []); return {children}; } const useNav = () => useContext(NavCtx); // ── Auth Context ──────────────────────────────────────────────────────────── // Backend-backed auth: cookie session (HttpOnly tek_sid) + double-submit CSRF // (tek_csrf, mirrored to X-CSRF-Token by src/lib/api.js). The server is the // only source of truth — there's no `user` in localStorage anymore. const AuthCtx = createContext(null); function AuthProvider({ children }) { const [user, setUser] = useState(null); const [bootstrapping, setBootstrapping] = useState(true); // On mount, ask the server who we are (cookie may already be valid from a // previous tab). 401 just means "not signed in" — not an error. const refreshMe = async () => { const res = await api.get('/api/auth/me'); setUser(res?.user ?? null); return res?.user ?? null; }; useEffect(() => { let cancelled = false; api.get('/api/auth/me') .then((res) => { if (!cancelled) setUser(res?.user ?? null); }) .catch((e) => { if (!(e instanceof ApiError && e.status === 401)) console.error('[auth/me]', e); }) .finally(() => { if (!cancelled) setBootstrapping(false); }); return () => { cancelled = true; }; }, []); const login = async (identifier, password, captchaId, userSolution) => { const res = await api.post('/api/auth/login', { identifier, password, captchaId, userSolution }); if (res?.user) setUser(res.user); return res?.twoFactorRequired ? res : (res?.user ?? null); }; const verifyTwoFactor = async (ticket, code) => { const res = await api.post('/api/auth/2fa/verify', { ticket, code }); if (res?.user) setUser(res.user); return res?.user ?? null; }; const signup = async ({ name, username, email, phone, password, captchaId, userSolution }) => { const res = await api.post('/api/auth/signup', { name, username, email, phone, password, captchaId, userSolution }); if (res?.user) setUser(res.user); return res?.user ?? null; }; const startOAuth = (provider) => { window.location.href = `/api/auth/oauth/${provider}/start`; }; 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 = { 'تحلیل عمیق': { bg: '#0F6B73', text: '#fff' }, 'راهنمای فنی': { bg: '#D49A2A', text: '#fff' }, 'داده‌نما': { bg: '#C76D4A', text: '#fff' }, 'پژوهش': { bg: '#2F8F6B', text: '#fff' }, 'راستی‌آزمایی‌شده': { bg: '#263238', text: '#fff' }, 'خبر فوری': { bg: '#C94C4C', text: '#fff' }, 'خبر تحلیلی': { bg: '#5F6B6D', text: '#fff' }, 'پرونده': { bg: '#20343A', text: '#FAF7F0' }, '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} ); } const STATUS_COLORS = { 'منتشرشده': '#2F8F6B', 'پیش‌نویس': '#5F6B6D', 'در انتظار بررسی': '#D49A2A', 'نیازمند اصلاح': '#C94C4C', 'زمان‌بندی‌شده': '#0F6B73', }; function StatusBadge({ status }) { const color = STATUS_COLORS[status] || '#5F6B6D'; return ( {status} ); } const ROLE_COLORS = { admin: '#C76D4A', editor: '#0F6B73', writer: '#D49A2A', reviewer: '#2F8F6B', reader: '#5F6B6D' }; const ROLE_NAMES = { admin: 'مدیر کل', editor: 'سردبیر', writer: 'نویسنده', reviewer: 'بازبین', reader: 'خواننده' }; function RoleBadge({ role }) { const color = ROLE_COLORS[role] || '#5F6B6D'; return ( {ROLE_NAMES[role] || role} ); } // ── Header ────────────────────────────────────────────────────────────────── const NAV_ITEMS = [ { label: 'خانه', path: '/' }, { label: 'هوش مصنوعی', path: '/category/ai' }, { label: 'علم داده', path: '/category/data' }, { label: 'استارتاپ', path: '/category/startup' }, { label: 'امنیت', path: '/category/security' }, { label: 'سخت‌افزار', path: '/category/hardware' }, { label: 'نرم‌افزار', path: '/category/software' }, { label: 'آینده فناوری', path: '/category/future' }, { label: 'نویسندگان', path: '/authors' }, ]; 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('/search?q=' + encodeURIComponent(searchQ)); setSearchOpen(false); setSearchQ(''); } }; return ( <>
{/* Logo */} {/* Nav */} {/* Actions */}
{user ? (
{user.role !== 'reader' && ( )} {user.username && ( )} {user.role === 'reader' && !user.username && ( {user.name} )}
) : ( )}
{/* Search bar */} {searchOpen && (
setSearchQ(e.target.value)} placeholder="جستجو در مقاله‌ها، نویسندگان و دسته‌بندی‌ها..." style={{ flex: 1, ...inputStyle, fontSize: 14 }} dir="rtl" aria-label="عبارت جستجو" />
)}
{/* Mobile Drawer */} {mobileOpen && (
setMobileOpen(false)} style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.4)' }} />
تکناو
{NAV_ITEMS.map(item => ( ))} {user ? (
{user.username && ( )} {user.role !== 'reader' && ( )}
) : ( )}
)} ); } function NotificationBell() { const { navigate } = useNav(); const [open, setOpen] = useState(false); const [items, setItems] = useState([]); const [unread, setUnread] = useState(0); useEffect(() => { let cancelled = false; engagementApi.listNotifications({ limit: 20 }) .then(res => { if (!cancelled) { setItems(res?.items ?? []); setUnread(res?.unread ?? 0); } }) .catch(() => {}); const es = new EventSource('/api/v1/auth/notifications/stream', { withCredentials: true }); es.addEventListener('notification', (event) => { const notification = JSON.parse(event.data); setItems(list => [notification, ...list].slice(0, 20)); setUnread(n => n + 1); }); es.onerror = () => {}; return () => { cancelled = true; es.close(); }; }, []); const openNotification = async (notification) => { if (!notification.read) { engagementApi.markNotificationRead(notification.id).catch(() => {}); setUnread(n => Math.max(0, n - 1)); setItems(list => list.map(item => item.id === notification.id ? { ...item, read: true } : item)); } const slug = notification.payload?.articleSlug; if (slug) navigate('/article/' + slug); setOpen(false); }; const markAll = async () => { await engagementApi.markNotificationsRead().catch(() => {}); setUnread(0); setItems(list => list.map(item => ({ ...item, read: true }))); }; return (
{open && (
اعلان‌ها
{items.length === 0 ? (
اعلانی وجود ندارد.
) : items.map(item => ( ))}
)}
); } function notificationText(item) { if (item.type === 'comment_reply') return `${item.payload?.actorName ?? 'کاربر'} به نظر شما پاسخ داد`; if (item.type === 'comment') return `${item.payload?.actorName ?? 'کاربر'} برای مقاله شما نظر گذاشت`; if (item.type === 'new_article') return `مقاله تازه از ${item.payload?.actorName ?? 'نویسنده'}`; return 'اعلان جدید'; } // ── Footer ────────────────────────────────────────────────────────────────── function Footer() { const { navigate } = useNav(); return (
{/* Decorative top accent */}
تکناو
TEKNAV

روایت عمیق فناوری. تحلیل‌های علمی و تخصصی از دنیای فناوری برای متخصصان ایرانی.

ارتباط با ما
{['توییتر', 'لینکدین', 'تلگرام'].map(s => ( ))}
info@teknav.ir
🌊 حامی خلیج فارس 🌐 حامی اینترنت آزاد برای عموم
© ۱۴۰۵ تکناو — تمام حقوق محفوظ است Designed By Coaxys
); } // ── Newsletter Form ───────────────────────────────────────────────────────── function NewsletterForm() { const [email, setEmail] = useState(''); const [state, setState] = useState('idle'); const [captcha, setCaptcha] = useState({ captchaId: '', userSolution: '' }); const toast = useToast(); const submit = async (e) => { e.preventDefault(); if (!email.includes('@') || !email.includes('.')) { toast('لطفاً یک ایمیل معتبر وارد کنید', 'error'); return; } if (!captcha.captchaId || !captcha.userSolution.trim()) { toast('لطفاً کد امنیتی را وارد کنید', 'error'); return; } try { const res = await contentApi.subscribeNewsletter(email, captcha.captchaId, captcha.userSolution); setState('done'); toast(res?.alreadySubscribed ? 'این ایمیل قبلاً ثبت شده است' : 'با موفقیت عضو خبرنامه شدید!', res?.alreadySubscribed ? 'warning' : 'success'); } catch (err) { if (err instanceof ApiError && err.status === 429) toast('تعداد درخواست‌ها از حد مجاز گذشت؛ کمی بعد دوباره تلاش کنید', 'warning'); else if (err instanceof ApiError && err.status === 400) toast('کد امنیتی نامعتبر است', 'error'); else toast('خطا در ثبت عضویت خبرنامه', 'error'); } }; if (state === 'done') return (
✓
عضویت شما ثبت شد!
جدیدترین مقاله‌های تکناو را در ایمیل دریافت خواهید کرد.
); return (
setEmail(e.target.value)} type="email" placeholder="آدرس ایمیل شما" style={{ flex: 1, minWidth: 200, ...inputStyle }} dir="rtl" /> ); } // ── Skeleton Loader ───────────────────────────────────────────────────────── function SkeletonLoader({ lines = 3, height = 16 }) { return (
{Array.from({ length: lines }).map((_, i) => (
))}
); } // ── Confirm Modal ─────────────────────────────────────────────────────────── function ConfirmModal({ msg, onConfirm, onCancel }) { useEffect(() => { const h = (e) => { if (e.key === 'Escape') onCancel(); }; window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h); }, [onCancel]); return (
تأیید عملیات
{msg}
); } // ── Empty State ───────────────────────────────────────────────────────────── function EmptyState({ title = 'نتیجه‌ای یافت نشد', subtitle = 'با فیلترهای دیگر امتحان کنید' }) { 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 }) { return (
{author.initials || author.name.slice(0, 2)}
); } // ── 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: 'Vazirmatn,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: 'Vazirmatn,sans-serif', background: '#fff', color: '#263238', outline: 'none', width: '100%', }; // ── Streak Widget ─────────────────────────────────────────────────────────── function StreakWidget() { const { user } = useAuth(); const [data, setData] = useState(null); useEffect(() => { if (user) engagementApi.getStreaks().then(setData).catch(() => {}); }, [user]); if (!user || !data || data.streak === 0) return null; return (
🔥
{data.streak.toLocaleString('fa-IR')} روز مطالعه متوالی!
به یادگیری ادامه دهید
); } // ── Continue Reading Bar ─────────────────────────────────────────────────── function ContinueReadingBar() { const { user } = useAuth(); const { navigate } = useNav(); const [data, setData] = useState(null); useEffect(() => { if (user) engagementApi.getStreaks().then(setData).catch(() => {}); }, [user]); if (!user || !data || !data.continueReading?.length) return null; return (
ادامه مطالعه: {data.continueReading.map(a => ( ))}
); } // ── Reading List Modal ────────────────────────────────────────────────────── function ReadingListModal({ articleId, onDone }) { const [lists, setLists] = useState([]); const [loading, setLoading] = useState(true); const [newListName, setNewListName] = useState(''); const toast = useToast(); const load = useCallback(() => { setLoading(true); engagementApi.listReadingLists() .then(res => setLists(res.items ?? [])) .finally(() => setLoading(false)); }, []); useEffect(() => { load(); }, [load]); const add = async (listId) => { try { await engagementApi.addToReadingList(listId, articleId); toast('به لیست اضافه شد'); onDone(); } catch (e) { toast('در این لیست موجود است', 'warning'); } }; const create = async (e) => { e.preventDefault(); if (!newListName.trim()) return; try { const res = await engagementApi.createReadingList(newListName); setNewListName(''); add(res.list.id); } catch { toast('خطا در ایجاد لیست', 'error'); } }; return (

ذخیره در لیست مطالعه

{loading ? : (
{lists.map(list => ( ))}
)}
setNewListName(e.target.value)} placeholder="نام لیست جدید..." style={{ ...inputStyle, flex: 1 }} />
); } // ── Push Notification Prompt ──────────────────────────────────────────────── import { pushApi } from './src/lib/engagement-api.js'; function PushNotificationPrompt({ user }) { const [visible, setVisible] = useState(false); const toast = useToast(); useEffect(() => { if ( user && 'PushManager' in window && 'serviceWorker' in navigator && typeof Notification !== 'undefined' && Notification.permission === 'default' && !localStorage.getItem('push_prompt_dismissed') ) { setVisible(true); } }, [user]); if (!visible) return null; const handleEnable = async () => { try { const perm = await Notification.requestPermission(); if (perm === 'granted') { await pushApi.subscribe(); toast('اعلان‌های مرورگر فعال شد'); } } catch { // ignore — user may have denied or push not configured } setVisible(false); }; const dismiss = () => { localStorage.setItem('push_prompt_dismissed', '1'); setVisible(false); }; return (
🔔
اعلان‌های مرورگر
مقالات جدید را از دست ندهید
); } // ── Install Prompt ────────────────────────────────────────────────────────── function InstallPrompt() { const [deferredPrompt, setDeferredPrompt] = useState(null); const [visible, setVisible] = useState(false); useEffect(() => { const handler = (e) => { e.preventDefault(); setDeferredPrompt(e); setVisible(true); }; window.addEventListener('beforeinstallprompt', handler); return () => window.removeEventListener('beforeinstallprompt', handler); }, []); const install = async () => { if (!deferredPrompt) return; deferredPrompt.prompt(); const { outcome } = await deferredPrompt.userChoice; if (outcome === 'accepted') setVisible(false); setDeferredPrompt(null); }; if (!visible) return null; return (
نصب اپلیکیشن تکناو
دسترسی سریع‌تر و آفلاین به مقالات
); } function useViewportPrefetch(ref, slug) { useEffect(() => { if (!ref.current || !slug) return; const conn = navigator.connection; if (conn && (conn.saveData || /2g|3g/.test(conn.effectiveType))) { return; } const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const prefetch = () => { contentApi.getArticle(slug).catch(() => {}); }; if ('requestIdleCallback' in window) { window.requestIdleCallback(prefetch); } else { setTimeout(prefetch, 200); } observer.disconnect(); } }); }, { rootMargin: '100px' }); observer.observe(ref.current); return () => observer.disconnect(); }, [ref, slug]); } export { ToastProvider, useToast, NavProvider, useNav, AuthProvider, useAuth, CategoryBadge, TypeBadge, StatusBadge, RoleBadge, AuthorAvatar, Header, Footer, NewsletterForm, SkeletonLoader, ConfirmModal, EmptyState, ReadingProgress, StreakWidget, ContinueReadingBar, ReadingListModal, InstallPrompt, PushNotificationPrompt, iconBtnStyle, pillBtn, inputStyle, useViewportPrefetch, };