From c9951caa956905964e0a6cdf70349010ad40899d Mon Sep 17 00:00:00 2001 From: Christopher Date: Thu, 22 Jan 2026 13:50:55 -0800 Subject: [PATCH] Various fixes and improvments --- src/app/[handle]/page.tsx | 166 ++-------- src/app/[handle]/posts/[id]/page.tsx | 152 +++++++++ src/app/api/posts/[id]/repost/route.ts | 69 +++- src/app/api/posts/[id]/route.ts | 96 ++++++ src/app/api/posts/route.ts | 85 +++-- src/app/globals.css | 94 +++++- src/app/page.tsx | 416 ++----------------------- src/components/Compose.tsx | 206 ++++++++++++ src/components/PostCard.tsx | 180 +++++++++++ src/lib/types.ts | 41 +++ 10 files changed, 947 insertions(+), 558 deletions(-) create mode 100644 src/app/[handle]/posts/[id]/page.tsx create mode 100644 src/app/api/posts/[id]/route.ts create mode 100644 src/components/Compose.tsx create mode 100644 src/components/PostCard.tsx create mode 100644 src/lib/types.ts diff --git a/src/app/[handle]/page.tsx b/src/app/[handle]/page.tsx index 0627a72..63934e8 100644 --- a/src/app/[handle]/page.tsx +++ b/src/app/[handle]/page.tsx @@ -2,25 +2,13 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; -import { useParams } from 'next/navigation'; -import { ArrowLeftIcon, CalendarIcon, HeartIcon, RepeatIcon, MessageIcon, FlagIcon } from '@/components/Icons'; +import { useParams, useRouter } from 'next/navigation'; +import { ArrowLeftIcon, CalendarIcon } from '@/components/Icons'; +import { PostCard } from '@/components/PostCard'; +import { User, Post } from '@/lib/types'; import AutoTextarea from '@/components/AutoTextarea'; import { Rocket } from 'lucide-react'; -interface User { - id: string; - handle: string; - displayName: string; - bio?: string; - avatarUrl?: string; - headerUrl?: string; - followersCount: number; - followingCount: number; - postsCount: number; - createdAt: string; - movedTo?: string; // New actor URL if account has migrated -} - interface UserSummary { id: string; handle: string; @@ -29,26 +17,6 @@ interface UserSummary { avatarUrl?: string | null; } -interface MediaItem { - id: string; - url: string; - altText?: string | null; -} - -interface Post { - id: string; - content: string; - createdAt: string; - likesCount: number; - repostsCount: number; - repliesCount: number; - author: User; - media?: MediaItem[]; -} - -// Icons - - function UserRow({ user }: { user: UserSummary }) { return ( @@ -70,106 +38,9 @@ function UserRow({ user }: { user: UserSummary }) { ); } -function PostCard({ post }: { post: Post }) { - const [liked, setLiked] = useState(false); - const [reposted, setReposted] = useState(false); - const [reporting, setReporting] = useState(false); - - const formatTime = (dateStr: string) => { - const date = new Date(dateStr); - const now = new Date(); - const diff = now.getTime() - date.getTime(); - const minutes = Math.floor(diff / 60000); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - if (minutes < 1) return 'now'; - if (minutes < 60) return `${minutes}m`; - if (hours < 24) return `${hours}h`; - if (days < 7) return `${days}d`; - return date.toLocaleDateString(); - }; - - return ( -
-
-
- {post.author.avatarUrl ? ( - {post.author.displayName} - ) : ( - post.author.displayName?.charAt(0).toUpperCase() || post.author.handle.charAt(0).toUpperCase() - )} -
-
- - {post.author.displayName || post.author.handle} - - @{post.author.handle} · {formatTime(post.createdAt)} -
-
-
{post.content}
- {post.media && post.media.length > 0 && ( -
- {post.media.map((item) => ( -
- {item.altText -
- ))} -
- )} -
- - - - -
-
- ); -} - export default function ProfilePage() { const params = useParams(); + const router = useRouter(); const handle = (params.handle as string)?.replace(/^@/, '') || ''; const [user, setUser] = useState(null); @@ -212,13 +83,28 @@ export default function ProfilePage() { }) .catch(() => setLoading(false)); - // Get posts fetch(`/api/users/${handle}/posts`) .then(res => res.json()) .then(data => setPosts(data.posts || [])) .catch(() => { }); }, [handle]); + const handleLike = async (postId: string, currentLiked: boolean) => { + const method = currentLiked ? 'DELETE' : 'POST'; + await fetch(`/api/posts/${postId}/like`, { method }); + }; + + const handleRepost = async (postId: string, currentReposted: boolean) => { + const method = currentReposted ? 'DELETE' : 'POST'; + await fetch(`/api/posts/${postId}/repost`, { method }); + }; + + const handleComment = (post: Post) => { + // Navigation is handled by the PostCard overlay, + // but we can also use router.push if they explicitly click the comment button. + router.push(`/${post.author.handle}/posts/${post.id}`); + }; + useEffect(() => { if (user && currentUser?.handle === user.handle) { setProfileForm({ @@ -651,7 +537,15 @@ export default function ProfilePage() {

