@@ -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}
+
+
+ Cancel
+
+
+ )}
+
setContent(e.target.value)}
+ maxLength={maxLength + 50} // Allow some overflow for better UX
+ />
+ {attachments.length > 0 && (
+
+ {attachments.map((item) => (
+
+
+
handleRemoveAttachment(item.id)}
+ >
+ x
+
+
+ ))}
+
+ )}
+
+ {linkPreview && (
+
+
setLinkPreview(null)}
+ >
+ x
+
+
+ {linkPreview.image && (
+
+
+
+ )}
+
+
{linkPreview.title}
+
{new URL(linkPreview.url.startsWith('http') ? linkPreview.url : `https://${linkPreview.url}`).hostname}
+
+
+
+ )}
+
+ {uploadError && (
+ {uploadError}
+ )}
+
+
+ {remaining}
+
+
+
+ {isUploading ? 'Uploading...' : 'Add media'}
+ = 4}
+ className="compose-media-input"
+ />
+
+
+ {isPosting ? 'Posting...' : isReply ? 'Reply' : 'Post'}
+
+
+
+
+ );
+}
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?.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) => (
+
+
+
+ ))}
+
+ )}
+
+ {post.linkPreviewUrl && (
+ e.stopPropagation()}
+ >
+ {post.linkPreviewImage && (
+
+
+
+ )}
+
+
{post.linkPreviewTitle}
+ {post.linkPreviewDescription && (
+
{post.linkPreviewDescription}
+ )}
+
+ {new URL(post.linkPreviewUrl.startsWith('http') ? post.linkPreviewUrl : `https://${post.linkPreviewUrl}`).hostname}
+
+
+
+ )}
+
+
+
+
+ {post.repliesCount || ''}
+
+
+
+ {(post.repostsCount - (post.isReposted ? 1 : 0)) + (reposted ? 1 : 0) || ''}
+
+
+
+ {(post.likesCount - (post.isLiked ? 1 : 0)) + (liked ? 1 : 0) || ''}
+
+
+
+ {reporting ? '...' : ''}
+
+
+
+ );
+}
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;
+}