This commit is contained in:
eric
2026-03-08 01:10:10 -06:00
parent 0ef4d51406
commit f589b71b19
14 changed files with 2484 additions and 152 deletions

View File

@@ -0,0 +1,203 @@
"use client";
import { useState, type FormEvent } from "react";
const PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || "https://pocketbase.hackrobot.cn";
interface AuthModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: (email: string) => void;
}
export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps) {
const [mode, setMode] = useState<"login" | "register">("login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
if (mode === "register") {
if (password !== passwordConfirm) {
setError("两次密码不一致");
setLoading(false);
return;
}
if (password.length < 8) {
setError("密码至少 8 位");
setLoading(false);
return;
}
const res = await fetch(`${PB_URL}/api/collections/users/records`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
password,
passwordConfirm,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err?.message || "注册失败");
}
// 注册成功后自动登录
const authRes = await fetch(`${PB_URL}/api/collections/users/auth-with-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: email, password }),
});
if (!authRes.ok) {
throw new Error("注册成功,请手动登录");
}
const authData = await authRes.json();
if (authData?.token) {
localStorage.setItem("pb_token", authData.token);
localStorage.setItem("pb_user", JSON.stringify(authData.record));
}
} else {
const res = await fetch(`${PB_URL}/api/collections/users/auth-with-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: email, password }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err?.message || "登录失败");
}
const data = await res.json();
if (data?.token) {
localStorage.setItem("pb_token", data.token);
localStorage.setItem("pb_user", JSON.stringify(data.record));
}
}
onSuccess(email);
onClose();
setEmail("");
setPassword("");
setPasswordConfirm("");
} catch (err) {
setError(err instanceof Error ? err.message : "操作失败");
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
aria-hidden
/>
<div className="relative w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl">
<button
onClick={onClose}
className="absolute right-4 top-4 text-slate-400 hover:text-slate-600"
aria-label="关闭"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<h2 className="text-xl font-bold text-slate-800">
{mode === "login" ? "登录" : "注册"}
</h2>
<p className="mt-1 text-sm text-slate-500">
{mode === "login" ? "使用邮箱密码登录" : "创建新账号"}
</p>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-slate-700"></label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
required
className="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-slate-800 placeholder-slate-400 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700"></label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="至少 8 位"
required
minLength={mode === "register" ? 8 : 1}
className="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-slate-800 placeholder-slate-400 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
/>
</div>
{mode === "register" && (
<div>
<label className="mb-1 block text-sm font-medium text-slate-700"></label>
<input
type="password"
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
placeholder="再次输入密码"
required
className="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-slate-800 placeholder-slate-400 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
/>
</div>
)}
{error && (
<p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-sky-500 py-3 font-semibold text-white transition-colors hover:bg-sky-600 disabled:opacity-70"
>
{loading ? "处理中…" : mode === "login" ? "登录" : "注册"}
</button>
</form>
<p className="mt-4 text-center text-sm text-slate-500">
{mode === "login" ? (
<>
{" "}
<button
type="button"
onClick={() => {
setMode("register");
setError(null);
}}
className="font-medium text-sky-500 hover:text-sky-600"
>
</button>
</>
) : (
<>
{" "}
<button
type="button"
onClick={() => {
setMode("login");
setError(null);
}}
className="font-medium text-sky-500 hover:text-sky-600"
>
</button>
</>
)}
</p>
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import AuthModal from "./AuthModal";
const navLinks = [
{ label: "学习路径", href: "#roadmap" },
@@ -9,16 +10,34 @@ const navLinks = [
{ label: "社区", href: "#community" },
];
function getStoredUser(): string | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem("pb_user");
if (!raw) return null;
const user = JSON.parse(raw);
return user?.email ?? null;
} catch {
return null;
}
}
export default function Header() {
const [mobileOpen, setMobileOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const [authOpen, setAuthOpen] = useState(false);
const [userEmail, setUserEmail] = useState<string | null>(null);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 20);
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
setUserEmail(getStoredUser());
}, []);
const handleLogout = () => {
localStorage.removeItem("pb_token");
localStorage.removeItem("pb_user");
setUserEmail(null);
};
return (
<header
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
@@ -48,6 +67,26 @@ export default function Header() {
</nav>
<div className="flex items-center gap-3">
{userEmail ? (
<div className="flex items-center gap-2">
<span className="hidden max-w-[120px] truncate text-sm text-slate-600 sm:inline">
{userEmail}
</span>
<button
onClick={handleLogout}
className="rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50"
>
退
</button>
</div>
) : (
<button
onClick={() => setAuthOpen(true)}
className="hidden rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 sm:inline-flex"
>
/
</button>
)}
<a
href="#roadmap"
className="hidden rounded-full bg-sky-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-sky-600 sm:inline-flex"
@@ -87,6 +126,30 @@ export default function Header() {
{link.label}
</a>
))}
{userEmail ? (
<div className="mt-2 flex items-center justify-between rounded-lg border border-slate-100 bg-slate-50 px-4 py-2.5">
<span className="truncate text-sm text-slate-600">{userEmail}</span>
<button
onClick={() => {
handleLogout();
setMobileOpen(false);
}}
className="text-sm font-medium text-sky-500"
>
退
</button>
</div>
) : (
<button
onClick={() => {
setMobileOpen(false);
setAuthOpen(true);
}}
className="mt-2 block w-full rounded-full border border-slate-200 px-4 py-2.5 text-center text-base font-medium text-slate-600"
>
/
</button>
)}
<a
href="#roadmap"
onClick={() => setMobileOpen(false)}
@@ -97,6 +160,12 @@ export default function Header() {
</nav>
</div>
)}
<AuthModal
isOpen={authOpen}
onClose={() => setAuthOpen(false)}
onSuccess={(email) => setUserEmail(email)}
/>
</header>
);
}

