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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user