refactor: Update user profile routing from @[handle] to [handle], add an explore page, and introduce a user API route.

This commit is contained in:
Christopher
2026-01-22 04:03:09 -08:00
parent 72a3562b50
commit 76bc648028
6 changed files with 586 additions and 7 deletions
+638
View File
@@ -0,0 +1,638 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
interface User {
id: string;
handle: string;
displayName: string;
bio?: string;
avatarUrl?: string;
headerUrl?: string;
followersCount: number;
followingCount: number;
postsCount: number;
createdAt: string;
}
interface UserSummary {
id: string;
handle: string;
displayName?: string | null;
bio?: 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
const ArrowLeftIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</svg>
);
const CalendarIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
);
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 UserRow({ user }: { user: UserSummary }) {
return (
<Link href={`/${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={{ fontWeight: 600 }}>{user.displayName || user.handle}</div>
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '13px' }}>@{user.handle}</div>
{user.bio && (
<div className="user-row-bio">{user.bio}</div>
)}
</div>
</Link>
);
}
function PostCard({ post }: { post: Post }) {
const [liked, setLiked] = useState(false);
const [reposted, setReposted] = useState(false);
const [reporting, setReporting] = useState(false);
const formatTime = (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (minutes < 1) return 'now';
if (minutes < 60) return `${minutes}m`;
if (hours < 24) return `${hours}h`;
if (days < 7) return `${days}d`;
return date.toLocaleDateString();
};
return (
<article className="post">
<div className="post-header">
<div className="avatar">
{post.author.avatarUrl ? (
<img src={post.author.avatarUrl} alt={post.author.displayName} />
) : (
post.author.displayName?.charAt(0).toUpperCase() || post.author.handle.charAt(0).toUpperCase()
)}
</div>
<div className="post-author">
<Link href={`/${post.author.handle}`} className="post-handle">
{post.author.displayName || post.author.handle}
</Link>
<span className="post-time">@{post.author.handle} · {formatTime(post.createdAt)}</span>
</div>
</div>
<div className="post-content">{post.content}</div>
{post.media && post.media.length > 0 && (
<div className="post-media-grid">
{post.media.map((item) => (
<div className="post-media-item" key={item.id}>
<img src={item.url} alt={item.altText || 'Post media'} loading="lazy" />
</div>
))}
</div>
)}
<div className="post-actions">
<button className="post-action">
<MessageIcon />
<span>{post.repliesCount || ''}</span>
</button>
<button className={`post-action ${reposted ? 'reposted' : ''}`} onClick={() => setReposted(!reposted)}>
<RepeatIcon />
<span>{post.repostsCount + (reposted ? 1 : 0) || ''}</span>
</button>
<button className={`post-action ${liked ? 'liked' : ''}`} onClick={() => setLiked(!liked)}>
<HeartIcon filled={liked} />
<span>{post.likesCount + (liked ? 1 : 0) || ''}</span>
</button>
<button
className="post-action"
onClick={async () => {
if (reporting) return;
const reason = window.prompt('Why are you reporting this post?');
if (!reason) return;
setReporting(true);
try {
const res = await fetch('/api/reports', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetType: 'post', targetId: post.id, reason }),
});
if (!res.ok) {
if (res.status === 401) {
alert('Please log in to report.');
} else {
alert('Report failed. Please try again.');
}
} else {
alert('Report submitted. Thank you.');
}
} catch {
alert('Report failed. Please try again.');
} finally {
setReporting(false);
}
}}
disabled={reporting}
>
<FlagIcon />
<span>{reporting ? '...' : ''}</span>
</button>
</div>
</article>
);
}
export default function ProfilePage() {
const params = useParams();
const handle = (params.handle as string)?.replace(/^@/, '') || '';
const [user, setUser] = useState<User | null>(null);
const [posts, setPosts] = 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' | 'followers' | 'following'>('posts');
const [followers, setFollowers] = useState<UserSummary[]>([]);
const [following, setFollowing] = useState<UserSummary[]>([]);
const [followersLoading, setFollowersLoading] = useState(false);
const [followingLoading, setFollowingLoading] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [profileForm, setProfileForm] = useState({
displayName: '',
bio: '',
avatarUrl: '',
headerUrl: '',
});
const [saveError, setSaveError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
setIsEditing(false);
setSaveError(null);
setFollowers([]);
setFollowing([]);
// 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));
// Get posts
fetch(`/api/users/${handle}/posts`)
.then(res => res.json())
.then(data => setPosts(data.posts || []))
.catch(() => { });
}, [handle]);
useEffect(() => {
if (user && currentUser?.handle === user.handle) {
setProfileForm({
displayName: user.displayName || '',
bio: user.bio || '',
avatarUrl: user.avatarUrl || '',
headerUrl: user.headerUrl || '',
});
}
}, [user, currentUser]);
useEffect(() => {
if (!currentUser || !user || currentUser.handle === user.handle) {
setIsFollowing(false);
return;
}
fetch(`/api/users/${handle}/follow`)
.then(res => res.json())
.then(data => setIsFollowing(!!data.following))
.catch(() => setIsFollowing(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));
}
}, [activeTab, handle]);
const handleFollow = async () => {
if (!currentUser) return;
const method = isFollowing ? 'DELETE' : 'POST';
const res = await fetch(`/api/users/${handle}/follow`, { method });
if (res.ok) {
setIsFollowing(!isFollowing);
if (user) {
setUser({
...user,
followersCount: isFollowing ? user.followersCount - 1 : user.followersCount + 1,
});
}
}
};
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,
}}>
<Link href="/" style={{ color: 'var(--foreground)' }}>
<ArrowLeftIcon />
</Link>
<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>
{/* 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-end',
marginTop: '-48px',
}}>
<div className="avatar avatar-lg" style={{
width: '96px',
height: '96px',
fontSize: '36px',
border: '4px solid var(--background)',
}}>
{user.avatarUrl ? (
<img src={user.avatarUrl} alt={user.displayName || user.handle} />
) : (
(user.displayName || user.handle).charAt(0).toUpperCase()
)}
</div>
{!isOwnProfile && currentUser && (
<button
className={`btn ${isFollowing ? '' : 'btn-primary'}`}
onClick={handleFollow}
style={{ marginBottom: '12px' }}
>
{isFollowing ? 'Following' : 'Follow'}
</button>
)}
{isOwnProfile && (
<button className="btn" style={{ marginBottom: '12px' }} onClick={() => setIsEditing(!isEditing)}>
{isEditing ? 'Close' : 'Edit Profile'}
</button>
)}
</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)' }}>@{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)}</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>
<textarea
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)' }}>Avatar URL</label>
<input
className="input"
value={profileForm.avatarUrl}
onChange={(e) => setProfileForm({ ...profileForm, avatarUrl: e.target.value })}
placeholder="https://"
/>
</div>
<div>
<label style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Header URL</label>
<input
className="input"
value={profileForm.headerUrl}
onChange={(e) => setProfileForm({ ...profileForm, headerUrl: e.target.value })}
placeholder="https://"
/>
</div>
{saveError && (
<div style={{ color: 'var(--error)', fontSize: '13px' }}>{saveError}</div>
)}
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<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)' }}>
{(['posts', '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' && (
posts.length === 0 ? (
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
<p>No posts yet</p>
</div>
) : (
posts.map(post => <PostCard key={post.id} post={post} />)
)
)}
{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>
);
}