Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
499 lines
26 KiB
React
499 lines
26 KiB
React
// 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 (
|
|
<ToastCtx.Provider value={add}>
|
|
{children}
|
|
<div style={{ position: 'fixed', bottom: 28, right: 24, zIndex: 9999, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
{toasts.map(t => (
|
|
<div key={t.id} style={{
|
|
padding: '11px 20px', borderRadius: 8, color: '#fff', fontSize: 14, fontFamily: 'Inter,sans-serif',
|
|
background: t.type === 'error' ? '#C94C4C' : t.type === 'warning' ? '#D08A22' : '#2F8F6B',
|
|
boxShadow: '0 4px 20px rgba(0,0,0,0.18)',
|
|
animation: 'slideUp 0.25s ease',
|
|
}}>{t.msg}</div>
|
|
))}
|
|
</div>
|
|
</ToastCtx.Provider>
|
|
);
|
|
}
|
|
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 <NavCtx.Provider value={{ page, navigate }}>{children}</NavCtx.Provider>;
|
|
}
|
|
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 <AuthCtx.Provider value={{ user, logout, bootstrapping }}>{children}</AuthCtx.Provider>;
|
|
}
|
|
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 (
|
|
<span style={{
|
|
display: 'inline-block', padding: small ? '2px 8px' : '3px 10px',
|
|
borderRadius: 4, fontSize: small ? 11 : 12, fontWeight: 600,
|
|
background: color ? color + '18' : '#0F6B7318', color: color || '#0F6B73',
|
|
border: `1px solid ${color || '#0F6B73'}30`,
|
|
}}>{name}</span>
|
|
);
|
|
}
|
|
function TypeBadge({ type, small }) {
|
|
const c = TYPE_COLORS[type] || TYPE_COLORS.default;
|
|
return (
|
|
<span style={{
|
|
display: 'inline-block', padding: small ? '2px 8px' : '3px 10px',
|
|
borderRadius: 3, fontSize: small ? 10 : 11, fontWeight: 700, letterSpacing: '0.03em',
|
|
background: c.bg, color: c.text,
|
|
}}>{type}</span>
|
|
);
|
|
}
|
|
|
|
// ── 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 (
|
|
<>
|
|
<header role="banner" style={{
|
|
position: 'fixed', top: 0, left: 0, right: 0, zIndex: 100, direction: 'ltr',
|
|
background: scrolled ? 'rgba(250,247,240,0.95)' : 'rgba(250,247,240,0.85)',
|
|
backdropFilter: 'blur(12px)', borderBottom: scrolled ? '1px solid #E4DDD2' : '1px solid transparent',
|
|
transition: 'all 0.3s',
|
|
}}>
|
|
<div className="site-header-inner" style={{ maxWidth: 1280, margin: '0 auto', padding: '0 24px', display: 'flex', alignItems: 'center', height: 64, gap: 32 }}>
|
|
{/* Logo */}
|
|
<button onClick={() => navigate('/')} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', flexDirection: 'column', lineHeight: 1 }} aria-label="Teknav home">
|
|
<span style={{ fontSize: 22, fontWeight: 900, color: '#0F6B73', fontFamily: 'Inter,sans-serif', letterSpacing: '-0.02em' }}>Teknav</span>
|
|
<span style={{ fontSize: 9, color: '#5F6B6D', letterSpacing: '0.15em', fontFamily: 'sans-serif' }}>GLOBAL</span>
|
|
</button>
|
|
|
|
{/* Nav */}
|
|
<nav role="navigation" style={{ display: 'flex', gap: 0, flex: 1, overflowX: 'auto' }} className="desktop-nav" aria-label="Main navigation">
|
|
{NAV_ITEMS.map(item => (
|
|
<button key={item.path} onClick={() => navigate(item.path)} style={{
|
|
background: 'none', border: 'none', cursor: 'pointer', padding: '8px 12px',
|
|
fontSize: 13, fontFamily: 'Inter,sans-serif', whiteSpace: 'nowrap',
|
|
color: page === item.path ? '#0F6B73' : '#263238',
|
|
fontWeight: page === item.path ? 700 : 400,
|
|
borderBottom: page === item.path ? '2px solid #0F6B73' : '2px solid transparent',
|
|
transition: 'all 0.15s',
|
|
}} aria-current={page === item.path ? 'page' : undefined}>{item.label}</button>
|
|
))}
|
|
</nav>
|
|
|
|
{/* Actions */}
|
|
<div className="site-header-actions" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<button onClick={() => setSearchOpen(s => !s)} style={iconBtnStyle} aria-label="Search Teknav" aria-expanded={searchOpen}>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#263238" strokeWidth="2" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
|
</button>
|
|
<a href="https://www.teknav.ir" target="_blank" rel="noopener noreferrer" style={{ ...pillBtn, background: '#F4EFE6', color: '#263238', border: '1px solid #E4DDD2', fontSize: 12 }}>
|
|
فارسی
|
|
</a>
|
|
{user ? (
|
|
<div className="site-auth-actions" style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
{user.username && (
|
|
<span style={{ fontSize: 13, color: '#5F6B6D', fontWeight: 600 }}>@{user.username}</span>
|
|
)}
|
|
<button onClick={() => logout()} style={{ ...pillBtn, background: '#F4EFE6', color: '#263238' }}>Sign out</button>
|
|
</div>
|
|
) : (
|
|
<button onClick={() => navigate('/login')} style={{ ...pillBtn, background: '#C46A4D', color: '#fff' }}>Sign in</button>
|
|
)}
|
|
<button onClick={() => setMobileOpen(o => !o)} style={{ ...iconBtnStyle, display: 'none' }} className="mobile-menu-btn" aria-label="Open mobile menu" aria-expanded={mobileOpen}>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#263238" strokeWidth="2" aria-hidden="true"><path d="M3 12h18M3 6h18M3 18h18"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search bar */}
|
|
{searchOpen && (
|
|
<div className="site-search-strip" style={{ borderTop: '1px solid #E4DDD2', padding: '12px 24px', background: '#FAF7F0' }}>
|
|
<form className="site-search-form" role="search" onSubmit={handleSearch} style={{ maxWidth: 600, margin: '0 auto', display: 'flex', gap: 8 }}>
|
|
<input ref={searchRef} value={searchQ} onChange={e => setSearchQ(e.target.value)}
|
|
placeholder="Search articles, authors, topics..."
|
|
style={{ flex: 1, ...inputStyle, fontSize: 14 }} aria-label="Search query" />
|
|
<button type="submit" style={{ ...pillBtn, background: '#0F6B73', color: '#fff' }}>Search</button>
|
|
</form>
|
|
</div>
|
|
)}
|
|
</header>
|
|
|
|
{/* Mobile Drawer */}
|
|
{mobileOpen && (
|
|
<div style={{ position: 'fixed', inset: 0, zIndex: 200, direction: 'ltr' }}>
|
|
<div onClick={() => setMobileOpen(false)} style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.4)' }} />
|
|
<div className="mobile-drawer-panel" style={{ position: 'absolute', top: 0, left: 0, width: 280, height: '100%', background: '#FAF7F0', padding: 24, overflowY: 'auto', boxShadow: '4px 0 24px rgba(0,0,0,0.12)' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
|
<span style={{ fontSize: 20, fontWeight: 900, color: '#0F6B73' }}>Teknav</span>
|
|
<button onClick={() => setMobileOpen(false)} style={iconBtnStyle}>✕</button>
|
|
</div>
|
|
{NAV_ITEMS.map(item => (
|
|
<button key={item.path} onClick={() => { navigate(item.path); setMobileOpen(false); }} style={{
|
|
display: 'block', width: '100%', textAlign: 'left', background: 'none', border: 'none',
|
|
padding: '12px 0', fontSize: 15, fontFamily: 'Inter,sans-serif', cursor: 'pointer',
|
|
color: page === item.path ? '#0F6B73' : '#263238', fontWeight: page === item.path ? 700 : 400,
|
|
borderBottom: '1px solid #E4DDD2',
|
|
}}>{item.label}</button>
|
|
))}
|
|
<a href="https://www.teknav.ir" style={{ display: 'block', marginTop: 20, ...pillBtn, background: '#F4EFE6', color: '#263238', textAlign: 'center', justifyContent: 'center' }}>فارسی (Persian)</a>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<style>{`
|
|
.desktop-nav { display: flex !important; }
|
|
.mobile-menu-btn { display: none !important; }
|
|
@media (max-width: 768px) {
|
|
.desktop-nav { display: none !important; }
|
|
.mobile-menu-btn { display: flex !important; }
|
|
}
|
|
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
|
* { box-sizing: border-box; }
|
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
::-webkit-scrollbar-track { background: #FAF7F0; }
|
|
::-webkit-scrollbar-thumb { background: #E4DDD2; border-radius: 3px; }
|
|
`}</style>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ── Footer ──────────────────────────────────────────────────────────────────
|
|
function Footer() {
|
|
const { navigate } = useNav();
|
|
return (
|
|
<footer role="contentinfo" style={{ background: '#ebe3d2', color: '#4A5A5C', direction: 'ltr', padding: '56px 24px 28px', marginTop: 80, position: 'relative', overflow: 'hidden' }}>
|
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 3, background: 'linear-gradient(90deg, transparent 0%, #0F6B73 25%, #D49A2A 50%, #C46A4D 75%, transparent 100%)' }} />
|
|
<div style={{ maxWidth: 1280, margin: '0 auto', position: 'relative' }}>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(200px,1fr))', gap: 40, marginBottom: 40 }}>
|
|
<section aria-label="About Teknav">
|
|
<div style={{ fontSize: 28, fontWeight: 900, color: '#0F2A2E', marginBottom: 6, fontFamily: "Inter,sans-serif", letterSpacing: '-0.02em' }}>Teknav</div>
|
|
<div style={{ fontSize: 11, color: '#C46A4D', letterSpacing: '0.18em', marginBottom: 14, fontWeight: 700 }}>GLOBAL</div>
|
|
<p style={{ fontSize: 13, lineHeight: 1.9, color: '#5F6B6D' }}>Deep technology analysis. Independent, rigorous coverage of AI engineering, security, and the future of software for builders worldwide.</p>
|
|
</section>
|
|
<nav aria-label="Category links">
|
|
<div style={{ fontSize: 14, fontWeight: 700, color: '#0F2A2E', marginBottom: 16, fontFamily: "Inter,sans-serif" }}>Topics</div>
|
|
{[
|
|
['AI Engineering', '/category/ai'],
|
|
['Security', '/category/security'],
|
|
['Software Architecture', '/category/software'],
|
|
['Hardware & Compute', '/category/hardware'],
|
|
['AI Policy', '/category/policy'],
|
|
].map(([label, path]) => (
|
|
<div key={path} onClick={() => navigate(path)} style={{ fontSize: 13, marginBottom: 9, cursor: 'pointer', color: '#5F6B6D', transition: 'color 0.15s, transform 0.15s' }}
|
|
onMouseEnter={e => { e.currentTarget.style.color = '#0F6B73'; e.currentTarget.style.transform = 'translateX(4px)'; }}
|
|
onMouseLeave={e => { e.currentTarget.style.color = '#5F6B6D'; e.currentTarget.style.transform = 'translateX(0)'; }}>{label}</div>
|
|
))}
|
|
</nav>
|
|
<nav aria-label="Site links">
|
|
<div style={{ fontSize: 14, fontWeight: 700, color: '#0F2A2E', marginBottom: 16, fontFamily: "Inter,sans-serif" }}>Teknav</div>
|
|
{[
|
|
['About Us', '/about'],
|
|
['Privacy Policy', '/privacy'],
|
|
['Terms of Use', '/terms'],
|
|
['Editorial Policy', '/editorial-policy'],
|
|
['Persian Edition', 'https://www.teknav.ir'],
|
|
].map(([label, path]) => (
|
|
path.startsWith('http') ? (
|
|
<a key={path} href={path} target="_blank" rel="noopener noreferrer" style={{ display: 'block', fontSize: 13, marginBottom: 9, color: '#5F6B6D', transition: 'color 0.15s' }}
|
|
onMouseEnter={e => e.currentTarget.style.color = '#0F6B73'}
|
|
onMouseLeave={e => e.currentTarget.style.color = '#5F6B6D'}>{label}</a>
|
|
) : (
|
|
<div key={path} onClick={() => navigate(path)} style={{ fontSize: 13, marginBottom: 9, cursor: 'pointer', color: '#5F6B6D', transition: 'color 0.15s, transform 0.15s' }}
|
|
onMouseEnter={e => { e.currentTarget.style.color = '#0F6B73'; e.currentTarget.style.transform = 'translateX(4px)'; }}
|
|
onMouseLeave={e => { e.currentTarget.style.color = '#5F6B6D'; e.currentTarget.style.transform = 'translateX(0)'; }}>{label}</div>
|
|
)
|
|
))}
|
|
</nav>
|
|
<section aria-label="Connect">
|
|
<div style={{ fontSize: 14, fontWeight: 700, color: '#0F2A2E', marginBottom: 16, fontFamily: "Inter,sans-serif" }}>Connect</div>
|
|
<div style={{ display: 'flex', gap: 10, marginBottom: 16 }}>
|
|
{[['Twitter/X', 'https://x.com/teknavir'], ['LinkedIn', 'https://linkedin.com/company/teknav'], ['Telegram', 'https://t.me/teknavir']].map(([s, href]) => (
|
|
<a key={s} href={href} target="_blank" rel="noopener noreferrer" style={{ background: 'rgba(15,107,115,0.08)', border: '1px solid rgba(15,107,115,0.25)', color: '#0F6B73', padding: '7px 14px', borderRadius: 8, cursor: 'pointer', fontSize: 12, fontWeight: 600, fontFamily: 'Inter,sans-serif', transition: 'all 0.18s', textDecoration: 'none', display: 'inline-block' }}
|
|
onMouseEnter={e => { e.currentTarget.style.background = '#0F6B73'; e.currentTarget.style.color = '#fff'; e.currentTarget.style.transform = 'translateY(-2px)'; }}
|
|
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(15,107,115,0.08)'; e.currentTarget.style.color = '#0F6B73'; e.currentTarget.style.transform = 'translateY(0)'; }}>{s}</a>
|
|
))}
|
|
</div>
|
|
<div style={{ fontSize: 12, color: '#C46A4D', fontWeight: 600 }}>info@teknav.ir</div>
|
|
</section>
|
|
</div>
|
|
<div style={{ borderTop: '1px solid rgba(15,42,46,0.12)', paddingTop: 22, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
|
|
<span style={{ fontSize: 12, color: '#5F6B6D' }}>© 2026 Teknav — All rights reserved</span>
|
|
<span style={{ fontSize: 12, color: '#5F6B6D', letterSpacing: '0.04em' }}>
|
|
Designed By <a href="https://www.asabbagh.com" target="_blank" rel="noopener noreferrer" style={{ color: '#0F6B73', fontWeight: 700, letterSpacing: '0.08em', textDecoration: 'none' }}>Coaxys</a>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</footer>
|
|
);
|
|
}
|
|
|
|
// ── 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 (
|
|
<div style={{ textAlign: 'center', padding: '32px 0' }}>
|
|
<div style={{ fontSize: 32, marginBottom: 8 }}>✓</div>
|
|
<div style={{ fontSize: 16, fontWeight: 600, color: '#2F8F6B' }}>You're subscribed!</div>
|
|
<div style={{ fontSize: 13, color: '#5F6B6D', marginTop: 4 }}>You'll receive Teknav's latest analysis in your inbox.</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<form onSubmit={submit} style={{ display: 'flex', gap: 10, maxWidth: 460, margin: '0 auto' }}>
|
|
<input value={email} onChange={e => setEmail(e.target.value)} type="email"
|
|
placeholder="Your email address"
|
|
style={{ flex: 1, minWidth: 200, ...inputStyle }} />
|
|
<button type="submit" style={{ ...pillBtn, background: '#C46A4D', color: '#fff', padding: '10px 24px', whiteSpace: 'nowrap' }}>Subscribe</button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
// ── Skeleton Loader ─────────────────────────────────────────────────────────
|
|
function SkeletonLoader({ lines = 3, height = 16 }) {
|
|
return (
|
|
<div>
|
|
{Array.from({ length: lines }).map((_, i) => (
|
|
<div key={i} style={{ height, borderRadius: 6, background: 'linear-gradient(90deg,#E4DDD2 0%,#F4EFE6 50%,#E4DDD2 100%)', backgroundSize: '200% 100%', animation: 'shimmer 1.4s infinite', marginBottom: 10, width: i === lines - 1 ? '60%' : '100%' }} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Empty State ─────────────────────────────────────────────────────────────
|
|
function EmptyState({ title = 'No results found', subtitle = 'Try different filters or search terms' }) {
|
|
return (
|
|
<div style={{ textAlign: 'center', padding: '60px 24px', color: '#5F6B6D' }}>
|
|
<div style={{ fontSize: 48, marginBottom: 12, opacity: 0.4 }}>◎</div>
|
|
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 6, color: '#263238' }}>{title}</div>
|
|
<div style={{ fontSize: 13 }}>{subtitle}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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 (
|
|
<div style={{ position: 'fixed', top: 64, left: 0, right: 0, height: 3, zIndex: 99, background: '#E4DDD2' }}>
|
|
<div style={{ height: '100%', width: pct + '%', background: '#0F6B73', transition: 'width 0.1s linear' }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Author Avatar ───────────────────────────────────────────────────────────
|
|
function AuthorAvatar({ author, size = 40 }) {
|
|
const a = typeof author === 'string' ? authors.find(x => x.id === author) : author;
|
|
if (!a) return null;
|
|
return (
|
|
<div style={{
|
|
width: size, height: size, borderRadius: '50%', background: a.color || '#0F6B73',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
color: '#fff', fontSize: size * 0.32, fontWeight: 700, flexShrink: 0,
|
|
}}>{a.initials || a.name.slice(0, 2)}</div>
|
|
);
|
|
}
|
|
|
|
// ── 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 (
|
|
<div style={{
|
|
position: 'fixed', bottom: 100, right: 24, zIndex: 490,
|
|
background: '#1A2E30', border: '1px solid #2A4045', borderRadius: 16,
|
|
padding: '14px 18px', display: 'flex', alignItems: 'center', gap: 14,
|
|
boxShadow: '0 12px 40px rgba(0,0,0,0.22)', animation: 'slideUp 0.3s ease',
|
|
maxWidth: 320,
|
|
}}>
|
|
<span style={{ fontSize: 20 }}>🔔</span>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ fontSize: 13, fontWeight: 700, color: '#FAF7F0', marginBottom: 3 }}>Browser notifications</div>
|
|
<div style={{ fontSize: 11, color: '#8AA5A8' }}>Never miss a Teknav article</div>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
<button onClick={dismiss} style={{ ...pillBtn, background: 'transparent', color: '#8AA5A8', border: '1px solid #2A4045', padding: '5px 10px', fontSize: 11 }}>Later</button>
|
|
<button onClick={handleEnable} style={{ ...pillBtn, background: '#C46A4D', color: '#fff', padding: '5px 12px', fontSize: 11 }}>Enable</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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,
|
|
};
|