diff --git a/app/teknav-ui.jsx b/app/teknav-ui.jsx new file mode 100644 index 0000000..39d2970 --- /dev/null +++ b/app/teknav-ui.jsx @@ -0,0 +1,938 @@ +// 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, +}; +