diff --git a/src/app/@[handle]/default.tsx b/src/app/@[handle]/default.tsx deleted file mode 100644 index 6ddf1b7..0000000 --- a/src/app/@[handle]/default.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Default() { - return null; -} diff --git a/src/app/@[handle]/page.tsx b/src/app/[handle]/page.tsx similarity index 99% rename from src/app/@[handle]/page.tsx rename to src/app/[handle]/page.tsx index d0d1fe2..552a3f5 100644 --- a/src/app/@[handle]/page.tsx +++ b/src/app/[handle]/page.tsx @@ -89,7 +89,7 @@ const FlagIcon = () => ( function UserRow({ user }: { user: UserSummary }) { return ( - +
{user.avatarUrl ? ( {user.displayName @@ -139,7 +139,7 @@ function PostCard({ post }: { post: Post }) { )}
- + {post.author.displayName || post.author.handle} @{post.author.handle} · {formatTime(post.createdAt)} diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts new file mode 100644 index 0000000..98d48ca --- /dev/null +++ b/src/app/api/users/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/db'; +import { users } from '@/db'; +import { desc, sql } from 'drizzle-orm'; + +export async function GET(request: NextRequest) { + try { + if (!db) { + return NextResponse.json({ users: [] }); + } + + const searchParams = request.nextUrl.searchParams; + const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50); + + const userList = await db + .select({ + id: users.id, + handle: users.handle, + displayName: users.displayName, + bio: users.bio, + avatarUrl: users.avatarUrl, + createdAt: users.createdAt, + }) + .from(users) + .where(sql`${users.status} IS NULL OR ${users.status} != 'suspended'`) + .orderBy(desc(users.createdAt)) + .limit(limit); + + return NextResponse.json({ users: userList }); + } catch (error) { + console.error('List users error:', error); + return NextResponse.json({ users: [] }); + } +} diff --git a/src/app/explore/page.tsx b/src/app/explore/page.tsx new file mode 100644 index 0000000..e81a719 --- /dev/null +++ b/src/app/explore/page.tsx @@ -0,0 +1,348 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; + +interface User { + id: string; + handle: string; + displayName: string; + avatarUrl?: string; + bio?: string; +} + +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[]; +} + +const SearchIcon = () => ( + + + + +); + +const TrendingIcon = () => ( + + + + +); + +const UsersIcon = () => ( + + + + + + +); + +const HeartIcon = ({ filled }: { filled?: boolean }) => ( + + + +); + +const RepeatIcon = () => ( + + + + + + +); + +const MessageIcon = () => ( + + + +); + +function PostCard({ post }: { post: Post }) { + const [liked, setLiked] = useState(false); + const [reposted, setReposted] = 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 = async () => { + setLiked(!liked); + await fetch(`/api/posts/${post.id}/like`, { method: 'POST' }); + }; + + const handleRepost = async () => { + setReposted(!reposted); + await fetch(`/api/posts/${post.id}/repost`, { method: 'POST' }); + }; + + return ( +
+
+
+ {post.author.avatarUrl ? ( + {post.author.displayName} + ) : ( + post.author.displayName?.charAt(0).toUpperCase() || post.author.handle.charAt(0).toUpperCase() + )} +
+
+ + {post.author.displayName || post.author.handle} + + @{post.author.handle} · {formatTime(post.createdAt)} +
+
+
{post.content}
+ {post.media && post.media.length > 0 && ( +
+ {post.media.map((item) => ( +
+ {item.altText +
+ ))} +
+ )} +
+ + + +
+
+ ); +} + +function UserCard({ user }: { user: User }) { + return ( + +
+ {user.avatarUrl ? ( + {user.displayName} + ) : ( + user.displayName?.charAt(0).toUpperCase() || user.handle.charAt(0).toUpperCase() + )} +
+
+
{user.displayName || user.handle}
+
@{user.handle}
+ {user.bio &&
{user.bio}
} +
+ + ); +} + +export default function ExplorePage() { + const [query, setQuery] = useState(''); + const [activeTab, setActiveTab] = useState<'trending' | 'users' | 'search'>('trending'); + const [trendingPosts, setTrendingPosts] = useState([]); + const [users, setUsers] = useState([]); + const [searchResults, setSearchResults] = useState<{ posts: Post[]; users: User[] }>({ posts: [], users: [] }); + const [loading, setLoading] = useState(true); + const [searching, setSearching] = useState(false); + + useEffect(() => { + // Load trending posts + const loadTrending = async () => { + setLoading(true); + try { + const res = await fetch('/api/posts?type=curated&limit=20'); + const data = await res.json(); + setTrendingPosts(data.posts || []); + } catch { + setTrendingPosts([]); + } finally { + setLoading(false); + } + }; + + loadTrending(); + }, []); + + useEffect(() => { + // Load users when tab changes to users + if (activeTab === 'users' && users.length === 0) { + const loadUsers = async () => { + setLoading(true); + try { + const res = await fetch('/api/users?limit=20'); + const data = await res.json(); + setUsers(data.users || []); + } catch { + setUsers([]); + } finally { + setLoading(false); + } + }; + loadUsers(); + } + }, [activeTab, users.length]); + + const handleSearch = async (e: React.FormEvent) => { + e.preventDefault(); + if (!query.trim()) return; + + setSearching(true); + setActiveTab('search'); + + try { + const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`); + const data = await res.json(); + setSearchResults({ + posts: data.posts || [], + users: data.users || [], + }); + } catch { + setSearchResults({ posts: [], users: [] }); + } finally { + setSearching(false); + } + }; + + return ( +
+
+

Explore

+
+ + setQuery(e.target.value)} + /> + +
+ +
+ + + {searchResults.posts.length > 0 || searchResults.users.length > 0 ? ( + + ) : null} +
+ +
+ {activeTab === 'trending' && ( + loading ? ( +
Loading trending posts...
+ ) : trendingPosts.length === 0 ? ( +
+ +

No trending posts yet

+
+ ) : ( +
+ {trendingPosts.map((post) => ( + + ))} +
+ ) + )} + + {activeTab === 'users' && ( + loading ? ( +
Loading users...
+ ) : users.length === 0 ? ( +
+ +

No users found

+
+ ) : ( +
+ {users.map((user) => ( + + ))} +
+ ) + )} + + {activeTab === 'search' && ( + searching ? ( +
Searching...
+ ) : ( +
+ {searchResults.users.length > 0 && ( +
+

Users

+
+ {searchResults.users.map((user) => ( + + ))} +
+
+ )} + {searchResults.posts.length > 0 && ( +
+

Posts

+
+ {searchResults.posts.map((post) => ( + + ))} +
+
+ )} + {searchResults.users.length === 0 && searchResults.posts.length === 0 && ( +
+ +

No results found for “{query}”

+
+ )} +
+ ) + )} +
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index da6777e..f5618a5 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1128,3 +1128,184 @@ a.btn-primary:visited { padding-bottom: 80px; } } + +/* Explore Page */ +.explore-page { + max-width: 700px; + margin: 0 auto; + min-height: 100vh; +} + +.explore-header { + padding: 20px 16px; + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + background: var(--background); + z-index: 10; + backdrop-filter: blur(12px); +} + +.explore-header h1 { + font-size: 22px; + font-weight: 600; + margin-bottom: 16px; +} + +.explore-search { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: var(--background-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-full); + transition: border-color 0.15s ease; +} + +.explore-search:focus-within { + border-color: var(--accent); +} + +.explore-search input { + flex: 1; + background: transparent; + border: none; + color: var(--foreground); + font-size: 15px; +} + +.explore-search input:focus { + outline: none; +} + +.explore-search input::placeholder { + color: var(--foreground-tertiary); +} + +.explore-tabs { + display: flex; + border-bottom: 1px solid var(--border); +} + +.explore-tab { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 14px 16px; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--foreground-tertiary); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: color 0.15s ease, border-color 0.15s ease; +} + +.explore-tab:hover { + color: var(--foreground-secondary); +} + +.explore-tab.active { + color: var(--foreground); + border-bottom-color: var(--accent); +} + +.explore-content { + min-height: 300px; +} + +.explore-loading, +.explore-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 48px 24px; + color: var(--foreground-tertiary); + text-align: center; +} + +.explore-empty svg { + width: 48px; + height: 48px; + opacity: 0.5; +} + +.explore-posts, +.explore-users { + display: flex; + flex-direction: column; +} + +.search-section { + padding: 16px; + border-bottom: 1px solid var(--border); +} + +.search-section h2 { + font-size: 14px; + font-weight: 600; + color: var(--foreground-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 12px; +} + +.search-section .explore-posts, +.search-section .explore-users { + background: var(--background-secondary); + border-radius: var(--radius-md); + border: 1px solid var(--border); + overflow: hidden; +} + +/* User Card */ +.user-card { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + border-bottom: 1px solid var(--border); + color: var(--foreground); + transition: background 0.15s ease; +} + +.user-card:hover { + background: var(--background-secondary); +} + +.user-card:last-child { + border-bottom: none; +} + +.user-card-info { + min-width: 0; + flex: 1; +} + +.user-card-name { + font-weight: 600; + margin-bottom: 2px; +} + +.user-card-handle { + font-size: 13px; + color: var(--foreground-tertiary); +} + +.user-card-bio { + font-size: 13px; + color: var(--foreground-secondary); + margin-top: 6px; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + diff --git a/src/app/page.tsx b/src/app/page.tsx index bc0f89c..0d1c767 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,6 +3,12 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; +const ShieldIcon = () => ( + + + +); + interface User { id: string; handle: string; @@ -190,7 +196,7 @@ function PostCard({ post, onLike, onRepost }: { post: Post; onLike: (id: string) )}
- + {post.author.displayName || post.author.handle} @{post.author.handle} · {formatTime(post.createdAt)} @@ -367,6 +373,7 @@ function Compose({ onPost }: { onPost: (content: string, mediaIds: string[]) => export default function Home() { const [user, setUser] = useState(null); + const [isAdmin, setIsAdmin] = useState(false); const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); const [feedType, setFeedType] = useState<'latest' | 'curated'>('latest'); @@ -404,6 +411,12 @@ export default function Home() { .then(res => res.json()) .then(data => setUser(data.user)) .catch(() => { }); + + // Check admin status + fetch('/api/admin/me') + .then(res => res.json()) + .then(data => setIsAdmin(!!data.isAdmin)) + .catch(() => { }); }, []); useEffect(() => { @@ -457,7 +470,7 @@ export default function Home() { Notifications {user ? ( - + Profile @@ -467,6 +480,12 @@ export default function Home() { Login )} + {isAdmin && ( + + + Admin + + )} {user && (