Migrate user/profile URLs to /u/[handle] and remove legacy scripts
All user and post profile URLs have been updated from /[handle] to /u/[handle] for consistency. Added a redirect page for /posts/[id] to the canonical /u/[handle]/posts/[id] route. Deprecated and removed legacy scripts and documentation related to chat and federation endpoints. Push-based federation endpoints are now disabled in favor of real-time pull-based federation.
This commit is contained in:
@@ -0,0 +1,947 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
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, MoreHorizontal } from 'lucide-react';
|
||||
import { formatFullHandle } from '@/lib/utils/handle';
|
||||
import { Bot } from 'lucide-react';
|
||||
|
||||
interface BotOwner {
|
||||
id: string;
|
||||
handle: string;
|
||||
displayName?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
interface UserSummary {
|
||||
id: string;
|
||||
handle: string;
|
||||
displayName?: string | null;
|
||||
bio?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
isBot?: boolean;
|
||||
}
|
||||
|
||||
// Strip HTML tags from a string
|
||||
const stripHtml = (html: string | null | undefined): string | null => {
|
||||
if (!html) return null;
|
||||
return html.replace(/<[^>]*>/g, '').trim() || null;
|
||||
};
|
||||
|
||||
function UserRow({ user }: { user: UserSummary }) {
|
||||
return (
|
||||
<Link href={`/u/${user.handle}`} className="user-row">
|
||||
<div className="avatar">
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt={user.displayName || user.handle} />
|
||||
) : (
|
||||
(user.displayName || user.handle).charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div className="user-row-content">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span style={{ fontWeight: 600 }}>{user.displayName || user.handle}</span>
|
||||
{user.isBot && (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '3px',
|
||||
fontSize: '10px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
background: 'var(--accent-muted)',
|
||||
color: 'var(--accent)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<Bot size={12} />
|
||||
AI Account
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '13px' }}>{formatFullHandle(user.handle)}</div>
|
||||
{user.bio && stripHtml(user.bio) && (
|
||||
<div className="user-row-bio">{stripHtml(user.bio)}</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const handle = (params.handle as string)?.replace(/^@/, '') || '';
|
||||
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [likedPosts, setLikedPosts] = useState<Post[]>([]);
|
||||
const [currentUser, setCurrentUser] = useState<{ id: string; handle: string } | null>(null);
|
||||
const [isFollowing, setIsFollowing] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'posts' | 'replies' | 'likes' | 'followers' | 'following'>('posts');
|
||||
const [followers, setFollowers] = useState<UserSummary[]>([]);
|
||||
const [following, setFollowing] = useState<UserSummary[]>([]);
|
||||
const [repliesPosts, setRepliesPosts] = useState<Post[]>([]);
|
||||
const [postsLoading, setPostsLoading] = useState(true);
|
||||
const [likesLoading, setLikesLoading] = useState(false);
|
||||
const [repliesLoading, setRepliesLoading] = useState(false);
|
||||
const [followersLoading, setFollowersLoading] = useState(false);
|
||||
const [followingLoading, setFollowingLoading] = useState(false);
|
||||
const [postsLoadingMore, setPostsLoadingMore] = useState(false);
|
||||
const [repliesLoadingMore, setRepliesLoadingMore] = useState(false);
|
||||
const [postsCursor, setPostsCursor] = useState<string | null>(null);
|
||||
const [repliesCursor, setRepliesCursor] = useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [profileForm, setProfileForm] = useState({
|
||||
displayName: '',
|
||||
bio: '',
|
||||
avatarUrl: '',
|
||||
headerUrl: '',
|
||||
website: '',
|
||||
});
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isBlocked, setIsBlocked] = useState(false);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsEditing(false);
|
||||
setSaveError(null);
|
||||
setFollowers([]);
|
||||
setFollowing([]);
|
||||
setLikedPosts([]);
|
||||
setRepliesPosts([]);
|
||||
// Get current user
|
||||
fetch('/api/auth/me')
|
||||
.then(res => res.json())
|
||||
.then(data => setCurrentUser(data.user))
|
||||
.catch(() => { });
|
||||
|
||||
// Get profile
|
||||
fetch(`/api/users/${handle}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setUser(data.user);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
|
||||
setPostsLoading(true);
|
||||
setPostsCursor(null);
|
||||
setRepliesCursor(null);
|
||||
fetch(`/api/users/${handle}/posts`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setPosts(data.posts || []);
|
||||
setPostsCursor(data.nextCursor || null);
|
||||
})
|
||||
.catch(() => { })
|
||||
.finally(() => setPostsLoading(false));
|
||||
}, [handle]);
|
||||
|
||||
// Infinite scroll ref
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load more posts
|
||||
const loadMorePosts = useCallback(async () => {
|
||||
if (!postsCursor || postsLoadingMore) return;
|
||||
setPostsLoadingMore(true);
|
||||
try {
|
||||
const res = await fetch(`/api/users/${handle}/posts?cursor=${postsCursor}`);
|
||||
const data = await res.json();
|
||||
setPosts(prev => [...prev, ...(data.posts || [])]);
|
||||
setPostsCursor(data.nextCursor || null);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setPostsLoadingMore(false);
|
||||
}
|
||||
}, [handle, postsCursor, postsLoadingMore]);
|
||||
|
||||
// Load more replies
|
||||
const loadMoreReplies = useCallback(async () => {
|
||||
if (!repliesCursor || repliesLoadingMore || !user) return;
|
||||
setRepliesLoadingMore(true);
|
||||
try {
|
||||
const res = await fetch(`/api/posts?type=replies&userId=${user.id}&cursor=${repliesCursor}`);
|
||||
const data = await res.json();
|
||||
setRepliesPosts(prev => [...prev, ...(data.posts || [])]);
|
||||
setRepliesCursor(data.nextCursor || null);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setRepliesLoadingMore(false);
|
||||
}
|
||||
}, [user, repliesCursor, repliesLoadingMore]);
|
||||
|
||||
// Infinite scroll observer
|
||||
useEffect(() => {
|
||||
if (!loadMoreRef.current) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
if (activeTab === 'posts' && postsCursor && !postsLoadingMore) {
|
||||
loadMorePosts();
|
||||
} else if (activeTab === 'replies' && repliesCursor && !repliesLoadingMore) {
|
||||
loadMoreReplies();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
observer.observe(loadMoreRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [activeTab, postsCursor, repliesCursor, postsLoadingMore, repliesLoadingMore, loadMorePosts, loadMoreReplies]);
|
||||
|
||||
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(`/u/${post.author.handle}/posts/${post.id}`);
|
||||
};
|
||||
|
||||
const handleDelete = (postId: string) => {
|
||||
setPosts(prev => prev.filter(p => p.id !== postId));
|
||||
if (user && isOwnProfile) {
|
||||
setUser({
|
||||
...user,
|
||||
postsCount: (user.postsCount || 0) - 1
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user && currentUser?.handle === user.handle) {
|
||||
setProfileForm({
|
||||
displayName: user.displayName || '',
|
||||
bio: user.bio || '',
|
||||
avatarUrl: user.avatarUrl || '',
|
||||
headerUrl: user.headerUrl || '',
|
||||
website: user.website || '',
|
||||
});
|
||||
}
|
||||
}, [user, currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser || !user || currentUser.handle === user.handle) {
|
||||
setIsFollowing(false);
|
||||
setIsBlocked(false);
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/users/${handle}/follow`)
|
||||
.then(res => res.json())
|
||||
.then(data => setIsFollowing(!!data.following))
|
||||
.catch(() => setIsFollowing(false));
|
||||
|
||||
fetch(`/api/users/${handle}/block`)
|
||||
.then(res => res.json())
|
||||
.then(data => setIsBlocked(!!data.blocked))
|
||||
.catch(() => setIsBlocked(false));
|
||||
}, [currentUser, user, handle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'followers') {
|
||||
setFollowersLoading(true);
|
||||
fetch(`/api/users/${handle}/followers`)
|
||||
.then(res => res.json())
|
||||
.then(data => setFollowers(data.followers || []))
|
||||
.catch(() => setFollowers([]))
|
||||
.finally(() => setFollowersLoading(false));
|
||||
}
|
||||
|
||||
if (activeTab === 'following') {
|
||||
setFollowingLoading(true);
|
||||
fetch(`/api/users/${handle}/following`)
|
||||
.then(res => res.json())
|
||||
.then(data => setFollowing(data.following || []))
|
||||
.catch(() => setFollowing([]))
|
||||
.finally(() => setFollowingLoading(false));
|
||||
}
|
||||
|
||||
if (activeTab === 'likes') {
|
||||
setLikesLoading(true);
|
||||
fetch(`/api/users/${handle}/likes`)
|
||||
.then(res => res.json())
|
||||
.then(data => setLikedPosts(data.posts || []))
|
||||
.catch(() => setLikedPosts([]))
|
||||
.finally(() => setLikesLoading(false));
|
||||
}
|
||||
|
||||
if (activeTab === 'replies' && user) {
|
||||
setRepliesLoading(true);
|
||||
setRepliesCursor(null);
|
||||
fetch(`/api/posts?type=replies&userId=${user.id}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setRepliesPosts(data.posts || []);
|
||||
setRepliesCursor(data.nextCursor || null);
|
||||
})
|
||||
.catch(() => setRepliesPosts([]))
|
||||
.finally(() => setRepliesLoading(false));
|
||||
}
|
||||
}, [activeTab, handle, user]);
|
||||
|
||||
const handleFollow = async () => {
|
||||
if (!currentUser) return;
|
||||
|
||||
const method = isFollowing ? 'DELETE' : 'POST';
|
||||
const res = await fetch(`/api/users/${handle}/follow`, { method });
|
||||
|
||||
if (res.ok && user) {
|
||||
setIsFollowing(!isFollowing);
|
||||
setUser({
|
||||
...user,
|
||||
followersCount: isFollowing ? (user.followersCount || 0) - 1 : (user.followersCount || 0) + 1,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlock = async () => {
|
||||
if (!currentUser) return;
|
||||
|
||||
const method = isBlocked ? 'DELETE' : 'POST';
|
||||
const res = await fetch(`/api/users/${handle}/block`, { method });
|
||||
|
||||
if (res.ok) {
|
||||
setIsBlocked(!isBlocked);
|
||||
if (!isBlocked) {
|
||||
// If blocking, also unfollow
|
||||
setIsFollowing(false);
|
||||
}
|
||||
setShowMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
if (!isOwnProfile) return;
|
||||
setIsSaving(true);
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(profileForm),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Failed to update profile');
|
||||
}
|
||||
|
||||
setUser(data.user);
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
console.error('Profile update failed', error);
|
||||
setSaveError('Unable to update profile. Please try again.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--foreground-tertiary)',
|
||||
}}>
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '16px',
|
||||
}}>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 600 }}>User not found</h1>
|
||||
<Link href="/" className="btn btn-primary">Go home</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isOwnProfile = currentUser?.handle === user.handle;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '600px', margin: '0 auto', minHeight: '100vh' }}>
|
||||
{/* Header */}
|
||||
<header style={{
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '24px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
background: 'var(--background)',
|
||||
zIndex: 10,
|
||||
}}>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
style={{
|
||||
color: 'var(--foreground)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
</button>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '18px', fontWeight: 600 }}>{user.displayName || user.handle}</h1>
|
||||
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}>{user.postsCount} posts</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Account Moved Banner */}
|
||||
{user.movedTo && (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
background: 'rgba(245, 158, 11, 0.1)',
|
||||
borderBottom: '1px solid rgba(245, 158, 11, 0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}>
|
||||
<Rocket size={24} style={{ color: 'var(--warning)' }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, color: 'var(--warning)', marginBottom: '4px' }}>
|
||||
This account has moved
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: 'var(--foreground-secondary)' }}>
|
||||
This user has migrated to a new node:{' '}
|
||||
<a
|
||||
href={user.movedTo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
>
|
||||
{user.movedTo.replace('https://', '').replace('/api/users/', '/@').replace('/users/', '/@')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile Header */}
|
||||
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
{/* Banner */}
|
||||
<div style={{
|
||||
height: '150px',
|
||||
background: user.headerUrl
|
||||
? `url(${user.headerUrl}) center/cover`
|
||||
: 'linear-gradient(135deg, var(--accent-muted) 0%, var(--background-tertiary) 100%)',
|
||||
}} />
|
||||
|
||||
{/* Avatar & Actions */}
|
||||
<div style={{ padding: '0 16px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
}}>
|
||||
<div className="avatar avatar-lg" style={{
|
||||
width: '96px',
|
||||
height: '96px',
|
||||
fontSize: '36px',
|
||||
border: '4px solid var(--background)',
|
||||
marginTop: '-48px',
|
||||
}}>
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt={user.displayName || user.handle} />
|
||||
) : (
|
||||
(user.displayName || user.handle).charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ paddingTop: '12px', display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
{!isOwnProfile && currentUser && (
|
||||
<>
|
||||
{!isBlocked && (
|
||||
<button
|
||||
className={`btn ${isFollowing ? '' : 'btn-primary'}`}
|
||||
onClick={handleFollow}
|
||||
>
|
||||
{isFollowing ? 'Following' : 'Follow'}
|
||||
</button>
|
||||
)}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
style={{ padding: '8px' }}
|
||||
>
|
||||
<MoreHorizontal size={20} />
|
||||
</button>
|
||||
{showMenu && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 99,
|
||||
}}
|
||||
onClick={() => setShowMenu(false)}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: '100%',
|
||||
marginTop: '4px',
|
||||
background: 'var(--background-secondary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
minWidth: '160px',
|
||||
zIndex: 100,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<button
|
||||
onClick={handleBlock}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
color: isBlocked ? 'var(--foreground)' : 'var(--error)',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{isBlocked ? 'Unblock user' : 'Block user'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isOwnProfile && (
|
||||
<button className="btn" onClick={() => setIsEditing(!isEditing)}>
|
||||
{isEditing ? 'Close' : 'Edit Profile'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
<div style={{ padding: '12px 0' }}>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: 700 }}>{user.displayName || user.handle}</h2>
|
||||
<p style={{ color: 'var(--foreground-tertiary)' }}>{formatFullHandle(user.handle)}</p>
|
||||
|
||||
{user.bio && (
|
||||
<p style={{ marginTop: '12px', lineHeight: 1.5 }}>{user.bio}</p>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginTop: '12px',
|
||||
color: 'var(--foreground-tertiary)',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
<CalendarIcon />
|
||||
<span>Joined {formatDate(user.createdAt || new Date().toISOString())}</span>
|
||||
</div>
|
||||
|
||||
{user.website && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginTop: '4px',
|
||||
color: 'var(--accent)',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
<Link
|
||||
href={user.website.startsWith('http') ? user.website : `https://${user.website}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'inherit', textDecoration: 'none' }}
|
||||
>
|
||||
{user.website.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '')}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bot indicator and owner info */}
|
||||
{user.isBot && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginTop: '12px',
|
||||
padding: '8px 12px',
|
||||
background: 'var(--accent-muted)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
<Bot size={16} style={{ color: 'var(--accent)' }} />
|
||||
<span style={{ color: 'var(--foreground-secondary)' }}>
|
||||
Automated account
|
||||
{(user as any).botOwner && (
|
||||
<>
|
||||
{' · Managed by '}
|
||||
<Link
|
||||
href={`/u/${(user as any).botOwner.handle}`}
|
||||
style={{ color: 'var(--accent)', fontWeight: 500 }}
|
||||
>
|
||||
@{(user as any).botOwner.handle}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '16px', marginTop: '12px' }}>
|
||||
<button
|
||||
onClick={() => setActiveTab('following')}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--foreground)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<strong>{user.followingCount}</strong>{' '}
|
||||
<span style={{ color: 'var(--foreground-tertiary)' }}>Following</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('followers')}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--foreground)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<strong>{user.followersCount}</strong>{' '}
|
||||
<span style={{ color: 'var(--foreground-tertiary)' }}>Followers</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOwnProfile && isEditing && (
|
||||
<div style={{ padding: '0 16px 16px' }}>
|
||||
<div className="card" style={{ padding: '16px' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: '12px' }}>Edit profile</div>
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Display name</label>
|
||||
<input
|
||||
className="input"
|
||||
value={profileForm.displayName}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, displayName: e.target.value })}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Bio</label>
|
||||
<AutoTextarea
|
||||
className="input"
|
||||
value={profileForm.bio}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, bio: e.target.value })}
|
||||
maxLength={160}
|
||||
style={{ minHeight: '80px', resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Website</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="https://example.com"
|
||||
value={profileForm.website}
|
||||
onChange={(e) => setProfileForm({ ...profileForm, website: e.target.value })}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Avatar</label>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||
<label className="btn btn-ghost btn-sm" style={{ cursor: 'pointer' }}>
|
||||
{isSaving ? 'Uploading...' : 'Choose File'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await fetch('/api/uploads', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
setProfileForm(prev => ({ ...prev, avatarUrl: data.url }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setSaveError('Upload failed');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}}
|
||||
disabled={isSaving}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
{profileForm.avatarUrl && (
|
||||
<div style={{ width: '40px', height: '40px', borderRadius: '50%', overflow: 'hidden', border: '1px solid var(--border)' }}>
|
||||
<img src={profileForm.avatarUrl} alt="Preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Header</label>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||
<label className="btn btn-ghost btn-sm" style={{ cursor: 'pointer' }}>
|
||||
{isSaving ? 'Uploading...' : 'Choose File'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await fetch('/api/uploads', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
setProfileForm(prev => ({ ...prev, headerUrl: data.url }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setSaveError('Upload failed');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}}
|
||||
disabled={isSaving}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
{profileForm.headerUrl && (
|
||||
<div style={{ width: '80px', height: '40px', borderRadius: '4px', overflow: 'hidden', border: '1px solid var(--border)' }}>
|
||||
<img src={profileForm.headerUrl} alt="Preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{saveError && (
|
||||
<div style={{ color: 'var(--error)', fontSize: '13px' }}>{saveError}</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end', marginTop: '8px' }}>
|
||||
<button className="btn btn-ghost" onClick={() => setIsEditing(false)} disabled={isSaving}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleSaveProfile} disabled={isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', borderTop: '1px solid var(--border)' }}>
|
||||
{(user?.isBot
|
||||
? ['posts', 'replies', 'followers', 'following'] as const
|
||||
: ['posts', 'replies', 'likes', 'followers', 'following'] as const
|
||||
).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '16px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === tab ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
color: activeTab === tab ? 'var(--foreground)' : 'var(--foreground-tertiary)',
|
||||
fontWeight: activeTab === tab ? 600 : 400,
|
||||
cursor: 'pointer',
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === 'posts' && (
|
||||
postsLoading ? (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
) : posts.length === 0 ? (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
<p>No posts yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{posts.map((post, index) => (
|
||||
<PostCard
|
||||
key={`${post.id}-${index}`}
|
||||
post={post}
|
||||
onLike={handleLike}
|
||||
onRepost={handleRepost}
|
||||
onComment={handleComment}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
<div ref={loadMoreRef} style={{ padding: '24px', textAlign: 'center' }}>
|
||||
{postsLoadingMore && (
|
||||
<span style={{ color: 'var(--foreground-tertiary)' }}>Loading more...</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === 'replies' && (
|
||||
repliesLoading ? (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
) : repliesPosts.length === 0 ? (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
<p>No replies yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{repliesPosts.map((post, index) => (
|
||||
<PostCard
|
||||
key={`${post.id}-${index}`}
|
||||
post={post}
|
||||
onLike={handleLike}
|
||||
onRepost={handleRepost}
|
||||
onComment={handleComment}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
<div ref={loadMoreRef} style={{ padding: '24px', textAlign: 'center' }}>
|
||||
{repliesLoadingMore && (
|
||||
<span style={{ color: 'var(--foreground-tertiary)' }}>Loading more...</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === 'likes' && (
|
||||
likesLoading ? (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
) : likedPosts.length === 0 ? (
|
||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
<p>No liked posts yet</p>
|
||||
</div>
|
||||
) : (
|
||||
likedPosts.map((post, index) => (
|
||||
<PostCard
|
||||
key={`${post.id}-${index}`}
|
||||
post={post}
|
||||
onLike={handleLike}
|
||||
onRepost={handleRepost}
|
||||
onComment={handleComment}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === 'followers' && (
|
||||
followersLoading ? (
|
||||
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
Loading followers...
|
||||
</div>
|
||||
) : followers.length === 0 ? (
|
||||
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
<p>No followers yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{followers.map(follower => (
|
||||
<UserRow key={follower.id} user={follower} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === 'following' && (
|
||||
followingLoading ? (
|
||||
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
Loading following...
|
||||
</div>
|
||||
) : following.length === 0 ? (
|
||||
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||
<p>Not following anyone yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{following.map(userItem => (
|
||||
<UserRow key={userItem.id} user={userItem} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
'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, isNsfw?: boolean) => {
|
||||
// Check if we're replying to a swarm post
|
||||
let swarmReplyTo: { postId: string; nodeDomain: string; content?: string; author?: any } | undefined;
|
||||
let localReplyToId: string | undefined = replyToId;
|
||||
|
||||
if (post?.isSwarm && post.nodeDomain && post.originalPostId) {
|
||||
// This is a reply to a swarm post - send to the origin node
|
||||
swarmReplyTo = {
|
||||
postId: post.originalPostId,
|
||||
nodeDomain: post.nodeDomain,
|
||||
content: post.content,
|
||||
author: post.author,
|
||||
};
|
||||
localReplyToId = undefined; // Can't use UUID foreign key for swarm posts
|
||||
}
|
||||
|
||||
const res = await fetch('/api/posts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, mediaIds, linkPreview, replyToId: localReplyToId, swarmReplyTo, isNsfw }),
|
||||
});
|
||||
|
||||
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 });
|
||||
};
|
||||
|
||||
const handleDelete = (postId: string) => {
|
||||
if (postId === id) {
|
||||
router.push(`/u/${handle}`);
|
||||
} else {
|
||||
setReplies(prev => prev.filter(r => r.id !== postId));
|
||||
if (post) {
|
||||
setPost({ ...post, repliesCount: Math.max(0, post.repliesCount - 1) });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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}
|
||||
onDelete={handleDelete}
|
||||
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}
|
||||
onDelete={handleDelete}
|
||||
parentPostAuthorId={post.author.id}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user