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 (
-
+
-
+
{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?.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) => (
+
+

+
+ ))}
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+function UserCard({ user }: { user: User }) {
+ return (
+
+
+ {user.avatarUrl ? (
+

+ ) : (
+ 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 (
+
+
+
+
+
+
+ {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 ? (
+
+ ) : (
+
+ {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 && (