Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/app/topics/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Metadata } from 'next';
import TopicFeed from '@/components/social/TopicFeed';

interface TopicPageProps {
params: Promise<{ slug: string }>;
}

export async function generateMetadata({ params }: TopicPageProps): Promise<Metadata> {
const { slug } = await params;
const name = slug.replace(/-/g, ' ');
return {
title: `#${name} · TeachLink`,
description: `Explore posts and discussions about ${name} on TeachLink.`,
};
}

export default async function TopicPage({ params }: TopicPageProps) {
const { slug } = await params;

return (
<main className="min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="max-w-2xl mx-auto px-4 py-8">
<TopicFeed slug={slug} />
</div>
</main>
);
}
216 changes: 216 additions & 0 deletions src/components/social/TopicFeed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
'use client';

import { useEffect, useRef } from 'react';
import Image from 'next/image';
import { UserCircle, Heart, MessageCircle, TrendingUp, Clock, ArrowUp } from 'lucide-react';
import { useTopicFeed, type SortOption } from '@/hooks/useTopicFeed';
import { getRelativeTime, formatFollowerCount } from '@/utils/socialUtils';

// ─── Skeleton ─────────────────────────────────────────────────────────────────

function PostSkeleton() {
return (
<div className="p-4 animate-pulse" aria-hidden="true">
<div className="flex gap-3">
<div className="w-9 h-9 rounded-full bg-gray-200 dark:bg-gray-700 shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/4" />
<div className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-3/4" />
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-full" />
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-2/3" />
</div>
</div>
</div>
);
}

// ─── Sort controls ─────────────────────────────────────────────────────────────

const SORT_OPTIONS: { value: SortOption; label: string; icon: React.ReactNode }[] = [
{ value: 'latest', label: 'Latest', icon: <Clock className="w-3.5 h-3.5" /> },
{ value: 'popular', label: 'Popular', icon: <TrendingUp className="w-3.5 h-3.5" /> },
{ value: 'oldest', label: 'Oldest', icon: <ArrowUp className="w-3.5 h-3.5" /> },
];

interface SortBarProps {
current: SortOption;
onChange: (s: SortOption) => void;
}

