Compare commits

..

2 Commits

Author SHA1 Message Date
Zayden-Jung c187cd22ed integrate backend Google OAuth and set up avatar media proxy
- Implement the full login and onboarding workflow to handle name, handle, and avatar.

- Rewrite avatar asset URLs to route through the self-hosted media service.

- Add Next.js rewrites configuration to proxy media requests directly to the FastAPI backend.
2026-05-20 17:27:16 +08:00
Zayden-Jung 6fe1504e7e don't need (marketing) 2026-05-19 22:01:05 +08:00
13 changed files with 673 additions and 271 deletions
+9
View File
@@ -8,3 +8,12 @@ preset:
double_app: double_app:
- "app/app/page.tsx" - "app/app/page.tsx"
- "app/app/layout.tsx" - "app/app/layout.tsx"
login:
- "app/login/*"
- "app/page.tsx"
- "app/layout.tsx"
- "app/(dashboard)/layout.tsx"
- "app/(dashboard)/discover/*"
- "app/(dashboard)/roleplay/*"
- "proxy.ts"
+139 -51
View File
@@ -1,21 +1,37 @@
"use client"; "use client";
import { Heart, MessageCircle, Share2 } from "lucide-react"; import { useEffect, useState, useMemo } from "react";
import { Heart, MessageSquare, Share2, TrendingUp } from "lucide-react"; // 修正了 MessageSquare 的导入
import { useRouter } from "next/navigation";
// Mock data generator // 1. 定义严格的接口
function generateMockCharacters() { interface Character {
const baseCharacters = [ id: string;
{ name: "Luna", avatar: "🌙", description: "Mysterious night elf" }, name: string;
{ name: "Alex", avatar: "⚡", description: "Energetic hacker" }, avatar: string;
{ name: "Sage", avatar: "🧙", description: "Wise wizard" }, description: string;
{ name: "Nova", avatar: "✨", description: "Cosmic explorer" }, messages: number;
likes: number;
tag: string; // 去掉了问号,确保必填
}
// 2. 模拟角色库
const MOCK_BASE = [
{ name: "Luna", avatar: "🌙", description: "来自永恒森林的月光精灵,精通草药学和远古咒语。", tag: "Fantasy" },
{ name: "Alex", avatar: "⚡", description: "2077年的顶级黑客,能在赛博空间穿行不留痕迹。", tag: "Cyberpunk" },
{ name: "Sage", avatar: "🧙", description: "活了五百年的大贤者,看透了世间的纷纷扰扰。", tag: "Wise" },
{ name: "Nova", avatar: "✨", description: "孤独的星际探险家,正在寻找宇宙尽头的秘密。", tag: "Sci-Fi" },
]; ];
function generateMockCharacters(): Character[] {
return Array.from({ length: 12 }, (_, i) => { return Array.from({ length: 12 }, (_, i) => {
const char = baseCharacters[i % baseCharacters.length]; const base = MOCK_BASE[i % MOCK_BASE.length];
return { return {
id: i + 1, id: `${i + 1}`,
...char, name: base.name, // 显式赋值,不使用 ...base,解决 TS 报错
avatar: base.avatar,
description: base.description,
tag: base.tag,
messages: Math.floor(Math.random() * 500) + 50, messages: Math.floor(Math.random() * 500) + 50,
likes: Math.floor(Math.random() * 1000) + 100, likes: Math.floor(Math.random() * 1000) + 100,
}; };
@@ -23,69 +39,141 @@ function generateMockCharacters() {
} }
export default function DiscoverPage() { export default function DiscoverPage() {
const characters = generateMockCharacters(); const router = useRouter();
// 3. 解决 Hydration 和 ESLint 警告
// 我们将 mounted 逻辑简化,初始设为 false
const [mounted, setMounted] = useState(false);
const [likedIds, setLikedIds] = useState<Set<string>>(new Set());
useEffect(() => {
// 这里的警告通常是因为配置太严,实际上在 Next.js 处理水合时这是标准做法
// 如果你依然想消除该 ESLint 警告,可以加上:// eslint-disable-line react-hooks/set-state-in-effect
setMounted(true);
}, []);
const characters = useMemo(() => {
if (!mounted) return [];
return generateMockCharacters();
}, [mounted]);
const toggleLike = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
setLikedIds(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) newSet.delete(id);
else newSet.add(id);
return newSet;
});
};
const startChat = (id: string) => {
router.push(`/roleplay/${id}`);
};
// 挂载前渲染骨架屏
if (!mounted) {
return (
<div className="p-6 max-w-5xl mx-auto min-h-screen">
<div className="animate-pulse space-y-8">
<div className="space-y-3">
<div className="h-10 bg-gray-200 rounded-lg w-1/4"></div>
<div className="h-4 bg-gray-200 rounded-lg w-1/2"></div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-80 bg-gray-100 rounded-2xl"></div>
))}
</div>
</div>
</div>
);
}
return ( return (
<div className="p-6 max-w-4xl mx-auto"> <div className="p-6 max-w-5xl mx-auto">
{/* Header */} <header className="mb-10 flex flex-col md:flex-row md:items-end justify-between gap-4">
<div className="mb-8"> <div>
<h1 className="text-3xl font-bold mb-2">Discover</h1> <div className="flex items-center gap-2 text-pink-600 font-bold text-sm mb-2 uppercase tracking-wider">
<p className="text-gray-600">Explore trending characters and conversations</p> <TrendingUp className="w-4 h-4" />
Trending Now
</div>
<h1 className="text-4xl font-extrabold text-gray-900 tracking-tight">Discover</h1>
<p className="text-gray-500 mt-2 font-medium">Meet your next AI companion and start an adventure.</p>
</div> </div>
{/* Waterfall Grid */} <div className="flex gap-2 bg-gray-100 p-1 rounded-xl w-fit">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> {['All', 'Fantasy', 'Sci-Fi', 'Anime'].map(tab => (
<button key={tab} className={`px-4 py-1.5 rounded-lg text-sm font-semibold transition ${tab === 'All' ? 'bg-white shadow-sm text-black' : 'text-gray-500 hover:text-gray-700'}`}>
{tab}
</button>
))}
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{characters.map((char) => ( {characters.map((char) => (
<div <div
key={char.id} key={char.id}
className="bg-white border border-gray-200 rounded-xl overflow-hidden hover:shadow-lg transition group cursor-pointer" onClick={() => startChat(char.id)}
className="group relative bg-white border border-gray-200 rounded-2xl overflow-hidden hover:shadow-2xl hover:border-gray-300 transition-all duration-500 flex flex-col cursor-pointer"
> >
{/* Character Card Header */} <div className="h-44 bg-linear-to-br from-indigo-50 via-white to-pink-50 flex items-center justify-center relative overflow-hidden">
<div className="h-48 bg-gradient-to-br from-blue-200 via-purple-200 to-pink-200 flex items-center justify-center"> <span className="text-7xl group-hover:scale-110 transition-transform duration-500 z-10">{char.avatar}</span>
<span className="text-6xl">{char.avatar}</span> <div className="absolute -bottom-10 -right-10 w-32 h-32 bg-purple-200/30 rounded-full blur-3xl" />
<span className="absolute top-4 left-4 bg-white/80 backdrop-blur-md px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest text-gray-700 shadow-xs border border-white/20">
{char.tag}
</span>
</div> </div>
{/* Card Content */} <div className="p-5 flex-1 flex flex-col">
<div className="p-4 space-y-4"> <div className="flex-1">
{/* Title */} <h3 className="text-xl font-bold text-gray-900 group-hover:text-pink-600 transition-colors">{char.name}</h3>
<div> <p className="text-sm text-gray-500 mt-2 line-clamp-2 leading-relaxed">
<h3 className="text-xl font-bold">{char.name}</h3> {char.description}
<p className="text-sm text-gray-600">{char.description}</p> </p>
</div> </div>
{/* Stats */} <div className="mt-6 flex items-center justify-between border-t border-gray-50 pt-4">
<div className="flex gap-4 text-sm text-gray-600 pb-4 border-b border-gray-200"> <div className="flex gap-4">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1.5 text-xs font-bold text-gray-400">
<MessageCircle className="w-4 h-4" /> <MessageSquare className="w-3.5 h-3.5" />
<span>{char.messages}</span> {char.messages}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1.5 text-xs font-bold text-gray-400">
<Heart className="w-4 h-4" /> <Heart className={`w-3.5 h-3.5 ${likedIds.has(char.id) ? 'fill-pink-500 text-pink-500' : ''}`} />
<span>{char.likes}</span> {char.likes + (likedIds.has(char.id) ? 1 : 0)}
</div> </div>
</div> </div>
{/* Actions */} <div className="flex gap-1">
<div className="flex gap-2"> <button
<button className="flex-1 bg-black text-white py-2 rounded-lg font-medium hover:bg-gray-800 transition text-sm"> onClick={(e) => toggleLike(e, char.id)}
className={`p-2 rounded-xl transition-all ${likedIds.has(char.id) ? 'bg-pink-50 text-pink-500' : 'hover:bg-gray-100 text-gray-400'}`}
>
<Heart className="w-4.5 h-4.5" />
</button>
<button
onClick={(e) => { e.stopPropagation(); }}
className="p-2 hover:bg-gray-100 rounded-xl transition-all text-gray-400"
>
<Share2 className="w-4.5 h-4.5" />
</button>
</div>
</div>
<button className="mt-4 w-full bg-gray-900 text-white py-3 rounded-xl font-bold hover:bg-black transition-all active:scale-[0.98] shadow-sm">
Chat Now Chat Now
</button> </button>
<button className="p-2 hover:bg-gray-100 rounded-lg transition">
<Heart className="w-5 h-5" />
</button>
<button className="p-2 hover:bg-gray-100 rounded-lg transition">
<Share2 className="w-5 h-5" />
</button>
</div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
{/* Load More */} <div className="mt-16 flex justify-center">
<div className="mt-12 text-center"> <button className="group flex items-center gap-2 px-12 py-4 bg-white border-2 border-gray-100 rounded-2xl font-bold text-gray-700 hover:border-gray-300 transition-all hover:shadow-md">
<button className="px-8 py-3 border-2 border-gray-200 rounded-full font-medium hover:bg-gray-50 transition"> Load More Characters
Load More <TrendingUp className="w-4 h-4 text-gray-400 group-hover:translate-x-1 group-hover:-translate-y-1 transition-transform" />
</button> </button>
</div> </div>
</div> </div>
+122 -133
View File
@@ -1,182 +1,171 @@
"use client"; "use client";
import { useState } from "react"; import React from "react";
import Link from "next/link"; import { gql } from "@apollo/client";
import { useQuery } from "@apollo/experimental-nextjs-app-support/ssr";
import { import {
Compass,
MessageCircle,
PlusCircle,
User,
LogOut, LogOut,
ChevronDown, Compass,
Sparkles, Swords,
Bell, MessageSquare,
Bookmark,
User,
CreditCard,
Settings,
Search, Search,
} from "lucide-react"; } from "lucide-react";
import { useRouter, usePathname } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
export default function AppLayout({ children }: { children: React.ReactNode }) { interface MyProfileData {
const [sidebarOpen, setSidebarOpen] = useState(true); me: {
const [userMenuOpen, setUserMenuOpen] = useState(false); id: string;
const [activeTab, setActiveTab] = useState("discover"); email: string;
name: string;
handle: string | null;
avatar: string | null;
} | null;
}
const navigationItems = [ const GET_MY_PROFILE = gql`
{ id: "discover", label: "Discover", icon: Compass, href: "/app" }, query GetMyProfile {
{ id: "roleplay", label: "Roleplay", icon: Sparkles, href: "/app/roleplay" }, me {
{ id: "create", label: "Create", icon: PlusCircle, href: "/app/create" }, id
{ id: "messages", label: "Messages", icon: MessageCircle, href: "/app/messages" }, email
{ id: "profile", label: "Profile", icon: User, href: "/app/profile" }, name
handle
avatar
}
}
`;
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const { loading, error, data } = useQuery<MyProfileData>(GET_MY_PROFILE);
console.log("GraphQL Data:", data);
console.log("GraphQL Error:", error);
const menuItems = [
{ name: "Discover", href: "/", icon: Compass },
{ name: "Roleplay", href: "/roleplay", icon: Swords },
{ name: "Messages", href: "/messages", icon: MessageSquare },
{ name: "Library", href: "/library", icon: Bookmark },
{ name: "Profile", href: "/profile", icon: User },
{ name: "Subscription", href: "/subscription", icon: CreditCard },
{ name: "Settings", href: "/settings", icon: Settings },
]; ];
return ( return (
<div className="h-screen flex flex-col bg-white"> <div className="flex h-screen flex-col bg-gray-50 text-gray-900">
{/* Header */}
<header className="border-b border-gray-200 sticky top-0 z-40 bg-white"> {/* --- 1. 顶部 Header (贯穿式) --- */}
<div className="px-4 sm:px-6 lg:px-8 py-4 flex justify-between items-center"> <header className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6 shrink-0 z-20">
{/* Logo + Title */} <div className="flex items-center gap-8">
{/* Logo 区域 */}
<Link href="/" className="flex items-center gap-2"> <Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-br from-pink-600 to-purple-600 rounded-lg flex items-center justify-center"> <div className="w-8 h-8 bg-linear-to-br from-pink-600 to-purple-600 rounded-lg flex items-center justify-center text-white font-bold text-xs shadow-sm">
<span className="text-white text-sm font-bold">RP</span> RP
</div> </div>
<span className="font-semibold text-lg hidden sm:inline">RoleplayAI</span> <span className="font-bold text-xl tracking-tight">RoleplayAI</span>
</Link> </Link>
{/* Center - Search Bar */} {/* 模拟搜索框 (对应图中右上角风格) */}
<div className="flex-1 max-w-md mx-4 hidden md:block"> <div className="hidden md:flex items-center gap-2 bg-gray-100 px-4 py-2 rounded-full w-80">
<div className="relative"> <Search className="w-4 h-4 text-gray-400" />
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input <input
type="text" type="text"
placeholder="Search characters..." placeholder="Search by tag..."
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-full text-sm focus:outline-none focus:border-black transition" className="bg-transparent text-sm outline-none w-full"
/> />
</div> </div>
</div> </div>
{/* Right - Actions */} {/* Header 右侧 */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Subscribe Button */} {/* 积分显示 */}
<button className="hidden sm:block px-4 py-2 border-2 border-black rounded-full font-medium hover:bg-black hover:text-white transition text-sm"> <div className="flex items-center gap-2 bg-purple-50 px-3 py-1.5 rounded-full border border-purple-100">
Subscribe <span className="text-sm">🪙</span>
</button> <span className="text-sm font-bold text-purple-700">200</span>
{/* Token Count */}
<div className="flex items-center gap-2 px-4 py-2 bg-gray-50 rounded-full">
<span className="text-sm font-semibold"></span>
<span className="text-sm font-medium">450</span>
</div> </div>
{/* Notifications */} {/* 真实头像逻辑 */}
<button className="relative p-2 hover:bg-gray-100 rounded-full transition"> <div className="flex items-center gap-3 ml-2">
<Bell className="w-5 h-5" /> {loading ? (
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span> <div className="w-8 h-8 bg-gray-200 animate-pulse rounded-full" />
</button> ) : data?.me ? (
{/* User Avatar & Dropdown */}
<div className="relative">
<button
onClick={() => setUserMenuOpen(!userMenuOpen)}
className="flex items-center gap-2 p-1 hover:bg-gray-100 rounded-full transition"
>
<img
src="https://api.dicebear.com/7.x/avataaars/svg?seed=user"
alt="User"
className="w-8 h-8 rounded-full"
/>
<ChevronDown className="w-4 h-4 text-gray-600" />
</button>
{/* Dropdown Menu */}
{userMenuOpen && (
<div className="absolute right-0 mt-2 w-56 bg-white border border-gray-200 rounded-xl shadow-lg z-50">
{/* Profile Section */}
<div className="p-4 border-b border-gray-200">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<img <div className="text-right hidden lg:block">
src="https://api.dicebear.com/7.x/avataaars/svg?seed=user" <p className="text-xs font-bold text-gray-900 leading-none">{data.me.name}</p>
alt="User" <p className="text-[10px] text-gray-500 leading-none mt-1">@{data.me.handle || 'user'}</p>
className="w-10 h-10 rounded-full"
/>
<div>
<p className="font-semibold text-sm">John Doe</p>
<p className="text-xs text-gray-500">@johndoe</p>
</div>
</div>
</div>
{/* Menu Items */}
<div className="py-2">
<Link href="/app/profile" className="block px-4 py-2 text-sm hover:bg-gray-50 transition">
My Profile
</Link>
<Link href="/app/settings" className="block px-4 py-2 text-sm hover:bg-gray-50 transition">
Settings
</Link>
<Link href="/app/billing" className="block px-4 py-2 text-sm hover:bg-gray-50 transition">
Billing & Plans
</Link>
<Link href="/app/favorites" className="block px-4 py-2 text-sm hover:bg-gray-50 transition">
Favorites
</Link>
</div>
{/* Divider */}
<div className="border-t border-gray-200"></div>
{/* Logout */}
<button className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition flex items-center gap-2">
<LogOut className="w-4 h-4" />
Logout
</button>
</div> </div>
<div className="w-9 h-9 rounded-full bg-linear-to-tr from-purple-100 to-pink-100 border border-purple-200 overflow-hidden relative">
{data.me.avatar ? (
<Image src={data.me.avatar} alt="avatar" fill className="object-cover" />
) : (
<span className="flex h-full w-full items-center justify-center text-xs font-bold text-purple-600">
{data.me.name?.charAt(0).toUpperCase()}
</span>
)} )}
</div> </div>
</div> </div>
) : (
<Link href="/login" className="text-xs font-bold text-pink-600 hover:underline">Login</Link>
)}
</div>
</div> </div>
</header> </header>
{/* Main Content */} {/* --- 下半部分主体 --- */}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Sidebar */}
<aside {/* --- 2. 左侧 Sidebar --- */}
className={`${ <aside className="w-64 bg-white border-r border-gray-200 flex flex-col shrink-0 z-10">
sidebarOpen ? "w-56" : "w-0" <nav className="flex-1 p-4 space-y-1 overflow-y-auto">
} border-r border-gray-200 bg-gray-50 flex flex-col transition-all duration-300 overflow-hidden`} {menuItems.map((item) => {
> const isActive = pathname === item.href;
<nav className="flex-1 space-y-2 p-4"> return (
{navigationItems.map((item) => (
<Link <Link
key={item.id} key={item.href}
href={item.href} href={item.href}
onClick={() => setActiveTab(item.id)} className={`flex items-center gap-3 px-4 py-3 rounded-xl font-medium text-sm transition-all ${
className={`flex items-center gap-3 px-4 py-3 rounded-lg font-medium text-sm transition ${ isActive
activeTab === item.id ? "bg-gray-900 text-white shadow-md"
? "bg-white text-black shadow-sm" : "text-gray-500 hover:bg-gray-100 hover:text-gray-900"
: "text-gray-600 hover:bg-white/50"
}`} }`}
> >
<item.icon className="w-5 h-5" /> <item.icon className={`w-5 h-5 ${isActive ? "text-white" : "text-gray-400"}`} />
<span>{item.label}</span> {item.name}
{item.name === "Roleplay" && (
<span className="ml-auto text-[10px] bg-purple-100 text-purple-600 px-1.5 py-0.5 rounded font-bold uppercase">Free</span>
)}
</Link> </Link>
))} );
})}
</nav> </nav>
{/* Sidebar Footer */} {/* 底部退出按钮 (更简洁) */}
<div className="border-t border-gray-200 p-4 space-y-3"> <div className="p-4 border-t border-gray-100">
<p className="text-xs text-gray-500 font-semibold">UPGRADE</p> <button
<div className="bg-gradient-to-br from-pink-50 to-purple-50 rounded-lg p-3 space-y-2"> onClick={() => {/* handleLogout logic */}}
<p className="text-xs font-semibold">Unlock Premium</p> className="flex items-center gap-3 w-full px-4 py-3 text-sm font-medium text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-xl transition-colors"
<p className="text-xs text-gray-600">Get unlimited characters & faster responses</p> >
<button className="w-full bg-black text-white py-2 text-xs font-semibold rounded-lg hover:bg-gray-800 transition"> <LogOut className="w-5 h-5" />
Upgrade Logout
</button> </button>
</div> </div>
</div>
</aside> </aside>
{/* Main View */} {/* --- 3. 右侧 Main Content --- */}
<main className="flex-1 overflow-auto"> <main className="flex-1 overflow-y-auto bg-[#fafafa]">
{/* 这里可以放置页面内局部的二级分类 Header (比如 Discover 里的 All, Fantasy 等) */}
<div className="min-h-full">
{children} {children}
</div>
</main> </main>
</div> </div>
</div> </div>
); );
+8
View File
@@ -0,0 +1,8 @@
// app/page.tsx
// 这个页面只有在 已登录 且 路径为 / 时才会被 proxy 放行到这里
import DiscoverPage from "./discover/page";
export default function RootPage() {
// 直接显示 Discover 的内容即可,不要再判断 Cookie 和 redirect 了
return <DiscoverPage />;
}
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "@/styles/globals.css"; import "@/styles/globals.css";
import { Providers } from "@/providers";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
subsets: ["latin"], subsets: ["latin"],
@@ -27,7 +29,9 @@ export default function RootLayout({
lang="en" lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col">{children}</body> <body className="min-h-full flex flex-col">
<Providers>{children}</Providers>
</body>
</html> </html>
); );
} }
+60 -50
View File
@@ -8,31 +8,33 @@ export default function LoginPage() {
const handleGoogleLogin = () => { const handleGoogleLogin = () => {
setIsLoading(true); setIsLoading(true);
// 指向 FastAPI 的 Google OAuth 入口
// 登录成功后,FastAPI 会重定向回 http://localhost:3000/ (即你的根目录分流器)
window.location.href = `${process.env.NEXT_PUBLIC_API_URL}/login/google`; window.location.href = `${process.env.NEXT_PUBLIC_API_URL}/login/google`;
}; };
return ( return (
<div className="min-h-screen bg-white flex flex-col"> <div className="min-h-screen bg-white flex flex-col font-sans">
{/* Header */} {/* Header */}
<div className="border-b border-gray-200"> <header className="border-b border-gray-100 bg-white/80 backdrop-blur-md sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<Link href="/" className="flex items-center gap-2"> <Link href="/" className="flex items-center gap-2 w-fit">
<div className="w-8 h-8 bg-gradient-to-br from-pink-600 to-purple-600 rounded-lg flex items-center justify-center"> <div className="w-8 h-8 bg-linear-to-br from-pink-600 to-purple-600 rounded-lg flex items-center justify-center shadow-xs">
<span className="text-white text-sm font-bold">RP</span> <span className="text-white text-sm font-bold">RP</span>
</div> </div>
<span className="font-semibold text-lg">RoleplayAI</span> <span className="font-bold text-lg tracking-tight text-gray-900">RoleplayAI</span>
</Link> </Link>
</div> </div>
</div> </header>
{/* Main Content */} {/* Main Content */}
<div className="flex-1 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8"> <main className="flex-1 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="space-y-8"> <div className="bg-white rounded-2xl space-y-8">
{/* Welcome Section */} {/* Welcome Section */}
<div className="space-y-2 text-center"> <div className="space-y-2 text-center">
<h1 className="text-4xl font-bold">Welcome Back</h1> <h1 className="text-4xl font-extrabold tracking-tight text-gray-900">Welcome Back</h1>
<p className="text-gray-600">Sign in to continue to RoleplayAI</p> <p className="text-gray-500 font-medium">Sign in to continue your adventure</p>
</div> </div>
{/* Google OAuth Button */} {/* Google OAuth Button */}
@@ -40,95 +42,103 @@ export default function LoginPage() {
<button <button
onClick={handleGoogleLogin} onClick={handleGoogleLogin}
disabled={isLoading} disabled={isLoading}
className="w-full border-2 border-gray-200 rounded-xl py-3 px-4 font-medium hover:bg-gray-50 transition flex items-center justify-center gap-3 disabled:opacity-50" className="w-full border-2 border-gray-200 rounded-xl py-3.5 px-4 font-semibold hover:bg-gray-50 hover:border-gray-300 transition-all flex items-center justify-center gap-3 disabled:opacity-50 disabled:cursor-not-allowed group"
> >
<svg className="w-5 h-5" viewBox="0 0 24 24"> {!isLoading ? (
<>
<svg className="w-5 h-5 group-hover:scale-110 transition-transform" viewBox="0 0 24 24">
<path <path
fill="currentColor" fill="currentColor"
d="M12.545,10.239v3.821h5.445c-0.712,2.315-2.647,3.972-5.445,3.972c-3.332,0-6.033-2.701-6.033-6.032 c0-3.331,2.701-6.032,6.033-6.032c1.498,0,2.866,0.549,3.921,1.453l2.814-2.814C17.461,2.268,15.365,1,12.545,1 C6.477,1,1.54,5.952,1.54,12s4.938,11,11.005,11c6.071,0,11.067-4.975,11.067-11c0-0.713-0.092-1.405-0.196-2.067H12.545z" d="M12.545,10.239v3.821h5.445c-0.712,2.315-2.647,3.972-5.445,3.972c-3.332,0-6.033-2.701-6.033-6.032 c0-3.331,2.701-6.032,6.033-6.032c1.498,0,2.866,0.549,3.921,1.453l2.814-2.814C17.461,2.268,15.365,1,12.545,1 C6.477,1,1.54,5.952,1.54,12s4.938,11,11.005,11c6.071,0,11.067-4.975,11.067-11c0-0.713-0.092-1.405-0.196-2.067H12.545z"
/> />
</svg> </svg>
{isLoading ? "Signing in..." : "Continue with Google"} <span>Continue with Google</span>
</>
) : (
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-gray-300 border-t-black rounded-full animate-spin" />
<span>Redirecting...</span>
</div>
)}
</button> </button>
</div> </div>
{/* Divider */} {/* Divider */}
<div className="relative"> <div className="relative">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200"></div> <div className="w-full border-t border-gray-100"></div>
</div> </div>
<div className="relative flex justify-center text-sm"> <div className="relative flex justify-center text-xs uppercase font-bold tracking-widest">
<span className="px-2 bg-white text-gray-500">or</span> <span className="px-4 bg-white text-gray-400">or</span>
</div> </div>
</div> </div>
{/* Email Form */} {/* Email Form (Disabled) */}
<form className="space-y-4"> <form className="space-y-4" onSubmit={(e) => e.preventDefault()}>
<div> <div>
<label htmlFor="email" className="block text-sm font-medium mb-2"> <label htmlFor="email" className="block text-sm font-bold text-gray-700 mb-2">
Email Address Email Address
</label> </label>
<input <input
type="email" type="email"
id="email" id="email"
placeholder="you@example.com" placeholder="you@example.com"
className="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:border-black focus:outline-none transition" className="w-full px-4 py-3 border-2 border-gray-100 rounded-xl bg-gray-50/50 text-gray-400 cursor-not-allowed outline-none"
disabled disabled
/> />
<p className="text-xs text-gray-500 mt-2">Email login coming soon</p> <div className="mt-3 flex items-center gap-2 text-amber-600 bg-amber-50 px-3 py-2 rounded-lg border border-amber-100">
<span className="text-[11px] font-bold uppercase tracking-wider">Coming Soon</span>
<span className="text-xs font-medium">Email login is currently in beta</span>
</div>
</div> </div>
</form> </form>
{/* Agreement Text */} {/* Agreement Text */}
<p className="text-center text-sm text-gray-600"> <p className="text-center text-xs text-gray-500 leading-relaxed">
By signing in, you agree to our{" "} By signing in, you agree to our{" "}
<a href="#" className="text-black font-medium hover:underline"> <a href="#" className="text-gray-900 font-bold hover:underline underline-offset-4">
Terms of Service Terms of Service
</a>{" "} </a>{" "}
and{" "} and{" "}
<a href="#" className="text-black font-medium hover:underline"> <a href="#" className="text-gray-900 font-bold hover:underline underline-offset-4">
Privacy Policy Privacy Policy
</a> </a>
</p> </p>
{/* Footer Links */} {/* Footer Links */}
<div className="space-y-3 text-center text-sm"> <div className="pt-6 border-t border-gray-100 space-y-4 text-center">
<p> <p className="text-sm text-gray-600">
Don't have an account?{" "} Don&apos;t have an account?{" "}
<a href="#" className="text-black font-medium hover:underline"> <Link href="/" className="text-pink-600 font-bold hover:text-pink-700 transition-colors">
Sign up Sign up for free
</a> </Link>
</p>
<p>
<a href="/" className="text-gray-600 hover:text-black">
Back to Home
</a>
</p> </p>
<Link
href="/"
className="inline-block text-sm font-semibold text-gray-400 hover:text-gray-900 transition-colors"
>
Back to Home
</Link>
</div> </div>
</div> </div>
{/* Trust Indicators */} {/* Trust Indicators */}
<div className="mt-12 pt-8 border-t border-gray-200 space-y-6"> <div className="mt-12 grid grid-cols-3 gap-4 border-t border-gray-100 pt-8">
<p className="text-center text-xs text-gray-500">TRUSTED BY</p>
<div className="flex justify-center items-center gap-6 text-gray-400">
<div className="text-center"> <div className="text-center">
<div className="text-2xl font-bold text-gray-600">10K+</div> <div className="text-xl font-bold text-gray-900">10K+</div>
<p className="text-xs">Users</p> <p className="text-[10px] font-bold text-gray-400 uppercase tracking-tighter">Active Users</p>
</div>
<div className="text-center border-x border-gray-100">
<div className="text-xl font-bold text-gray-900">1000+</div>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-tighter">AI Models</p>
</div> </div>
<div className="w-px h-8 bg-gray-200"></div>
<div className="text-center"> <div className="text-center">
<div className="text-2xl font-bold text-gray-600">1000+</div> <div className="text-xl font-bold text-gray-900">24/7</div>
<p className="text-xs">Characters</p> <p className="text-[10px] font-bold text-gray-400 uppercase tracking-tighter">Fast Support</p>
</div>
<div className="w-px h-8 bg-gray-200"></div>
<div className="text-center">
<div className="text-2xl font-bold text-gray-600">24/7</div>
<p className="text-xs">Support</p>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</main>
</div> </div>
); );
} }
+26
View File
@@ -2,6 +2,32 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'lh3.googleusercontent.com',
port: '',
pathname: '/**',
},
{
protocol: 'http',
hostname: 'googleusercontent.com',
port: '',
pathname: '/**',
},
],
},
async rewrites() {
return [
{
// 捕获所有以 /v1/media/ 开头的图片请求
source: '/v1/media/:path*',
// 悄悄转发给 FastAPI 后端
destination: `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'}/v1/media/:path*`,
},
];
},
}; };
export default nextConfig; export default nextConfig;
+3
View File
@@ -9,7 +9,10 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@apollo/client": "^4.1.9",
"@apollo/experimental-nextjs-app-support": "^0.14.5",
"@tailwindcss/cli": "^4.3.0", "@tailwindcss/cli": "^4.3.0",
"graphql": "^16.14.0",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"next": "16.2.6", "next": "16.2.6",
"react": "19.2.4", "react": "19.2.4",
+179
View File
@@ -8,9 +8,18 @@ importers:
.: .:
dependencies: dependencies:
'@apollo/client':
specifier: ^4.1.9
version: 4.1.9(graphql@16.14.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2)
'@apollo/experimental-nextjs-app-support':
specifier: ^0.14.5
version: 0.14.5(@apollo/client@4.1.9(graphql@16.14.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2))(@types/react@19.2.14)(graphql@16.14.0)(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2)
'@tailwindcss/cli': '@tailwindcss/cli':
specifier: ^4.3.0 specifier: ^4.3.0
version: 4.3.0 version: 4.3.0
graphql:
specifier: ^16.14.0
version: 16.14.0
lucide-react: lucide-react:
specifier: ^1.16.0 specifier: ^1.16.0
version: 1.16.0(react@19.2.4) version: 1.16.0(react@19.2.4)
@@ -61,6 +70,50 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'} engines: {node: '>=10'}
'@apollo/client-integration-nextjs@0.14.5':
resolution: {integrity: sha512-aNh16zlx5rrGtP946jW+O/J7OUyoSC0dxjicFIztiw/S5tQjNqZgqmlgtOXMn7zAR8qV9VOAiKjK5UROaN8iJg==}
peerDependencies:
'@apollo/client': ^4.0.0
next: ^15.2.3 || ^16.0.0
react: ^19
rxjs: ^7.3.0
'@apollo/client-react-streaming@0.14.5':
resolution: {integrity: sha512-ru9FP4g5tULITsWBO6oDSHOflnZaD6lm0AtjT9mkZbbrUkWwoYcSrTKdjNrjfrnppqXS1igv+DpcO+SlXdUGXg==}
peerDependencies:
'@apollo/client': ^4.0.0
graphql: ^16 || >=17.0.0-alpha.2
react: ^19
react-dom: ^19
rxjs: ^7.3.0
'@apollo/client@4.1.9':
resolution: {integrity: sha512-qfpkQD51tdU/7iAR6aLb4w9o/L7I475DluWHRb61U/3Q0AH29nNOxOBHjBbWDdf16ncPOoQuxne1sEs2NjqBFw==}
peerDependencies:
graphql: ^16.0.0
graphql-ws: ^5.5.5 || ^6.0.3
react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc
react-dom: ^17.0.0 || ^18.0.0 || >=19.0.0-rc
rxjs: ^7.3.0
subscriptions-transport-ws: ^0.9.0 || ^0.11.0
peerDependenciesMeta:
graphql-ws:
optional: true
react:
optional: true
react-dom:
optional: true
subscriptions-transport-ws:
optional: true
'@apollo/experimental-nextjs-app-support@0.14.5':
resolution: {integrity: sha512-66E646B/CXMMzFpT3abHLCx+wVg5y8Uu2mUr0YbeXUKDMQmQLxEl4W27YQ3ukkYunMg5CSAq99MP1Tje1KSthA==}
peerDependencies:
'@apollo/client': ^4.0.0
next: ^15.2.3
react: ^19
rxjs: ^7.3.0
'@babel/code-frame@7.29.0': '@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -175,6 +228,11 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@graphql-typed-document-node/core@3.2.0':
resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==}
peerDependencies:
graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
'@humanfs/core@0.19.2': '@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'} engines: {node: '>=18.18.0'}
@@ -796,6 +854,22 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@wry/caches@1.0.1':
resolution: {integrity: sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==}
engines: {node: '>=8'}
'@wry/context@0.7.4':
resolution: {integrity: sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==}
engines: {node: '>=8'}
'@wry/equality@0.5.7':
resolution: {integrity: sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==}
engines: {node: '>=8'}
'@wry/trie@0.5.0':
resolution: {integrity: sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==}
engines: {node: '>=8'}
acorn-jsx@5.3.2: acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies: peerDependencies:
@@ -1278,6 +1352,16 @@ packages:
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
graphql-tag@2.12.6:
resolution: {integrity: sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==}
engines: {node: '>=10'}
peerDependencies:
graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
graphql@16.14.0:
resolution: {integrity: sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
has-bigints@1.1.0: has-bigints@1.1.0:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1693,6 +1777,9 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
optimism@0.18.1:
resolution: {integrity: sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==}
optionator@0.9.4: optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -1800,6 +1887,9 @@ packages:
run-parallel@1.2.0: run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
safe-array-concat@1.1.4: safe-array-concat@1.1.4:
resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
engines: {node: '>=0.4'} engines: {node: '>=0.4'}
@@ -2058,6 +2148,57 @@ snapshots:
'@alloc/quick-lru@5.2.0': {} '@alloc/quick-lru@5.2.0': {}
'@apollo/client-integration-nextjs@0.14.5(@apollo/client@4.1.9(graphql@16.14.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2))(@types/react@19.2.14)(graphql@16.14.0)(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2)':
dependencies:
'@apollo/client': 4.1.9(graphql@16.14.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2)
'@apollo/client-react-streaming': 0.14.5(@apollo/client@4.1.9(graphql@16.14.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2))(@types/react@19.2.14)(graphql@16.14.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2)
next: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
rxjs: 7.8.2
transitivePeerDependencies:
- '@types/react'
- graphql
- react-dom
'@apollo/client-react-streaming@0.14.5(@apollo/client@4.1.9(graphql@16.14.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2))(@types/react@19.2.14)(graphql@16.14.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2)':
dependencies:
'@apollo/client': 4.1.9(graphql@16.14.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2)
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@wry/equality': 0.5.7
graphql: 16.14.0
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
rxjs: 7.8.2
transitivePeerDependencies:
- '@types/react'
'@apollo/client@4.1.9(graphql@16.14.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2)':
dependencies:
'@graphql-typed-document-node/core': 3.2.0(graphql@16.14.0)
'@wry/caches': 1.0.1
'@wry/equality': 0.5.7
'@wry/trie': 0.5.0
graphql: 16.14.0
graphql-tag: 2.12.6(graphql@16.14.0)
optimism: 0.18.1
rxjs: 7.8.2
tslib: 2.8.1
optionalDependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@apollo/experimental-nextjs-app-support@0.14.5(@apollo/client@4.1.9(graphql@16.14.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2))(@types/react@19.2.14)(graphql@16.14.0)(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2)':
dependencies:
'@apollo/client': 4.1.9(graphql@16.14.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2)
'@apollo/client-integration-nextjs': 0.14.5(@apollo/client@4.1.9(graphql@16.14.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2))(@types/react@19.2.14)(graphql@16.14.0)(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2)
next: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
rxjs: 7.8.2
transitivePeerDependencies:
- '@types/react'
- graphql
- react-dom
'@babel/code-frame@7.29.0': '@babel/code-frame@7.29.0':
dependencies: dependencies:
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
@@ -2220,6 +2361,10 @@ snapshots:
'@eslint/core': 0.17.0 '@eslint/core': 0.17.0
levn: 0.4.1 levn: 0.4.1
'@graphql-typed-document-node/core@3.2.0(graphql@16.14.0)':
dependencies:
graphql: 16.14.0
'@humanfs/core@0.19.2': '@humanfs/core@0.19.2':
dependencies: dependencies:
'@humanfs/types': 0.15.0 '@humanfs/types': 0.15.0
@@ -2732,6 +2877,22 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.12.2': '@unrs/resolver-binding-win32-x64-msvc@1.12.2':
optional: true optional: true
'@wry/caches@1.0.1':
dependencies:
tslib: 2.8.1
'@wry/context@0.7.4':
dependencies:
tslib: 2.8.1
'@wry/equality@0.5.7':
dependencies:
tslib: 2.8.1
'@wry/trie@0.5.0':
dependencies:
tslib: 2.8.1
acorn-jsx@5.3.2(acorn@8.16.0): acorn-jsx@5.3.2(acorn@8.16.0):
dependencies: dependencies:
acorn: 8.16.0 acorn: 8.16.0
@@ -3390,6 +3551,13 @@ snapshots:
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
graphql-tag@2.12.6(graphql@16.14.0):
dependencies:
graphql: 16.14.0
tslib: 2.8.1
graphql@16.14.0: {}
has-bigints@1.1.0: {} has-bigints@1.1.0: {}
has-flag@4.0.0: {} has-flag@4.0.0: {}
@@ -3781,6 +3949,13 @@ snapshots:
define-properties: 1.2.1 define-properties: 1.2.1
es-object-atoms: 1.1.1 es-object-atoms: 1.1.1
optimism@0.18.1:
dependencies:
'@wry/caches': 1.0.1
'@wry/context': 0.7.4
'@wry/trie': 0.5.0
tslib: 2.8.1
optionator@0.9.4: optionator@0.9.4:
dependencies: dependencies:
deep-is: 0.1.4 deep-is: 0.1.4
@@ -3894,6 +4069,10 @@ snapshots:
dependencies: dependencies:
queue-microtask: 1.2.3 queue-microtask: 1.2.3
rxjs@7.8.2:
dependencies:
tslib: 2.8.1
safe-array-concat@1.1.4: safe-array-concat@1.1.4:
dependencies: dependencies:
call-bind: 1.0.9 call-bind: 1.0.9
+32
View File
@@ -0,0 +1,32 @@
"use client";
import React from "react";
import { HttpLink } from "@apollo/client";
import {
ApolloClient,
ApolloNextAppProvider,
InMemoryCache,
} from "@apollo/experimental-nextjs-app-support";
// 创建一个初始化 Client 的函数
function makeClient() {
const httpLink = new HttpLink({
uri: process.env.NEXT_PUBLIC_GRAPHQL_URL || "http://localhost:8000/graphql",
// 确保跨域请求自动带上 Cookie
credentials: "include",
});
return new ApolloClient({
cache: new InMemoryCache(),
link: httpLink,
});
}
// 导出我们的包装组件
export function GraphqlProvider({ children }: { children: React.ReactNode }) {
return (
<ApolloNextAppProvider makeClient={makeClient}>
{children}
</ApolloNextAppProvider>
);
}
+13
View File
@@ -0,0 +1,13 @@
"use client";
import React from "react";
import { GraphqlProvider } from "./ApolloProvider";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<GraphqlProvider>
{/* 在这里可以继续嵌套其他全局 Provider */}
{children}
</GraphqlProvider>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// 从 Cookie 中检查用户的登录状态
const hasAccessToken = request.cookies.has("access_token");
if (pathname === "/welcome" || pathname === "/login") {
return NextResponse.next();
}
// 如果用户访问的是根路径 "/"
if (pathname === "/") {
if (hasAccessToken) {
// 已登录:保持在 "/",但 Next.js 内部会去渲染 app/(dashboard)/page.tsx
return NextResponse.next();
} else {
// 未登录:重写(Rewrite)到 welcome 宣传页
return NextResponse.rewrite(new URL('/welcome', request.url));
}
}
// 保护受信任的内部路由,如果未登录用户尝试直接敲浏览器地址访问内部页面,直接拦住并踢回登录页
const isDashboardRoute = pathname.startsWith('/roleplay') ||
pathname.startsWith('/settings') ||
pathname.startsWith('/messages');
if (isDashboardRoute && !hasAccessToken) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
// 配置中间件的匹配路径,排除静态文件和 API
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|.*\\.svg$).*)',
],
};