View File

@@ -63,55 +63,33 @@ export default function Roadmap() {
</p>
</div>
<div className="relative mt-16">
{/* Desktop: vertical timeline line */}
<div className="absolute top-0 bottom-0 left-[27px] hidden w-0.5 bg-gradient-to-b from-sky-200 via-amber-200 to-rose-200 lg:left-1/2 lg:block lg:-translate-x-px" />
<div className="space-y-6 lg:space-y-8">
{days.map((d, i) => (
<div
key={d.day}
className={`relative lg:flex lg:items-center lg:gap-8 ${
i % 2 === 0 ? "lg:flex-row" : "lg:flex-row-reverse"
}`}
>
{/* Timeline dot (desktop) */}
<div className="absolute left-1/2 top-8 z-10 hidden h-4 w-4 -translate-x-1/2 rounded-full border-4 border-white shadow-sm lg:block"
style={{ background: `var(--tw-shadow-color, #0ea5e9)` }}
{/* 参考 openclaw101.dev卡片网格非时间轴 */}
<div className="mt-14 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{days.map((d) => (
<a
key={d.day}
href={`/day/${d.day}`}
className="card-hover group flex flex-col rounded-2xl border border-slate-100 bg-white p-5 shadow-sm transition-all hover:border-sky-200 hover:shadow-md"
>
<div className="flex items-center gap-3">
<div
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-xs font-bold text-white ${d.color}`}
>
<div className={`h-full w-full rounded-full ${d.color}`} />
</div>
<div className="hidden lg:block lg:w-1/2" />
<div className="card-hover w-full rounded-2xl border border-slate-100 bg-white p-6 shadow-sm lg:w-1/2">
<div className="flex items-start gap-4">
<div
className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-xl text-white text-sm font-bold ${d.color}`}
>
DAY {d.day}
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<span className="text-xl">{d.icon}</span>
<h3 className="text-lg font-bold text-slate-900">
{d.title}
</h3>
</div>
<p className="text-sm leading-relaxed text-slate-600">
{d.desc}
</p>
<a
href={`/day/${d.day}`}
className="mt-3 inline-block text-sm font-medium text-sky-600 transition-colors hover:text-sky-700"
>
</a>
</div>
</div>
DAY {d.day}
</div>
<span className="text-xl">{d.icon}</span>
<h3 className="text-base font-bold text-slate-900 group-hover:text-sky-600">
{d.title}
</h3>
</div>
))}
</div>
<p className="mt-3 flex-1 text-sm leading-relaxed text-slate-600">
{d.desc}
</p>
<span className="mt-3 inline-flex items-center text-sm font-medium text-sky-600">
</span>
</a>
))}
</div>
{/* Training camp CTA */}