Import source: app/src/pages/TopicHubPage

Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
arsam
2026-09-18 23:25:53 +03:30
parent cf78d9c61c
commit 9be4f5b893
+166
View File
@@ -0,0 +1,166 @@
import { useState, useEffect } from 'react';
import { TeknavData } from '../../teknav-data.js';
import { useNav, useToast, useAuth, AuthorAvatar, EmptyState, pillBtn } from '../../teknav-ui.jsx';
import { ArticleRow } from '../../teknav-articles.jsx';
import { contentApi } from '../../src/lib/content-api.js';
import { engagementApi, pushApi } from '../../src/lib/engagement-api.js';
import { authorProfilePath } from './shared.jsx';
function toDateKey(article) {
return article?.dateEn || article?.publishedAt || article?.date || '';
}
export 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 });
});
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>
);
}