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
87 changes: 85 additions & 2 deletions src/app/topics/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { TrendingUp, Hash } from 'lucide-react';
import TopicFeed from '@/components/social/TopicFeed';

interface TopicPageProps {
Expand All @@ -14,13 +16,94 @@ export async function generateMetadata({ params }: TopicPageProps): Promise<Meta
};
}

// Static sidebar data — replace with API calls when available
const RELATED_TOPICS = [
{ slug: 'web3', name: 'web3' },
{ slug: 'blockchain', name: 'blockchain' },
{ slug: 'defi', name: 'defi' },
{ slug: 'starknet', name: 'starknet' },
{ slug: 'cairo', name: 'cairo' },
];

const TRENDING_POSTS = [
{ id: '1', title: 'Getting started with Starknet', author: 'alice.stark' },
{ id: '2', title: 'Cairo 1.0 deep dive', author: 'bob.dev' },
{ id: '3', title: 'DeFi protocols explained', author: 'carol.eth' },
];

function Sidebar({ currentSlug }: { currentSlug: string }) {
const related = RELATED_TOPICS.filter((t) => t.slug !== currentSlug);

return (
<aside className="space-y-4" aria-label="Sidebar">
{/* Related topics */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-1.5">
<Hash className="w-4 h-4 text-blue-500" aria-hidden="true" />
Related Topics
</h2>
<ul className="space-y-1">
{related.map((topic) => (
<li key={topic.slug}>
<Link
href={`/topics/${topic.slug}`}
className="flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<span className="text-gray-400 dark:text-gray-500">#</span>
{topic.name}
</Link>
</li>
))}
</ul>
</div>

{/* Trending posts */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-1.5">
<TrendingUp className="w-4 h-4 text-orange-500" aria-hidden="true" />
Trending
</h2>
<ol className="space-y-3">
{TRENDING_POSTS.map((post, i) => (
<li key={post.id} className="flex gap-2">
<span
className="text-xs font-bold text-gray-400 dark:text-gray-600 w-4 shrink-0 mt-0.5"
aria-hidden="true"
>
{i + 1}
</span>
<div className="min-w-0">
<Link
href={`/post/${post.id}`}
className="text-sm font-medium text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400 transition-colors line-clamp-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 rounded"
>
{post.title}
</Link>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">by {post.author}</p>
</div>
</li>
))}
</ol>
</div>
</aside>
);
}

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 className="max-w-5xl mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-6 items-start">
{/* Main feed */}
<TopicFeed slug={slug} />

{/* Sidebar */}
<div className="lg:sticky lg:top-8">
<Sidebar currentSlug={slug} />
</div>
</div>
</div>
</main>
);
Expand Down
205 changes: 165 additions & 40 deletions src/components/social/TopicFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
'use client';

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

Expand Down Expand Up @@ -35,26 +47,38 @@ const SORT_OPTIONS: { value: SortOption; label: string; icon: React.ReactNode }[
interface SortBarProps {
current: SortOption;
onChange: (s: SortOption) => void;
postCount?: number;
}

function SortBar({ current, onChange }: SortBarProps) {
function SortBar({ current, onChange, postCount }: 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 className="flex items-center justify-between gap-2">
<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>
{postCount !== undefined && (
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
{formatFollowerCount(postCount)} post{postCount !== 1 ? 's' : ''}
</span>
)}
</div>
);
}
Expand All @@ -66,8 +90,20 @@ interface TopicFeedProps {
}

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

// Infinite scroll via IntersectionObserver
Expand All @@ -92,13 +128,45 @@ export default function TopicFeed({ slug }: TopicFeedProps) {
<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 className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-24 mt-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="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<span
className="flex items-center justify-center w-10 h-10 rounded-xl bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-400 shrink-0"
aria-hidden="true"
>
<Hash className="w-5 h-5" />
</span>
<div className="min-w-0">
<h1 className="text-xl font-bold text-gray-900 dark:text-white truncate">
#{topic.name}
</h1>
{topic.description && (
<p className="mt-0.5 text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{topic.description}
</p>
)}
</div>
</div>

{/* Follow button */}
<button
onClick={toggleFollow}
disabled={followLoading}
aria-label={topic.isFollowing ? `Unfollow #${topic.name}` : `Follow #${topic.name}`}
className={`shrink-0 px-4 py-1.5 rounded-full text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-60 ${
topic.isFollowing
? 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/30 dark:hover:text-red-400'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{followLoading ? '…' : topic.isFollowing ? 'Following' : 'Follow'}
</button>
</div>

<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">
Expand All @@ -118,9 +186,7 @@ export default function TopicFeed({ slug }: TopicFeedProps) {
</div>

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

{/* 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">
Expand All @@ -131,23 +197,48 @@ export default function TopicFeed({ slug }: TopicFeedProps) {

{/* Error state */}
{error && !loading && (
<div className="p-8 text-center" role="alert">
<div className="p-10 text-center space-y-3" role="alert">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
<button
onClick={retry}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm font-medium hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
>
<RefreshCw className="w-4 h-4" aria-hidden="true" />
Retry
</button>
</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!
<div className="p-12 text-center space-y-3">
<span
className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-400"
aria-hidden="true"
>
<BookOpen className="w-6 h-6" />
</span>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
No posts yet in #{topic?.name ?? slug}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Be the first to share your knowledge!
</p>
<Link
href="/create"
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
Create a post
</Link>
</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">
<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
Expand All @@ -163,10 +254,15 @@ export default function TopicFeed({ slug }: TopicFeedProps) {

<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">
<Link
href={`/profile/${post.authorId}`}
className="font-medium text-gray-900 dark:text-white hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 rounded"
>
{post.authorName}
</Link>
<span className="text-gray-400 dark:text-gray-500" aria-hidden="true">
·
</span>
<span className="text-gray-400 dark:text-gray-500">·</span>
<time
dateTime={post.createdAt.toISOString()}
className="text-gray-500 dark:text-gray-400"
Expand All @@ -175,14 +271,33 @@ export default function TopicFeed({ slug }: TopicFeedProps) {
</time>
</div>

<h2 className="mt-1 text-base font-semibold text-gray-900 dark:text-white leading-snug">
{post.title}
</h2>
<Link
href={`/post/${post.id}`}
className="group block mt-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 rounded"
>
<h2 className="text-base font-semibold text-gray-900 dark:text-white leading-snug group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
{post.title}
</h2>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{post.body}
</p>
</Link>

<p className="mt-1 text-sm text-gray-600 dark:text-gray-400 line-clamp-3">
{post.body}
</p>
{/* Tags */}
{post.tags && post.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1" aria-label="Post tags">
{post.tags.map((tag) => (
<span
key={tag}
className="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-800 text-xs text-gray-600 dark:text-gray-400"
>
{tag}
</span>
))}
</div>
)}

{/* Actions row */}
<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" />
Expand All @@ -194,6 +309,16 @@ export default function TopicFeed({ slug }: TopicFeedProps) {
<span>{formatFollowerCount(post.commentCount)}</span>
<span className="sr-only">comments</span>
</span>

{/* Tip button */}
<Link
href={`/post/${post.id}?tip=1`}
className="ml-auto flex items-center gap-1 px-2.5 py-1 rounded-full bg-amber-50 dark:bg-amber-900/20 text-amber-600 dark:text-amber-400 text-xs font-medium hover:bg-amber-100 dark:hover:bg-amber-900/40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500"
aria-label={`Tip ${post.authorName} for this post`}
>
<Zap className="w-3.5 h-3.5" aria-hidden="true" />
Tip
</Link>
</div>
</div>
</div>
Expand Down
Loading
Loading