Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
342 lines
20 KiB
React
342 lines
20 KiB
React
// global-articles.jsx — Article detail + article list for Teknav Global (English, LTR)
|
||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||
import { articles as allArticles, authors, categories } from '../global-data.js';
|
||
import {
|
||
useNav, useToast, CategoryBadge, TypeBadge, AuthorAvatar, EmptyState,
|
||
ReadingProgress, SkeletonLoader, NewsletterForm,
|
||
inputStyle, pillBtn, iconBtnStyle,
|
||
} from './global-ui.jsx';
|
||
import { ArticleCard, sectionTitle, sectionSubtitle } from './global-home.jsx';
|
||
|
||
// ── Article List Row ─────────────────────────────────────────────────────────
|
||
function ArticleRow({ article, idx }) {
|
||
const { navigate } = useNav();
|
||
const [vis, setVis] = useState(false);
|
||
const ref = useRef();
|
||
const cat = categories.find(c => c.id === article.category);
|
||
const author = authors.find(a => a.id === article.author);
|
||
|
||
useEffect(() => {
|
||
const obs = new IntersectionObserver(([e]) => { if (e.isIntersecting) setVis(true); }, { threshold: 0.05 });
|
||
if (ref.current) obs.observe(ref.current);
|
||
return () => obs.disconnect();
|
||
}, []);
|
||
|
||
return (
|
||
<div ref={ref} className="article-row" style={{
|
||
display: 'grid', gridTemplateColumns: '1fr 140px', gap: 24,
|
||
padding: '32px 0', borderBottom: '1px solid #E4DDD2',
|
||
cursor: 'pointer', opacity: vis ? 1 : 0, transform: vis ? 'none' : 'translateY(16px)',
|
||
transition: `opacity 0.4s ${idx * 60}ms, transform 0.4s ${idx * 60}ms`,
|
||
}} onClick={() => navigate('/article/' + article.slug)}
|
||
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
|
||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}>
|
||
<div>
|
||
<div className="article-row-meta" style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 10, flexWrap: 'wrap' }}>
|
||
<span style={{ fontSize: 12, color: '#5F6B6D' }}>{article.date}</span>
|
||
<span style={{ color: '#E4DDD2' }}>·</span>
|
||
{article.type && <TypeBadge type={article.type} small />}
|
||
<CategoryBadge name={cat?.name || article.category} color={cat?.color} small />
|
||
</div>
|
||
<h2 style={{ fontSize: 19, fontWeight: 800, color: '#263238', margin: '0 0 6px', lineHeight: 1.5, fontFamily: 'Inter,sans-serif' }}>{article.title}</h2>
|
||
{article.subtitle && <div style={{ fontSize: 14, color: '#0F6B73', fontWeight: 600, margin: '0 0 8px' }}>{article.subtitle}</div>}
|
||
<div style={{ fontSize: 13, color: '#5F6B6D', margin: '0 0 8px' }}>
|
||
<span style={{ fontWeight: 700, color: '#263238' }}>{author?.name}</span>
|
||
{author?.specialty && <span style={{ color: '#E4DDD2' }}> · </span>}
|
||
{author?.specialty && <span>{author.specialty}</span>}
|
||
</div>
|
||
<div className="article-row-stats" style={{ display: 'flex', gap: 16, fontSize: 12, color: '#5F6B6D', alignItems: 'center' }}>
|
||
<span style={{ color: '#C46A4D', fontWeight: 600 }}>{article.type}</span>
|
||
<span>·</span>
|
||
<span>{article.readTime} min read</span>
|
||
{article.tags?.slice(0, 2).map(t => (
|
||
<span key={t} style={{ background: '#F4EFE6', padding: '2px 8px', borderRadius: 4, fontSize: 11 }}>{t}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="article-row-thumb" style={{ background: '#F4EFE6', borderRadius: 10, overflow: 'hidden', height: 120, alignSelf: 'center' }}>
|
||
{article.photo && (
|
||
<img src={article.photo} alt={article.title} loading="lazy"
|
||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||
onError={e => { e.target.style.display = 'none'; }} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Filter Bar ───────────────────────────────────────────────────────────────
|
||
function FilterBar({ filters, onChange }) {
|
||
const cats = [{ id: '', name: 'All' }, ...categories];
|
||
const sorts = [{ v: 'latest', l: 'Latest' }, { v: 'popular', l: 'Popular' }, { v: 'readtime', l: 'Read time' }];
|
||
|
||
const chip = (active) => ({
|
||
border: active ? '1.5px solid #0F6B73' : '1px solid #E4DDD2',
|
||
background: active ? '#0F6B7312' : '#fff',
|
||
color: active ? '#0F6B73' : '#5F6B6D',
|
||
padding: '5px 12px', borderRadius: 20, fontSize: 12, cursor: 'pointer',
|
||
fontFamily: 'Inter,sans-serif', fontWeight: active ? 700 : 400,
|
||
transition: 'all 0.15s', whiteSpace: 'nowrap',
|
||
});
|
||
|
||
return (
|
||
<div className="filter-bar" style={{ marginBottom: 32, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
<div className="filter-chip-row" style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||
<span style={{ fontSize: 12, color: '#5F6B6D', whiteSpace: 'nowrap' }}>Topic:</span>
|
||
{cats.map(c => (
|
||
<button key={c.id} style={chip(filters.cat === c.id)} onClick={() => onChange({ ...filters, cat: c.id })}>{c.name}</button>
|
||
))}
|
||
</div>
|
||
<div className="filter-chip-row" style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||
<span style={{ fontSize: 12, color: '#5F6B6D' }}>Sort:</span>
|
||
{sorts.map(s => (
|
||
<button key={s.v} style={chip(filters.sort === s.v)} onClick={() => onChange({ ...filters, sort: s.v })}>{s.l}</button>
|
||
))}
|
||
</div>
|
||
{filters.q && (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, background: '#F4EFE6', padding: '5px 12px', borderRadius: 20 }}>
|
||
<span style={{ fontSize: 12, color: '#263238' }}>Search: "{filters.q}"</span>
|
||
<button onClick={() => onChange({ ...filters, q: '' })} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#5F6B6D', fontSize: 14 }}>✕</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Article List Page ────────────────────────────────────────────────────────
|
||
export function ArticlesListPage({ initQ = '' }) {
|
||
const [filters, setFilters] = useState({ cat: '', sort: 'latest', q: initQ });
|
||
const [searchInput, setSearchInput] = useState(initQ);
|
||
const [page, setPage] = useState(1);
|
||
const PER = 8;
|
||
|
||
const filtered = useMemo(() => {
|
||
let list = allArticles;
|
||
if (filters.q) {
|
||
const q = filters.q.toLowerCase();
|
||
list = list.filter(a =>
|
||
a.title.toLowerCase().includes(q) ||
|
||
(a.subtitle || '').toLowerCase().includes(q) ||
|
||
authors.find(au => au.id === a.author)?.name.toLowerCase().includes(q) ||
|
||
categories.find(c => c.id === a.category)?.name.toLowerCase().includes(q)
|
||
);
|
||
}
|
||
if (filters.cat) list = list.filter(a => a.category === filters.cat);
|
||
if (filters.sort === 'popular') list = [...list].sort((a, b) => (b.views || 0) - (a.views || 0));
|
||
else if (filters.sort === 'readtime') list = [...list].sort((a, b) => b.readTime - a.readTime);
|
||
else list = [...list].sort((a, b) => (b.dateEn || b.date || '').localeCompare(a.dateEn || a.date || ''));
|
||
return list;
|
||
}, [filters]);
|
||
|
||
const visible = filtered.slice(0, page * PER);
|
||
const hasMore = visible.length < filtered.length;
|
||
const doSearch = e => { e.preventDefault(); setFilters(f => ({ ...f, q: searchInput })); setPage(1); };
|
||
|
||
return (
|
||
<div className="article-list-page" style={{ paddingTop: 80, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Inter,sans-serif' }}>
|
||
<div className="article-list-inner" style={{ maxWidth: 1000, margin: '0 auto', padding: '0 24px' }}>
|
||
<div className="article-list-head" style={{ padding: '32px 0 0' }}>
|
||
<h1 style={{ fontSize: 28, fontWeight: 900, color: '#263238', margin: '0 0 4px' }}>All Articles</h1>
|
||
<p style={{ fontSize: 14, color: '#5F6B6D', margin: '0 0 28px' }}>{filtered.length} articles found</p>
|
||
<form className="article-search-form" onSubmit={doSearch} style={{ display: 'flex', gap: 10, marginBottom: 24 }}>
|
||
<input value={searchInput} onChange={e => setSearchInput(e.target.value)}
|
||
placeholder="Search by title, author, topic..."
|
||
style={{ flex: 1, ...inputStyle }} />
|
||
<button type="submit" style={{ ...pillBtn, background: '#0F6B73', color: '#fff' }}>Search</button>
|
||
</form>
|
||
</div>
|
||
<FilterBar filters={filters} onChange={f => { setFilters(f); setPage(1); }} />
|
||
|
||
{visible.length === 0 ? (
|
||
<EmptyState title="No articles found" subtitle="Try different filters or search terms" />
|
||
) : (
|
||
<div>
|
||
{visible.map((a, i) => <ArticleRow key={a.id} article={a} idx={i} />)}
|
||
</div>
|
||
)}
|
||
|
||
{hasMore && (
|
||
<div className="load-more-wrap" style={{ textAlign: 'center', padding: '40px 0' }}>
|
||
<button onClick={() => setPage(p => p + 1)} style={{ ...pillBtn, background: '#0F6B73', color: '#fff', padding: '12px 32px' }}>
|
||
Load more ({filtered.length - visible.length} remaining)
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Article Detail Page ──────────────────────────────────────────────────────
|
||
export default function ArticlePage({ slug }) {
|
||
const { navigate } = useNav();
|
||
|
||
const article = allArticles.find(a => a.slug === slug);
|
||
|
||
if (!article) {
|
||
return (
|
||
<div style={{ paddingTop: 64, minHeight: '100vh', background: '#FAF7F0', fontFamily: 'Inter,sans-serif' }}>
|
||
<div style={{ maxWidth: 720, margin: '0 auto', padding: '60px 24px', textAlign: 'center' }}>
|
||
<div style={{ fontSize: 48, fontWeight: 900, color: '#0F6B73', opacity: 0.2, marginBottom: 16 }}>404</div>
|
||
<h2 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12, color: '#263238' }}>Article not found</h2>
|
||
<p style={{ color: '#5F6B6D', marginBottom: 8 }}>This article may be available in the Persian edition.</p>
|
||
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 28, flexWrap: 'wrap' }}>
|
||
<button onClick={() => navigate('/')} style={{ ...pillBtn, background: '#0F6B73', color: '#fff' }}>Home</button>
|
||
<a href="https://www.teknav.ir" style={{ ...pillBtn, background: '#F4EFE6', color: '#263238', border: '1px solid #E4DDD2' }}>فارسی (Persian Edition)</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const author = authors.find(a => a.id === article.author);
|
||
const cat = categories.find(c => c.id === article.category);
|
||
const related = allArticles.filter(a => a.id !== article.id && (a.category === article.category || (a.tags || []).some(t => (article.tags || []).includes(t)))).slice(0, 3);
|
||
|
||
return (
|
||
<div className="article-detail-page" style={{ paddingTop: 64, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Inter,sans-serif' }}>
|
||
<ReadingProgress />
|
||
|
||
{/* Breadcrumb */}
|
||
<div className="article-breadcrumb" style={{ background: '#F4EFE6', borderBottom: '1px solid #E4DDD2', padding: '10px 24px', fontSize: 12, color: '#5F6B6D' }}>
|
||
<div style={{ maxWidth: 1280, margin: '0 auto', display: 'flex', gap: 6, alignItems: 'center' }}>
|
||
<button onClick={() => navigate('/')} style={{ background: 'none', border: 'none', color: '#5F6B6D', cursor: 'pointer', fontSize: 12, fontFamily: 'Inter,sans-serif' }}>Home</button>
|
||
<span>›</span>
|
||
<button onClick={() => navigate('/category/' + article.category)} style={{ background: 'none', border: 'none', color: '#5F6B6D', cursor: 'pointer', fontSize: 12, fontFamily: 'Inter,sans-serif' }}>{cat?.name || article.category}</button>
|
||
<span>›</span>
|
||
<span style={{ color: '#263238', fontWeight: 600 }}>{article.title.slice(0, 50)}{article.title.length > 50 ? '…' : ''}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Layout */}
|
||
<div className="article-layout" style={{ maxWidth: 1280, margin: '0 auto', padding: '40px 24px', display: 'grid', gridTemplateColumns: '1fr 300px', gap: 56, alignItems: 'start' }}>
|
||
|
||
{/* Main content */}
|
||
<article className="article-main">
|
||
{/* Header */}
|
||
<div className="article-header" style={{ marginBottom: 32, paddingBottom: 24, borderBottom: '1px solid #E4DDD2' }}>
|
||
<div style={{ display: 'flex', gap: 8, marginBottom: 14, flexWrap: 'wrap' }}>
|
||
{article.type && <TypeBadge type={article.type} />}
|
||
<CategoryBadge name={cat?.name || article.category} color={cat?.color} />
|
||
{article.persianUrl && (
|
||
<a href={article.persianUrl} target="_blank" rel="noopener noreferrer" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '3px 10px', borderRadius: 4, fontSize: 11, fontWeight: 600, background: '#F4EFE6', color: '#5F6B6D', border: '1px solid #E4DDD2', textDecoration: 'none' }}>
|
||
فارسی ↗
|
||
</a>
|
||
)}
|
||
</div>
|
||
<h1 style={{ fontSize: 'clamp(24px,3.5vw,36px)', fontWeight: 800, color: '#0F2A2E', margin: '0 0 12px', lineHeight: 1.3, fontFamily: 'Inter,sans-serif', letterSpacing: '-0.02em' }}>{article.title}</h1>
|
||
{article.subtitle && <p style={{ fontSize: 18, color: '#5F6B6D', lineHeight: 1.7, margin: '0 0 16px' }}>{article.subtitle}</p>}
|
||
|
||
{/* Byline */}
|
||
<div className="article-byline-row" style={{ display: 'flex', alignItems: 'center', gap: 16, paddingTop: 16, borderTop: '1px solid #E4DDD2', flexWrap: 'wrap' }}>
|
||
{author && <AuthorAvatar author={author} size={40} />}
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontSize: 14, fontWeight: 700, color: '#263238' }}>{author?.name}</div>
|
||
<div style={{ fontSize: 12, color: '#5F6B6D' }}>{author?.role}</div>
|
||
</div>
|
||
<div className="article-meta-line" style={{ display: 'flex', gap: 12, fontSize: 12, color: '#5F6B6D', alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<span>{article.date}</span>
|
||
<span>·</span>
|
||
<span>{article.readTime} min read</span>
|
||
{article.tags?.slice(0, 2).map(t => (
|
||
<span key={t} style={{ background: '#F4EFE6', padding: '2px 8px', borderRadius: 4 }}>{t}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Hero image */}
|
||
{article.photo && (
|
||
<div style={{ borderRadius: 12, overflow: 'hidden', marginBottom: 28, border: '1px solid #E4DDD2' }}>
|
||
<img src={article.photo} alt={article.title} loading="eager"
|
||
style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 480, objectFit: 'cover' }}
|
||
onError={e => { e.target.parentElement.style.display = 'none'; }} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Article body */}
|
||
<div className="article-content" style={{ fontSize: 17, lineHeight: 1.9, color: '#263238', fontFamily: 'Inter,sans-serif' }}
|
||
dangerouslySetInnerHTML={{ __html: article.content }} />
|
||
|
||
{/* Author box */}
|
||
{author && (
|
||
<div className="article-author-box" style={{ display: 'flex', gap: 16, padding: 20, background: '#F4EFE6', borderRadius: 12, marginTop: 40, border: '1px solid #E4DDD2', alignItems: 'flex-start' }}>
|
||
<AuthorAvatar author={author} size={52} />
|
||
<div>
|
||
<div style={{ fontSize: 13, fontWeight: 800, color: '#263238', marginBottom: 4 }}>{author.name}</div>
|
||
<div style={{ fontSize: 12, color: '#0F6B73', fontWeight: 600, marginBottom: 8 }}>{author.role}</div>
|
||
<div style={{ fontSize: 13, color: '#5F6B6D', lineHeight: 1.7 }}>{author.bio}</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Related articles */}
|
||
{related.length > 0 && (
|
||
<div className="article-related" style={{ marginTop: 40 }}>
|
||
<h2 style={{ fontSize: 18, fontWeight: 800, color: '#263238', marginBottom: 20 }}>Related Articles</h2>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(240px,1fr))', gap: 16 }}>
|
||
{related.map(a => <ArticleCard key={a.id} article={a} />)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Newsletter */}
|
||
<div style={{ marginTop: 60, background: '#ebe3d2', borderRadius: 16, padding: '40px 28px', textAlign: 'center', border: '1px solid rgba(15,42,46,0.06)' }}>
|
||
<h3 style={{ fontSize: 20, fontWeight: 800, color: '#0F2A2E', margin: '0 0 8px', fontFamily: 'Inter,sans-serif' }}>Subscribe to Teknav</h3>
|
||
<p style={{ fontSize: 14, color: '#4A5A5C', margin: '0 0 24px', lineHeight: 1.8 }}>Deep tech analysis in your inbox, every week</p>
|
||
<NewsletterForm />
|
||
</div>
|
||
</article>
|
||
|
||
{/* Sidebar */}
|
||
<aside style={{ position: 'sticky', top: 84 }}>
|
||
{/* Tags */}
|
||
{article.tags?.length > 0 && (
|
||
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 20, marginBottom: 20 }}>
|
||
<h3 style={{ fontSize: 13, fontWeight: 700, color: '#263238', marginBottom: 14 }}>Tags</h3>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||
{article.tags.map(t => (
|
||
<span key={t} style={{ background: '#F4EFE6', border: '1px solid #E4DDD2', padding: '4px 10px', borderRadius: 6, fontSize: 12, color: '#5F6B6D' }}>{t}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Persian link */}
|
||
{article.persianUrl && (
|
||
<div style={{ background: '#F4EFE6', border: '1px solid #E4DDD2', borderRadius: 12, padding: 20, marginBottom: 20 }}>
|
||
<h3 style={{ fontSize: 13, fontWeight: 700, color: '#263238', marginBottom: 10 }}>Also in Persian</h3>
|
||
<p style={{ fontSize: 12, color: '#5F6B6D', marginBottom: 12, lineHeight: 1.6 }}>This article is available in Persian on Teknav.ir</p>
|
||
<a href={article.persianUrl} target="_blank" rel="noopener noreferrer" style={{ display: 'block', textAlign: 'center', ...pillBtn, background: '#0F6B73', color: '#fff', justifyContent: 'center', textDecoration: 'none' }}>
|
||
Read in Persian ↗
|
||
</a>
|
||
</div>
|
||
)}
|
||
|
||
{/* More from category */}
|
||
{related.length > 0 && (
|
||
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 20 }}>
|
||
<h3 style={{ fontSize: 13, fontWeight: 700, color: '#263238', marginBottom: 14 }}>More in {cat?.name}</h3>
|
||
{related.slice(0, 3).map(a => (
|
||
<button key={a.id} onClick={() => navigate('/article/' + a.slug)} style={{ display: 'block', width: '100%', textAlign: 'left', background: 'none', border: 'none', padding: '10px 0', borderBottom: '1px solid #F4EFE6', cursor: 'pointer', fontFamily: 'Inter,sans-serif' }}>
|
||
<div style={{ fontSize: 13, fontWeight: 600, color: '#263238', lineHeight: 1.5, marginBottom: 4 }}>{a.title}</div>
|
||
<div style={{ fontSize: 11, color: '#5F6B6D' }}>{a.readTime} min · {a.date}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</aside>
|
||
</div>
|
||
|
||
<style>{`
|
||
@media (max-width: 768px) {
|
||
.article-layout { grid-template-columns: 1fr !important; }
|
||
aside { display: none; }
|
||
}
|
||
`}</style>
|
||
</div>
|
||
);
|
||
}
|