Various fixes and improvments

This commit is contained in:
Christopher
2026-01-22 13:50:55 -08:00
parent fc86bc1901
commit c9951caa95
10 changed files with 947 additions and 558 deletions
+30 -136
View File
@@ -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 (
<Link href={`/${user.handle}`} className="user-row">
@@ -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 (
<article className="post">
<div className="post-header">
<div className="avatar">
{post.author.avatarUrl ? (
<img src={post.author.avatarUrl} alt={post.author.displayName} />
) : (
post.author.displayName?.charAt(0).toUpperCase() || post.author.handle.charAt(0).toUpperCase()
)}
</div>
<div className="post-author">
<Link href={`/${post.author.handle}`} className="post-handle">
{post.author.displayName || post.author.handle}
</Link>
<span className="post-time">@{post.author.handle} · {formatTime(post.createdAt)}</span>
</div>
</div>
<div className="post-content">{post.content}</div>
{post.media && post.media.length > 0 && (
<div className="post-media-grid">
{post.media.map((item) => (
<div className="post-media-item" key={item.id}>
<img src={item.url} alt={item.altText || 'Post media'} loading="lazy" />
</div>
))}
</div>
)}
<div className="post-actions">
<button className="post-action">
<MessageIcon />
<span>{post.repliesCount || ''}</span>
</button>
<button className={`post-action ${reposted ? 'reposted' : ''}`} onClick={() => setReposted(!reposted)}>
<RepeatIcon />
<span>{post.repostsCount + (reposted ? 1 : 0) || ''}</span>
</button>
<button className={`post-action ${liked ? 'liked' : ''}`} onClick={() => setLiked(!liked)}>
<HeartIcon filled={liked} />
<span>{post.likesCount + (liked ? 1 : 0) || ''}</span>
</button>
<button
className="post-action"
onClick={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);
}
}}
disabled={reporting}
>
<FlagIcon />
<span>{reporting ? '...' : ''}</span>
</button>
</div>
</article>
);
}
export default function ProfilePage() {
const params = useParams();
const router = useRouter();
const handle = (params.handle as string)?.replace(/^@/, '') || '';
const [user, setUser] = useState<User | null>(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() {
<p>No posts yet</p>
</div>
) : (
posts.map(post => <PostCard key={post.id} post={post} />)
posts.map(post => (
<PostCard
key={post.id}
post={post}
onLike={handleLike}
onRepost={handleRepost}
onComment={handleComment}
/>
))
)
)}
+152
View File
@@ -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<Post | null>(null);
const [replies, setReplies] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
Loading...
</div>
);
}
if (error || !post) {
return (
<div style={{ padding: '48px', textAlign: 'center' }}>
<h1 style={{ fontSize: '20px', marginBottom: '16px' }}>{error || 'Post not found'}</h1>
<button className="btn btn-primary" onClick={() => router.back()}>Go Back</button>
</div>
);
}
return (
<>
<header style={{
padding: '16px',
borderBottom: '1px solid var(--border)',
position: 'sticky',
top: 0,
background: 'var(--background)',
zIndex: 10,
backdropFilter: 'blur(12px)',
display: 'flex',
alignItems: 'center',
gap: '24px',
}}>
<button
onClick={() => router.back()}
style={{ background: 'none', border: 'none', color: 'var(--foreground)', cursor: 'pointer', display: 'flex' }}
>
<ArrowLeftIcon />
</button>
<h1 style={{ fontSize: '18px', fontWeight: 600 }}>Post</h1>
</header>
<PostCard
post={post}
isDetail
onLike={handleLike}
onRepost={handleRepost}
onComment={() => {
const composer = document.querySelector('.compose-input') as HTMLTextAreaElement;
composer?.focus();
}}
/>
{user && (
<div style={{ borderBottom: '1px solid var(--border)' }}>
<Compose
onPost={handlePost}
replyingTo={post}
isReply
placeholder="Post your reply"
/>
</div>
)}
<div className="replies-list" style={{ paddingBottom: '64px' }}>
{replies.map((reply) => (
<PostCard
key={reply.id}
post={reply}
onLike={handleLike}
onRepost={handleRepost}
onComment={(p) => {
// 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();
}}
/>
))}
</div>
</>
);
}
+68 -1
View File
@@ -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 });
}
}
+96
View File
@@ -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 }
);
}
}
+58 -27
View File
@@ -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 }
+92 -2
View File
@@ -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);
}
+24 -392
View File
@@ -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 }) => (
<svg width="18" height="18" viewBox="0 0 24 24" fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
);
const RepeatIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="17 1 21 5 17 9" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
<polyline points="7 23 3 19 7 15" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
</svg>
);
const MessageIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
);
const FlagIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 5v16" />
<path d="M5 5h11l-1 4 1 4H5" />
</svg>
);
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 (
<article className="post">
<div className="post-header">
<div className="avatar">
{post.author.avatarUrl ? (
<img src={post.author.avatarUrl} alt={post.author.displayName} />
) : (
post.author.displayName?.charAt(0).toUpperCase() || post.author.handle.charAt(0).toUpperCase()
)}
</div>
<div className="post-author">
<Link href={`/${post.author.handle}`} className="post-handle">
{post.author.displayName || post.author.handle}
</Link>
<span className="post-time">@{post.author.handle} · {formatTime(post.createdAt)}</span>
</div>
</div>
<div className="post-content">{post.content}</div>
{post.feedMeta?.reasons?.length ? (
<div className="post-reasons">
{post.feedMeta.reasons.map((reason, index) => (
<span key={`${post.id}-reason-${index}`} className="post-reason-chip">
{reason}
</span>
))}
</div>
) : null}
{post.media && post.media.length > 0 && (
<div className="post-media-grid">
{post.media.map((item) => (
<div className="post-media-item" key={item.id}>
<img src={item.url} alt={item.altText || 'Post media'} loading="lazy" />
</div>
))}
</div>
)}
{post.linkPreviewUrl && (
<a href={post.linkPreviewUrl} target="_blank" rel="noopener noreferrer" className="link-preview-card">
{post.linkPreviewImage && (
<div className="link-preview-image">
<img src={post.linkPreviewImage} alt={post.linkPreviewTitle || ''} />
</div>
)}
<div className="link-preview-info">
<div className="link-preview-title">{post.linkPreviewTitle}</div>
{post.linkPreviewDescription && (
<div className="link-preview-description">{post.linkPreviewDescription}</div>
)}
<div className="link-preview-url">
{new URL(post.linkPreviewUrl.startsWith('http') ? post.linkPreviewUrl : `https://${post.linkPreviewUrl}`).hostname}
</div>
</div>
</a>
)}
<div className="post-actions">
<button className="post-action" onClick={() => { }}>
<MessageIcon />
<span>{post.repliesCount || ''}</span>
</button>
<button className={`post-action ${reposted ? 'reposted' : ''}`} onClick={handleRepost}>
<RepeatIcon />
<span>{post.repostsCount + (reposted ? 1 : 0) || ''}</span>
</button>
<button className={`post-action ${liked ? 'liked' : ''}`} onClick={handleLike}>
<HeartIcon filled={liked} />
<span>{post.likesCount + (liked ? 1 : 0) || ''}</span>
</button>
<button className="post-action" onClick={handleReport} disabled={reporting}>
<FlagIcon />
<span>{reporting ? '...' : ''}</span>
</button>
</div>
</article>
);
}
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<Attachment[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [linkPreview, setLinkPreview] = useState<any>(null);
const [fetchingPreview, setFetchingPreview] = useState(false);
const [lastDetectedUrl, setLastDetectedUrl] = useState<string | null>(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<HTMLInputElement>) => {
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 (
<div className="compose">
<AutoTextarea
className="compose-input"
placeholder="What's happening?"
value={content}
onChange={(e) => setContent(e.target.value)}
maxLength={maxLength + 50} // Allow some overflow for better UX
/>
{attachments.length > 0 && (
<div className="compose-media-grid">
{attachments.map((item) => (
<div className="compose-media-item" key={item.id}>
<img src={item.url} alt={item.altText || 'Upload preview'} />
<button
type="button"
className="compose-media-remove"
onClick={() => handleRemoveAttachment(item.id)}
>
x
</button>
</div>
))}
</div>
)}
{linkPreview && (
<div className="compose-link-preview">
<button
type="button"
className="compose-link-preview-remove"
onClick={() => setLinkPreview(null)}
>
x
</button>
<div className="link-preview-card mini">
{linkPreview.image && (
<div className="link-preview-image">
<img src={linkPreview.image} alt="" />
</div>
)}
<div className="link-preview-info">
<div className="link-preview-title">{linkPreview.title}</div>
<div className="link-preview-url">{new URL(linkPreview.url.startsWith('http') ? linkPreview.url : `https://${linkPreview.url}`).hostname}</div>
</div>
</div>
</div>
)}
{uploadError && (
<div className="compose-media-error">{uploadError}</div>
)}
<div className="compose-footer">
<span className={`compose-counter ${remaining < 50 ? (remaining < 0 ? 'error' : 'warning') : ''}`}>
{remaining}
</span>
<div className="compose-actions">
<label className="btn btn-ghost btn-sm compose-media-button">
{isUploading ? 'Uploading...' : 'Add media'}
<input
type="file"
accept="image/*"
multiple
onChange={handleMediaSelect}
disabled={isUploading || attachments.length >= 4}
className="compose-media-input"
/>
</label>
<button
className="btn btn-primary"
onClick={handleSubmit}
disabled={!content.trim() || remaining < 0 || isPosting || isUploading}
>
{isPosting ? 'Posting...' : 'Post'}
</button>
</div>
</div>
</div>
);
}
export default function Home() {
const { user } = useAuth();
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [replyingTo, setReplyingTo] = useState<Post | null>(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() {
</div>
</header>
{user && <Compose onPost={handlePost} />}
{user && (
<Compose
onPost={handlePost}
replyingTo={replyingTo}
onCancelReply={() => setReplyingTo(null)}
/>
)}
{!user && (
<div style={{ padding: '24px', textAlign: 'center', borderBottom: '1px solid var(--border)' }}>
@@ -535,6 +163,10 @@ export default function Home() {
post={post}
onLike={handleLike}
onRepost={handleRepost}
onComment={(p) => {
setReplyingTo(p);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
/>
))
)}
+206
View File
@@ -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<Attachment[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [linkPreview, setLinkPreview] = useState<any>(null);
const [fetchingPreview, setFetchingPreview] = useState(false);
const [lastDetectedUrl, setLastDetectedUrl] = useState<string | null>(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<HTMLInputElement>) => {
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 (
<div className={`compose ${isReply ? 'reply-compose' : ''}`}>
{replyingTo && !isReply && (
<div className="compose-reply-target">
<div className="compose-reply-info">
Replying to <span className="compose-reply-handle">@{replyingTo.author.handle}</span>
</div>
<button type="button" className="compose-reply-cancel" onClick={onCancelReply}>
Cancel
</button>
</div>
)}
<AutoTextarea
className="compose-input"
placeholder={placeholder}
value={content}
onChange={(e) => setContent(e.target.value)}
maxLength={maxLength + 50} // Allow some overflow for better UX
/>
{attachments.length > 0 && (
<div className="compose-media-grid">
{attachments.map((item) => (
<div className="compose-media-item" key={item.id}>
<img src={item.url} alt={item.altText || 'Upload preview'} />
<button
type="button"
className="compose-media-remove"
onClick={() => handleRemoveAttachment(item.id)}
>
x
</button>
</div>
))}
</div>
)}
{linkPreview && (
<div className="compose-link-preview">
<button
type="button"
className="compose-link-preview-remove"
onClick={() => setLinkPreview(null)}
>
x
</button>
<div className="link-preview-card mini">
{linkPreview.image && (
<div className="link-preview-image">
<img src={linkPreview.image} alt="" />
</div>
)}
<div className="link-preview-info">
<div className="link-preview-title">{linkPreview.title}</div>
<div className="link-preview-url">{new URL(linkPreview.url.startsWith('http') ? linkPreview.url : `https://${linkPreview.url}`).hostname}</div>
</div>
</div>
</div>
)}
{uploadError && (
<div className="compose-media-error">{uploadError}</div>
)}
<div className="compose-footer">
<span className={`compose-counter ${remaining < 50 ? (remaining < 0 ? 'error' : 'warning') : ''}`}>
{remaining}
</span>
<div className="compose-actions">
<label className="btn btn-ghost btn-sm compose-media-button">
{isUploading ? 'Uploading...' : 'Add media'}
<input
type="file"
accept="image/*"
multiple
onChange={handleMediaSelect}
disabled={isUploading || attachments.length >= 4}
className="compose-media-input"
/>
</label>
<button
className="btn btn-primary"
onClick={handleSubmit}
disabled={!content.trim() || remaining < 0 || isPosting || isUploading}
>
{isPosting ? 'Posting...' : isReply ? 'Reply' : 'Post'}
</button>
</div>
</div>
</div>
);
}
+180
View File
@@ -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 (
<article className={`post ${isDetail ? 'detail' : ''}`}>
<Link href={postUrl} className="post-link-overlay" aria-label="View post" />
<div className="post-header">
<Link href={`/${post.author.handle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}>
<div className="avatar">
{post.author.avatarUrl ? (
<img src={post.author.avatarUrl} alt={post.author.displayName} />
) : (
post.author.displayName?.charAt(0).toUpperCase() || post.author.handle.charAt(0).toUpperCase()
)}
</div>
</Link>
<div className="post-author">
<Link href={`/${post.author.handle}`} className="post-handle" onClick={(e) => e.stopPropagation()}>
{post.author.displayName || post.author.handle}
</Link>
<span className="post-time">@{post.author.handle} · {formatTime(post.createdAt)}</span>
</div>
</div>
{post.replyTo && (
<div className="post-reply-to">
Replied to <Link href={`/${post.replyTo.author.handle}`} onClick={(e) => e.stopPropagation()}>@{post.replyTo.author.handle}</Link>
</div>
)}
<div className="post-content">{post.content}</div>
{post.media && post.media.length > 0 && (
<div className="post-media-grid">
{post.media.map((item) => (
<div className="post-media-item" key={item.id}>
<img src={item.url} alt={item.altText || 'Post media'} loading="lazy" />
</div>
))}
</div>
)}
{post.linkPreviewUrl && (
<a
href={post.linkPreviewUrl}
target="_blank"
rel="noopener noreferrer"
className="link-preview-card"
onClick={(e) => e.stopPropagation()}
>
{post.linkPreviewImage && (
<div className="link-preview-image">
<img src={post.linkPreviewImage} alt={post.linkPreviewTitle || ''} />
</div>
)}
<div className="link-preview-info">
<div className="link-preview-title">{post.linkPreviewTitle}</div>
{post.linkPreviewDescription && (
<div className="link-preview-description">{post.linkPreviewDescription}</div>
)}
<div className="link-preview-url">
{new URL(post.linkPreviewUrl.startsWith('http') ? post.linkPreviewUrl : `https://${post.linkPreviewUrl}`).hostname}
</div>
</div>
</a>
)}
<div className="post-actions">
<button className="post-action" onClick={handleComment}>
<MessageIcon />
<span>{post.repliesCount || ''}</span>
</button>
<button className={`post-action ${reposted ? 'reposted' : ''}`} onClick={handleRepost}>
<RepeatIcon />
<span>{(post.repostsCount - (post.isReposted ? 1 : 0)) + (reposted ? 1 : 0) || ''}</span>
</button>
<button className={`post-action ${liked ? 'liked' : ''}`} onClick={handleLike}>
<HeartIcon filled={liked} />
<span>{(post.likesCount - (post.isLiked ? 1 : 0)) + (liked ? 1 : 0) || ''}</span>
</button>
<button className="post-action" onClick={handleReport} disabled={reporting}>
<FlagIcon />
<span>{reporting ? '...' : ''}</span>
</button>
</div>
</article>
);
}
+41
View File
@@ -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;
}