File size: 8,220 Bytes
60faa09 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 | "use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { useSession, signIn } from "next-auth/react";
import {
Search, Flame, Sparkles, TrendingUp, Loader2, Eye, MessageCircle, Filter,
} from "lucide-react";
import toast, { Toaster } from "react-hot-toast";
export default function ExplorePage() {
const { data: session, status } = useSession();
const [characters, setCharacters] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
useEffect(() => { fetchCharacters(); }, []);
const fetchCharacters = async () => {
setLoading(true);
try {
const res = await fetch("/api/characters");
const data = await res.json();
if (data.characters) {
const publicCustom = data.characters.filter((c) => c.isPublic && c.isCustom);
setCharacters(publicCustom);
}
} catch (err) {
console.error("Failed to load characters", err);
toast.error("Failed to load characters");
} finally { setLoading(false); }
};
const filtered = characters.filter((c) => {
const matchesSearch =
!search ||
c.name.toLowerCase().includes(search.toLowerCase()) ||
c.description.toLowerCase().includes(search.toLowerCase()) ||
c.personality.toLowerCase().includes(search.toLowerCase());
return matchesSearch;
});
if (status === "loading") {
return (
<div className="min-h-dvh flex items-center justify-center bg-bg-page text-primary-text">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
);
}
return (
<div className="min-h-dvh bg-bg-page text-primary-text pb-20">
<Toaster position="top-right" />
<div className="relative overflow-hidden border-b border-divider/50">
<div className="absolute inset-0 bg-gradient-to-br from-yellow-500/5 via-transparent to-primary/5 pointer-events-none" />
<div className="relative max-w-7xl mx-auto px-6 py-16">
<div className="flex items-center gap-2 text-yellow-400 text-xs font-bold uppercase tracking-widest mb-3">
<Sparkles className="w-3.5 h-3.5" />
<span>HuggingFace Community Marketplace</span>
</div>
<h1 className="text-4xl md:text-5xl font-black tracking-tight mb-4">Explore Community Characters</h1>
<p className="text-secondary-text text-sm max-w-2xl leading-relaxed">
Discover personas crafted by other HuggingFace users. From historical figures to anime companions, find your next conversation partner — or publish your own creations to the community.
</p>
<div className="mt-8 flex flex-col sm:flex-row gap-3 max-w-2xl">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-secondary-text" />
<input
type="text" value={search} onChange={(e) => setSearch(e.target.value)}
placeholder="Search characters, personalities, or topics..."
className="w-full bg-bg-card border border-divider rounded-lg pl-10 pr-4 py-3 text-sm text-primary-text placeholder-secondary-text/50 focus:outline-none focus:border-primary/50"
/>
</div>
<Link href="/" className="bg-primary text-white px-6 py-3 rounded-lg text-sm font-bold flex items-center justify-center gap-2 hover:bg-primary/90 transition shadow-md">
<Flame className="w-4 h-4" />
Create Your Own
</Link>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-6 mt-10">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-emerald-400" />
<h2 className="text-lg font-black uppercase tracking-wider">
{filtered.length} Public Character{filtered.length === 1 ? "" : "s"}
</h2>
</div>
<span className="text-xs text-secondary-text">Sorted by most recent</span>
</div>
{loading ? (
<div className="h-64 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : filtered.length === 0 ? (
<div className="h-64 border border-dashed border-divider/60 rounded-xl flex flex-col items-center justify-center text-center p-8">
<span className="text-4xl mb-3">🎭</span>
<h3 className="font-black text-lg mb-1">No characters found</h3>
<p className="text-xs text-secondary-text max-w-sm">
{search ? `No matches for "${search}". Try a different keyword.` : "Be the first to publish a public character to the marketplace!"}
</p>
<Link href="/" className="mt-4 bg-primary text-white px-5 py-2 rounded-full text-xs font-bold hover:bg-primary/90 transition">+ Create Character</Link>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{filtered.map((c) => <CharacterCard key={c.id} character={c} session={session} />)}
</div>
)}
</div>
</div>
);
}
function CharacterCard({ character, session }) {
const slug = character.name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
const [starting, setStarting] = useState(false);
const handleStart = async (e) => {
e.preventDefault();
if (!session?.user) return;
setStarting(true);
try {
const res = await fetch("/api/chats", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ character_id: character.id }),
});
if (res.ok) {
const data = await res.json();
if (data.chat) window.location.href = `/${slug}/${data.chat.id}`;
}
} catch (err) { console.error(err); }
finally { setStarting(false); }
};
return (
<div className="group relative bg-bg-card border border-divider/50 rounded-xl overflow-hidden hover:border-primary/40 transition-all duration-300 hover:shadow-lg hover:shadow-primary/5">
<div className="aspect-square bg-bg-page/50 flex items-center justify-center overflow-hidden relative">
{character.profileUrl || (character.avatar?.length > 2 && character.avatar?.startsWith("http")) ? (
<img src={character.profileUrl || character.avatar} alt={character.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
) : (
<span className="text-5xl">{character.avatar || "🤖"}</span>
)}
<div className="absolute top-2 right-2 bg-black/60 backdrop-blur-sm px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider text-emerald-400 border border-emerald-400/30">Public</div>
</div>
<div className="p-3 space-y-2">
<div>
<h3 className="font-black text-sm truncate">{character.name}</h3>
<p className="text-[10px] text-secondary-text truncate">{character.description}</p>
</div>
<p className="text-[10px] text-zinc-500 line-clamp-2 leading-relaxed">{character.personality}</p>
{session?.user ? (
<button onClick={handleStart} disabled={starting}
className="w-full mt-2 py-1.5 rounded-lg bg-primary/10 border border-primary/30 text-primary text-[10px] font-bold uppercase tracking-wider hover:bg-primary hover:text-white transition disabled:opacity-50 flex items-center justify-center gap-1.5">
{starting ? <Loader2 className="w-3 h-3 animate-spin" /> : <MessageCircle className="w-3 h-3" />}
<span>{starting ? "Starting..." : "Start Chat"}</span>
</button>
) : (
<Link href="/login" className="w-full mt-2 py-1.5 rounded-lg bg-bg-page border border-divider text-secondary-text text-[10px] font-bold uppercase tracking-wider hover:text-primary-text transition flex items-center justify-center gap-1.5">
<Eye className="w-3 h-3" /> Sign in to chat
</Link>
)}
</div>
</div>
);
}
|