No posts yet

) : ( - posts.map(post => ) + posts.map(post => ( + + )) ) )} diff --git a/src/app/[handle]/posts/[id]/page.tsx b/src/app/[handle]/posts/[id]/page.tsx new file mode 100644 index 0000000..e0a30ea --- /dev/null +++ b/src/app/[handle]/posts/[id]/page.tsx @@ -0,0 +1,152 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; +import { ArrowLeftIcon } from '@/components/Icons'; +import { PostCard } from '@/components/PostCard'; +import { Compose } from '@/components/Compose'; +import { useAuth } from '@/lib/contexts/AuthContext'; +import { Post } from '@/lib/types'; + +export default function PostDetailPage() { + const params = useParams(); + const router = useRouter(); + const { user } = useAuth(); + const handle = params.handle as string; + const id = params.id as string; + + const [post, setPost] = useState(null); + const [replies, setReplies] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchPostDetail = async () => { + try { + const res = await fetch(`/api/posts/${id}`); + if (!res.ok) { + throw new Error('Post not found'); + } + const data = await res.json(); + setPost(data.post); + setReplies(data.replies || []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load post'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchPostDetail(); + }, [id]); + + const handlePost = async (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string) => { + const res = await fetch('/api/posts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content, mediaIds, linkPreview, replyToId }), + }); + + if (res.ok) { + const data = await res.json(); + // Add the new reply to the top of the list or re-fetch + setReplies([{ ...data.post, author: user }, ...replies]); + if (post) { + setPost({ ...post, repliesCount: post.repliesCount + 1 }); + } + } + }; + + const handleLike = async (postId: string, currentLiked: boolean) => { + const method = currentLiked ? 'DELETE' : 'POST'; + await fetch(`/api/posts/${postId}/like`, { method }); + }; + + const handleRepost = async (postId: string, currentReposted: boolean) => { + const method = currentReposted ? 'DELETE' : 'POST'; + await fetch(`/api/posts/${postId}/repost`, { method }); + }; + + if (loading) { + return ( +
+ Loading... +
+ ); + } + + if (error || !post) { + return ( +
+

{error || 'Post not found'}

+ +
+ ); + } + + return ( + <> +
+ +

Post

+
+ + { + const composer = document.querySelector('.compose-input') as HTMLTextAreaElement; + composer?.focus(); + }} + /> + + {user && ( +
+ +
+ )} + +
+ {replies.map((reply) => ( + { + // In detail view, commenting on a reply should probably just focus the main composer + // but we could also implement nested replies later. + // For now, let's keep it simple. + const composer = document.querySelector('.compose-input') as HTMLTextAreaElement; + composer?.focus(); + }} + /> + ))} +
+ + ); +} diff --git a/src/app/api/posts/[id]/repost/route.ts b/src/app/api/posts/[id]/repost/route.ts index c3dd79e..efa6e7f 100644 --- a/src/app/api/posts/[id]/repost/route.ts +++ b/src/app/api/posts/[id]/repost/route.ts @@ -28,6 +28,19 @@ export async function POST(request: Request, context: RouteContext) { return NextResponse.json({ error: 'Post not found' }, { status: 404 }); } + // Check if already reposted by this user + const existingRepost = await db.query.posts.findFirst({ + where: and( + eq(posts.userId, user.id), + eq(posts.repostOfId, postId), + eq(posts.isRemoved, false) + ), + }); + + if (existingRepost) { + return NextResponse.json({ error: 'Already reposted' }, { status: 400 }); + } + // Create repost const [repost] = await db.insert(posts).values({ userId: user.id, @@ -58,7 +71,7 @@ export async function POST(request: Request, context: RouteContext) { // TODO: Federate the repost (Announce activity) - return NextResponse.json({ success: true, repost }); + return NextResponse.json({ success: true, repost, reposted: true }); } catch (error) { if (error instanceof Error && error.message === 'Authentication required') { return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); @@ -66,3 +79,57 @@ export async function POST(request: Request, context: RouteContext) { return NextResponse.json({ error: 'Failed to repost' }, { status: 500 }); } } + +// Unrepost a post +export async function DELETE(request: Request, context: RouteContext) { + try { + const user = await requireAuth(); + const { id: postId } = await context.params; + + if (user.isSuspended || user.isSilenced) { + return NextResponse.json({ error: 'Account restricted' }, { status: 403 }); + } + + // Check if original post exists + const originalPost = await db.query.posts.findFirst({ + where: eq(posts.id, postId), + }); + + // Find the repost by this user + const repost = await db.query.posts.findFirst({ + where: and( + eq(posts.userId, user.id), + eq(posts.repostOfId, postId), + eq(posts.isRemoved, false) + ), + }); + + if (!repost) { + return NextResponse.json({ error: 'Not reposted' }, { status: 400 }); + } + + // Mark repost as removed + await db.update(posts) + .set({ isRemoved: true }) + .where(eq(posts.id, repost.id)); + + // Update original post's repost count + if (originalPost) { + await db.update(posts) + .set({ repostsCount: Math.max(0, originalPost.repostsCount - 1) }) + .where(eq(posts.id, postId)); + } + + // Update user's post count + await db.update(users) + .set({ postsCount: Math.max(0, user.postsCount - 1) }) + .where(eq(users.id, user.id)); + + return NextResponse.json({ success: true, reposted: false }); + } catch (error) { + if (error instanceof Error && error.message === 'Authentication required') { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } + return NextResponse.json({ error: 'Failed to unrepost' }, { status: 500 }); + } +} diff --git a/src/app/api/posts/[id]/route.ts b/src/app/api/posts/[id]/route.ts new file mode 100644 index 0000000..fde168e --- /dev/null +++ b/src/app/api/posts/[id]/route.ts @@ -0,0 +1,96 @@ +import { NextResponse } from 'next/server'; +import { db, posts, users, media } from '@/db'; +import { eq, desc, and } from 'drizzle-orm'; + +export async function GET( + request: Request, + { params }: { params: { id: string } } +) { + try { + const { id } = params; + + // Fetch the main post + const post = await db.query.posts.findFirst({ + where: eq(posts.id, id), + with: { + author: true, + media: true, + replyTo: { + with: { author: true }, + }, + }, + }); + + if (!post) { + return NextResponse.json({ error: 'Post not found' }, { status: 404 }); + } + + // Fetch replies to this post + const replies = await db.query.posts.findMany({ + where: and( + eq(posts.replyToId, id), + eq(posts.isRemoved, false) + ), + with: { + author: true, + media: true, + }, + orderBy: [desc(posts.createdAt)], + }); + + let mainPost = post; + let replyPosts = replies; + + try { + const { requireAuth } = await import('@/lib/auth'); + const { likes } = await import('@/db'); + const { inArray } = await import('drizzle-orm'); + + const viewer = await requireAuth(); + const allPostIds = [post.id, ...replies.map(r => r.id)]; + + if (allPostIds.length > 0) { + const viewerLikes = await db.query.likes.findMany({ + where: and( + eq(likes.userId, viewer.id), + inArray(likes.postId, allPostIds) + ), + }); + const likedPostIds = new Set(viewerLikes.map(l => l.postId)); + + const viewerReposts = await db.query.posts.findMany({ + where: and( + eq(posts.userId, viewer.id), + inArray(posts.repostOfId, allPostIds) + ), + }); + const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId)); + + mainPost = { + ...post, + isLiked: likedPostIds.has(post.id), + isReposted: repostedPostIds.has(post.id), + }; + + replyPosts = replies.map(r => ({ + ...r, + isLiked: likedPostIds.has(r.id), + isReposted: repostedPostIds.has(r.id), + })); + } + } catch { + // Not authenticated or other error, skip flags + } + + return NextResponse.json({ + post: mainPost, + replies: replyPosts, + }); + } catch (error) { + console.error('Get post detail error:', error); + return NextResponse.json( + { error: 'Failed to get post detail' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index e5e5ef7..57564e8 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from 'next/server'; -import { db, posts, users, media, follows, mutes, blocks } from '@/db'; +import { db, posts, users, media, follows, mutes, blocks, likes } from '@/db'; import { requireAuth } from '@/lib/auth'; import { eq, desc, and, inArray, isNull, notInArray, or } from 'drizzle-orm'; import type { SQL } from 'drizzle-orm'; @@ -128,13 +128,8 @@ export async function GET(request: Request) { const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50); let feedPosts; - const moderatedUsers = await db.select({ id: users.id }) - .from(users) - .where(or(eq(users.isSuspended, true), eq(users.isSilenced, true))); - const moderatedIds = moderatedUsers.map((item) => item.id); const baseFilter = buildWhere( - eq(posts.isRemoved, false), - moderatedIds.length ? notInArray(posts.userId, moderatedIds) : undefined + eq(posts.isRemoved, false) ); if (type === 'public') { @@ -168,7 +163,9 @@ export async function GET(request: Request) { } else if (type === 'curated') { let viewer = null; try { - viewer = await requireAuth(); + const { getSession } = await import('@/lib/auth'); + const session = await getSession(); + viewer = session?.user || null; } catch { viewer = null; } @@ -229,7 +226,7 @@ export async function GET(request: Request) { } if (engagement >= 5) { reasons.push(`Popular: ${post.likesCount} likes, ${post.repostsCount} reposts`); - } else if (engagement > 0) { + } else if (post.repliesCount > 0) { reasons.push(`Active conversation: ${post.repliesCount} replies`); } if (ageHours <= 6) { @@ -264,21 +261,7 @@ export async function GET(request: Request) { }) .slice(0, limit); - return NextResponse.json({ - posts: rankedPosts, - meta: { - algorithm: 'curated-v1', - windowHours: CURATION_WINDOW_HOURS, - seedLimit, - weights: { - engagement: 1.4, - recency: 1.1, - followBoost: 0.9, - selfBoost: 0.5, - }, - }, - nextCursor: rankedPosts.length === limit ? rankedPosts[rankedPosts.length - 1]?.id : null, - }); + feedPosts = rankedPosts; } else { // Home timeline - need auth try { @@ -315,12 +298,60 @@ export async function GET(request: Request) { } } + // Populate isLiked and isReposted for authenticated users + try { + const { getSession } = await import('@/lib/auth'); + const session = await getSession(); + + if (session?.user && feedPosts && feedPosts.length > 0) { + const viewer = session.user; + const postIds = feedPosts.map(p => p.id).filter(Boolean); + + if (postIds.length > 0) { + const viewerLikes = await db.query.likes.findMany({ + where: and( + eq(likes.userId, viewer.id), + inArray(likes.postId, postIds) + ), + }); + const likedPostIds = new Set(viewerLikes.map(l => l.postId)); + + const viewerReposts = await db.query.posts.findMany({ + where: and( + eq(posts.userId, viewer.id), + inArray(posts.repostOfId, postIds) + ), + }); + const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId)); + + feedPosts = feedPosts.map(p => ({ + ...p, + isLiked: likedPostIds.has(p.id), + isReposted: repostedPostIds.has(p.id), + })); + } + } + } catch (error) { + console.error('Error populating interaction flags:', error); + } + return NextResponse.json({ - posts: feedPosts, - nextCursor: feedPosts.length === limit ? feedPosts[feedPosts.length - 1]?.id : null, + posts: feedPosts || [], + meta: type === 'curated' ? { + algorithm: 'curated-v1', + windowHours: CURATION_WINDOW_HOURS, + seedLimit: Math.min(limit * CURATION_SEED_MULTIPLIER, CURATION_SEED_CAP), + weights: { + engagement: 1.4, + recency: 1.1, + followBoost: 0.9, + selfBoost: 0.5, + }, + } : undefined, + nextCursor: (feedPosts?.length === limit) ? feedPosts[feedPosts.length - 1]?.id : null, }); } catch (error) { - console.error('Get feed error:', error); + console.error('Get feed error details:', error); return NextResponse.json( { error: 'Failed to get feed' }, { status: 500 } diff --git a/src/app/globals.css b/src/app/globals.css index d6c2590..13691e4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1354,8 +1354,11 @@ a.btn-primary:visited { .link-preview-image img { width: 100%; - max-height: 240px; - object-fit: cover; + height: auto; + max-height: 400px; + display: block; + object-fit: contain; + background: var(--background-tertiary); border-bottom: 1px solid var(--border); } @@ -1433,3 +1436,90 @@ a.btn-primary:visited { flex-direction: column; justify-content: center; } + +/* Reply Styles */ +.post-reply-to { + font-size: 13px; + color: var(--foreground-tertiary); + margin-bottom: 8px; +} + +.post-reply-to a { + color: var(--accent); + font-weight: 500; +} + +.compose-reply-target { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--background-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 8px 12px; + margin-bottom: 12px; +} + +.compose-reply-info { + font-size: 13px; + color: var(--foreground-secondary); +} + +.compose-reply-handle { + color: var(--accent); + font-weight: 600; +} + +.compose-reply-cancel { + background: transparent; + border: none; + color: var(--foreground-tertiary); + font-size: 12px; + cursor: pointer; + padding: 4px; +} + +.compose-reply-cancel:hover { + color: var(--error); +} + +/* Clickable Post Card */ +.post { + position: relative; +} + +.post-link-overlay { + position: absolute; + inset: 0; + z-index: 0; +} + +.post-header, +.post-reply-to, +.post-media-grid, +.link-preview-card, +.post-actions, +.post-content { + position: relative; + z-index: 1; +} + +/* Post Detail Styles */ +.post.detail { + padding: 24px; +} + +.post.detail .post-content { + font-size: 18px; + line-height: 1.6; +} + +.post.detail .post-time { + font-size: 14px; +} + +/* Thread view styles */ +.thread-line { + margin-left: 20px; + border-left: 1px solid var(--border); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index f3a6362..73984fa 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,20 +3,9 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; import { useAuth } from '@/lib/contexts/AuthContext'; -import AutoTextarea from '@/components/AutoTextarea'; - -interface User { - id: string; - handle: string; - displayName: string; - avatarUrl?: string; -} - -interface MediaItem { - id: string; - url: string; - altText?: string | null; -} +import { PostCard } from '@/components/PostCard'; +import { Compose } from '@/components/Compose'; +import { Post } from '@/lib/types'; interface FeedMeta { score: number; @@ -28,381 +17,11 @@ interface FeedMeta { }; } -interface Post { - id: string; - content: string; - createdAt: string; - likesCount: number; - repostsCount: number; - repliesCount: number; - author: User; - media?: MediaItem[]; - feedMeta?: FeedMeta; - linkPreviewUrl?: string | null; - linkPreviewTitle?: string | null; - linkPreviewDescription?: string | null; - linkPreviewImage?: string | null; -} - -// Icons as simple SVG components - - -const HeartIcon = ({ filled }: { filled?: boolean }) => ( - - - -); - -const RepeatIcon = () => ( - - - - - - -); - -const MessageIcon = () => ( - - - -); - -const FlagIcon = () => ( - - - - -); - - - -function PostCard({ post, onLike, onRepost }: { post: Post; onLike: (id: string) => void; onRepost: (id: string) => void }) { - const [liked, setLiked] = useState(false); - const [reposted, setReposted] = useState(false); - const [reporting, setReporting] = useState(false); - - const formatTime = (dateStr: string) => { - const date = new Date(dateStr); - const now = new Date(); - const diff = now.getTime() - date.getTime(); - const minutes = Math.floor(diff / 60000); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - if (minutes < 1) return 'now'; - if (minutes < 60) return `${minutes}m`; - if (hours < 24) return `${hours}h`; - if (days < 7) return `${days}d`; - return date.toLocaleDateString(); - }; - - const handleLike = () => { - setLiked(!liked); - onLike(post.id); - }; - - const handleRepost = () => { - setReposted(!reposted); - onRepost(post.id); - }; - - const handleReport = async () => { - if (reporting) return; - const reason = window.prompt('Why are you reporting this post?'); - if (!reason) return; - setReporting(true); - try { - const res = await fetch('/api/reports', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ targetType: 'post', targetId: post.id, reason }), - }); - if (!res.ok) { - if (res.status === 401) { - alert('Please log in to report.'); - } else { - alert('Report failed. Please try again.'); - } - } else { - alert('Report submitted. Thank you.'); - } - } catch { - alert('Report failed. Please try again.'); - } finally { - setReporting(false); - } - }; - - return ( -
-
-
- {post.author.avatarUrl ? ( - {post.author.displayName} - ) : ( - post.author.displayName?.charAt(0).toUpperCase() || post.author.handle.charAt(0).toUpperCase() - )} -
-
- - {post.author.displayName || post.author.handle} - - @{post.author.handle} · {formatTime(post.createdAt)} -
-
-
{post.content}
- {post.feedMeta?.reasons?.length ? ( -
- {post.feedMeta.reasons.map((reason, index) => ( - - {reason} - - ))} -
- ) : null} - {post.media && post.media.length > 0 && ( -
- {post.media.map((item) => ( -
- {item.altText -
- ))} -
- )} - {post.linkPreviewUrl && ( - - {post.linkPreviewImage && ( -
- {post.linkPreviewTitle -
- )} -
-
{post.linkPreviewTitle}
- {post.linkPreviewDescription && ( -
{post.linkPreviewDescription}
- )} -
- {new URL(post.linkPreviewUrl.startsWith('http') ? post.linkPreviewUrl : `https://${post.linkPreviewUrl}`).hostname} -
-
-
- )} -
- - - - -
-
- ); -} - -type Attachment = { - id: string; - url: string; - altText?: string | null; -}; - -function Compose({ onPost }: { onPost: (content: string, mediaIds: string[], linkPreview?: any) => void }) { - const [content, setContent] = useState(''); - const [isPosting, setIsPosting] = useState(false); - const [attachments, setAttachments] = useState([]); - const [isUploading, setIsUploading] = useState(false); - const [uploadError, setUploadError] = useState(null); - const [linkPreview, setLinkPreview] = useState(null); - const [fetchingPreview, setFetchingPreview] = useState(false); - const [lastDetectedUrl, setLastDetectedUrl] = useState(null); - const maxLength = 400; - const remaining = maxLength - content.length; - - // Detect URLs in content - useEffect(() => { - const urlRegex = /(?:https?:\/\/)?((?:[a-zA-Z0-9-]+\.)+[a-z]{2,63})\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/gi; - const matches = content.match(urlRegex); - - if (matches && matches[0]) { - const url = matches[0]; - if (url !== lastDetectedUrl) { - setLastDetectedUrl(url); - fetchPreview(url); - } - } else if (!content.trim()) { - setLinkPreview(null); - setLastDetectedUrl(null); - } - }, [content]); - - const fetchPreview = async (url: string) => { - setFetchingPreview(true); - try { - const res = await fetch(`/api/media/preview?url=${encodeURIComponent(url)}`); - if (res.ok) { - const data = await res.json(); - setLinkPreview(data); - } - } catch (err) { - console.error('Preview error', err); - } finally { - setFetchingPreview(false); - } - }; - - const handleSubmit = async () => { - if (!content.trim() || isPosting || isUploading) return; - setIsPosting(true); - await onPost(content, attachments.map((item) => item.id).filter(Boolean), linkPreview); - setContent(''); - setAttachments([]); - setLinkPreview(null); - setLastDetectedUrl(null); - setIsPosting(false); - }; - - const handleMediaSelect = async (event: React.ChangeEvent) => { - const files = Array.from(event.target.files || []); - event.target.value = ''; - if (files.length === 0) return; - - const remainingSlots = Math.max(0, 4 - attachments.length); - const selectedFiles = files.slice(0, remainingSlots); - if (selectedFiles.length === 0) return; - - setUploadError(null); - setIsUploading(true); - - const uploaded: Attachment[] = []; - - for (const file of selectedFiles) { - try { - const formData = new FormData(); - formData.append('file', file); - const res = await fetch('/api/media/upload', { - method: 'POST', - body: formData, - }); - const data = await res.json(); - - if (!res.ok || !data.media?.id) { - throw new Error(data.error || 'Upload failed'); - } - - uploaded.push({ - id: data.media.id, - url: data.media.url || data.url, - altText: data.media.altText ?? null, - }); - } catch (error) { - console.error('Upload failed', error); - setUploadError('One or more uploads failed. Try again.'); - } - } - - setAttachments((prev) => [...prev, ...uploaded].slice(0, 4)); - setIsUploading(false); - }; - - const handleRemoveAttachment = (id: string) => { - setAttachments((prev) => prev.filter((item) => item.id !== id)); - }; - - return ( -
- setContent(e.target.value)} - maxLength={maxLength + 50} // Allow some overflow for better UX - /> - {attachments.length > 0 && ( -
- {attachments.map((item) => ( -
- {item.altText - -
- ))} -
- )} - - {linkPreview && ( -
- -
- {linkPreview.image && ( -
- -
- )} -
-
{linkPreview.title}
-
{new URL(linkPreview.url.startsWith('http') ? linkPreview.url : `https://${linkPreview.url}`).hostname}
-
-
-
- )} - - {uploadError && ( -
{uploadError}
- )} -
- - {remaining} - -
- - -
-
-
- ); -} - export default function Home() { const { user } = useAuth(); const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); + const [replyingTo, setReplyingTo] = useState(null); const [feedType, setFeedType] = useState<'latest' | 'curated'>('latest'); const [feedMeta, setFeedMeta] = useState<{ algorithm: string; @@ -436,11 +55,11 @@ export default function Home() { loadFeed(feedType); }, [feedType]); - const handlePost = async (content: string, mediaIds: string[], linkPreview?: any) => { + const handlePost = async (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string) => { const res = await fetch('/api/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content, mediaIds, linkPreview }), + body: JSON.stringify({ content, mediaIds, linkPreview, replyToId }), }); if (res.ok) { @@ -450,15 +69,18 @@ export default function Home() { } else { setPosts([{ ...data.post, author: user }, ...posts]); } + setReplyingTo(null); } }; - const handleLike = async (postId: string) => { - await fetch(`/api/posts/${postId}/like`, { method: 'POST' }); + const handleLike = async (postId: string, currentLiked: boolean) => { + const method = currentLiked ? 'DELETE' : 'POST'; + await fetch(`/api/posts/${postId}/like`, { method }); }; - const handleRepost = async (postId: string) => { - await fetch(`/api/posts/${postId}/repost`, { method: 'POST' }); + const handleRepost = async (postId: string, currentReposted: boolean) => { + const method = currentReposted ? 'DELETE' : 'POST'; + await fetch(`/api/posts/${postId}/repost`, { method }); }; return ( @@ -491,7 +113,13 @@ export default function Home() { - {user && } + {user && ( + setReplyingTo(null)} + /> + )} {!user && (
@@ -535,6 +163,10 @@ export default function Home() { post={post} onLike={handleLike} onRepost={handleRepost} + onComment={(p) => { + setReplyingTo(p); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }} /> )) )} diff --git a/src/components/Compose.tsx b/src/components/Compose.tsx new file mode 100644 index 0000000..9295245 --- /dev/null +++ b/src/components/Compose.tsx @@ -0,0 +1,206 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import AutoTextarea from '@/components/AutoTextarea'; +import { Post, Attachment } from '@/lib/types'; + +interface ComposeProps { + onPost: (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string) => void; + replyingTo?: Post | null; + onCancelReply?: () => void; + placeholder?: string; + isReply?: boolean; +} + +export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What's happening?", isReply }: ComposeProps) { + const [content, setContent] = useState(''); + const [isPosting, setIsPosting] = useState(false); + const [attachments, setAttachments] = useState([]); + const [isUploading, setIsUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + const [linkPreview, setLinkPreview] = useState(null); + const [fetchingPreview, setFetchingPreview] = useState(false); + const [lastDetectedUrl, setLastDetectedUrl] = useState(null); + const maxLength = 400; + const remaining = maxLength - content.length; + + // Detect URLs in content + useEffect(() => { + const urlRegex = /(?:https?:\/\/)?((?:[a-zA-Z0-9-]+\.)+[a-z]{2,63})\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/gi; + const matches = content.match(urlRegex); + + if (matches && matches[0]) { + const url = matches[0]; + if (url !== lastDetectedUrl) { + setLastDetectedUrl(url); + fetchPreview(url); + } + } else if (!content.trim()) { + setLinkPreview(null); + setLastDetectedUrl(null); + } + }, [content, lastDetectedUrl]); + + const fetchPreview = async (url: string) => { + setFetchingPreview(true); + try { + const res = await fetch(`/api/media/preview?url=${encodeURIComponent(url)}`); + if (res.ok) { + const data = await res.json(); + setLinkPreview(data); + } + } catch (err) { + console.error('Preview error', err); + } finally { + setFetchingPreview(false); + } + }; + + const handleSubmit = async () => { + if (!content.trim() || isPosting || isUploading) return; + setIsPosting(true); + await onPost(content, attachments.map((item) => item.id).filter(Boolean), linkPreview, replyingTo?.id); + setContent(''); + setAttachments([]); + setLinkPreview(null); + setLastDetectedUrl(null); + setIsPosting(false); + }; + + const handleMediaSelect = async (event: React.ChangeEvent) => { + const files = Array.from(event.target.files || []); + event.target.value = ''; + if (files.length === 0) return; + + const remainingSlots = Math.max(0, 4 - attachments.length); + const selectedFiles = files.slice(0, remainingSlots); + if (selectedFiles.length === 0) return; + + setUploadError(null); + setIsUploading(true); + + const uploaded: Attachment[] = []; + + for (const file of selectedFiles) { + try { + const formData = new FormData(); + formData.append('file', file); + const res = await fetch('/api/media/upload', { + method: 'POST', + body: formData, + }); + const data = await res.json(); + + if (!res.ok || !data.media?.id) { + throw new Error(data.error || 'Upload failed'); + } + + uploaded.push({ + id: data.media.id, + url: data.media.url || data.url, + altText: data.media.altText ?? null, + }); + } catch (error) { + console.error('Upload failed', error); + setUploadError('One or more uploads failed. Try again.'); + } + } + + setAttachments((prev) => [...prev, ...uploaded].slice(0, 4)); + setIsUploading(false); + }; + + const handleRemoveAttachment = (id: string) => { + setAttachments((prev) => prev.filter((item) => item.id !== id)); + }; + + return ( +
+ {replyingTo && !isReply && ( +
+
+ Replying to @{replyingTo.author.handle} +
+ +
+ )} + setContent(e.target.value)} + maxLength={maxLength + 50} // Allow some overflow for better UX + /> + {attachments.length > 0 && ( +
+ {attachments.map((item) => ( +
+ {item.altText + +
+ ))} +
+ )} + + {linkPreview && ( +
+ +
+ {linkPreview.image && ( +
+ +
+ )} +
+
{linkPreview.title}
+
{new URL(linkPreview.url.startsWith('http') ? linkPreview.url : `https://${linkPreview.url}`).hostname}
+
+
+
+ )} + + {uploadError && ( +
{uploadError}
+ )} +
+ + {remaining} + +
+ + +
+
+
+ ); +} diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx new file mode 100644 index 0000000..798a3fa --- /dev/null +++ b/src/components/PostCard.tsx @@ -0,0 +1,180 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { HeartIcon, RepeatIcon, MessageIcon, FlagIcon } from '@/components/Icons'; +import { Post } from '@/lib/types'; + +interface PostCardProps { + post: Post; + onLike?: (id: string) => void; + onRepost?: (id: string) => void; + onComment?: (post: Post) => void; + isDetail?: boolean; +} + +export function PostCard({ post, onLike, onRepost, onComment, isDetail }: PostCardProps) { + const [liked, setLiked] = useState(post.isLiked || false); + const [reposted, setReposted] = useState(post.isReposted || false); + const [reporting, setReporting] = useState(false); + + // Sync state if post changes (e.g. after a re-render from parent) + useEffect(() => { + setLiked(post.isLiked || false); + setReposted(post.isReposted || false); + }, [post.isLiked, post.isReposted, post.id]); + + const formatTime = (dateStr: string) => { + const date = new Date(dateStr); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (minutes < 1) return 'now'; + if (minutes < 60) return `${minutes}m`; + if (hours < 24) return `${hours}h`; + if (days < 7) return `${days}d`; + return date.toLocaleDateString(); + }; + + const handleLike = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const currentLiked = liked; + setLiked(!currentLiked); + onLike?.(post.id, currentLiked); // Pass current state before toggle + }; + + const handleRepost = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const currentReposted = reposted; + setReposted(!currentReposted); + onRepost?.(post.id, currentReposted); // Pass current state before toggle + }; + + const handleComment = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onComment?.(post); + }; + + const handleReport = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (reporting) return; + const reason = window.prompt('Why are you reporting this post?'); + if (!reason) return; + setReporting(true); + try { + const res = await fetch('/api/reports', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetType: 'post', targetId: post.id, reason }), + }); + if (!res.ok) { + if (res.status === 401) { + alert('Please log in to report.'); + } else { + alert('Report failed. Please try again.'); + } + } else { + alert('Report submitted. Thank you.'); + } + } catch { + alert('Report failed. Please try again.'); + } finally { + setReporting(false); + } + }; + + const postUrl = `/${post.author.handle}/posts/${post.id}`; + + return ( +
+ + +
+ e.stopPropagation()}> +
+ {post.author.avatarUrl ? ( + {post.author.displayName} + ) : ( + post.author.displayName?.charAt(0).toUpperCase() || post.author.handle.charAt(0).toUpperCase() + )} +
+ +
+ e.stopPropagation()}> + {post.author.displayName || post.author.handle} + + @{post.author.handle} · {formatTime(post.createdAt)} +
+
+ + {post.replyTo && ( +
+ Replied to e.stopPropagation()}>@{post.replyTo.author.handle} +
+ )} + +
{post.content}
+ + {post.media && post.media.length > 0 && ( +
+ {post.media.map((item) => ( +
+ {item.altText +
+ ))} +
+ )} + + {post.linkPreviewUrl && ( + e.stopPropagation()} + > + {post.linkPreviewImage && ( +
+ {post.linkPreviewTitle +
+ )} +
+
{post.linkPreviewTitle}
+ {post.linkPreviewDescription && ( +
{post.linkPreviewDescription}
+ )} +
+ {new URL(post.linkPreviewUrl.startsWith('http') ? post.linkPreviewUrl : `https://${post.linkPreviewUrl}`).hostname} +
+
+
+ )} + +
+ + + + +
+
+ ); +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..166a25f --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,41 @@ +export interface User { + id: string; + handle: string; + displayName: string; + avatarUrl?: string | null; +} + +export interface MediaItem { + id: string; + url: string; + altText?: string | null; +} + +export interface Attachment { + id: string; + url: string; + altText?: string | null; +} + +export interface Post { + id: string; + content: string; + createdAt: string; + likesCount: number; + repostsCount: number; + repliesCount: number; + author: User; + media?: MediaItem[]; + linkPreviewUrl?: string | null; + linkPreviewTitle?: string | null; + linkPreviewDescription?: string | null; + linkPreviewImage?: string | null; + replyTo?: { + author: { + handle: string; + displayName: string; + }; + } | null; + isLiked?: boolean; + isReposted?: boolean; +}