Various fixes and improvments
This commit is contained in:
+30
-136
@@ -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}
|
||||
/>
|
||||
))
|
||||
)
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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' });
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user