Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2028 lines
118 KiB
React
2028 lines
118 KiB
React
// teknav-pages.jsx — Search, Category, Authors pages (ES module)
|
||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||
import { TeknavData } from './teknav-data.js';
|
||
|
||
function authorProfilePath(author) {
|
||
const username = author.username || TeknavData.authors.find(a => a.slug === author.slug)?.username;
|
||
return username ? `/profile/@${username}` : `/author/${author.slug}`;
|
||
}
|
||
import {
|
||
useNav, useToast, useAuth,
|
||
AuthorAvatar, CategoryBadge, TypeBadge, EmptyState, SkeletonLoader,
|
||
inputStyle, pillBtn,
|
||
} from './teknav-ui.jsx';
|
||
import { DiagramRenderer } from './teknav-diagrams.jsx';
|
||
import { ArticleRow } from './teknav-articles.jsx';
|
||
import { contentApi } from './src/lib/content-api.js';
|
||
import { engagementApi, pushApi } from './src/lib/engagement-api.js';
|
||
|
||
// ── Search Page ─────────────────────────────────────────────────────────────
|
||
function SearchPage({ initQ = '' }) {
|
||
const [q, setQ] = useState(initQ);
|
||
const [input, setInput] = useState(initQ);
|
||
const [filters, setFilters] = useState({ category: '', author: '', type: '', tag: '', dateRange: '' });
|
||
const [items, setItems] = useState([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const { navigate } = useNav();
|
||
|
||
const load = useCallback(() => {
|
||
setLoading(true);
|
||
const params = {
|
||
q,
|
||
category: filters.category,
|
||
author: filters.author,
|
||
type: filters.type,
|
||
tag: filters.tag,
|
||
limit: 50,
|
||
};
|
||
const apiCall = q ? contentApi.search(params) : contentApi.listArticles(params);
|
||
apiCall
|
||
.then(res => setItems(res.items ?? []))
|
||
.finally(() => setLoading(false));
|
||
}, [q, filters]);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const doSearch = (e) => { e.preventDefault(); setQ(input); };
|
||
|
||
const highlightText = (text, query) => {
|
||
if (!query) return text;
|
||
const parts = text.split(new RegExp(`(${query})`, 'gi'));
|
||
return parts.map((part, i) =>
|
||
part.toLowerCase() === query.toLowerCase()
|
||
? `<mark style="background:#D49A2A30;color:#263238;border-radius:2px;padding:0 2px">${part}</mark>`
|
||
: part
|
||
).join('');
|
||
};
|
||
|
||
return (
|
||
<div style={{ paddingTop: 80, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
|
||
<div style={{ maxWidth: 1000, margin: '0 auto', padding: '40px 24px' }}>
|
||
<h1 style={{ fontSize: 28, fontWeight: 900, color: '#263238', margin: '0 0 8px' }}>جستجوی پیشرفته</h1>
|
||
|
||
<form onSubmit={doSearch} style={{ display: 'flex', gap: 10, marginBottom: 32 }}>
|
||
<input value={input} onChange={e => setInput(e.target.value)} placeholder="جستجو در مقالهها..."
|
||
style={{ flex: 1, ...inputStyle, fontSize: 16 }} dir="rtl" autoFocus />
|
||
<button type="submit" style={{ ...pillBtn, background: '#0F6B73', color: '#fff', padding: '12px 32px' }}>جستجو</button>
|
||
</form>
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 32 }} className="search-layout">
|
||
<aside style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||
<div>
|
||
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 12, color: '#263238' }}>دستهبندی</div>
|
||
<select value={filters.category} onChange={e => setFilters(f => ({ ...f, category: e.target.value }))} style={inputStyle}>
|
||
<option value="">همه دستهها</option>
|
||
{TeknavData.categories.map(c => <option key={c.id} value={c.slug}>{c.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 12, color: '#263238' }}>نویسنده</div>
|
||
<select value={filters.author} onChange={e => setFilters(f => ({ ...f, author: e.target.value }))} style={inputStyle}>
|
||
<option value="">همه نویسندگان</option>
|
||
{TeknavData.authors.map(a => <option key={a.id} value={a.slug}>{a.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 12, color: '#263238' }}>نوع محتوا</div>
|
||
<select value={filters.type} onChange={e => setFilters(f => ({ ...f, type: e.target.value }))} style={inputStyle}>
|
||
<option value="">همه انواع</option>
|
||
{['تحلیل عمیق', 'راهنمای فنی', 'دادهنما', 'خبر فوری'].map(t => <option key={t} value={t}>{t}</option>)}
|
||
</select>
|
||
</div>
|
||
<button onClick={() => setFilters({ category: '', author: '', type: '', tag: '', dateRange: '' })} style={{ ...pillBtn, background: '#F4EFE6', color: '#C94C4C', justifyContent: 'center' }}>پاک کردن فیلترها</button>
|
||
</aside>
|
||
|
||
<section>
|
||
{loading ? <SkeletonLoader lines={5} /> : (
|
||
<>
|
||
{items.length === 0 ? <EmptyState title="نتیجهای یافت نشد" /> : (
|
||
<div>
|
||
<div style={{ fontSize: 13, color: '#5F6B6D', marginBottom: 20 }}>{items.length.toLocaleString('fa-IR')} نتیجه پیدا شد</div>
|
||
{items.map((a, i) => <SearchResult key={a.id} article={a} q={q} highlight={highlightText} idx={i} />)}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</div>
|
||
<style>{`.search-layout { grid-template-columns: 240px 1fr; } @media(max-width:768px){ .search-layout { grid-template-columns: 1fr !important; } }`}</style>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SearchResult({ article, q, highlight, idx }) {
|
||
const { navigate } = useNav();
|
||
return (
|
||
<div onClick={() => navigate('/article/' + article.slug)} style={{
|
||
padding: '24px 0', borderBottom: '1px solid #E4DDD2', cursor: 'pointer',
|
||
opacity: 0, animation: `fadeIn 0.3s ${idx * 40}ms forwards`,
|
||
}}>
|
||
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
|
||
<CategoryBadge name={article.categoryName} color={article.categoryColor} small />
|
||
<span style={{ fontSize: 11, color: '#5F6B6D' }}>{article.date} · {article.readTime} دقیقه</span>
|
||
</div>
|
||
<h3 style={{ fontSize: 17, fontWeight: 800, color: '#263238', margin: '0 0 8px' }}
|
||
dangerouslySetInnerHTML={{ __html: highlight(article.title, q) }} />
|
||
<p style={{ fontSize: 14, color: '#5F6B6D', lineHeight: 1.7, margin: 0 }}
|
||
dangerouslySetInnerHTML={{ __html: highlight(article.summary.slice(0, 180) + '…', q) }} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Category Page ───────────────────────────────────────────────────────────
|
||
function CategoryPage({ slug }) {
|
||
const { navigate } = useNav();
|
||
const [cat, setCat] = useState(() => TeknavData.categories.find(c => c.slug === slug));
|
||
const [articles, setArticles] = useState(() => contentApi.fallbackArticles().filter(a => a.category === slug));
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
setCat(TeknavData.categories.find(c => c.slug === slug));
|
||
setArticles(contentApi.fallbackArticles().filter(a => a.category === slug));
|
||
Promise.all([
|
||
contentApi.getCategory(slug).catch(() => null),
|
||
contentApi.listArticles({ category: slug, limit: 100 }).catch(() => null),
|
||
]).then(([category, result]) => {
|
||
if (cancelled) return;
|
||
if (category) setCat({ ...(TeknavData.categories.find(c => c.slug === slug) ?? {}), ...category, id: category.slug });
|
||
if (result?.items) setArticles(result.items);
|
||
});
|
||
return () => { cancelled = true; };
|
||
}, [slug]);
|
||
|
||
if (!cat) return <EmptyState title="دستهبندی یافت نشد" />;
|
||
|
||
return (
|
||
<div style={{ paddingTop: 64, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif' }}>
|
||
{/* Category Hero */}
|
||
{(() => {
|
||
const HERO_IMAGES = {
|
||
ai: '/images/category-hero-ai-v2.jpg',
|
||
startup: '/images/category-hero-startup.jpg',
|
||
hardware: '/images/category-hero-hardware.jpg',
|
||
security: '/images/category-hero-security.jpg',
|
||
software: '/images/category-hero-software.jpg',
|
||
future: '/images/category-hero-future.jpg',
|
||
data: '/images/category-hero-data.jpg',
|
||
};
|
||
const heroImg = HERO_IMAGES[cat.slug];
|
||
return (
|
||
<div style={{ position: 'relative', borderBottom: '1px solid #E4DDD2', direction: 'rtl', overflow: 'hidden', minHeight: 340 }}>
|
||
{/* Background: photo for AI, soft tinted gradient for others */}
|
||
{heroImg
|
||
? <div style={{ position: 'absolute', inset: 0, backgroundImage: `url(${heroImg})`, backgroundSize: 'cover', backgroundPosition: 'center' }} aria-hidden="true" />
|
||
: <div style={{ position: 'absolute', inset: 0, background: `linear-gradient(135deg, ${cat.color}12 0%, #F4EFE6 100%)` }} aria-hidden="true" />
|
||
}
|
||
|
||
{/* Content grid */}
|
||
<div style={{ position: 'relative', zIndex: 1, maxWidth: 1280, margin: '0 auto', display: 'grid', gridTemplateColumns: '1fr 420px', gap: 32, alignItems: 'stretch', padding: '64px 32px' }} className="cat-hero-grid">
|
||
|
||
{/* Text card */}
|
||
<div style={{
|
||
background: '#FFFFFF',
|
||
border: '1px solid #E4DDD2',
|
||
borderRight: `5px solid ${cat.color}`,
|
||
borderRadius: '0 20px 20px 0',
|
||
padding: '44px 48px 40px',
|
||
boxShadow: '0 6px 40px rgba(15,42,46,0.10)',
|
||
}}>
|
||
{/* Pill badge */}
|
||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: cat.color + '12', border: `1px solid ${cat.color}30`, color: cat.color, padding: '5px 16px', borderRadius: 999, fontSize: 12, fontWeight: 700, marginBottom: 22, letterSpacing: '0.06em' }}>
|
||
<span style={{ width: 7, height: 7, borderRadius: '50%', background: cat.color, display: 'inline-block' }} />
|
||
دستهبندی
|
||
</div>
|
||
|
||
<h1 style={{ fontSize: 'clamp(32px,5vw,52px)', fontWeight: 900, color: '#263238', margin: '0 0 14px', fontFamily: "'PelakFA','Vazirmatn',sans-serif", lineHeight: 1.2 }}>{cat.name}</h1>
|
||
<p style={{ fontSize: 16, color: '#5F6B6D', lineHeight: 2.0, margin: '0 0 30px', maxWidth: 560 }}>{cat.description}</p>
|
||
|
||
{/* Stats */}
|
||
<div style={{ display: 'flex', gap: 0, borderTop: `1px solid #E4DDD2`, paddingTop: 24 }}>
|
||
{[['تعداد مقاله', cat.articleCount], ['نویسنده فعال', cat.authorCount], ['میانگین زمان مطالعه', `${cat.avgReadTime} دقیقه`]].map(([label, val], i) => (
|
||
<div key={label} style={{ flex: '1 1 100px', textAlign: 'center', padding: '0 20px', borderRight: i > 0 ? '1px solid #E4DDD2' : 'none' }}>
|
||
<div style={{ fontSize: 'clamp(22px,3vw,30px)', fontWeight: 900, color: cat.color, fontFamily: "'PelakFA','Vazirmatn',sans-serif", lineHeight: 1 }}>{val}</div>
|
||
<div style={{ fontSize: 12, color: '#9AA5A6', marginTop: 6 }}>{label}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Diagram card */}
|
||
<div style={{
|
||
background: '#FFFFFF',
|
||
border: '1px solid #E4DDD2',
|
||
borderRadius: 20,
|
||
padding: '28px',
|
||
boxShadow: '0 6px 32px rgba(15,42,46,0.08)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}} className="cat-hero-diagram">
|
||
<DiagramRenderer type={cat.diagram} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
<div style={{ maxWidth: 1280, margin: '0 auto', padding: '40px 24px', direction: 'rtl' }}>
|
||
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#263238', margin: '0 0 24px' }}>مقالههای {cat.name} ({articles.length})</h2>
|
||
{articles.length === 0 ? <EmptyState title="مقالهای در این دسته وجود ندارد" /> : (
|
||
articles.map((a, i) => <ArticleRow key={a.id} article={a} idx={i} />)
|
||
)}
|
||
</div>
|
||
<style>{`
|
||
.cat-hero-grid { grid-template-columns: 1fr 420px; }
|
||
@media(max-width: 1024px) {
|
||
.cat-hero-grid { grid-template-columns: 1fr 340px !important; padding: 48px 24px !important; }
|
||
}
|
||
@media(max-width: 860px) {
|
||
.cat-hero-grid { grid-template-columns: 1fr !important; gap: 20px !important; padding: 36px 20px !important; }
|
||
.cat-hero-diagram { display: none !important; }
|
||
}
|
||
@media(max-width: 560px) {
|
||
.cat-hero-grid { padding: 28px 14px !important; }
|
||
.cat-hero-grid > div:first-child { padding: 28px 22px !important; border-radius: 14px !important; }
|
||
}
|
||
`}</style>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Authors List Page ────────────────────────────────────────────────────────
|
||
function TagPage({ tag }) {
|
||
const decoded = decodeURIComponent(tag);
|
||
const [articles, setArticles] = useState(() => contentApi.fallbackArticles().filter((item) => item.tags?.includes(decoded)));
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
setArticles(contentApi.fallbackArticles().filter((item) => item.tags?.includes(decoded)));
|
||
contentApi.listArticles({ tag: decoded, limit: 100 })
|
||
.then((res) => { if (!cancelled) setArticles(res.items ?? []); })
|
||
.catch(() => {});
|
||
return () => { cancelled = true; };
|
||
}, [decoded]);
|
||
return (
|
||
<div style={{ paddingTop: 80, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
|
||
<div style={{ maxWidth: 960, margin: '0 auto', padding: '40px 24px' }}>
|
||
<h1 style={{ fontSize: 30, fontWeight: 900, color: '#263238', margin: '0 0 10px' }}>برچسب {decoded}</h1>
|
||
<p style={{ color: '#5F6B6D', lineHeight: 1.9, margin: '0 0 28px' }}>همه مقالههایی که با این برچسب منتشر شدهاند.</p>
|
||
{articles.length === 0 ? <EmptyState title="مقالهای برای این برچسب پیدا نشد" /> : articles.map((article, index) => (
|
||
<ArticleRow key={article.id} article={article} idx={index} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function GlossaryPage({ slug }) {
|
||
const { navigate } = useNav();
|
||
const term = TeknavData.glossary?.find((item) => item.slug === slug);
|
||
const articles = contentApi.fallbackArticles().filter((article) => (
|
||
article.tags?.some((tag) => tag.toLowerCase() === term?.term?.toLowerCase() || tag.toLowerCase() === term?.english?.toLowerCase())
|
||
|| article.summary?.toLowerCase().includes(term?.english?.toLowerCase() || '')
|
||
|| article.title?.toLowerCase().includes(term?.english?.toLowerCase() || '')
|
||
)).slice(0, 6);
|
||
|
||
if (!term) return <EmptyState title="واژه پیدا نشد" />;
|
||
|
||
const related = (term.related ?? []).map((item) => TeknavData.glossary?.find((g) => g.slug === item)).filter(Boolean);
|
||
const topic = TeknavData.topicHubs?.find((item) => item.slug === term.topic);
|
||
|
||
return (
|
||
<main style={{ paddingTop: 76, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
|
||
<section style={{ borderBottom: '1px solid #E4DDD2', background: 'linear-gradient(135deg,#FFFFFF 0%,#F4EFE6 100%)', padding: '44px 24px' }}>
|
||
<div style={{ maxWidth: 1040, margin: '0 auto' }}>
|
||
<button onClick={() => navigate('/articles')} style={{ ...pillBtn, background: '#F4EFE6', color: '#0F6B73', border: '1px solid #E4DDD2', marginBottom: 18 }}>بازگشت به مقالهها</button>
|
||
<div style={{ color: '#0F6B73', fontSize: 12, fontWeight: 900, marginBottom: 8 }}>فرهنگ واژگان تکنّاو</div>
|
||
<h1 style={{ fontSize: 'clamp(30px,5vw,48px)', color: '#263238', margin: '0 0 6px', fontWeight: 900 }}>{term.term}</h1>
|
||
<div style={{ color: '#5F6B6D', fontSize: 18, direction: 'ltr', textAlign: 'right', marginBottom: 18 }}>{term.english}</div>
|
||
<p style={{ maxWidth: 720, color: '#263238', lineHeight: 2, fontSize: 17, margin: 0 }}>{term.summary}</p>
|
||
</div>
|
||
</section>
|
||
<section style={{ maxWidth: 1040, margin: '0 auto', padding: '32px 24px', display: 'grid', gridTemplateColumns: '1.1fr 0.9fr', gap: 24 }} className="glossary-grid">
|
||
<div style={{ display: 'grid', gap: 18 }}>
|
||
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 8, padding: 20 }}>
|
||
<h2 style={{ margin: '0 0 14px', color: '#263238', fontSize: 20 }}>پرسشهای رایج</h2>
|
||
{(term.faqs ?? []).map(([q, a]) => (
|
||
<div key={q} style={{ borderTop: '1px solid #E4DDD2', padding: '14px 0' }}>
|
||
<strong style={{ color: '#0F6B73' }}>{q}</strong>
|
||
<p style={{ margin: '8px 0 0', color: '#5F6B6D', lineHeight: 1.9 }}>{a}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 8, padding: 20 }}>
|
||
<h2 style={{ margin: '0 0 14px', color: '#263238', fontSize: 20 }}>مقالههای مرتبط</h2>
|
||
{articles.length === 0 ? <EmptyState title="هنوز مقاله مرتبطی پیدا نشد" /> : articles.map((article, index) => <ArticleRow key={article.id} article={article} idx={index} />)}
|
||
</div>
|
||
</div>
|
||
<aside style={{ display: 'grid', gap: 14, alignContent: 'start' }}>
|
||
{topic && (
|
||
<button onClick={() => navigate(`/topics/${topic.slug}`)} style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 8, padding: 18, textAlign: 'right', cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif' }}>
|
||
<div style={{ fontSize: 12, color: '#5F6B6D' }}>خوشه موضوعی</div>
|
||
<div style={{ fontSize: 18, color: '#0F6B73', fontWeight: 900 }}>{topic.title}</div>
|
||
</button>
|
||
)}
|
||
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 8, padding: 18 }}>
|
||
<h3 style={{ margin: '0 0 12px', color: '#263238', fontSize: 16 }}>واژههای مرتبط</h3>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||
{related.map((item) => (
|
||
<button key={item.slug} onClick={() => navigate(`/glossary/${item.slug}`)} style={{ ...pillBtn, background: '#F4EFE6', color: '#0F6B73', border: '1px solid #E4DDD2', fontSize: 12 }}>{item.term}</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
</section>
|
||
<style>{`@media(max-width:850px){.glossary-grid{grid-template-columns:1fr!important}}`}</style>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
function toDateKey(article) {
|
||
return article?.dateEn || article?.publishedAt || article?.date || '';
|
||
}
|
||
|
||
function AuthorsListPage() {
|
||
const { navigate } = useNav();
|
||
const [authors, setAuthors] = useState(() => contentApi.fallbackAuthors());
|
||
const [articles, setArticles] = useState(() => contentApi.fallbackArticles());
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
Promise.all([
|
||
contentApi.listAuthors().catch(() => null),
|
||
contentApi.listArticles({ limit: 100 }).catch(() => null),
|
||
]).then(([authorRows, articleRows]) => {
|
||
if (cancelled) return;
|
||
if (authorRows) setAuthors(authorRows);
|
||
if (articleRows?.items) setArticles(articleRows.items);
|
||
});
|
||
return () => { cancelled = true; };
|
||
}, []);
|
||
return (
|
||
<div style={{ paddingTop: 64, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif' }}>
|
||
|
||
{/* Authors Hero */}
|
||
<div style={{ position: 'relative', borderBottom: '1px solid #E4DDD2', direction: 'rtl', overflow: 'hidden', minHeight: 220 }}>
|
||
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'url(/images/authors-hero.jpg)', backgroundSize: 'cover', backgroundPosition: 'center' }} aria-hidden="true" />
|
||
<div style={{ position: 'relative', zIndex: 1, maxWidth: 1100, margin: '0 auto', padding: '52px 32px' }}>
|
||
<div style={{
|
||
display: 'inline-block',
|
||
background: '#FFFFFF',
|
||
border: '1px solid #E4DDD2',
|
||
borderRight: '5px solid #C46A4D',
|
||
borderRadius: '0 20px 20px 0',
|
||
padding: '32px 44px 28px',
|
||
boxShadow: '0 6px 40px rgba(15,42,46,0.10)',
|
||
maxWidth: 560,
|
||
}}>
|
||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: '#C46A4D12', border: '1px solid #C46A4D30', color: '#C46A4D', padding: '5px 16px', borderRadius: 999, fontSize: 12, fontWeight: 700, marginBottom: 18, letterSpacing: '0.06em' }}>
|
||
<span style={{ width: 7, height: 7, borderRadius: '50%', background: '#C46A4D', display: 'inline-block' }} />
|
||
نویسندگان
|
||
</div>
|
||
<h1 style={{ fontSize: 'clamp(26px,4vw,40px)', fontWeight: 900, color: '#263238', margin: '0 0 10px', fontFamily: "'PelakFA','Vazirmatn',sans-serif", lineHeight: 1.2 }}>نویسندگان تکناو</h1>
|
||
<p style={{ fontSize: 15, color: '#5F6B6D', margin: 0, lineHeight: 1.9 }}>متخصصانی که روایت عمیق فناوری را مینویسند</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ maxWidth: 1100, margin: '0 auto', padding: '0 24px', direction: 'rtl' }}>
|
||
<div style={{ display: 'none' }} />
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(300px,1fr))', gap: 20, padding: '32px 0' }}>
|
||
{authors.map(author => {
|
||
const latestArt = articles.find(a => a.authorId === author.id || a.authorSlug === author.slug);
|
||
return (
|
||
<div key={author.id} onClick={() => navigate(authorProfilePath(author))} style={{
|
||
background: '#fff', border: '1px solid #E4DDD2', borderRadius: 14, padding: 24,
|
||
cursor: 'pointer', direction: 'rtl', transition: 'all 0.2s',
|
||
}} onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-3px)'; e.currentTarget.style.boxShadow = '0 10px 32px rgba(0,0,0,0.08)'; }}
|
||
onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = 'none'; }}>
|
||
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
|
||
<AuthorAvatar author={author} size={60} />
|
||
<div>
|
||
<div style={{ fontSize: 16, fontWeight: 700, color: '#263238', marginBottom: 3, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||
{author.name}
|
||
{author.verifiedExpert && <span style={{ fontSize: 10, color: '#0F6B73', background: '#0F6B7314', border: '1px solid #0F6B7330', borderRadius: 999, padding: '2px 7px' }}>تاییدشده</span>}
|
||
</div>
|
||
<div style={{ fontSize: 12, color: '#5F6B6D', marginBottom: 6 }}>{author.specialty}</div>
|
||
<div style={{ fontSize: 12, color: '#0F6B73', fontWeight: 600 }}>{author.liveArticleCount ?? author.articleCount} مقاله منتشرشده</div>
|
||
</div>
|
||
</div>
|
||
<p style={{ fontSize: 13, color: '#5F6B6D', lineHeight: 1.7, margin: '0 0 16px' }}>{author.bio.slice(0, 120)}…</p>
|
||
{latestArt && (
|
||
<div style={{ background: '#F4EFE6', borderRadius: 8, padding: '10px 14px', fontSize: 12, color: '#263238' }}>
|
||
<span style={{ color: '#5F6B6D' }}>آخرین مقاله: </span>
|
||
{latestArt.title.slice(0, 50)}…
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Author Profile Page ─────────────────────────────────────────────────────
|
||
function SeriesPage({ slug }) {
|
||
const { navigate } = useNav();
|
||
const [series, setSeries] = useState(null);
|
||
const [error, setError] = useState(false);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
setError(false);
|
||
contentApi.getSeries(slug)
|
||
.then((item) => { if (!cancelled) setSeries(item); })
|
||
.catch(() => { if (!cancelled) setError(true); });
|
||
return () => { cancelled = true; };
|
||
}, [slug]);
|
||
|
||
if (error) {
|
||
return (
|
||
<div style={{ paddingTop: 100, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
|
||
<EmptyState title="سری مقاله پیدا نشد" subtitle="این مجموعه در API وجود ندارد یا هنوز منتشر نشده است." />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!series) {
|
||
return <div style={{ paddingTop: 120, minHeight: '60vh', textAlign: 'center', fontFamily: 'Vazirmatn,sans-serif', color: '#5F6B6D' }}>در حال بارگذاری...</div>;
|
||
}
|
||
|
||
return (
|
||
<div style={{ paddingTop: 80, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
|
||
<div style={{ maxWidth: 960, margin: '0 auto', padding: '40px 24px' }}>
|
||
<button onClick={() => navigate('/articles')} style={{ ...pillBtn, background: '#F4EFE6', color: '#0F6B73', border: '1px solid #E4DDD2', marginBottom: 18 }}>بازگشت به مقالهها</button>
|
||
<h1 style={{ fontSize: 30, fontWeight: 900, color: '#263238', margin: '0 0 10px' }}>{series.title}</h1>
|
||
{series.description && <p style={{ color: '#5F6B6D', fontSize: 15, lineHeight: 1.9, margin: '0 0 28px' }}>{series.description}</p>}
|
||
{(series.articles ?? []).length === 0 ? <EmptyState title="هنوز مقالهای در این سری نیست" /> : (
|
||
<div style={{ display: 'grid', gap: 14 }}>
|
||
{series.articles.map((item, index) => (
|
||
<div key={item.article.id} style={{ display: 'grid', gridTemplateColumns: '44px 1fr', gap: 14, alignItems: 'start' }}>
|
||
<div style={{ width: 36, height: 36, borderRadius: 999, background: '#0F6B73', color: '#fff', display: 'grid', placeItems: 'center', fontWeight: 900 }}>{(item.position ?? index + 1).toLocaleString('fa-IR')}</div>
|
||
<ArticleRow article={item.article} idx={index} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TopicHubPage({ slug }) {
|
||
const { navigate } = useNav();
|
||
const toast = useToast();
|
||
const { user } = useAuth();
|
||
const topic = TeknavData.topicHubs.find((item) => item.slug === slug);
|
||
const category = TeknavData.categories.find((item) => item.slug === topic?.categorySlug);
|
||
const [articles, setArticles] = useState(() => contentApi.fallbackArticles().filter((item) => item.category === topic?.categorySlug));
|
||
const [series, setSeries] = useState([]);
|
||
const [follow, setFollow] = useState({ following: false, count: 0 });
|
||
const [guestPushEnabled, setGuestPushEnabled] = useState(false);
|
||
const [guestPushBusy, setGuestPushBusy] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!topic) return;
|
||
let cancelled = false;
|
||
setArticles(contentApi.fallbackArticles().filter((item) => item.category === topic.categorySlug));
|
||
Promise.all([
|
||
contentApi.listArticles({ category: topic.categorySlug, limit: 100 }).catch(() => null),
|
||
contentApi.listSeries().catch(() => []),
|
||
engagementApi.topicFollowers(topic.slug).catch(() => null),
|
||
]).then(([articleRows, seriesRows, followRow]) => {
|
||
if (cancelled) return;
|
||
if (articleRows?.items) setArticles(articleRows.items);
|
||
setSeries((seriesRows ?? []).filter((row) => (row.articles ?? []).some((entry) => entry.article?.category === topic.categorySlug)));
|
||
if (followRow) setFollow({ following: !!followRow.following, count: followRow.count ?? 0 });
|
||
});
|
||
// Check if guest push already subscribed
|
||
if ('PushManager' in window && 'serviceWorker' in navigator) {
|
||
pushApi.isSubscribed().then(setGuestPushEnabled).catch(() => {});
|
||
}
|
||
return () => { cancelled = true; };
|
||
}, [topic?.slug]);
|
||
|
||
if (!topic) {
|
||
return (
|
||
<div style={{ paddingTop: 100, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
|
||
<EmptyState title="موضوع پیدا نشد" subtitle="این خوشه محتوایی هنوز در تکناو تعریف نشده است." />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const featured = articles.filter((item) => item.featured).slice(0, 3);
|
||
const latest = articles.slice().sort((a, b) => String(toDateKey(b)).localeCompare(String(toDateKey(a)))).slice(0, 8);
|
||
const authors = TeknavData.authors.filter((author) => articles.some((article) => article.authorId === author.id || article.authorSlug === author.slug));
|
||
|
||
const toggleTopicFollow = async () => {
|
||
if (!user) {
|
||
toast('برای دنبال کردن موضوع ابتدا وارد شوید', 'warning');
|
||
return;
|
||
}
|
||
try {
|
||
const result = follow.following
|
||
? await engagementApi.unfollowTopic(topic.slug)
|
||
: await engagementApi.followTopic(topic.slug);
|
||
setFollow({ following: !!result.following, count: result.count ?? 0 });
|
||
toast(result.following ? 'موضوع دنبال شد' : 'دنبال کردن موضوع لغو شد');
|
||
} catch {
|
||
toast('دنبال کردن موضوع انجام نشد', 'warning');
|
||
}
|
||
};
|
||
|
||
const toggleGuestPush = async () => {
|
||
if (!('PushManager' in window) || !('serviceWorker' in navigator)) {
|
||
toast('مرورگر شما از اعلانهای وب پشتیبانی نمیکند', 'warning');
|
||
return;
|
||
}
|
||
setGuestPushBusy(true);
|
||
try {
|
||
if (guestPushEnabled) {
|
||
await pushApi.unsubscribeGuest();
|
||
setGuestPushEnabled(false);
|
||
toast('اعلانهای این موضوع غیرفعال شد');
|
||
} else {
|
||
const perm = typeof Notification !== 'undefined' ? await Notification.requestPermission() : 'denied';
|
||
if (perm !== 'granted') {
|
||
toast('اجازه اعلان داده نشد', 'warning');
|
||
return;
|
||
}
|
||
await pushApi.subscribeAsGuest([topic.slug]);
|
||
setGuestPushEnabled(true);
|
||
toast('اعلانهای مقالات جدید این موضوع فعال شد');
|
||
}
|
||
} catch {
|
||
toast('عملیات ناموفق بود', 'error');
|
||
} finally {
|
||
setGuestPushBusy(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div style={{ paddingTop: 72, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
|
||
<section style={{ borderBottom: '1px solid #E4DDD2', background: `linear-gradient(135deg, ${category?.color ?? '#0F6B73'}18, #F4EFE6)`, padding: '44px 24px' }}>
|
||
<div style={{ maxWidth: 1120, margin: '0 auto' }}>
|
||
<button onClick={() => navigate('/articles')} style={{ ...pillBtn, background: '#FFFFFFAA', color: '#0F6B73', border: '1px solid #E4DDD2', marginBottom: 18 }}>همه مقالهها</button>
|
||
<h1 style={{ fontSize: 'clamp(30px,5vw,48px)', color: '#263238', fontWeight: 900, margin: '0 0 12px' }}>{topic.title}</h1>
|
||
<p style={{ maxWidth: 760, color: '#4F5D60', fontSize: 16, lineHeight: 1.95, margin: 0 }}>{topic.intro}</p>
|
||
<div style={{ display: 'flex', gap: 10, marginTop: 18, flexWrap: 'wrap' }}>
|
||
<button onClick={toggleTopicFollow} style={{ ...pillBtn, background: follow.following ? '#0F6B73' : '#fff', color: follow.following ? '#fff' : '#0F6B73', border: '1px solid #0F6B7330' }}>
|
||
{follow.following ? 'دنبال میکنید' : 'دنبال کردن موضوع'} · {(follow.count ?? 0).toLocaleString('fa-IR')}
|
||
</button>
|
||
{'PushManager' in window && (
|
||
<button onClick={toggleGuestPush} disabled={guestPushBusy} style={{ ...pillBtn, background: guestPushEnabled ? '#fff' : '#FAF7F0', color: '#0F6B73', border: '1px solid #0F6B7330', opacity: guestPushBusy ? 0.6 : 1 }}>
|
||
{guestPushBusy ? '…' : guestPushEnabled ? 'اعلان فعال است' : 'دریافت اعلان مقالات'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<div style={{ maxWidth: 1120, margin: '0 auto', padding: '36px 24px', display: 'grid', gap: 34 }}>
|
||
<section>
|
||
<h2 style={{ color: '#263238', fontSize: 22, margin: '0 0 18px' }}>مقالههای شاخص</h2>
|
||
{featured.length === 0 ? <EmptyState title="مقاله شاخصی برای این موضوع ثبت نشده است" /> : (
|
||
<div style={{ display: 'grid', gap: 14 }}>
|
||
{featured.map((article, index) => <ArticleRow key={article.id} article={article} idx={index} />)}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<section>
|
||
<h2 style={{ color: '#263238', fontSize: 22, margin: '0 0 18px' }}>تازهترین تحلیلها</h2>
|
||
{latest.length === 0 ? <EmptyState title="هنوز مقالهای در این موضوع منتشر نشده است" /> : latest.map((article, index) => <ArticleRow key={article.id} article={article} idx={index} />)}
|
||
</section>
|
||
|
||
<section style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(260px,1fr))', gap: 18 }}>
|
||
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 20 }}>
|
||
<h2 style={{ color: '#263238', fontSize: 18, margin: '0 0 14px' }}>سریهای مرتبط</h2>
|
||
{series.length === 0 ? <p style={{ color: '#5F6B6D', lineHeight: 1.8, margin: 0 }}>سری مقاله مرتبط پس از تایید سردبیر اینجا نمایش داده میشود.</p> : series.map((item) => (
|
||
<button key={item.id} onClick={() => navigate(`/series/${item.slug}`)} style={{ display: 'block', width: '100%', textAlign: 'right', background: '#F4EFE6', border: '1px solid #E4DDD2', borderRadius: 8, padding: 12, marginBottom: 10, color: '#263238', cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif' }}>{item.title}</button>
|
||
))}
|
||
</div>
|
||
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 20 }}>
|
||
<h2 style={{ color: '#263238', fontSize: 18, margin: '0 0 14px' }}>نویسندگان مرتبط</h2>
|
||
{authors.length === 0 ? <p style={{ color: '#5F6B6D', lineHeight: 1.8, margin: 0 }}>نویسنده مرتبطی پیدا نشد.</p> : authors.map((author) => (
|
||
<button key={author.id} onClick={() => navigate(authorProfilePath(author))} style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', background: 'transparent', border: 0, padding: '8px 0', color: '#263238', cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif', textAlign: 'right' }}>
|
||
<AuthorAvatar author={author} size={36} />
|
||
<span>{author.name}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 20 }}>
|
||
<h2 style={{ color: '#263238', fontSize: 18, margin: '0 0 14px' }}>پرسشهای رایج</h2>
|
||
{topic.faqs.map(([question, answer]) => (
|
||
<details key={question} style={{ borderTop: '1px solid #E4DDD2', padding: '12px 0' }}>
|
||
<summary style={{ cursor: 'pointer', fontWeight: 700, color: '#263238' }}>{question}</summary>
|
||
<p style={{ color: '#5F6B6D', lineHeight: 1.9, margin: '10px 0 0' }}>{answer}</p>
|
||
</details>
|
||
))}
|
||
</section>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AuthorProfilePage({ slug }) {
|
||
const { navigate } = useNav();
|
||
const [author, setAuthor] = useState(null);
|
||
const [articles, setArticles] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
useEffect(() => {
|
||
// Redirect /author/:slug → /profile/@:username
|
||
const staticAuthor = TeknavData.authors.find(a => a.slug === slug);
|
||
const username = staticAuthor?.username || slug;
|
||
navigate(`/profile/@${username}`);
|
||
}, [slug]);
|
||
|
||
if (loading) return <div style={{ paddingTop: 120, textAlign: 'center', fontFamily: 'Vazirmatn,sans-serif', color: '#5F6B6D' }}>در حال بارگذاری…</div>;
|
||
if (!author) return <EmptyState title="نویسنده یافت نشد" />;
|
||
|
||
return (
|
||
<div style={{ paddingTop: 80, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
|
||
{/* Profile header */}
|
||
<div style={{ background: 'linear-gradient(135deg,#20343A,#0F2830)', padding: '48px 24px' }}>
|
||
<div style={{ maxWidth: 900, margin: '0 auto', display: 'flex', gap: 28, alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<AuthorAvatar author={author} size={96} />
|
||
<div>
|
||
<h1 style={{ fontSize: 28, fontWeight: 900, color: '#FAF7F0', margin: '0 0 6px', display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||
{author.name}
|
||
{author.verifiedExpert && <span style={{ fontSize: 12, color: '#D49A2A', background: 'rgba(212,154,42,0.12)', border: '1px solid rgba(212,154,42,0.45)', borderRadius: 999, padding: '3px 10px' }}>متخصص تاییدشده</span>}
|
||
</h1>
|
||
{author.verifiedExpert && author.verificationNote && <div style={{ color: '#CFE2DD', fontSize: 12, marginBottom: 8 }}>{author.verificationNote}</div>}
|
||
<div style={{ fontSize: 14, color: '#D49A2A', fontWeight: 600, marginBottom: 10 }}>{author.specialty}</div>
|
||
<p style={{ fontSize: 14, color: '#90A4AE', lineHeight: 1.8, margin: '0 0 16px', maxWidth: 520 }}>{author.bio}</p>
|
||
<div style={{ display: 'flex', gap: 24 }}>
|
||
<div><span style={{ fontSize: 22, fontWeight: 800, color: '#D49A2A' }}>{author.articleCount}</span><span style={{ fontSize: 12, color: '#90A4AE', marginRight: 4 }}>مقاله</span></div>
|
||
<div><span style={{ fontSize: 22, fontWeight: 800, color: '#D49A2A' }}>{articles.reduce((s, a) => s + (a.views || 0), 0).toLocaleString('fa')}</span><span style={{ fontSize: 12, color: '#90A4AE', marginRight: 4 }}>بازدید</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ maxWidth: 900, margin: '0 auto', padding: '32px 24px' }}>
|
||
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#263238', margin: '0 0 24px' }}>مقالههای {author.name}</h2>
|
||
{articles.length === 0 ? <EmptyState title="مقالهای منتشر نشده" /> :
|
||
articles.map((a, i) => <ArticleRow key={a.id} article={a} idx={i} />)
|
||
}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NewsletterArchivePage() {
|
||
const { navigate } = useNav();
|
||
const [items, setItems] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
contentApi.listNewsletterArchive()
|
||
.then((rows) => { if (!cancelled) setItems(rows); })
|
||
.catch(() => { if (!cancelled) setItems([]); })
|
||
.finally(() => { if (!cancelled) setLoading(false); });
|
||
return () => { cancelled = true; };
|
||
}, []);
|
||
|
||
return (
|
||
<div style={{ paddingTop: 80, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
|
||
<div style={{ maxWidth: 900, margin: '0 auto', padding: '40px 24px' }}>
|
||
<h1 style={{ fontSize: 32, fontWeight: 900, color: '#263238', margin: '0 0 10px' }}>آرشیو خبرنامه تکنـاو</h1>
|
||
<p style={{ color: '#5F6B6D', lineHeight: 1.9, margin: '0 0 28px' }}>نسخههای ارسالشده خبرنامه برای دنبال کردن روندهای مهم فناوری، هوش مصنوعی و امنیت.</p>
|
||
{loading ? <div style={{ color: '#5F6B6D' }}>در حال بارگذاری...</div> : items.length === 0 ? <EmptyState title="هنوز خبرنامهای منتشر نشده است" /> : (
|
||
<div style={{ display: 'grid', gap: 14 }}>
|
||
{items.map((item) => (
|
||
<button key={item.id} onClick={() => navigate(`/newsletter/${item.slug}`)} style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 10, padding: 18, textAlign: 'right', cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif' }}>
|
||
<div style={{ fontSize: 18, fontWeight: 800, color: '#263238', marginBottom: 6 }}>{item.subject}</div>
|
||
<div style={{ color: '#5F6B6D', fontSize: 12 }}>{item.sentAt ? new Date(item.sentAt).toLocaleDateString('fa-IR') : 'منتشر نشده'}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NewsletterIssuePage({ slug }) {
|
||
const { navigate } = useNav();
|
||
const [issue, setIssue] = useState(null);
|
||
const [error, setError] = useState(false);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
setError(false);
|
||
contentApi.getNewsletterIssue(slug)
|
||
.then((row) => { if (!cancelled) setIssue(row); })
|
||
.catch(() => { if (!cancelled) setError(true); });
|
||
return () => { cancelled = true; };
|
||
}, [slug]);
|
||
|
||
if (error) return <div style={{ paddingTop: 100, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}><EmptyState title="خبرنامه پیدا نشد" /></div>;
|
||
if (!issue) return <div style={{ paddingTop: 120, textAlign: 'center', color: '#5F6B6D', fontFamily: 'Vazirmatn,sans-serif' }}>در حال بارگذاری...</div>;
|
||
|
||
return (
|
||
<div style={{ paddingTop: 80, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
|
||
<article style={{ maxWidth: 760, margin: '0 auto', padding: '40px 24px' }}>
|
||
<button onClick={() => navigate('/newsletter')} style={{ ...pillBtn, background: '#F4EFE6', color: '#0F6B73', border: '1px solid #E4DDD2', marginBottom: 18 }}>آرشیو خبرنامه</button>
|
||
<h1 style={{ fontSize: 30, fontWeight: 900, color: '#263238', lineHeight: 1.45, margin: '0 0 10px' }}>{issue.subject}</h1>
|
||
<div style={{ color: '#5F6B6D', fontSize: 12, marginBottom: 28 }}>{issue.sentAt ? new Date(issue.sentAt).toLocaleDateString('fa-IR') : ''}</div>
|
||
<div className="article-content" style={{ fontSize: 16, lineHeight: 2, color: '#263238', background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 24 }} dangerouslySetInnerHTML={{ __html: issue.bodyHtml }} />
|
||
</article>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function JobsPage() {
|
||
const toast = useToast();
|
||
const [items, setItems] = useState([]);
|
||
const [form, setForm] = useState({ company: '', title: '', location: 'Remote', remote: true, url: '', contactEmail: '', description: '' });
|
||
const [busy, setBusy] = useState(false);
|
||
useEffect(() => {
|
||
contentApi.listJobs().then(setItems).catch(() => setItems([]));
|
||
}, []);
|
||
const set = (key, value) => setForm((current) => ({ ...current, [key]: value }));
|
||
const submit = async (event) => {
|
||
event.preventDefault();
|
||
setBusy(true);
|
||
try {
|
||
await contentApi.submitJob(form);
|
||
setForm({ company: '', title: '', location: 'Remote', remote: true, url: '', contactEmail: '', description: '' });
|
||
toast('آگهی شغلی برای بررسی ارسال شد');
|
||
} catch {
|
||
toast('ارسال آگهی ناموفق بود', 'error');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
return (
|
||
<div style={{ paddingTop: 80, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
|
||
<div style={{ maxWidth: 1040, margin: '0 auto', padding: '40px 24px' }}>
|
||
<h1 style={{ fontSize: 32, fontWeight: 900, color: '#263238', margin: '0 0 8px' }}>فرصتهای شغلی فناوری</h1>
|
||
<p style={{ color: '#5F6B6D', lineHeight: 1.9, margin: '0 0 28px' }}>جایگاههای منتخب شرکتهای فنی فارسیزبان. آگهیهای جدید بعد از بررسی تحریریه منتشر میشوند.</p>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 360px', gap: 22 }} className="jobs-grid">
|
||
<section style={{ display: 'grid', gap: 12 }}>
|
||
{items.length === 0 ? <EmptyState title="هنوز آگهی منتشرشدهای وجود ندارد" /> : items.map((job) => (
|
||
<article key={job.id} style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 18 }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||
<div>
|
||
<h2 style={{ margin: '0 0 6px', fontSize: 18, color: '#263238' }}>{job.title}</h2>
|
||
<div style={{ color: '#0F6B73', fontWeight: 800, fontSize: 13 }}>{job.company}</div>
|
||
</div>
|
||
<span style={{ color: '#5F6B6D', fontSize: 12 }}>{job.remote ? 'Remote' : job.location}</span>
|
||
</div>
|
||
<p style={{ color: '#5F6B6D', lineHeight: 1.8, fontSize: 13 }}>{job.description}</p>
|
||
{job.url && <a href={job.url} target="_blank" rel="noreferrer" style={{ color: '#0F6B73', fontWeight: 800 }}>مشاهده و ارسال رزومه</a>}
|
||
</article>
|
||
))}
|
||
</section>
|
||
<form onSubmit={submit} style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 18, alignSelf: 'start', display: 'grid', gap: 10 }}>
|
||
<h2 style={{ margin: 0, fontSize: 17, color: '#263238' }}>ثبت آگهی شرکت</h2>
|
||
<input required value={form.company} onChange={e => set('company', e.target.value)} placeholder="نام شرکت" style={inputStyle} />
|
||
<input required value={form.title} onChange={e => set('title', e.target.value)} placeholder="عنوان شغل" style={inputStyle} />
|
||
<input value={form.location} onChange={e => set('location', e.target.value)} placeholder="موقعیت" style={inputStyle} />
|
||
<input value={form.contactEmail} onChange={e => set('contactEmail', e.target.value)} placeholder="ایمیل تماس" style={inputStyle} dir="ltr" />
|
||
<input value={form.url} onChange={e => set('url', e.target.value)} placeholder="لینک درخواست" style={inputStyle} dir="ltr" />
|
||
<label style={{ display: 'flex', gap: 8, fontSize: 13, color: '#263238' }}><input type="checkbox" checked={form.remote} onChange={e => set('remote', e.target.checked)} /> امکان دورکاری</label>
|
||
<textarea required value={form.description} onChange={e => set('description', e.target.value)} placeholder="شرح موقعیت" style={{ ...inputStyle, minHeight: 110, resize: 'vertical' }} />
|
||
<button disabled={busy} style={{ ...pillBtn, background: '#0F6B73', color: '#fff', justifyContent: 'center' }}>{busy ? 'در حال ارسال...' : 'ارسال برای بررسی'}</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
<style>{`@media(max-width:850px){.jobs-grid{grid-template-columns:1fr!important}}`}</style>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CoursesPage() {
|
||
const [items, setItems] = useState([]);
|
||
useEffect(() => { contentApi.listCourses().then(setItems).catch(() => setItems([])); }, []);
|
||
return (
|
||
<div style={{ paddingTop: 80, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
|
||
<div style={{ maxWidth: 1040, margin: '0 auto', padding: '40px 24px' }}>
|
||
<h1 style={{ fontSize: 32, fontWeight: 900, color: '#263238', margin: '0 0 8px' }}>دورههای تخصصی تکناو</h1>
|
||
<p style={{ color: '#5F6B6D', lineHeight: 1.9, margin: '0 0 28px' }}>مسیرهای آموزشی فارسی برای تیمهایی که فناوری را در محصول و عملیات واقعی به کار میگیرند.</p>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(260px,1fr))', gap: 16 }}>
|
||
{items.map(course => (
|
||
<article key={course.id} style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 20 }}>
|
||
<div style={{ color: '#0F6B73', fontSize: 12, fontWeight: 800, marginBottom: 8 }}>{course.level}</div>
|
||
<h2 style={{ margin: '0 0 10px', color: '#263238', fontSize: 19 }}>{course.title}</h2>
|
||
<p style={{ color: '#5F6B6D', lineHeight: 1.9, fontSize: 13 }}>{course.summary}</p>
|
||
<div style={{ marginTop: 16, color: '#D49A2A', fontWeight: 900 }}>{course.price ? `${course.price.toLocaleString('fa-IR')} تومان` : 'بهزودی'}</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MembershipPage() {
|
||
const { navigate } = useNav();
|
||
const { user } = useAuth();
|
||
const toast = useToast();
|
||
const [loading, setLoading] = useState(false);
|
||
const [status, setStatus] = useState(null);
|
||
|
||
useEffect(() => {
|
||
fetch('/api/v1/membership/status', { credentials: 'include' })
|
||
.then((r) => r.ok ? r.json() : null)
|
||
.then((d) => d && setStatus(d))
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
const subscribe = async () => {
|
||
if (!user) { navigate('/login'); return; }
|
||
setLoading(true);
|
||
try {
|
||
const res = await fetch('/api/v1/membership/subscribe', {
|
||
method: 'POST', credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
const data = await res.json();
|
||
if (data.redirectUrl) {
|
||
window.location.href = data.redirectUrl;
|
||
} else {
|
||
toast(data.error === 'payment_unavailable' ? 'درگاه پرداخت در دسترس نیست' : 'خطا در اتصال به درگاه پرداخت');
|
||
setLoading(false);
|
||
}
|
||
} catch {
|
||
toast('خطا در ارتباط با سرور');
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const isPremium = status?.active;
|
||
|
||
return (
|
||
<div style={{ paddingTop: 80, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
|
||
<div style={{ maxWidth: 900, margin: '0 auto', padding: '44px 24px' }}>
|
||
<h1 style={{ fontSize: 32, fontWeight: 900, color: '#263238', margin: '0 0 8px' }}>عضویت پریمیوم تکناو</h1>
|
||
<p style={{ color: '#5F6B6D', lineHeight: 1.9, marginBottom: 28 }}>
|
||
تجربه بدون تبلیغ، مقالههای اختصاصی، دورههای کوتاه و گزارشهای عمیق برای خوانندگان حرفهای.
|
||
</p>
|
||
|
||
{isPremium && (
|
||
<div style={{ background: '#0F6B7318', border: '1px solid #0F6B7340', borderRadius: 12, padding: '16px 20px', marginBottom: 28, color: '#0F6B73', fontWeight: 700 }}>
|
||
✓ شما در حال حاضر عضو ویژه هستید
|
||
{status?.expiresAt && ` — اعتبار تا ${new Date(status.expiresAt).toLocaleDateString('fa-IR')}`}
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(240px,1fr))', gap: 16, marginBottom: 32 }}>
|
||
{[
|
||
['👑', 'بدون تبلیغ', 'خواندن آرام و سریعتر بدون جایگاههای تبلیغاتی.'],
|
||
['📖', 'محتوای اختصاصی', 'تحلیلهای عمیقتر و پروندههای تخصصی برای اعضا.'],
|
||
['🎓', 'تخفیف دورهها', 'دسترسی زودتر و تخفیف روی دورههای فارسی تکناو.'],
|
||
].map(([icon, title, body]) => (
|
||
<section key={title} style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 20 }}>
|
||
<div style={{ fontSize: 24, marginBottom: 8 }}>{icon}</div>
|
||
<h2 style={{ margin: '0 0 8px', fontSize: 18, color: '#263238' }}>{title}</h2>
|
||
<p style={{ margin: 0, color: '#5F6B6D', lineHeight: 1.8 }}>{body}</p>
|
||
</section>
|
||
))}
|
||
</div>
|
||
|
||
{!isPremium && (
|
||
<div style={{ textAlign: 'center' }}>
|
||
<div style={{ fontSize: 13, color: '#5F6B6D', marginBottom: 16 }}>
|
||
سالانه — ۵۰۰,۰۰۰ تومان
|
||
</div>
|
||
<button onClick={subscribe} disabled={loading} style={{
|
||
background: 'linear-gradient(135deg, #D49A2A, #B8821E)', color: '#fff',
|
||
border: 'none', borderRadius: 10, padding: '14px 36px', fontSize: 16, fontWeight: 700,
|
||
cursor: loading ? 'not-allowed' : 'pointer', opacity: loading ? 0.7 : 1,
|
||
fontFamily: 'Vazirmatn,sans-serif',
|
||
}}>
|
||
{loading ? 'در حال اتصال به درگاه...' : 'شروع عضویت ویژه'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MembershipSuccessPage() {
|
||
const { navigate } = useNav();
|
||
return (
|
||
<div style={{ paddingTop: 80, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<div style={{ textAlign: 'center', maxWidth: 480, padding: '0 24px' }}>
|
||
<div style={{ fontSize: 56, marginBottom: 16 }}>👑</div>
|
||
<h1 style={{ fontSize: 26, fontWeight: 900, color: '#263238', marginBottom: 12 }}>عضویت ویژه فعال شد!</h1>
|
||
<p style={{ color: '#5F6B6D', lineHeight: 1.9, marginBottom: 28 }}>از حالا به همه مقالات پریمیوم دسترسی دارید. ممنون از حمایت شما از تکناو.</p>
|
||
<button onClick={() => navigate('/')} style={{ ...pillBtn, background: '#0F6B73', color: '#fff', padding: '12px 28px', fontSize: 14 }}>بازگشت به خانه</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Shared legal page shell ──────────────────────────────────────────────────
|
||
function LegalPage({ title, subtitle, children }) {
|
||
return (
|
||
<div style={{ paddingTop: 88, paddingBottom: 80, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
|
||
<div style={{ maxWidth: 820, margin: '0 auto', padding: '0 24px' }}>
|
||
<div style={{ marginBottom: 40 }}>
|
||
<h1 style={{ fontSize: 28, fontWeight: 800, color: '#0F2A2E', marginBottom: 8, fontFamily: "'PelakFA','Vazirmatn',sans-serif" }}>{title}</h1>
|
||
{subtitle && <p style={{ fontSize: 14, color: '#5F6B6D', lineHeight: 1.8 }}>{subtitle}</p>}
|
||
<div style={{ height: 3, width: 48, background: '#D49A2A', borderRadius: 2, marginTop: 16 }} />
|
||
</div>
|
||
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: '36px 40px', lineHeight: 1.95, color: '#374549', fontSize: 14 }}>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LegalSection({ title, children }) {
|
||
return (
|
||
<section style={{ marginBottom: 32 }}>
|
||
<h2 style={{ fontSize: 16, fontWeight: 700, color: '#0F2A2E', marginBottom: 10, borderRight: '3px solid #0F6B73', paddingRight: 10 }}>{title}</h2>
|
||
{children}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// ── Privacy Policy ───────────────────────────────────────────────────────────
|
||
function PrivacyPolicyPage() {
|
||
return (
|
||
<LegalPage
|
||
title="قوانین حریم خصوصی"
|
||
subtitle="آخرین بروزرسانی: اردیبهشت ۱۴۰۵ — لطفاً این سیاست را پیش از استفاده از خدمات تکناو مطالعه کنید."
|
||
>
|
||
<LegalSection title="۱. اطلاعاتی که جمعآوری میکنیم">
|
||
<p style={{ marginBottom: 10 }}>تکناو اطلاعات زیر را از کاربران جمعآوری میکند:</p>
|
||
<ul style={{ paddingRight: 20, margin: 0, listStyle: 'disc' }}>
|
||
<li style={{ marginBottom: 6 }}><strong>اطلاعات حساب کاربری:</strong> نام، آدرس ایمیل و نام کاربری هنگام ثبتنام.</li>
|
||
<li style={{ marginBottom: 6 }}><strong>اطلاعات استفاده:</strong> صفحات بازدیدشده، مقالات مطالعهشده، مدت زمان حضور در سایت و واکنشهای کاربری — بهصورت ناشناس و جمعی.</li>
|
||
<li style={{ marginBottom: 6 }}><strong>اطلاعات فنی:</strong> نوع مرورگر، سیستمعامل و آدرس IP بهمنظور امنیت و رفع اشکال.</li>
|
||
<li style={{ marginBottom: 6 }}><strong>اطلاعات خبرنامه:</strong> آدرس ایمیل در صورت عضویت اختیاری در خبرنامه.</li>
|
||
</ul>
|
||
</LegalSection>
|
||
|
||
<LegalSection title="۲. نحوه استفاده از اطلاعات">
|
||
<p>اطلاعات جمعآوریشده صرفاً برای موارد زیر استفاده میشود:</p>
|
||
<ul style={{ paddingRight: 20, margin: '8px 0 0', listStyle: 'disc' }}>
|
||
<li style={{ marginBottom: 6 }}>ارائه و بهبود محتوا و خدمات تکناو</li>
|
||
<li style={{ marginBottom: 6 }}>ارسال خبرنامه در صورت اشتراک داوطلبانه</li>
|
||
<li style={{ marginBottom: 6 }}>تشخیص و پیشگیری از دسترسی غیرمجاز و سوءاستفاده</li>
|
||
<li style={{ marginBottom: 6 }}>پاسخ به درخواستها و پشتیبانی کاربران</li>
|
||
</ul>
|
||
<p style={{ marginTop: 10 }}>تکناو اطلاعات شخصی شما را به هیچ شخص ثالثی نمیفروشد.</p>
|
||
</LegalSection>
|
||
|
||
<LegalSection title="۳. کوکیها و ذخیرهسازی محلی">
|
||
<p>تکناو از کوکیهای ضروری برای مدیریت نشست (session) و از localStorage برای ذخیره تنظیمات رابط کاربری استفاده میکند. هیچ کوکی تبلیغاتی یا ردیابی شخصثالث استفاده نمیشود.</p>
|
||
</LegalSection>
|
||
|
||
<LegalSection title="۴. امنیت اطلاعات">
|
||
<p>اطلاعات کاربران با رمزنگاری Argon2id (کلمه عبور) و HTTPS (انتقال داده) محافظت میشوند. دسترسی به دادههای شخصی به حداقل افراد ضروری محدود است.</p>
|
||
</LegalSection>
|
||
|
||
<LegalSection title="۵. حقوق کاربر">
|
||
<p>شما حق دارید هر زمان درخواست دسترسی، اصلاح، یا حذف اطلاعات شخصی خود را از طریق <strong>info@teknav.ir</strong> ارسال کنید. درخواستها ظرف ۱۴ روز کاری پردازش میشوند.</p>
|
||
</LegalSection>
|
||
|
||
<LegalSection title="۶. تغییرات در این سیاست">
|
||
<p>تکناو ممکن است این سیاست را بهروزرسانی کند. تغییرات مهم از طریق اعلان درونسایتی اطلاعرسانی خواهند شد. ادامه استفاده از سایت پس از انتشار تغییرات بهمنزله پذیرش آنها است.</p>
|
||
</LegalSection>
|
||
|
||
<LegalSection title="۷. تماس">
|
||
<p>برای سوالات مرتبط با حریم خصوصی با ما از طریق <strong>info@teknav.ir</strong> تماس بگیرید.</p>
|
||
</LegalSection>
|
||
</LegalPage>
|
||
);
|
||
}
|
||
|
||
// ── Terms of Use ─────────────────────────────────────────────────────────────
|
||
function TermsOfUsePage() {
|
||
return (
|
||
<LegalPage
|
||
title="شرایط استفاده"
|
||
subtitle="آخرین بروزرسانی: اردیبهشت ۱۴۰۵ — استفاده از وبسایت تکناو به معنای پذیرش این شرایط است."
|
||
>
|
||
<LegalSection title="۱. پذیرش شرایط">
|
||
<p>با دسترسی به وبسایت تکناو (teknav.ir) شما موافقت خود را با این شرایط اعلام میکنید. اگر با هر بخشی از این شرایط موافق نیستید، لطفاً از استفاده از سایت خودداری کنید.</p>
|
||
</LegalSection>
|
||
|
||
<LegalSection title="۲. محتوا و مالکیت معنوی">
|
||
<p>تمامی محتوای منتشرشده در تکناو — شامل مقالات، تحلیلها، نمودارها، و طراحیها — متعلق به تکناو یا نویسندگان مربوطه است و تحت قوانین مالکیت معنوی محافظت میشود.</p>
|
||
<p style={{ marginTop: 8 }}>نقلقول کوتاه با ذکر منبع (لینک مستقیم به مقاله) مجاز است. بازنشر کامل یا ترجمه بدون اجازه کتبی ممنوع است.</p>
|
||
</LegalSection>
|
||
|
||
<LegalSection title="۳. حساب کاربری">
|
||
<ul style={{ paddingRight: 20, margin: 0, listStyle: 'disc' }}>
|
||
<li style={{ marginBottom: 6 }}>مسئولیت حفظ امنیت اطلاعات ورود به سیستم با کاربر است.</li>
|
||
<li style={{ marginBottom: 6 }}>ایجاد چندین حساب کاربری توسط یک شخص مجاز نیست.</li>
|
||
<li style={{ marginBottom: 6 }}>تکناو حق تعلیق یا حذف حسابهای ناقض قوانین را دارد.</li>
|
||
</ul>
|
||
</LegalSection>
|
||
|
||
<LegalSection title="۴. رفتار کاربران">
|
||
<p>کاربران متعهد میشوند که از سایت برای موارد زیر استفاده نکنند:</p>
|
||
<ul style={{ paddingRight: 20, margin: '8px 0 0', listStyle: 'disc' }}>
|
||
<li style={{ marginBottom: 6 }}>انتشار محتوای توهینآمیز، خلاف قانون، یا گمراهکننده</li>
|
||
<li style={{ marginBottom: 6 }}>ارسال هرزنامه (spam) یا تلاش برای دسترسی غیرمجاز</li>
|
||
<li style={{ marginBottom: 6 }}>تداخل در عملکرد سیستم یا سرویسهای تکناو</li>
|
||
</ul>
|
||
</LegalSection>
|
||
|
||
<LegalSection title="۵. سلب مسئولیت">
|
||
<p>محتوای تکناو صرفاً جنبه اطلاعرسانی و آموزشی دارد. تکناو در قبال تصمیماتی که بر اساس محتوای منتشرشده اتخاذ میشوند مسئولیتی نمیپذیرد. صحت اطلاعات فنی در حد توان بررسی میشود اما ضمانت کامل داده نمیشود.</p>
|
||
</LegalSection>
|
||
|
||
<LegalSection title="۶. تغییر خدمات">
|
||
<p>تکناو حق دارد هر زمان خدمات، محتوا، یا این شرایط را تغییر دهد. اطلاعرسانی تغییرات مهم از طریق سایت انجام میشود.</p>
|
||
</LegalSection>
|
||
|
||
<LegalSection title="۷. قانون حاکم">
|
||
<p>این شرایط تابع قوانین جمهوری اسلامی ایران است. هرگونه اختلاف در صلاحیت دادگاههای ایران رسیدگی خواهد شد.</p>
|
||
</LegalSection>
|
||
|
||
<LegalSection title="۸. تماس">
|
||
<p>برای سوالات حقوقی با ما از طریق <strong>info@teknav.ir</strong> در ارتباط باشید.</p>
|
||
</LegalSection>
|
||
</LegalPage>
|
||
);
|
||
}
|
||
|
||
// ── About Page ───────────────────────────────────────────────────────────────
|
||
function AboutPage() {
|
||
const { navigate } = useNav();
|
||
return (
|
||
<LegalPage
|
||
title="درباره تکناو"
|
||
subtitle="روایت عمیق فناوری — تحلیلهای علمی و تخصصی برای متخصصان ایرانی"
|
||
>
|
||
<LegalSection title="تکناو چیست؟">
|
||
<p>تکناو یک نشریه فناوری فارسیزبان است که با هدف تولید محتوای عمیق، علمی، و کاربردی برای متخصصان، پژوهشگران، و علاقهمندان ایرانی فعالیت میکند. ما باور داریم که جامعه فارسیزبان به منابع تخصصیای نیاز دارد که نه صرفاً ترجمه، بلکه تحلیل و تولید اصیل باشند.</p>
|
||
</LegalSection>
|
||
|
||
<LegalSection title="حوزههای تخصصی">
|
||
<ul style={{ paddingRight: 20, margin: 0, listStyle: 'none' }}>
|
||
{[
|
||
['هوش مصنوعی', 'یادگیری ماشین، مدلهای زبانی بزرگ، سیستمهای هوشمند'],
|
||
['علم داده', 'مهندسی داده، پایپلاینها، ارزیابی کمی'],
|
||
['امنیت سایبری', 'تهدیدات نوظهور، رمزنگاری، تست نفوذ'],
|
||
['سختافزار', 'پردازندهها، شتابدهندههای هوش مصنوعی، زیرساخت'],
|
||
['نرمافزار', 'معماری سیستم، مهندسی نرمافزار، ابزارهای توسعه'],
|
||
['آینده فناوری', 'روندهای بلندمدت، تأثیر اجتماعی فناوری'],
|
||
].map(([t, d]) => (
|
||
<li key={t} style={{ marginBottom: 10, display: 'flex', gap: 10, alignItems: 'flex-start' }}>
|
||
<span style={{ minWidth: 8, height: 8, borderRadius: '50%', background: '#0F6B73', marginTop: 6, flexShrink: 0, display: 'inline-block' }} />
|
||
<span><strong>{t}:</strong> {d}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</LegalSection>
|
||
|
||
<LegalSection title="رویکرد ما">
|
||
<p>هر مقاله تکناو با بررسی منابع اولیه، مقالات علمی، و تجربه عملی تهیه میشود. ما از هوش مصنوعی بهعنوان ابزار کمکی در ویرایش استفاده میکنیم اما تحلیل، نتیجهگیری، و دیدگاهها کاملاً انسانی هستند.</p>
|
||
</LegalSection>
|
||
|
||
<LegalSection title="تیم">
|
||
<p>تکناو توسط <strong>آرسام صباغ</strong> — پژوهشگر ارشد هوش مصنوعی و مهندسی داده — بنیانگذاری شده و با همکاری نویسندگان متخصص در حوزههای مختلف فناوری فعالیت میکند.</p>
|
||
<div style={{ marginTop: 16, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||
<button onClick={() => navigate('/authors')} style={{ background: '#0F6B73', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 20px', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif' }}>
|
||
مشاهده تیم نویسندگان
|
||
</button>
|
||
<button onClick={() => navigate('/contact')} style={{ background: 'transparent', color: '#0F6B73', border: '1px solid #0F6B73', borderRadius: 8, padding: '10px 20px', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif' }}>
|
||
تماس با ما
|
||
</button>
|
||
</div>
|
||
</LegalSection>
|
||
|
||
<LegalSection title="اطلاعات تماس">
|
||
<p><strong>وبسایت:</strong> teknav.ir</p>
|
||
<p><strong>ایمیل:</strong> info@teknav.ir</p>
|
||
<p><strong>تهران، ایران</strong></p>
|
||
</LegalSection>
|
||
</LegalPage>
|
||
);
|
||
}
|
||
|
||
// ── Contact Page ─────────────────────────────────────────────────────────────
|
||
function ContactPage() {
|
||
const { navigate } = useNav();
|
||
const toast = useToast();
|
||
const [form, setForm] = useState({ name: '', email: '', subject: '', message: '' });
|
||
const [sent, setSent] = useState(false);
|
||
|
||
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
|
||
|
||
const submit = async (e) => {
|
||
e.preventDefault();
|
||
if (!form.name.trim() || !form.email.includes('@') || !form.message.trim()) {
|
||
toast('لطفاً همه فیلدهای ضروری را پر کنید', 'error');
|
||
return;
|
||
}
|
||
try {
|
||
await fetch('/api/contact', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(form),
|
||
});
|
||
} catch {}
|
||
setSent(true);
|
||
};
|
||
|
||
if (sent) return (
|
||
<div style={{ paddingTop: 88, paddingBottom: 80, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<div style={{ textAlign: 'center', maxWidth: 420, padding: '0 24px' }}>
|
||
<div style={{ fontSize: 56, marginBottom: 16 }}>✓</div>
|
||
<h2 style={{ fontSize: 22, fontWeight: 800, color: '#0F2A2E', marginBottom: 10 }}>پیام شما ارسال شد</h2>
|
||
<p style={{ color: '#5F6B6D', lineHeight: 1.8, marginBottom: 24 }}>ممنون از تماس شما. تیم تکناو بهزودی پاسخ خواهد داد.</p>
|
||
<button onClick={() => navigate('/')} style={{ background: '#0F6B73', color: '#fff', border: 'none', borderRadius: 8, padding: '12px 28px', fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif' }}>
|
||
بازگشت به خانه
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const field = { background: '#FAF7F0', border: '1px solid #D8D0C4', borderRadius: 8, padding: '11px 14px', fontSize: 14, fontFamily: 'Vazirmatn,sans-serif', color: '#263238', width: '100%', boxSizing: 'border-box', outline: 'none', direction: 'rtl' };
|
||
|
||
return (
|
||
<LegalPage title="تماس با ما" subtitle="سوال، پیشنهاد، یا همکاری؟ خوشحال میشویم بشنویم.">
|
||
<LegalSection title="راههای ارتباطی مستقیم">
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(200px,1fr))', gap: 16, marginBottom: 4 }}>
|
||
{[
|
||
['📧', 'ایمیل عمومی', 'info@teknav.ir'],
|
||
['✍️', 'همکاری محتوایی', 'admin@teknavglobal.com'],
|
||
['📍', 'موقعیت', 'تهران، ایران'],
|
||
].map(([icon, label, val]) => (
|
||
<div key={label} style={{ background: '#FAF7F0', border: '1px solid #E4DDD2', borderRadius: 10, padding: '16px 18px' }}>
|
||
<div style={{ fontSize: 22, marginBottom: 6 }}>{icon}</div>
|
||
<div style={{ fontSize: 12, color: '#5F6B6D', marginBottom: 2 }}>{label}</div>
|
||
<div style={{ fontSize: 13, fontWeight: 600, color: '#0F2A2E' }}>{val}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</LegalSection>
|
||
|
||
<LegalSection title="ارسال پیام">
|
||
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
|
||
<div>
|
||
<label style={{ fontSize: 12, fontWeight: 600, color: '#5F6B6D', display: 'block', marginBottom: 5 }}>نام <span style={{ color: '#C46A4D' }}>*</span></label>
|
||
<input value={form.name} onChange={e => set('name', e.target.value)} placeholder="نام شما" style={field} />
|
||
</div>
|
||
<div>
|
||
<label style={{ fontSize: 12, fontWeight: 600, color: '#5F6B6D', display: 'block', marginBottom: 5 }}>ایمیل <span style={{ color: '#C46A4D' }}>*</span></label>
|
||
<input value={form.email} onChange={e => set('email', e.target.value)} placeholder="example@email.com" style={{ ...field, direction: 'ltr' }} />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label style={{ fontSize: 12, fontWeight: 600, color: '#5F6B6D', display: 'block', marginBottom: 5 }}>موضوع</label>
|
||
<input value={form.subject} onChange={e => set('subject', e.target.value)} placeholder="موضوع پیام" style={field} />
|
||
</div>
|
||
<div>
|
||
<label style={{ fontSize: 12, fontWeight: 600, color: '#5F6B6D', display: 'block', marginBottom: 5 }}>پیام <span style={{ color: '#C46A4D' }}>*</span></label>
|
||
<textarea value={form.message} onChange={e => set('message', e.target.value)} placeholder="پیام خود را اینجا بنویسید..." rows={5} style={{ ...field, resize: 'vertical', lineHeight: 1.8 }} />
|
||
</div>
|
||
<button type="submit" style={{ background: '#0F6B73', color: '#fff', border: 'none', borderRadius: 8, padding: '12px 28px', fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif', alignSelf: 'flex-start' }}>
|
||
ارسال پیام
|
||
</button>
|
||
</form>
|
||
</LegalSection>
|
||
</LegalPage>
|
||
);
|
||
}
|
||
|
||
// ── Tools Page ───────────────────────────────────────────────────────────────
|
||
function ToolsPage() {
|
||
const { navigate } = useNav();
|
||
return (
|
||
<LegalPage title="ابزارهای تخصصی توسعهدهندگان" subtitle="ابزارهای کمکی و محاسباتی برای پردازش زبان طبیعی فارسی (NLP)، هوش مصنوعی و بازیابی اطلاعات (RAG).">
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 20 }}>
|
||
{[
|
||
{
|
||
title: 'نرمالساز متن فارسی (NLP)',
|
||
desc: 'اصلاح حروف عربی، تنظیم نیمفاصلهها، حذف کدهای HTML و فاصلههای مکرر در متون فارسی برای پیشپردازش سیستمهای هوش مصنوعی.',
|
||
path: '/tools/nlp-normalizer',
|
||
icon: '📝',
|
||
},
|
||
{
|
||
title: 'محاسبهگر چانک و زمینه RAG',
|
||
desc: 'شبیهسازی تکهبندی متون، همپوشانی (Overlap)، محاسبه تعداد چانکها و برآورد تعداد توکنهای فارسی برای پایپلاینهای Retrieval-Augmented Generation.',
|
||
path: '/tools/rag-calculator',
|
||
icon: '📐',
|
||
},
|
||
].map((t) => (
|
||
<div key={t.path} style={{ background: '#FAF7F0', border: '1px solid #E4DDD2', borderRadius: 12, padding: 24, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||
<div>
|
||
<div style={{ fontSize: 32, marginBottom: 12 }}>{t.icon}</div>
|
||
<h2 style={{ fontSize: 17, fontWeight: 800, color: '#0F2A2E', marginBottom: 10 }}>{t.title}</h2>
|
||
<p style={{ color: '#5F6B6D', fontSize: 13, lineHeight: 1.8, marginBottom: 20 }}>{t.desc}</p>
|
||
</div>
|
||
<button onClick={() => navigate(t.path)} style={{ background: '#0F6B73', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 18px', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif', width: '100%' }}>
|
||
ورود به ابزار
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</LegalPage>
|
||
);
|
||
}
|
||
|
||
// ── NLP Normalizer Page ──────────────────────────────────────────────────────
|
||
function NlpNormalizerPage() {
|
||
const { navigate } = useNav();
|
||
const toast = useToast();
|
||
const [text, setText] = useState('');
|
||
const [normalized, setNormalized] = useState('');
|
||
const [options, setOptions] = useState({
|
||
stripHtml: true,
|
||
normalizeLetters: true,
|
||
normalizeZwnj: true,
|
||
cleanWhitespace: true,
|
||
});
|
||
|
||
const toggle = (k) => setOptions((o) => ({ ...o, [k]: !o[k] }));
|
||
|
||
const handleNormalize = () => {
|
||
let t = text || '';
|
||
if (options.stripHtml) {
|
||
t = t.replace(/<[^>]*>/g, ' ');
|
||
}
|
||
if (options.normalizeLetters) {
|
||
t = t.replace(/ي/g, 'ی').replace(/ك/g, 'ک').replace(/ة/g, 'ه');
|
||
}
|
||
if (options.normalizeZwnj) {
|
||
t = t.replace(/\s+(می|نمی)\s+/g, ' $1');
|
||
t = t.replace(/\s+(تر|ترین|ها|های|ام|ات|اش|مان|تان|شان)\b/g, '$1');
|
||
t = t.replace(/\b(می|نمی)\b\s+/g, '$1');
|
||
}
|
||
if (options.cleanWhitespace) {
|
||
t = t.replace(/\s+/g, ' ');
|
||
t = t.replace(/\n\s*\n+/g, '\n\n');
|
||
}
|
||
setNormalized(t.trim());
|
||
toast('متن با موفقیت نرمالسازی شد', 'success');
|
||
};
|
||
|
||
const copyToClipboard = (val) => {
|
||
if (!val) return;
|
||
navigator.clipboard.writeText(val);
|
||
toast('کپی شد!', 'success');
|
||
};
|
||
|
||
const textareaStyle = {
|
||
width: '100%',
|
||
minHeight: 180,
|
||
background: '#FAF7F0',
|
||
border: '1px solid #D8D0C4',
|
||
borderRadius: 8,
|
||
padding: '12px 16px',
|
||
fontSize: 14,
|
||
fontFamily: 'Vazirmatn, sans-serif',
|
||
color: '#263238',
|
||
outline: 'none',
|
||
lineHeight: 1.7,
|
||
resize: 'vertical',
|
||
boxSizing: 'border-box',
|
||
};
|
||
|
||
return (
|
||
<LegalPage title="نرمالساز متن فارسی (NLP)" subtitle="اصلاح نویسهها، تنظیم فاصلهها و بهینهسازی متون فارسی برای ابزارهای پردازش زبان طبیعی و مدلهای زبانی.">
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||
<div>
|
||
<label style={{ fontSize: 13, fontWeight: 700, color: '#0F2A2E', display: 'block', marginBottom: 8 }}>متن ورودی:</label>
|
||
<textarea
|
||
value={text}
|
||
onChange={(e) => setText(e.target.value)}
|
||
placeholder="متن فارسی خود را برای نرمالسازی اینجا وارد کنید..."
|
||
style={textareaStyle}
|
||
/>
|
||
</div>
|
||
|
||
<div style={{ background: '#FAF7F0', border: '1px solid #E4DDD2', borderRadius: 10, padding: 18 }}>
|
||
<h3 style={{ fontSize: 14, fontWeight: 800, color: '#0F2A2E', marginBottom: 12 }}>تنظیمات نرمالسازی</h3>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12 }}>
|
||
{[
|
||
['stripHtml', 'حذف تگهای HTML'],
|
||
['normalizeLetters', 'تبدیل ی و ک عربی به فارسی'],
|
||
['normalizeZwnj', 'تنظیم نیمفاصلههای پیشوند/پسوند'],
|
||
['cleanWhitespace', 'اصلاح فاصلهها و تبهای مکرر'],
|
||
].map(([k, label]) => (
|
||
<label key={k} style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: 13, color: '#374549' }}>
|
||
<input type="checkbox" checked={options[k]} onChange={() => toggle(k)} style={{ accentColor: '#0F6B73' }} />
|
||
<span>{label}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: 12 }}>
|
||
<button onClick={handleNormalize} style={{ background: '#0F6B73', color: '#fff', border: 'none', borderRadius: 8, padding: '12px 28px', fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif' }}>
|
||
نرمالسازی متن
|
||
</button>
|
||
<button onClick={() => navigate('/tools')} style={{ background: 'transparent', color: '#5F6B6D', border: '1px solid #D8D0C4', borderRadius: 8, padding: '12px 20px', fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif' }}>
|
||
بازگشت
|
||
</button>
|
||
</div>
|
||
|
||
{normalized && (
|
||
<div style={{ marginTop: 10 }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||
<label style={{ fontSize: 13, fontWeight: 700, color: '#0F2A2E' }}>متن خروجی:</label>
|
||
<button onClick={() => copyToClipboard(normalized)} style={{ background: '#FAF7F0', border: '1px solid #D8D0C4', borderRadius: 6, padding: '6px 12px', fontSize: 12, cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
📋 کپی خروجی
|
||
</button>
|
||
</div>
|
||
<textarea
|
||
readOnly
|
||
value={normalized}
|
||
style={{ ...textareaStyle, background: '#fff' }}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ borderTop: '1px solid #E4DDD2', paddingTop: 20, marginTop: 10 }}>
|
||
<h3 style={{ fontSize: 14, fontWeight: 800, color: '#0F2A2E', marginBottom: 8 }}>ارجاع علمی و اشتراکگذاری</h3>
|
||
<p style={{ color: '#5F6B6D', fontSize: 12, lineHeight: 1.7, marginBottom: 12 }}>برای استفاده از خروجیهای این ابزار در تحقیقات یا کارهای متنباز خود، میتوانید از فرمت ارجاع زیر استفاده کنید:</p>
|
||
<div style={{ background: '#FAF7F0', border: '1px solid #E4DDD2', borderRadius: 8, padding: 12, fontSize: 11, fontFamily: 'monospace', direction: 'ltr', textAlign: 'left', overflowX: 'auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<code style={{ color: '#0F2A2E' }}>[Teknav Persian NLP Tool] (https://www.teknav.ir/tools/nlp-normalizer)</code>
|
||
<button onClick={() => copyToClipboard('[Teknav Persian NLP Tool] (https://www.teknav.ir/tools/nlp-normalizer)')} style={{ background: '#fff', border: '1px solid #D8D0C4', borderRadius: 4, padding: '2px 8px', fontSize: 10, cursor: 'pointer', fontFamily: 'sans-serif' }}>Copy</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</LegalPage>
|
||
);
|
||
}
|
||
|
||
// ── RAG Calculator Page ──────────────────────────────────────────────────────
|
||
function RagCalculatorPage() {
|
||
const { navigate } = useNav();
|
||
const toast = useToast();
|
||
const [text, setText] = useState('');
|
||
const [chunkSize, setChunkSize] = useState(500);
|
||
const [overlap, setOverlap] = useState(50);
|
||
const [chunks, setChunks] = useState([]);
|
||
|
||
const handleCalculate = () => {
|
||
if (!text.trim()) {
|
||
toast('لطفاً ابتدا متنی را برای چانکبندی وارد کنید', 'error');
|
||
return;
|
||
}
|
||
if (chunkSize <= 0 || overlap < 0 || overlap >= chunkSize) {
|
||
toast('اندازه چانک یا همپوشانی نامعتبر است', 'error');
|
||
return;
|
||
}
|
||
|
||
const t = text.trim();
|
||
const result = [];
|
||
let start = 0;
|
||
|
||
while (start < t.length) {
|
||
let end = start + chunkSize;
|
||
if (end > t.length) end = t.length;
|
||
|
||
const content = t.substring(start, end);
|
||
const estimatedTokens = Math.round(content.length / 3.8);
|
||
|
||
result.push({
|
||
id: result.length + 1,
|
||
content,
|
||
charCount: content.length,
|
||
tokenCount: estimatedTokens,
|
||
range: [start, end],
|
||
});
|
||
|
||
start += (chunkSize - overlap);
|
||
if (start >= t.length || end === t.length) break;
|
||
}
|
||
|
||
setChunks(result);
|
||
toast(`تکهبندی با موفقیت انجام شد (${result.length} چانک)`, 'success');
|
||
};
|
||
|
||
const copyToClipboard = (val) => {
|
||
if (!val) return;
|
||
navigator.clipboard.writeText(val);
|
||
toast('کپی شد!', 'success');
|
||
};
|
||
|
||
return (
|
||
<LegalPage title="محاسبهگر چانک و زمینه RAG" subtitle="شبیهسازی اندازه تکهها (Chunks)، همپوشانی (Overlap) و تخمین توکن برای سیستمهای پرسش و پاسخ با بازیابی اطلاعات.">
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||
<div>
|
||
<label style={{ fontSize: 13, fontWeight: 700, color: '#0F2A2E', display: 'block', marginBottom: 8 }}>متن نمونه:</label>
|
||
<textarea
|
||
value={text}
|
||
onChange={(e) => setText(e.target.value)}
|
||
placeholder="متن فارسی مورد نظر برای شبیهسازی RAG را در این قسمت بنویسید..."
|
||
style={{ width: '100%', minHeight: 140, background: '#FAF7F0', border: '1px solid #D8D0C4', borderRadius: 8, padding: '12px 16px', fontSize: 14, fontFamily: 'Vazirmatn, sans-serif', color: '#263238', outline: 'none', lineHeight: 1.7, resize: 'vertical', boxSizing: 'border-box' }}
|
||
/>
|
||
</div>
|
||
|
||
<div style={{ background: '#FAF7F0', border: '1px solid #E4DDD2', borderRadius: 10, padding: 18 }}>
|
||
<h3 style={{ fontSize: 14, fontWeight: 800, color: '#0F2A2E', marginBottom: 12 }}>پارامترهای شبیهسازی</h3>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 16 }}>
|
||
<div>
|
||
<label style={{ fontSize: 12, color: '#5F6B6D', display: 'block', marginBottom: 5 }}>اندازه هر تکه (به کاراکتر):</label>
|
||
<input type="number" value={chunkSize} onChange={(e) => setChunkSize(Number(e.target.value))} style={{ width: '100%', padding: '10px 12px', border: '1px solid #D8D0C4', borderRadius: 8, fontSize: 14, fontFamily: 'Vazirmatn, sans-serif' }} />
|
||
</div>
|
||
<div>
|
||
<label style={{ fontSize: 12, color: '#5F6B6D', display: 'block', marginBottom: 5 }}>اندازه همپوشانی (Overlap):</label>
|
||
<input type="number" value={overlap} onChange={(e) => setOverlap(Number(e.target.value))} style={{ width: '100%', padding: '10px 12px', border: '1px solid #D8D0C4', borderRadius: 8, fontSize: 14, fontFamily: 'Vazirmatn, sans-serif' }} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: 12 }}>
|
||
<button onClick={handleCalculate} style={{ background: '#0F6B73', color: '#fff', border: 'none', borderRadius: 8, padding: '12px 28px', fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif' }}>
|
||
محاسبه و شبیهسازی
|
||
</button>
|
||
<button onClick={() => navigate('/tools')} style={{ background: 'transparent', color: '#5F6B6D', border: '1px solid #D8D0C4', borderRadius: 8, padding: '12px 20px', fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif' }}>
|
||
بازگشت
|
||
</button>
|
||
</div>
|
||
|
||
{chunks.length > 0 && (
|
||
<div style={{ marginTop: 10 }}>
|
||
<h3 style={{ fontSize: 15, fontWeight: 800, color: '#0F2A2E', marginBottom: 12 }}>نتایج شبیهسازی تکهبندی:</h3>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12, marginBottom: 20 }}>
|
||
{[
|
||
['طول کل کاراکترها', text.length],
|
||
['تعداد تخمینی توکنها', Math.round(text.length / 3.8)],
|
||
['تعداد چانکهای خروجی', chunks.length],
|
||
].map(([lbl, val]) => (
|
||
<div key={lbl} style={{ background: '#FAF7F0', border: '1px solid #E4DDD2', borderRadius: 8, padding: 12, textAlign: 'center' }}>
|
||
<div style={{ fontSize: 11, color: '#5F6B6D', marginBottom: 4 }}>{lbl}</div>
|
||
<div style={{ fontSize: 16, fontWeight: 800, color: '#0F2A2E' }}>{val.toLocaleString('fa-IR')}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||
{chunks.map((c) => (
|
||
<div key={c.id} style={{ border: '1px solid #E4DDD2', borderRadius: 10, overflow: 'hidden' }}>
|
||
<div style={{ background: '#FAF7F0', borderBottom: '1px solid #E4DDD2', padding: '10px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<span style={{ fontSize: 12, fontWeight: 800, color: '#0F2A2E' }}>چانک #{c.id.toLocaleString('fa-IR')} (اندیس {c.range[0].toLocaleString('fa-IR')} تا {c.range[1].toLocaleString('fa-IR')})</span>
|
||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||
<span style={{ fontSize: 11, color: '#5F6B6D' }}>{c.charCount.toLocaleString('fa-IR')} کاراکتر · {c.tokenCount.toLocaleString('fa-IR')} توکن تخمینی</span>
|
||
<button onClick={() => copyToClipboard(c.content)} style={{ background: '#fff', border: '1px solid #D8D0C4', borderRadius: 4, padding: '3px 8px', fontSize: 11, cursor: 'pointer', fontFamily: 'Vazirmatn, sans-serif' }}>کپی</button>
|
||
</div>
|
||
</div>
|
||
<div style={{ padding: 16, fontSize: 13, lineHeight: 1.8, color: '#263238', background: '#fff', whiteSpace: 'pre-wrap' }}>
|
||
{c.content}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</LegalPage>
|
||
);
|
||
}
|
||
|
||
// ── TekRAG Persian Hub Page ──────────────────────────────────────────────────
|
||
function TekRagHubPage() {
|
||
const toast = useToast();
|
||
const copyToClipboard = (val) => {
|
||
navigator.clipboard.writeText(val);
|
||
toast('کپی شد!', 'success');
|
||
};
|
||
|
||
const citationText = `@article{tekrag_persian_2026,
|
||
title={TekRAG-Persian: A Data-Centric Evaluation Framework for Hallucination in Persian Retrieval-Augmented Generation},
|
||
author={Sabbagh, Arsam},
|
||
journal={Teknav Journal of AI Research},
|
||
volume={1},
|
||
number={1},
|
||
pages={10-24},
|
||
year={2026},
|
||
publisher={Teknav Media}
|
||
}`;
|
||
|
||
return (
|
||
<LegalPage title="مجموعه داده و بنچمارک TekRAG-Persian" subtitle="چارچوب دادهمحور برای ارزیابی پدیده توهم (Hallucination) در سیستمهای بازیابی اطلاعات فارسی.">
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||
<LegalSection title="معرفی چارچوب پژوهش">
|
||
<p style={{ margin: '0 0 12px', lineHeight: 1.8 }}><strong>TekRAG-Persian</strong> اولین مجموعه داده و چارچوب بنچمارک متمرکز بر زبان فارسی برای سنجش و کاهش میزان توهم در پیادهسازیهای RAG است. این پروژه بر مبنای تحلیل مقالههای تخصصی رسانه تکناو و پاسخدهی دقیق مدلهای زبانی بزرگ توسعه داده شده است.</p>
|
||
<p style={{ margin: 0, lineHeight: 1.8 }}>هدف این بنچمارک ارزیابی سه متغیر کلیدی در بهبود عملکرد سیستمهای بازیابی است: پاکسازی پیشرفته متن، افزودن متادیتاهای ساختاریافته به تکهها، و تأثیر هدایت مدل با تأکید بر استناد به منابع اصلی.</p>
|
||
</LegalSection>
|
||
|
||
<LegalSection title="شناسنامه و لینکهای دسترسی">
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 16 }}>
|
||
{[
|
||
['💾 مجموعه داده در Hugging Face', 'دسترسی به فایلهای خام متون و پرسشها', 'https://huggingface.co/datasets/teknav/tekrag-persian', 'مشاهده دیتاست'],
|
||
['🔗 شناسه دیجیتال Zenodo (DOI)', 'نسخه آرشیو شده و معتبر علمی پژوهش', 'https://doi.org/10.5281/zenodo.placeholder', 'مشاهده DOI'],
|
||
['⚙️ سورسکد در GitHub', 'کدهای پیادهسازی و ارزیابی بنچمارک', 'https://github.com/teknav/tekrag-persian', 'مشاهده ریپازیتوری'],
|
||
].map(([title, desc, link, btnText]) => (
|
||
<div key={title} style={{ background: '#FAF7F0', border: '1px solid #E4DDD2', borderRadius: 10, padding: 18, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||
<div>
|
||
<h3 style={{ fontSize: 14, fontWeight: 800, color: '#0F2A2E', marginBottom: 6 }}>{title}</h3>
|
||
<p style={{ fontSize: 12, color: '#5F6B6D', margin: '0 0 16px', lineHeight: 1.6 }}>{desc}</p>
|
||
</div>
|
||
<a href={link} target="_blank" rel="noopener noreferrer" style={{ display: 'block', textAlign: 'center', background: '#0F6B73', color: '#fff', textDecoration: 'none', borderRadius: 6, padding: '8px 14px', fontSize: 12, fontWeight: 600, fontFamily: 'Vazirmatn,sans-serif' }}>
|
||
{btnText}
|
||
</a>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</LegalSection>
|
||
|
||
<LegalSection title="نحوه ارجاع علمی (Citation)">
|
||
<p style={{ margin: '0 0 12px', lineHeight: 1.8 }}>اگر از این بنچمارک یا دیتاست در پژوهشهای دانشگاهی یا پروژههای علمی خود استفاده میکنید، لطفاً با فرمت BibTeX زیر به آن ارجاع دهید:</p>
|
||
<div style={{ position: 'relative' }}>
|
||
<button onClick={() => copyToClipboard(citationText)} style={{ position: 'absolute', top: 12, left: 12, background: '#fff', border: '1px solid #D8D0C4', borderRadius: 6, padding: '5px 10px', fontSize: 11, cursor: 'pointer', fontFamily: 'Vazirmatn, sans-serif' }}>
|
||
📋 کپی ارجاع
|
||
</button>
|
||
<pre style={{ background: '#FAF7F0', border: '1px solid #E4DDD2', borderRadius: 10, padding: '44px 20px 20px', fontSize: 12, fontFamily: 'monospace', direction: 'ltr', textAlign: 'left', overflowX: 'auto', margin: 0, lineHeight: 1.6 }}>
|
||
{citationText}
|
||
</pre>
|
||
</div>
|
||
</LegalSection>
|
||
|
||
<LegalSection title="ساختار مجموعه داده">
|
||
<p style={{ margin: '0 0 16px', lineHeight: 1.8 }}>این بنچمارک شامل بخشهای زیر در فرمت JSON است:</p>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||
{[
|
||
['Corpus (بدنه داده)', 'شامل مقالههای تخصصی با تگها، دستهبندی، پاراگرافها و عناوین مجزا.'],
|
||
['Questions (سوالات)', '۲۰۰ سوال واقعی طراحی شده توسط متخصصان در سطوح مختلف دشواری.'],
|
||
['Gold Annotations (پاسخهای طلایی)', 'تکههای دقیق پشتیبان برای هر سوال جهت سنجش بازیابی درست اطلاعات.'],
|
||
].map(([t, d]) => (
|
||
<div key={t} style={{ display: 'flex', gap: 12, alignItems: 'flex-start', background: '#FAF7F020', border: '1px solid #E4DDD255', borderRadius: 8, padding: 12 }}>
|
||
<span style={{ fontSize: 13, fontWeight: 700, color: '#0F6B73', minWidth: 140 }}>{t}</span>
|
||
<span style={{ fontSize: 13, color: '#5F6B6D' }}>{d}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</LegalSection>
|
||
</div>
|
||
</LegalPage>
|
||
);
|
||
}
|
||
|
||
function ComparisonVoteBar({ slug, item1, item2 }) {
|
||
const key = `cmp_vote_${slug}`;
|
||
const [vote, setVote] = useState(() => {
|
||
try { return localStorage.getItem(key) || null; } catch { return null; }
|
||
});
|
||
const [shared, setShared] = useState(false);
|
||
|
||
const cast = (choice) => {
|
||
try { localStorage.setItem(key, choice); } catch {}
|
||
setVote(choice);
|
||
};
|
||
|
||
const shareComparison = () => {
|
||
const url = `https://www.teknav.ir/compare/${slug}`;
|
||
if (navigator.share) {
|
||
navigator.share({ title: `مقایسه ${item1.name} در برابر ${item2.name} — تکناو`, url }).catch(() => {});
|
||
} else {
|
||
navigator.clipboard?.writeText(url).then(() => { setShared(true); setTimeout(() => setShared(false), 2000); }).catch(() => {});
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div style={{ background: '#F8F5F0', borderRadius: 12, padding: '20px 24px', marginBottom: 32, display: 'flex', gap: 20, flexWrap: 'wrap', alignItems: 'center' }}>
|
||
<div style={{ flex: 1, minWidth: 260 }}>
|
||
<p style={{ fontSize: 14, fontWeight: 700, color: '#263238', marginBottom: 12 }}>شما کدام را ترجیح میدهید؟</p>
|
||
<div style={{ display: 'flex', gap: 10 }}>
|
||
<button
|
||
onClick={() => cast('item1')}
|
||
style={{
|
||
flex: 1, padding: '9px 16px', borderRadius: 8, fontSize: 14, fontFamily: 'Vazirmatn,sans-serif', cursor: 'pointer', fontWeight: 700, transition: 'all 0.15s',
|
||
background: vote === 'item1' ? '#0F6B73' : '#fff',
|
||
color: vote === 'item1' ? '#fff' : '#0F6B73',
|
||
border: `2px solid ${vote === 'item1' ? '#0F6B73' : '#E4DDD2'}`,
|
||
}}
|
||
>
|
||
{vote === 'item1' ? '✓ ' : ''}{item1.name}
|
||
</button>
|
||
<button
|
||
onClick={() => cast('item2')}
|
||
style={{
|
||
flex: 1, padding: '9px 16px', borderRadius: 8, fontSize: 14, fontFamily: 'Vazirmatn,sans-serif', cursor: 'pointer', fontWeight: 700, transition: 'all 0.15s',
|
||
background: vote === 'item2' ? '#D49A2A' : '#fff',
|
||
color: vote === 'item2' ? '#fff' : '#D49A2A',
|
||
border: `2px solid ${vote === 'item2' ? '#D49A2A' : '#E4DDD2'}`,
|
||
}}
|
||
>
|
||
{vote === 'item2' ? '✓ ' : ''}{item2.name}
|
||
</button>
|
||
</div>
|
||
{vote && (
|
||
<p style={{ fontSize: 12, color: '#5F6B6D', marginTop: 10 }}>
|
||
انتخاب شما ثبت شد. برای تغییر دوباره کلیک کنید.
|
||
</p>
|
||
)}
|
||
</div>
|
||
<button
|
||
onClick={shareComparison}
|
||
style={{
|
||
background: shared ? '#2E7D32' : '#0C1E22', color: shared ? '#fff' : '#D49A2A',
|
||
border: 'none', borderRadius: 8, padding: '10px 20px', fontSize: 13,
|
||
fontFamily: 'Vazirmatn,sans-serif', cursor: 'pointer', fontWeight: 700, whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
{shared ? '✓ لینک کپی شد' : '🔗 اشتراکگذاری'}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ComparisonPage({ slug }) {
|
||
const { navigate } = useNav();
|
||
const comparison = (TeknavData.comparisons ?? []).find((c) => c.slug === slug);
|
||
|
||
if (!comparison) {
|
||
return (
|
||
<div style={{ padding: '120px 24px', textAlign: 'center', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
|
||
<EmptyState title="صفحه مقایسه یافت نشد" subtitle="مقایسهای با این آدرس وجود ندارد" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const { item1, item2, verdict, useCases = [], relatedArticles = [], relatedGlossary = [] } = comparison;
|
||
const articles = TeknavData.articles.filter((a) => relatedArticles.includes(a.slug));
|
||
const glossaryTerms = (TeknavData.glossary ?? []).filter((g) => relatedGlossary.includes(g.slug));
|
||
|
||
const cardStyle = {
|
||
background: '#fff', borderRadius: 12, padding: 24,
|
||
border: '1px solid #E4DDD2', flex: '1 1 300px',
|
||
};
|
||
const proStyle = { fontSize: 13, color: '#2E7D32', marginBottom: 6, display: 'flex', alignItems: 'flex-start', gap: 6 };
|
||
const conStyle = { fontSize: 13, color: '#C62828', marginBottom: 6, display: 'flex', alignItems: 'flex-start', gap: 6 };
|
||
|
||
return (
|
||
<div style={{ maxWidth: 960, margin: '0 auto', padding: '24px 16px 80px', direction: 'rtl', fontFamily: 'Vazirmatn,sans-serif' }}>
|
||
{/* Breadcrumb */}
|
||
<nav style={{ fontSize: 13, color: '#5F6B6D', marginBottom: 24, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||
<span style={{ cursor: 'pointer', color: '#0F6B73' }} onClick={() => navigate('/')}>تکناو</span>
|
||
<span>›</span>
|
||
<span style={{ cursor: 'pointer', color: '#0F6B73' }} onClick={() => navigate('/compare')}>مقایسهها</span>
|
||
<span>›</span>
|
||
<span>{comparison.title}</span>
|
||
</nav>
|
||
|
||
{/* Header */}
|
||
<h1 style={{ fontSize: 'clamp(22px,4vw,32px)', fontWeight: 800, color: '#0C1E22', lineHeight: 1.5, marginBottom: 12 }}>
|
||
{comparison.title}
|
||
</h1>
|
||
{comparison.metaDescription && (
|
||
<p style={{ fontSize: 16, color: '#5F6B6D', lineHeight: 1.8, marginBottom: 16 }}>{comparison.metaDescription}</p>
|
||
)}
|
||
{comparison.factCheckedAt && (
|
||
<p style={{ fontSize: 12, color: '#5F6B6D', marginBottom: 24 }}>
|
||
آخرین بروزرسانی: {comparison.factCheckedAt}
|
||
</p>
|
||
)}
|
||
|
||
{/* Vote + Share bar */}
|
||
<ComparisonVoteBar slug={slug} item1={item1} item2={item2} />
|
||
|
||
{/* Two-column cards */}
|
||
<div style={{ display: 'flex', gap: 20, flexWrap: 'wrap', marginBottom: 40 }}>
|
||
{[item1, item2].map((item) => (
|
||
<div key={item.slug} style={cardStyle}>
|
||
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#0F6B73', marginBottom: 10 }}>{item.name}</h2>
|
||
<p style={{ fontSize: 14, color: '#263238', lineHeight: 1.8, marginBottom: 16 }}>{item.summary}</p>
|
||
<div style={{ marginBottom: 8, fontSize: 13, fontWeight: 700, color: '#263238' }}>مزایا</div>
|
||
{(item.pros || []).map((p, i) => (
|
||
<div key={i} style={proStyle}><span>✓</span><span>{p}</span></div>
|
||
))}
|
||
<div style={{ marginBottom: 8, marginTop: 12, fontSize: 13, fontWeight: 700, color: '#263238' }}>معایب</div>
|
||
{(item.cons || []).map((c, i) => (
|
||
<div key={i} style={conStyle}><span>✗</span><span>{c}</span></div>
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Use Cases */}
|
||
{useCases.length > 0 && (
|
||
<section style={{ background: '#F8F5F0', borderRadius: 12, padding: 24, marginBottom: 32 }}>
|
||
<h2 style={{ fontSize: 18, fontWeight: 700, color: '#263238', marginBottom: 16 }}>چه زمانی کدام را انتخاب کنیم؟</h2>
|
||
{useCases.map(([left, right], i) => (
|
||
<div key={i} style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}>
|
||
<div style={{ flex: '1 1 280px', padding: '12px 16px', background: '#fff', borderRadius: 8, border: '1px solid #E4DDD2', fontSize: 13, lineHeight: 1.7 }}>
|
||
<span style={{ fontWeight: 700, color: '#0F6B73' }}>{item1.name}:</span> {left}
|
||
</div>
|
||
<div style={{ flex: '1 1 280px', padding: '12px 16px', background: '#fff', borderRadius: 8, border: '1px solid #E4DDD2', fontSize: 13, lineHeight: 1.7 }}>
|
||
<span style={{ fontWeight: 700, color: '#0F6B73' }}>{item2.name}:</span> {right}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</section>
|
||
)}
|
||
|
||
{/* Verdict */}
|
||
{verdict && (
|
||
<section style={{ background: '#0F3540', borderRadius: 12, padding: 24, marginBottom: 40, color: '#fff' }}>
|
||
<h2 style={{ fontSize: 18, fontWeight: 700, color: '#D49A2A', marginBottom: 12 }}>جمعبندی و نتیجهگیری</h2>
|
||
<p style={{ fontSize: 15, lineHeight: 1.9 }}>{verdict}</p>
|
||
</section>
|
||
)}
|
||
|
||
{/* Related Glossary */}
|
||
{glossaryTerms.length > 0 && (
|
||
<section style={{ marginBottom: 40 }}>
|
||
<h2 style={{ fontSize: 18, fontWeight: 700, color: '#263238', marginBottom: 16 }}>اصطلاحات مرتبط</h2>
|
||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||
{glossaryTerms.map((g) => (
|
||
<div
|
||
key={g.slug}
|
||
onClick={() => navigate(`/glossary/${g.slug}`)}
|
||
style={{ background: '#fff', border: '1px solid #0F6B73', borderRadius: 8, padding: '10px 16px', cursor: 'pointer' }}
|
||
>
|
||
<div style={{ fontSize: 14, fontWeight: 700, color: '#0F6B73' }}>{g.term}</div>
|
||
<div style={{ fontSize: 12, color: '#5F6B6D' }}>{g.english}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Related Articles */}
|
||
{articles.length > 0 && (
|
||
<section>
|
||
<h2 style={{ fontSize: 18, fontWeight: 700, color: '#263238', marginBottom: 16 }}>مقالات مرتبط</h2>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
{articles.map((a) => (
|
||
<div
|
||
key={a.id}
|
||
onClick={() => navigate(`/article/${a.slug}`)}
|
||
style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 8, padding: '14px 18px', cursor: 'pointer', display: 'flex', gap: 12, alignItems: 'flex-start' }}
|
||
>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontSize: 15, fontWeight: 700, color: '#263238', marginBottom: 4 }}>{a.title}</div>
|
||
<div style={{ fontSize: 13, color: '#5F6B6D' }}>{a.summary?.slice(0, 100)}…</div>
|
||
</div>
|
||
<span style={{ fontSize: 20 }}>←</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DirectoryListPage() {
|
||
const { navigate } = useNav();
|
||
const [companies] = useState(() => TeknavData.companies ?? []);
|
||
const [search, setSearch] = useState('');
|
||
|
||
const filtered = useMemo(() => {
|
||
if (!search.trim()) return companies;
|
||
const q = search.trim().toLocaleLowerCase('fa-IR');
|
||
return companies.filter((c) =>
|
||
c.name.toLocaleLowerCase('fa-IR').includes(q) ||
|
||
c.description.toLocaleLowerCase('fa-IR').includes(q) ||
|
||
(c.techStack || []).some((t) => t.toLocaleLowerCase('en-US').includes(q)),
|
||
);
|
||
}, [companies, search]);
|
||
|
||
return (
|
||
<div style={{ maxWidth: 960, margin: '0 auto', padding: '24px 16px 80px', direction: 'rtl', fontFamily: 'Vazirmatn,sans-serif' }}>
|
||
<nav style={{ fontSize: 13, color: '#5F6B6D', marginBottom: 24, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||
<span style={{ cursor: 'pointer', color: '#0F6B73' }} onClick={() => navigate('/')}>تکناو</span>
|
||
<span>›</span>
|
||
<span>دایرکتوری شرکتهای فناوری</span>
|
||
</nav>
|
||
<h1 style={{ fontSize: 'clamp(22px,4vw,30px)', fontWeight: 800, color: '#0C1E22', marginBottom: 8 }}>
|
||
دایرکتوری شرکتهای فناوری ایران
|
||
</h1>
|
||
<p style={{ fontSize: 15, color: '#5F6B6D', lineHeight: 1.8, marginBottom: 28 }}>
|
||
پروفایل شرکتهای فناوری ایران همراه با استک فنی، اطلاعات تماس و نشان تأیید.
|
||
</p>
|
||
<input
|
||
type="search"
|
||
placeholder="جستوجو در شرکتها…"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
style={{ ...inputStyle, marginBottom: 24, width: '100%', maxWidth: 400 }}
|
||
/>
|
||
{filtered.length === 0 && (
|
||
<EmptyState title="شرکتی یافت نشد" subtitle="جستوجوی دیگری امتحان کنید" />
|
||
)}
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(280px,1fr))', gap: 16 }}>
|
||
{filtered.map((company) => (
|
||
<div
|
||
key={company.slug}
|
||
onClick={() => navigate(`/directory/${company.slug}`)}
|
||
style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 20, cursor: 'pointer', transition: 'box-shadow 0.2s' }}
|
||
onMouseEnter={(e) => { e.currentTarget.style.boxShadow = '0 4px 16px rgba(0,0,0,0.1)'; }}
|
||
onMouseLeave={(e) => { e.currentTarget.style.boxShadow = ''; }}
|
||
>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 10 }}>
|
||
<span style={{ fontSize: 18, fontWeight: 700, color: '#0C1E22' }}>{company.name}</span>
|
||
{company.verified && <span style={{ fontSize: 11, background: '#E8F5E9', color: '#2E7D32', padding: '2px 8px', borderRadius: 20, fontWeight: 600 }}>✓ تأیید</span>}
|
||
</div>
|
||
<p style={{ fontSize: 13, color: '#5F6B6D', lineHeight: 1.7, marginBottom: 12 }}>{company.description.slice(0, 100)}…</p>
|
||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||
{(company.techStack || []).slice(0, 4).map((t) => (
|
||
<span key={t} style={{ background: '#F4EFE6', color: '#263238', fontSize: 11, padding: '2px 8px', borderRadius: 20 }}>{t}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CompanyProfilePage({ slug }) {
|
||
const { navigate } = useNav();
|
||
const company = (TeknavData.companies ?? []).find((c) => c.slug === slug);
|
||
|
||
if (!company) {
|
||
return (
|
||
<div style={{ padding: '120px 24px', textAlign: 'center', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
|
||
<EmptyState title="شرکت یافت نشد" subtitle="پروفایل شرکتی با این آدرس وجود ندارد" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div style={{ maxWidth: 800, margin: '0 auto', padding: '24px 16px 80px', direction: 'rtl', fontFamily: 'Vazirmatn,sans-serif' }}>
|
||
<nav style={{ fontSize: 13, color: '#5F6B6D', marginBottom: 24, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||
<span style={{ cursor: 'pointer', color: '#0F6B73' }} onClick={() => navigate('/')}>تکناو</span>
|
||
<span>›</span>
|
||
<span style={{ cursor: 'pointer', color: '#0F6B73' }} onClick={() => navigate('/directory')}>دایرکتوری</span>
|
||
<span>›</span>
|
||
<span>{company.name}</span>
|
||
</nav>
|
||
<div style={{ background: '#0C1E22', borderRadius: 16, padding: '28px 28px', marginBottom: 28, display: 'flex', gap: 20, alignItems: 'flex-start' }}>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||
<h1 style={{ fontSize: 'clamp(20px,4vw,28px)', fontWeight: 800, color: '#fff', margin: 0 }}>{company.name}</h1>
|
||
{company.verified && (
|
||
<span style={{ background: '#D49A2A', color: '#0C1E22', fontSize: 11, fontWeight: 700, padding: '3px 10px', borderRadius: 20 }}>✓ تأیید شده</span>
|
||
)}
|
||
</div>
|
||
<p style={{ fontSize: 15, color: 'rgba(255,255,255,0.8)', lineHeight: 1.8, margin: 0 }}>{company.description}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: 20, flexWrap: 'wrap', marginBottom: 28 }}>
|
||
{company.websiteUrl && (
|
||
<a
|
||
href={company.websiteUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: '#0F6B73', color: '#fff', padding: '10px 20px', borderRadius: 8, fontSize: 14, fontWeight: 700, textDecoration: 'none' }}
|
||
>
|
||
وبسایت ↗
|
||
</a>
|
||
)}
|
||
</div>
|
||
|
||
{(company.techStack || []).length > 0 && (
|
||
<section style={{ marginBottom: 28 }}>
|
||
<h2 style={{ fontSize: 18, fontWeight: 700, color: '#263238', marginBottom: 12 }}>استک فناوری</h2>
|
||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||
{company.techStack.map((t) => (
|
||
<span key={t} style={{ background: '#E4DDD2', color: '#263238', fontSize: 13, padding: '5px 14px', borderRadius: 20, fontWeight: 600 }}>{t}</span>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
<div style={{ background: '#F8F5F0', borderRadius: 12, padding: 24 }}>
|
||
<p style={{ fontSize: 14, color: '#5F6B6D', margin: '0 0 12px' }}>
|
||
این صفحه بخشی از دایرکتوری شرکتهای فناوری تکناو است. اگر این شرکت را نمایندگی میکنید، میتوانید پروفایل آن را ادعا و تأیید کنید.
|
||
</p>
|
||
<button
|
||
style={{ background: '#0F6B73', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 20px', fontSize: 14, fontFamily: 'Vazirmatn,sans-serif', cursor: 'pointer', fontWeight: 700 }}
|
||
onClick={() => navigate('/login')}
|
||
>
|
||
ادعای پروفایل
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const CATEGORY_COLORS = { ai: '#0F6B73', data: '#2F8F6B', security: '#C62828', software: '#1565C0', hardware: '#6A1B9A' };
|
||
|
||
function ComparisonsListPage() {
|
||
const { navigate } = useNav();
|
||
const comparisons = TeknavData.comparisons ?? [];
|
||
const [search, setSearch] = useState('');
|
||
|
||
const filtered = useMemo(() => {
|
||
if (!search.trim()) return comparisons;
|
||
const q = search.trim().toLocaleLowerCase('fa-IR');
|
||
return comparisons.filter((c) =>
|
||
c.title.toLocaleLowerCase('fa-IR').includes(q) ||
|
||
c.item1.name.toLocaleLowerCase('en-US').includes(q) ||
|
||
c.item2.name.toLocaleLowerCase('en-US').includes(q) ||
|
||
(c.tags || []).some((t) => t.toLocaleLowerCase().includes(q)),
|
||
);
|
||
}, [comparisons, search]);
|
||
|
||
return (
|
||
<div style={{ maxWidth: 960, margin: '0 auto', padding: '24px 16px 80px', direction: 'rtl', fontFamily: 'Vazirmatn,sans-serif' }}>
|
||
<nav style={{ fontSize: 13, color: '#5F6B6D', marginBottom: 24, display: 'flex', gap: 8 }}>
|
||
<span style={{ cursor: 'pointer', color: '#0F6B73' }} onClick={() => navigate('/')}>تکناو</span>
|
||
<span>›</span><span>مقایسهها</span>
|
||
</nav>
|
||
<h1 style={{ fontSize: 'clamp(22px,4vw,32px)', fontWeight: 800, color: '#0C1E22', marginBottom: 8 }}>مقایسههای فنی تکناو</h1>
|
||
<p style={{ fontSize: 15, color: '#5F6B6D', lineHeight: 1.8, marginBottom: 28 }}>
|
||
تحلیلهای مقایسهای جامع میان فناوریها، فریمورکها و ابزارهای رایج — برای انتخاب بهتر در پروژههای فارسیزبان.
|
||
</p>
|
||
<input
|
||
type="search"
|
||
placeholder="جستوجو در مقایسهها…"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
style={{ ...inputStyle, marginBottom: 28, width: '100%', maxWidth: 400 }}
|
||
/>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||
{filtered.map((c) => (
|
||
<div
|
||
key={c.slug}
|
||
onClick={() => navigate(`/compare/${c.slug}`)}
|
||
style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: '20px 24px', cursor: 'pointer', display: 'flex', gap: 20, alignItems: 'center' }}
|
||
onMouseEnter={(e) => { e.currentTarget.style.boxShadow = '0 4px 16px rgba(0,0,0,0.09)'; }}
|
||
onMouseLeave={(e) => { e.currentTarget.style.boxShadow = ''; }}
|
||
>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginBottom: 8, flexWrap: 'wrap' }}>
|
||
<span style={{ background: CATEGORY_COLORS[c.category] || '#0F6B73', color: '#fff', fontSize: 11, fontWeight: 700, padding: '2px 10px', borderRadius: 20 }}>
|
||
{c.category}
|
||
</span>
|
||
{(c.tags || []).slice(0, 3).map((t) => (
|
||
<span key={t} style={{ background: '#F4EFE6', color: '#5F6B6D', fontSize: 11, padding: '2px 8px', borderRadius: 20 }}>{t}</span>
|
||
))}
|
||
</div>
|
||
<h2 style={{ fontSize: 17, fontWeight: 700, color: '#0C1E22', margin: '0 0 8px', lineHeight: 1.5 }}>{c.title}</h2>
|
||
<div style={{ display: 'flex', gap: 16, fontSize: 13 }}>
|
||
<span style={{ background: '#E8F5E9', color: '#2E7D32', padding: '3px 12px', borderRadius: 20, fontWeight: 600 }}>{c.item1.name}</span>
|
||
<span style={{ color: '#5F6B6D', alignSelf: 'center' }}>در برابر</span>
|
||
<span style={{ background: '#FFF3E0', color: '#E65100', padding: '3px 12px', borderRadius: 20, fontWeight: 600 }}>{c.item2.name}</span>
|
||
</div>
|
||
</div>
|
||
<span style={{ fontSize: 22, color: '#0F6B73' }}>←</span>
|
||
</div>
|
||
))}
|
||
{filtered.length === 0 && <EmptyState title="مقایسهای یافت نشد" subtitle="جستوجوی دیگری امتحان کنید" />}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StoriesListPage() {
|
||
const { navigate } = useNav();
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const stories = (TeknavData.articles ?? []).filter((a) => {
|
||
if (a.status !== 'منتشرشده' && a.status) return false;
|
||
const d = String(a.dateEn || '').slice(0, 10);
|
||
return /^\d{4}-\d{2}-\d{2}$/.test(d) && d <= today;
|
||
});
|
||
|
||
return (
|
||
<div style={{ maxWidth: 960, margin: '0 auto', padding: '24px 16px 80px', direction: 'rtl', fontFamily: 'Vazirmatn,sans-serif' }}>
|
||
<nav style={{ fontSize: 13, color: '#5F6B6D', marginBottom: 24, display: 'flex', gap: 8 }}>
|
||
<span style={{ cursor: 'pointer', color: '#0F6B73' }} onClick={() => navigate('/')}>تکناو</span>
|
||
<span>›</span><span>استوریها</span>
|
||
</nav>
|
||
<h1 style={{ fontSize: 'clamp(22px,4vw,32px)', fontWeight: 800, color: '#0C1E22', marginBottom: 8 }}>استوریهای تکناو</h1>
|
||
<p style={{ fontSize: 15, color: '#5F6B6D', lineHeight: 1.8, marginBottom: 28 }}>
|
||
مقالات تکناو در قالب استوریهای بصری — سریع، قابل اسوایپ و بهینه برای موبایل.
|
||
</p>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(160px,1fr))', gap: 14 }}>
|
||
{stories.map((a) => {
|
||
const img = a.ogImage || `/images/og/${a.slug}.jpg`;
|
||
return (
|
||
<div
|
||
key={a.id}
|
||
onClick={() => { window.open(`/stories/${a.slug}`, '_blank'); }}
|
||
style={{ borderRadius: 14, overflow: 'hidden', cursor: 'pointer', position: 'relative', paddingBottom: '177%', background: '#0C1E22' }}
|
||
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.03)'; e.currentTarget.style.boxShadow = '0 8px 24px rgba(0,0,0,0.25)'; }}
|
||
onMouseLeave={(e) => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = ''; }}
|
||
>
|
||
<img
|
||
src={img}
|
||
alt={a.title}
|
||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', filter: 'brightness(0.55)' }}
|
||
loading="lazy"
|
||
/>
|
||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '12px 10px', background: 'linear-gradient(to top, rgba(5,20,25,0.95), transparent)' }}>
|
||
<div style={{ fontSize: 11, color: '#D49A2A', fontWeight: 700, marginBottom: 4 }}>{a.categoryName}</div>
|
||
<div style={{ fontSize: 12, color: '#fff', lineHeight: 1.4, fontWeight: 600 }}>{a.title.slice(0, 55)}{a.title.length > 55 ? '…' : ''}</div>
|
||
</div>
|
||
<div style={{ position: 'absolute', top: 10, right: 10, background: 'rgba(15,107,115,0.85)', borderRadius: 20, padding: '3px 8px', fontSize: 10, color: '#fff', fontWeight: 700 }}>
|
||
▶ استوری
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export { SearchPage, CategoryPage, TagPage, GlossaryPage, AuthorsListPage, AuthorProfilePage, SeriesPage, TopicHubPage, NewsletterArchivePage, NewsletterIssuePage, JobsPage, CoursesPage, MembershipPage, MembershipSuccessPage, PrivacyPolicyPage, TermsOfUsePage, AboutPage, ContactPage, ToolsPage, NlpNormalizerPage, RagCalculatorPage, TekRagHubPage, ComparisonPage, DirectoryListPage, CompanyProfilePage, ComparisonsListPage, StoriesListPage };
|