function SortBar({ current, onChange }: SortBarProps) {
return (
<div className="flex gap-1 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg" role="group" aria-label="Sort posts">
{SORT_OPTIONS.map(({ value, label, icon }) => (
<button
key={value}
onClick={() => onChange(value)}
aria-pressed={current === value}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${
current === value
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
}`}
>
{icon}
{label}
</button>
))}
</div>
);
}

// ─── TopicFeed ─────────────────────────────────────────────────────────────────

interface TopicFeedProps {
slug: string;
}

export default function TopicFeed({ slug }: TopicFeedProps) {
const { topic, posts, loading, loadingMore, hasMore, sort, setSort, loadMore, error } =
useTopicFeed(slug);
const sentinelRef = useRef<HTMLDivElement>(null);

// Infinite scroll via IntersectionObserver
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) loadMore();
},
{ threshold: 0.1 },
);
observer.observe(el);
return () => observer.disconnect();
}, [loadMore]);

return (
<div className="space-y-4">
{/* Topic header */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
{loading && !topic ? (
<div className="animate-pulse space-y-2">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-1/3" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-2/3" />
</div>
) : topic ? (
<>
<h1 className="text-xl font-bold text-gray-900 dark:text-white">#{topic.name}</h1>
{topic.description && (
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">{topic.description}</p>
)}
<div className="mt-3 flex gap-4 text-sm text-gray-500 dark:text-gray-400">
<span>
<strong className="text-gray-900 dark:text-white">
{formatFollowerCount(topic.postCount)}
</strong>{' '}
posts
</span>
<span>
<strong className="text-gray-900 dark:text-white">
{formatFollowerCount(topic.followerCount)}
</strong>{' '}
followers
</span>
</div>
</>
) : null}
</div>

{/* Sort bar */}
<div className="flex items-center justify-between">
<SortBar current={sort} onChange={setSort} />
</div>

{/* Posts list */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800">
{/* Initial loading skeletons */}
{loading &&
posts.length === 0 &&
Array.from({ length: 5 }).map((_, i) => <PostSkeleton key={i} />)}

{/* Error state */}
{error && !loading && (
<div className="p-8 text-center" role="alert">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}

{/* Empty state */}
{!loading && !error && posts.length === 0 && (
<div className="p-10 text-center">
<p className="text-sm text-gray-500 dark:text-gray-400">
No posts in this topic yet. Be the first to share!
</p>
</div>
)}

{/* Post items */}
{posts.map((post) => (
<article key={post.id} className="p-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
<div className="flex gap-3">
{post.authorAvatar ? (
<Image
src={post.authorAvatar}
alt={post.authorName}
width={36}
height={36}
className="w-9 h-9 rounded-full object-cover shrink-0"
/>
) : (
<UserCircle className="w-9 h-9 text-gray-400 shrink-0" aria-hidden="true" />
)}

<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-gray-900 dark:text-white">
{post.authorName}
</span>
<span className="text-gray-400 dark:text-gray-500">·</span>
<time
dateTime={post.createdAt.toISOString()}
className="text-gray-500 dark:text-gray-400"
>
{getRelativeTime(post.createdAt)}
</time>
</div>

<h2 className="mt-1 text-base font-semibold text-gray-900 dark:text-white leading-snug">
{post.title}
</h2>

<p className="mt-1 text-sm text-gray-600 dark:text-gray-400 line-clamp-3">
{post.body}
</p>

<div className="mt-3 flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
<span className="flex items-center gap-1">
<Heart className="w-4 h-4" aria-hidden="true" />
<span>{formatFollowerCount(post.likes)}</span>
<span className="sr-only">likes</span>
</span>
<span className="flex items-center gap-1">
<MessageCircle className="w-4 h-4" aria-hidden="true" />
<span>{formatFollowerCount(post.commentCount)}</span>
<span className="sr-only">comments</span>
</span>
</div>
</div>
</div>
</article>
))}

{/* Infinite scroll sentinel */}
{hasMore && <div ref={sentinelRef} className="h-4" aria-hidden="true" />}

{/* Load-more skeleton */}
{loadingMore && <PostSkeleton />}

{/* End of feed */}
{!loading && !hasMore && posts.length > 0 && (
<p className="py-4 text-center text-xs text-gray-400">You&apos;ve reached the end</p>
)}
</div>
</div>
);
}
90 changes: 90 additions & 0 deletions src/hooks/useTopicFeed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
'use client';

import { useState, useEffect, useCallback } from 'react';
import { apiClient } from '@/lib/api';
import type { Topic, TopicPost } from '@/utils/socialUtils';

export type SortOption = 'latest' | 'popular' | 'oldest';

interface UseTopicFeedReturn {
topic: Topic | null;
posts: TopicPost[];
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
sort: SortOption;
setSort: (s: SortOption) => void;
loadMore: () => void;
error: string | null;
}

export function useTopicFeed(slug: string): UseTopicFeedReturn {
const [topic, setTopic] = useState<Topic | null>(null);
const [posts, setPosts] = useState<TopicPost[]>([]);
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [sort, setSort] = useState<SortOption>('latest');
const [error, setError] = useState<string | null>(null);

const fetchPosts = useCallback(
async (nextCursor?: string, currentSort: SortOption = sort) => {
const isInitial = !nextCursor;
if (isInitial) setLoading(true);
else setLoadingMore(true);
setError(null);

try {
const params = new URLSearchParams({ limit: '20', sort: currentSort });
if (nextCursor) params.set('cursor', nextCursor);

const [topicRes, postsRes] = await Promise.all([
isInitial
? apiClient.get<Topic>(`/api/topics/${slug}`)
: Promise.resolve(null as unknown as Topic),
apiClient.get<{ data: TopicPost[]; nextCursor?: string }>(
`/api/topics/${slug}/posts?${params}`,
),
]);

if (isInitial && topicRes) setTopic(topicRes);

const normalized = postsRes.data.map((p) => ({
...p,
createdAt: new Date(p.createdAt),
}));

setPosts((prev) => (isInitial ? normalized : [...prev, ...normalized]));
setCursor(postsRes.nextCursor);
setHasMore(!!postsRes.nextCursor);
} catch {
setError('Failed to load topic feed. Please try again.');
setHasMore(false);
} finally {
setLoading(false);
setLoadingMore(false);
}
},
[slug, sort],
);

// Reset and reload when slug or sort changes
useEffect(() => {
setPosts([]);
setCursor(undefined);
setHasMore(true);
fetchPosts(undefined, sort);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [slug, sort]);

const loadMore = useCallback(() => {
if (!loadingMore && hasMore && cursor) fetchPosts(cursor);
}, [loadingMore, hasMore, cursor, fetchPosts]);

const handleSetSort = useCallback((s: SortOption) => {
setSort(s);
}, []);

return { topic, posts, loading, loadingMore, hasMore, sort, setSort: handleSetSort, loadMore, error };
}
21 changes: 21 additions & 0 deletions src/utils/socialUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
export interface Topic {
slug: string;
name: string;
description?: string;
postCount: number;
followerCount: number;
}

export interface TopicPost {
id: string;
authorId: string;
authorName: string;
authorAvatar?: string;
title: string;
body: string;
topicSlug: string;
likes: number;
commentCount: number;
createdAt: Date;
}

export interface Activity {
id: string;
actorId: string;
Expand Down
Loading