Various fixes and improvments
This commit is contained in:
+30
-136
@@ -2,25 +2,13 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { ArrowLeftIcon, CalendarIcon, HeartIcon, RepeatIcon, MessageIcon, FlagIcon } from '@/components/Icons';
|
import { ArrowLeftIcon, CalendarIcon } from '@/components/Icons';
|
||||||
|
import { PostCard } from '@/components/PostCard';
|
||||||
|
import { User, Post } from '@/lib/types';
|
||||||
import AutoTextarea from '@/components/AutoTextarea';
|
import AutoTextarea from '@/components/AutoTextarea';
|
||||||
import { Rocket } from 'lucide-react';
|
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 {
|
interface UserSummary {
|
||||||
id: string;
|
id: string;
|
||||||
handle: string;
|
handle: string;
|
||||||
@@ -29,26 +17,6 @@ interface UserSummary {
|
|||||||
avatarUrl?: string | null;
|
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 }) {
|
function UserRow({ user }: { user: UserSummary }) {
|
||||||
return (
|
return (
|
||||||
<Link href={`/${user.handle}`} className="user-row">
|
<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() {
|
export default function ProfilePage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
const handle = (params.handle as string)?.replace(/^@/, '') || '';
|
const handle = (params.handle as string)?.replace(/^@/, '') || '';
|
||||||
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
@@ -212,13 +83,28 @@ export default function ProfilePage() {
|
|||||||
})
|
})
|
||||||
.catch(() => setLoading(false));
|
.catch(() => setLoading(false));
|
||||||
|
|
||||||
// Get posts
|
|
||||||
fetch(`/api/users/${handle}/posts`)
|
fetch(`/api/users/${handle}/posts`)
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => setPosts(data.posts || []))
|
.then(data => setPosts(data.posts || []))
|
||||||
.catch(() => { });
|
.catch(() => { });
|
||||||
}, [handle]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (user && currentUser?.handle === user.handle) {
|
if (user && currentUser?.handle === user.handle) {
|
||||||
setProfileForm({
|
setProfileForm({
|
||||||
@@ -651,7 +537,15 @@ export default function ProfilePage() {
|
|||||||
<p>No posts yet</p>
|
<p>No posts yet</p>
|
||||||
</div>
|
</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 });
|
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
|
// Create repost
|
||||||
const [repost] = await db.insert(posts).values({
|
const [repost] = await db.insert(posts).values({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -58,7 +71,7 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
|
|
||||||
// TODO: Federate the repost (Announce activity)
|
// TODO: Federate the repost (Announce activity)
|
||||||
|
|
||||||
return NextResponse.json({ success: true, repost });
|
return NextResponse.json({ success: true, repost, reposted: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === 'Authentication required') {
|
if (error instanceof Error && error.message === 'Authentication required') {
|
||||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
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 });
|
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 { 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 { requireAuth } from '@/lib/auth';
|
||||||
import { eq, desc, and, inArray, isNull, notInArray, or } from 'drizzle-orm';
|
import { eq, desc, and, inArray, isNull, notInArray, or } from 'drizzle-orm';
|
||||||
import type { SQL } 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);
|
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
||||||
|
|
||||||
let feedPosts;
|
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(
|
const baseFilter = buildWhere(
|
||||||
eq(posts.isRemoved, false),
|
eq(posts.isRemoved, false)
|
||||||
moderatedIds.length ? notInArray(posts.userId, moderatedIds) : undefined
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (type === 'public') {
|
if (type === 'public') {
|
||||||
@@ -168,7 +163,9 @@ export async function GET(request: Request) {
|
|||||||
} else if (type === 'curated') {
|
} else if (type === 'curated') {
|
||||||
let viewer = null;
|
let viewer = null;
|
||||||
try {
|
try {
|
||||||
viewer = await requireAuth();
|
const { getSession } = await import('@/lib/auth');
|
||||||
|
const session = await getSession();
|
||||||
|
viewer = session?.user || null;
|
||||||
} catch {
|
} catch {
|
||||||
viewer = null;
|
viewer = null;
|
||||||
}
|
}
|
||||||
@@ -229,7 +226,7 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
if (engagement >= 5) {
|
if (engagement >= 5) {
|
||||||
reasons.push(`Popular: ${post.likesCount} likes, ${post.repostsCount} reposts`);
|
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`);
|
reasons.push(`Active conversation: ${post.repliesCount} replies`);
|
||||||
}
|
}
|
||||||
if (ageHours <= 6) {
|
if (ageHours <= 6) {
|
||||||
@@ -264,21 +261,7 @@ export async function GET(request: Request) {
|
|||||||
})
|
})
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
|
|
||||||
return NextResponse.json({
|
feedPosts = rankedPosts;
|
||||||
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,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// Home timeline - need auth
|
// Home timeline - need auth
|
||||||
try {
|
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({
|
return NextResponse.json({
|
||||||
posts: feedPosts,
|
posts: feedPosts || [],
|
||||||
nextCursor: feedPosts.length === limit ? feedPosts[feedPosts.length - 1]?.id : null,
|
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) {
|
} catch (error) {
|
||||||
console.error('Get feed error:', error);
|
console.error('Get feed error details:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to get feed' },
|
{ error: 'Failed to get feed' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
|
|||||||
+92
-2
@@ -1354,8 +1354,11 @@ a.btn-primary:visited {
|
|||||||
|
|
||||||
.link-preview-image img {
|
.link-preview-image img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 240px;
|
height: auto;
|
||||||
object-fit: cover;
|
max-height: 400px;
|
||||||
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
|
background: var(--background-tertiary);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1433,3 +1436,90 @@ a.btn-primary:visited {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
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 { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
import AutoTextarea from '@/components/AutoTextarea';
|
import { PostCard } from '@/components/PostCard';
|
||||||
|
import { Compose } from '@/components/Compose';
|
||||||
interface User {
|
import { Post } from '@/lib/types';
|
||||||
id: string;
|
|
||||||
handle: string;
|
|
||||||
displayName: string;
|
|
||||||
avatarUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MediaItem {
|
|
||||||
id: string;
|
|
||||||
url: string;
|
|
||||||
altText?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FeedMeta {
|
interface FeedMeta {
|
||||||
score: number;
|
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() {
|
export default function Home() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [posts, setPosts] = useState<Post[]>([]);
|
const [posts, setPosts] = useState<Post[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [replyingTo, setReplyingTo] = useState<Post | null>(null);
|
||||||
const [feedType, setFeedType] = useState<'latest' | 'curated'>('latest');
|
const [feedType, setFeedType] = useState<'latest' | 'curated'>('latest');
|
||||||
const [feedMeta, setFeedMeta] = useState<{
|
const [feedMeta, setFeedMeta] = useState<{
|
||||||
algorithm: string;
|
algorithm: string;
|
||||||
@@ -436,11 +55,11 @@ export default function Home() {
|
|||||||
loadFeed(feedType);
|
loadFeed(feedType);
|
||||||
}, [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', {
|
const res = await fetch('/api/posts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content, mediaIds, linkPreview }),
|
body: JSON.stringify({ content, mediaIds, linkPreview, replyToId }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -450,15 +69,18 @@ export default function Home() {
|
|||||||
} else {
|
} else {
|
||||||
setPosts([{ ...data.post, author: user }, ...posts]);
|
setPosts([{ ...data.post, author: user }, ...posts]);
|
||||||
}
|
}
|
||||||
|
setReplyingTo(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLike = async (postId: string) => {
|
const handleLike = async (postId: string, currentLiked: boolean) => {
|
||||||
await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
|
const method = currentLiked ? 'DELETE' : 'POST';
|
||||||
|
await fetch(`/api/posts/${postId}/like`, { method });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRepost = async (postId: string) => {
|
const handleRepost = async (postId: string, currentReposted: boolean) => {
|
||||||
await fetch(`/api/posts/${postId}/repost`, { method: 'POST' });
|
const method = currentReposted ? 'DELETE' : 'POST';
|
||||||
|
await fetch(`/api/posts/${postId}/repost`, { method });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -491,7 +113,13 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{user && <Compose onPost={handlePost} />}
|
{user && (
|
||||||
|
<Compose
|
||||||
|
onPost={handlePost}
|
||||||
|
replyingTo={replyingTo}
|
||||||
|
onCancelReply={() => setReplyingTo(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{!user && (
|
{!user && (
|
||||||
<div style={{ padding: '24px', textAlign: 'center', borderBottom: '1px solid var(--border)' }}>
|
<div style={{ padding: '24px', textAlign: 'center', borderBottom: '1px solid var(--border)' }}>
|
||||||
@@ -535,6 +163,10 @@ export default function Home() {
|
|||||||
post={post}
|
post={post}
|
||||||
onLike={handleLike}
|
onLike={handleLike}
|
||||||
onRepost={handleRepost}
|
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