's'
This commit is contained in:
14
POCKETBASE_SCHEMA.md
Normal file
14
POCKETBASE_SCHEMA.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# PocketBase solan 表需新增字段
|
||||||
|
|
||||||
|
PocketBase **不支持**通过 REST API 创建/修改表结构,需在 Admin 后台手动添加:
|
||||||
|
|
||||||
|
1. 登录 https://pocketbase.hackrobot.cn/_/
|
||||||
|
2. 进入 Collections → solan
|
||||||
|
3. 添加以下字段:
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `single` | Text | 感情状态(单身/恋爱中/已婚) |
|
||||||
|
| `media` | Text | 头像/自拍视频的 URL(来自 MinIO) |
|
||||||
|
|
||||||
|
`reason` 字段保持为「自我介绍」内容,不再包含感情状态。
|
||||||
113
app/api/join/route.ts
Normal file
113
app/api/join/route.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const PB_URL = process.env.POCKETBASE_URL || "https://pocketbase.hackrobot.cn";
|
||||||
|
const COLLECTION = "solan";
|
||||||
|
|
||||||
|
function getOrCreateUserId(): string {
|
||||||
|
return `user${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAdminToken(): Promise<string | null> {
|
||||||
|
const email = process.env.POCKETBASE_EMAIL;
|
||||||
|
const password = process.env.POCKETBASE_PASSWORD;
|
||||||
|
if (!email || !password) return null;
|
||||||
|
|
||||||
|
const res = await fetch(`${PB_URL}/api/admins/auth-with-password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ identity: email, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
return data?.token ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const userId = getOrCreateUserId();
|
||||||
|
const body = await request.json();
|
||||||
|
const formData = body && typeof body === "object" ? body : {};
|
||||||
|
|
||||||
|
const {
|
||||||
|
nickname,
|
||||||
|
gender,
|
||||||
|
single,
|
||||||
|
profession,
|
||||||
|
intro,
|
||||||
|
wechat,
|
||||||
|
education,
|
||||||
|
graduationYear,
|
||||||
|
phone,
|
||||||
|
media: mediaUrl,
|
||||||
|
} = formData;
|
||||||
|
|
||||||
|
// reason=自我介绍, single=感情状态, media=MinIO 上传后的 URL
|
||||||
|
const record: Record<string, string | number> = {
|
||||||
|
nickname: nickname || "",
|
||||||
|
occupation: profession || "",
|
||||||
|
reason: intro || "",
|
||||||
|
single: single || "",
|
||||||
|
wechatId: wechat || "",
|
||||||
|
gender: gender || "",
|
||||||
|
education: education || "",
|
||||||
|
gradYear: graduationYear || "",
|
||||||
|
phone: phone || "",
|
||||||
|
user_id: userId,
|
||||||
|
amount: 0,
|
||||||
|
order_id: "",
|
||||||
|
type: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mediaUrl && typeof mediaUrl === "string") {
|
||||||
|
(record as Record<string, string>)["media"] = mediaUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doCreate = async (authToken?: string) => {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
if (authToken) headers["Authorization"] = authToken;
|
||||||
|
|
||||||
|
return fetch(`${PB_URL}/api/collections/${COLLECTION}/records`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(record),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = await doCreate();
|
||||||
|
|
||||||
|
if (res.status === 403) {
|
||||||
|
const token = await getAdminToken();
|
||||||
|
if (token) res = await doCreate(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errText = await res.text();
|
||||||
|
console.error("PocketBase create error:", res.status, errText);
|
||||||
|
let errMsg = "提交失败,请稍后重试";
|
||||||
|
try {
|
||||||
|
const errJson = JSON.parse(errText);
|
||||||
|
if (errJson?.message) errMsg = errJson.message;
|
||||||
|
if (errJson?.data && typeof errJson.data === "object") {
|
||||||
|
const details = Object.entries(errJson.data)
|
||||||
|
.map(([k, v]) => `${k}: ${(v as { message?: string })?.message || v}`)
|
||||||
|
.join("; ");
|
||||||
|
if (details) errMsg += ` (${details})`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (errText.length < 200) errMsg = errText;
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: false, error: errMsg }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("Join API error:", e);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: `提交失败: ${msg}` },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/api/upload-media/route.ts
Normal file
72
app/api/upload-media/route.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import * as Minio from "minio";
|
||||||
|
|
||||||
|
// mc alias list myminio: http://minioweb.hackrobot.cn:9035
|
||||||
|
const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || "minioweb.hackrobot.cn";
|
||||||
|
const MINIO_PORT = parseInt(process.env.MINIO_PORT || "9035", 10);
|
||||||
|
const MINIO_USE_SSL = process.env.MINIO_USE_SSL === "true";
|
||||||
|
const MINIO_BUCKET = process.env.MINIO_BUCKET || "hackrobot";
|
||||||
|
const MINIO_ACCESS_KEY = process.env.MINIO_ACCESS_KEY || "6i56HHfg4zPfYItCZtnp";
|
||||||
|
const MINIO_SECRET_KEY = process.env.MINIO_SECRET_KEY || "vDJCqEit3ejH5UmWKAZnvqhziNfbVsoOlBW12G8Q";
|
||||||
|
// 公开访问 URL(https 无端口),API 上传用 9035
|
||||||
|
const MINIO_PUBLIC_URL =
|
||||||
|
process.env.MINIO_PUBLIC_URL ||
|
||||||
|
`https://${MINIO_ENDPOINT}/${MINIO_BUCKET}`;
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const form = await request.formData();
|
||||||
|
const file = form.get("file");
|
||||||
|
const fileObj =
|
||||||
|
file instanceof File ? file : file instanceof Blob ? file : null;
|
||||||
|
|
||||||
|
if (!fileObj || fileObj.size === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: "请选择文件" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileObj.size > 20 * 1024 * 1024) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: "文件大小不能超过 20MB" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new Minio.Client({
|
||||||
|
endPoint: MINIO_ENDPOINT,
|
||||||
|
port: MINIO_PORT,
|
||||||
|
useSSL: MINIO_USE_SSL,
|
||||||
|
accessKey: MINIO_ACCESS_KEY,
|
||||||
|
secretKey: MINIO_SECRET_KEY,
|
||||||
|
pathStyle: true,
|
||||||
|
region: process.env.MINIO_REGION || "china",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ext =
|
||||||
|
(fileObj instanceof File ? fileObj.name : "").split(".").pop() ||
|
||||||
|
(fileObj.type?.startsWith("video/") ? "mp4" : "jpg");
|
||||||
|
const objectKey = `joins/${Date.now()}_${Math.random().toString(36).slice(2)}.${ext}`;
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await fileObj.arrayBuffer());
|
||||||
|
const contentType =
|
||||||
|
(fileObj as File).type || "application/octet-stream";
|
||||||
|
|
||||||
|
await client.putObject(MINIO_BUCKET, objectKey, buffer, buffer.length, {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const base = MINIO_PUBLIC_URL.replace(/\/$/, "");
|
||||||
|
const url = `${base}/${objectKey}`;
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, url });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("Upload media error:", e);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: `上传失败: ${msg}` },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
203
app/components/AuthModal.tsx
Normal file
203
app/components/AuthModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import AuthModal from "./AuthModal";
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ label: "学习路径", href: "#roadmap" },
|
{ label: "学习路径", href: "#roadmap" },
|
||||||
@@ -9,16 +10,34 @@ const navLinks = [
|
|||||||
{ label: "社区", href: "#community" },
|
{ 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() {
|
export default function Header() {
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
|
const [authOpen, setAuthOpen] = useState(false);
|
||||||
|
const [userEmail, setUserEmail] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onScroll = () => setScrolled(window.scrollY > 20);
|
setUserEmail(getStoredUser());
|
||||||
window.addEventListener("scroll", onScroll);
|
|
||||||
return () => window.removeEventListener("scroll", onScroll);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem("pb_token");
|
||||||
|
localStorage.removeItem("pb_user");
|
||||||
|
setUserEmail(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||||
@@ -48,6 +67,26 @@ export default function Header() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<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
|
<a
|
||||||
href="#roadmap"
|
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"
|
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}
|
{link.label}
|
||||||
</a>
|
</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
|
<a
|
||||||
href="#roadmap"
|
href="#roadmap"
|
||||||
onClick={() => setMobileOpen(false)}
|
onClick={() => setMobileOpen(false)}
|
||||||
@@ -97,6 +160,12 @@ export default function Header() {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AuthModal
|
||||||
|
isOpen={authOpen}
|
||||||
|
onClose={() => setAuthOpen(false)}
|
||||||
|
onSuccess={(email) => setUserEmail(email)}
|
||||||
|
/>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,55 +63,33 @@ export default function Roadmap() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mt-16">
|
{/* 参考 openclaw101.dev:卡片网格,非时间轴 */}
|
||||||
{/* Desktop: vertical timeline line */}
|
<div className="mt-14 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
<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" />
|
{days.map((d) => (
|
||||||
|
<a
|
||||||
<div className="space-y-6 lg:space-y-8">
|
key={d.day}
|
||||||
{days.map((d, i) => (
|
href={`/day/${d.day}`}
|
||||||
<div
|
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"
|
||||||
key={d.day}
|
>
|
||||||
className={`relative lg:flex lg:items-center lg:gap-8 ${
|
<div className="flex items-center gap-3">
|
||||||
i % 2 === 0 ? "lg:flex-row" : "lg:flex-row-reverse"
|
<div
|
||||||
}`}
|
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-xs font-bold text-white ${d.color}`}
|
||||||
>
|
|
||||||
{/* 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)` }}
|
|
||||||
>
|
>
|
||||||
<div className={`h-full w-full rounded-full ${d.color}`} />
|
DAY {d.day}
|
||||||
</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>
|
|
||||||
</div>
|
</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">
|
||||||
</div>
|
{d.desc}
|
||||||
|
</p>
|
||||||
|
<span className="mt-3 inline-flex items-center text-sm font-medium text-sky-600">
|
||||||
|
查看详情 →
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Training camp CTA */}
|
{/* Training camp CTA */}
|
||||||
|
|||||||
76
app/course/VideoCard.tsx
Normal file
76
app/course/VideoCard.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { generateVideoPoster } from "./videoPoster";
|
||||||
|
|
||||||
|
type Lesson = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
duration: string;
|
||||||
|
videoUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
lesson: Lesson;
|
||||||
|
completed?: boolean;
|
||||||
|
onComplete?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VideoCard({ lesson, completed = false, onComplete }: Props) {
|
||||||
|
const [poster, setPoster] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPoster(generateVideoPoster(lesson.title, lesson.id));
|
||||||
|
}, [lesson.title, lesson.id]);
|
||||||
|
|
||||||
|
const handleEnded = useCallback(() => {
|
||||||
|
onComplete?.();
|
||||||
|
}, [onComplete]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={`group overflow-hidden rounded-2xl border shadow-sm transition-all duration-200 hover:shadow-md ${
|
||||||
|
completed
|
||||||
|
? "border-emerald-200 bg-emerald-50/50 hover:border-emerald-300"
|
||||||
|
: "border-slate-100 bg-white hover:border-slate-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 三级标题:视频上方,突出显示 */}
|
||||||
|
<div className="flex items-center gap-3 border-b border-slate-100 bg-white/80 px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-bold ${
|
||||||
|
completed ? "bg-emerald-100 text-emerald-600" : "bg-sky-50 text-sky-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{lesson.id}
|
||||||
|
</span>
|
||||||
|
<h4 className="min-w-0 flex-1 text-base font-semibold leading-snug text-slate-800 line-clamp-2">
|
||||||
|
{lesson.title}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div className="relative aspect-video overflow-hidden bg-slate-900">
|
||||||
|
<video
|
||||||
|
src={lesson.videoUrl}
|
||||||
|
poster={poster ?? undefined}
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
onEnded={handleEnded}
|
||||||
|
className="h-full w-full object-contain transition-transform duration-300 group-hover:scale-[1.01]"
|
||||||
|
preload="metadata"
|
||||||
|
>
|
||||||
|
您的浏览器不支持视频播放
|
||||||
|
</video>
|
||||||
|
<span className="absolute bottom-2 right-2 rounded bg-black/60 px-2 py-0.5 text-xs font-medium tabular-nums text-white/90 backdrop-blur-sm">
|
||||||
|
{lesson.duration}
|
||||||
|
</span>
|
||||||
|
{completed && (
|
||||||
|
<span className="absolute top-2 right-2 flex h-8 w-8 items-center justify-center rounded-full bg-emerald-500 text-white shadow-md">
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { VideoCard } from "./VideoCard";
|
||||||
|
import { useVideoProgress } from "./useVideoProgress";
|
||||||
|
import AuthModal from "../components/AuthModal";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── data ─── */
|
/* ─── data ─── */
|
||||||
|
|
||||||
@@ -61,68 +76,58 @@ const benefits = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 示例视频:w3schools 公开样本,可替换为 MinIO CDN URL(如 https://minioweb.hackrobot.cn/hackrobot/course/xxx.mp4)
|
||||||
|
const SAMPLE_VIDEO = "https://www.w3schools.com/html/mov_bbb.mp4";
|
||||||
|
|
||||||
|
// 一级:part 二级:section 三级:lesson
|
||||||
const curriculum = [
|
const curriculum = [
|
||||||
{
|
{
|
||||||
emoji: "🚀",
|
emoji: "🚀",
|
||||||
chapter: "基础篇 · 认识数字游民",
|
part: "基础篇",
|
||||||
lessons: [
|
sections: [
|
||||||
{ id: "01", title: "什么是数字游民?破除 5 个常见误解", duration: "12:30" },
|
{ name: "认识数字游民", lessons: [{ id: "01", title: "什么是数字游民?破除 5 个常见误解", duration: "12:30", videoUrl: SAMPLE_VIDEO }, { id: "02", title: "数字游民三种模式:蜜蜂、乌龟、候鸟", duration: "08:45", videoUrl: SAMPLE_VIDEO }] },
|
||||||
{ id: "02", title: "数字游民三种模式:蜜蜂、乌龟、候鸟", duration: "08:45" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
emoji: "💡",
|
emoji: "💡",
|
||||||
chapter: "技能篇 · 远程工作能力",
|
part: "技能篇",
|
||||||
lessons: [
|
sections: [
|
||||||
{ id: "03", title: "远程高薪技能全景图:找到你的方向", duration: "14:20" },
|
{ name: "远程工作能力", lessons: [{ id: "03", title: "远程高薪技能全景图:找到你的方向", duration: "14:20", videoUrl: SAMPLE_VIDEO }, { id: "04", title: "30天技能升级路线:从评估到接单", duration: "11:15", videoUrl: SAMPLE_VIDEO }, { id: "05", title: "建立个人品牌:让客户主动找你", duration: "09:40", videoUrl: SAMPLE_VIDEO }] },
|
||||||
{ id: "04", title: "30天技能升级路线:从评估到接单", duration: "11:15" },
|
|
||||||
{ id: "05", title: "建立个人品牌:让客户主动找你", duration: "09:40" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
emoji: "💰",
|
emoji: "💰",
|
||||||
chapter: "收入篇 · 构建收入体系",
|
part: "收入篇",
|
||||||
lessons: [
|
sections: [
|
||||||
{ id: "06", title: "远程全职 vs 自由职业:选哪条路?", duration: "10:05" },
|
{ name: "构建收入体系", lessons: [{ id: "06", title: "远程全职 vs 自由职业:选哪条路?", duration: "10:05", videoUrl: SAMPLE_VIDEO }, { id: "07", title: "自由职业平台实操:Upwork / Toptal / 电鸭", duration: "13:50", videoUrl: SAMPLE_VIDEO }, { id: "08", title: "被动收入搭建:数字产品 & 在线课程", duration: "12:20", videoUrl: SAMPLE_VIDEO }, { id: "09", title: "定价的艺术:如何报价不心虚", duration: "07:30", videoUrl: SAMPLE_VIDEO }] },
|
||||||
{ id: "07", title: "自由职业平台实操:Upwork / Toptal / 电鸭", duration: "13:50" },
|
|
||||||
{ id: "08", title: "被动收入搭建:数字产品 & 在线课程", duration: "12:20" },
|
|
||||||
{ id: "09", title: "定价的艺术:如何报价不心虚", duration: "07:30" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
emoji: "🛠️",
|
emoji: "🛠️",
|
||||||
chapter: "工具篇 · 移动办公装备",
|
part: "工具篇",
|
||||||
lessons: [
|
sections: [
|
||||||
{ id: "10", title: "硬件极简主义:一个背包装下办公室", duration: "06:45" },
|
{ name: "移动办公装备", lessons: [{ id: "10", title: "硬件极简主义:一个背包装下办公室", duration: "06:45", videoUrl: SAMPLE_VIDEO }, { id: "11", title: "远程协作工具栈:Slack / Notion / Linear", duration: "08:30", videoUrl: SAMPLE_VIDEO }, { id: "12", title: "安全必修课:VPN / 密码管理 / 2FA", duration: "05:55", videoUrl: SAMPLE_VIDEO }, { id: "13", title: "跨境财务工具:Wise / Stripe / Revolut", duration: "07:10", videoUrl: SAMPLE_VIDEO }] },
|
||||||
{ id: "11", title: "远程协作工具栈:Slack / Notion / Linear", duration: "08:30" },
|
|
||||||
{ id: "12", title: "安全必修课:VPN / 密码管理 / 2FA", duration: "05:55" },
|
|
||||||
{ id: "13", title: "跨境财务工具:Wise / Stripe / Revolut", duration: "07:10" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
emoji: "📋",
|
emoji: "📋",
|
||||||
chapter: "合规篇 · 签证税务保险",
|
part: "合规篇",
|
||||||
lessons: [
|
sections: [
|
||||||
{ id: "14", title: "55 国数字游民签证政策全解读", duration: "15:30" },
|
{ name: "签证税务保险", lessons: [{ id: "14", title: "55 国数字游民签证政策全解读", duration: "15:30", videoUrl: SAMPLE_VIDEO }, { id: "15", title: "税务居民身份与合规规划", duration: "11:40", videoUrl: SAMPLE_VIDEO }, { id: "16", title: "数字游民保险选购指南", duration: "06:20", videoUrl: SAMPLE_VIDEO }] },
|
||||||
{ id: "15", title: "税务居民身份与合规规划", duration: "11:40" },
|
|
||||||
{ id: "16", title: "数字游民保险选购指南", duration: "06:20" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
emoji: "🗺️",
|
emoji: "🗺️",
|
||||||
chapter: "目的地篇 · 选择你的城市",
|
part: "目的地篇",
|
||||||
lessons: [
|
sections: [
|
||||||
{ id: "17", title: "亚洲三城:清迈 / 巴厘岛 / 首尔深度对比", duration: "10:50" },
|
{ name: "选择你的城市", lessons: [{ id: "17", title: "亚洲三城:清迈 / 巴厘岛 / 首尔深度对比", duration: "10:50", videoUrl: SAMPLE_VIDEO }, { id: "18", title: "欧洲双雄:里斯本 / 巴塞罗那生活实录", duration: "09:25", videoUrl: SAMPLE_VIDEO }, { id: "19", title: "美洲探索:墨西哥城 / 麦德林旅居指南", duration: "08:15", videoUrl: SAMPLE_VIDEO }] },
|
||||||
{ id: "18", title: "欧洲双雄:里斯本 / 巴塞罗那生活实录", duration: "09:25" },
|
|
||||||
{ id: "19", title: "美洲探索:墨西哥城 / 麦德林旅居指南", duration: "08:15" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
emoji: "🏆",
|
emoji: "🏆",
|
||||||
chapter: "实战篇 · 启程出发",
|
part: "实战篇",
|
||||||
lessons: [
|
sections: [
|
||||||
{ id: "20", title: "出发前检查清单 & 打包哲学", duration: "07:40" },
|
{ name: "启程出发", lessons: [{ id: "20", title: "出发前检查清单 & 打包哲学", duration: "07:40", videoUrl: SAMPLE_VIDEO }, { id: "21", title: "第一个月生存指南 & 长期可持续策略", duration: "13:10", videoUrl: SAMPLE_VIDEO }] },
|
||||||
{ id: "21", title: "第一个月生存指南 & 长期可持续策略", duration: "13:10" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -155,22 +160,58 @@ const instructors = [
|
|||||||
|
|
||||||
export default function CoursePage() {
|
export default function CoursePage() {
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [authOpen, setAuthOpen] = useState(false);
|
||||||
|
const [userEmail, setUserEmail] = useState<string | null>(null);
|
||||||
|
const [expandedPart, setExpandedPart] = useState<string | null>(null);
|
||||||
|
const { markCompleted, isCompleted, isPartCompleted } = useVideoProgress();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUserEmail(getStoredUser());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem("pb_token");
|
||||||
|
localStorage.removeItem("pb_user");
|
||||||
|
setUserEmail(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white text-slate-900">
|
<div className="min-h-screen bg-white text-slate-900">
|
||||||
{/* ── Nav ── */}
|
{/* ── Nav ── */}
|
||||||
<header className="sticky top-0 z-50 border-b border-slate-100 bg-white/80 backdrop-blur-lg">
|
<header className="sticky top-0 z-50 border-b border-slate-100 bg-white/80 backdrop-blur-lg">
|
||||||
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-4 sm:px-6">
|
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between gap-3 px-4 sm:px-6">
|
||||||
<a href="/" className="flex items-center gap-2 text-sm font-bold">
|
<a href="/" className="flex items-center gap-2 text-sm font-bold">
|
||||||
<span className="text-lg">🌍</span>
|
<span className="text-lg">🌍</span>
|
||||||
数字游民指南
|
数字游民指南
|
||||||
</a>
|
</a>
|
||||||
<button
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
onClick={() => setModalOpen(true)}
|
{userEmail ? (
|
||||||
className="rounded-full bg-sky-500 px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-sky-600"
|
<>
|
||||||
>
|
<span className="hidden max-w-[120px] truncate text-sm text-slate-600 sm:inline">
|
||||||
立即报名
|
{userEmail}
|
||||||
</button>
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="rounded-full border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
退出
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setAuthOpen(true)}
|
||||||
|
className="rounded-full border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
登录 / 注册
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setModalOpen(true)}
|
||||||
|
className="rounded-full bg-sky-500 px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-sky-600"
|
||||||
|
>
|
||||||
|
立即报名
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -264,55 +305,133 @@ export default function CoursePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Curriculum ── */}
|
{/* ── Curriculum ── 参考:垂直时间线 + 学习路径视觉 */}
|
||||||
<section className="py-20 sm:py-24">
|
<section className="relative overflow-hidden bg-gradient-to-b from-white via-slate-50/50 to-slate-50 py-24 sm:py-28">
|
||||||
<div className="mx-auto max-w-5xl px-4 sm:px-6">
|
<div className="mx-auto max-w-4xl px-4 sm:px-6">
|
||||||
<h2 className="text-center text-2xl font-bold sm:text-3xl">
|
{/* 标题区:大数字 + 学习路径感 */}
|
||||||
21 节实战课程
|
<div className="mb-16 text-center">
|
||||||
</h2>
|
<div className="inline-flex items-baseline gap-2">
|
||||||
<p className="mx-auto mt-3 max-w-xl text-center text-slate-500">
|
<span className="gradient-text text-6xl font-extrabold tabular-nums tracking-tight sm:text-7xl">
|
||||||
从入门到启程,完整的学习路径
|
21
|
||||||
</p>
|
</span>
|
||||||
|
<span className="text-2xl font-bold text-slate-800 sm:text-3xl">
|
||||||
|
节实战课程
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-slate-500">
|
||||||
|
从入门到启程 · 完整的学习路径
|
||||||
|
</p>
|
||||||
|
<div className="mx-auto mt-6 h-px w-24 rounded-full bg-gradient-to-r from-transparent via-sky-300/60 to-transparent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-14 space-y-6">
|
{/* 垂直时间线:左侧竖线 + 节点 */}
|
||||||
{curriculum.map((ch) => (
|
<div className="relative">
|
||||||
<div
|
{/* 中央竖线 */}
|
||||||
key={ch.chapter}
|
<div
|
||||||
className="overflow-hidden rounded-2xl border border-slate-100 bg-white shadow-sm"
|
className="absolute left-[19px] top-6 bottom-6 w-0.5 bg-gradient-to-b from-sky-200/80 via-sky-300/60 to-sky-200/80 sm:left-[23px]"
|
||||||
>
|
aria-hidden
|
||||||
{/* Chapter header */}
|
/>
|
||||||
<div className="flex items-center gap-3 border-b border-slate-100 bg-slate-50/80 px-6 py-4">
|
|
||||||
<span className="text-xl">{ch.emoji}</span>
|
|
||||||
<h3 className="font-bold text-slate-800">{ch.chapter}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lessons */}
|
<div className="space-y-0">
|
||||||
<ul>
|
{curriculum.map((ch, idx) => {
|
||||||
{ch.lessons.map((lesson, i) => (
|
const allLessons = ch.sections.flatMap((s) => s.lessons);
|
||||||
<li
|
const partCompleted = isPartCompleted(allLessons.map((l) => l.id));
|
||||||
key={lesson.id}
|
const isExpanded = expandedPart === ch.part;
|
||||||
className={`flex items-center justify-between px-6 py-3.5 transition-colors hover:bg-sky-50/40 ${
|
const totalLessons = allLessons.length;
|
||||||
i < ch.lessons.length - 1
|
return (
|
||||||
? "border-b border-slate-50"
|
<div key={ch.part} className="relative flex gap-6 pb-10 last:pb-0">
|
||||||
: ""
|
{/* 左侧节点圆:完成时显示 ✓ */}
|
||||||
}`}
|
<div className="relative z-10 flex shrink-0 items-start pt-0.5">
|
||||||
>
|
<div
|
||||||
<div className="flex items-center gap-3">
|
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold shadow-sm transition-colors sm:h-12 sm:w-12 ${
|
||||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-slate-100 text-xs font-bold text-slate-500">
|
partCompleted
|
||||||
{lesson.id}
|
? "border-2 border-emerald-300 bg-emerald-50 text-emerald-600"
|
||||||
</span>
|
: "border-2 border-sky-200 bg-white text-sky-600"
|
||||||
<span className="text-sm font-medium text-slate-700 sm:text-base">
|
}`}
|
||||||
{lesson.title}
|
>
|
||||||
</span>
|
{partCompleted ? (
|
||||||
|
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
idx + 1
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-4 shrink-0 text-xs tabular-nums text-slate-400">
|
</div>
|
||||||
{lesson.duration}
|
|
||||||
</span>
|
{/* 右侧内容卡片 */}
|
||||||
</li>
|
<div className="min-w-0 flex-1">
|
||||||
))}
|
<div
|
||||||
</ul>
|
className={`overflow-hidden rounded-2xl border shadow-sm transition-colors ${
|
||||||
</div>
|
partCompleted ? "border-emerald-200/80 bg-emerald-50/30" : "border-slate-200/80 bg-white"
|
||||||
))}
|
}`}
|
||||||
|
>
|
||||||
|
{/* 一级:Part 标题,点击展开/收起 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpandedPart(isExpanded ? null : ch.part)}
|
||||||
|
className="flex w-full items-center justify-between gap-3 px-5 py-4 text-left transition-colors hover:bg-slate-50/80 sm:px-6"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">{ch.emoji}</span>
|
||||||
|
<h3 className="font-bold text-slate-800">{ch.part}</h3>
|
||||||
|
{partCompleted && (
|
||||||
|
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
|
||||||
|
已完成
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="flex items-center gap-2 text-sm text-slate-400">
|
||||||
|
<span>{totalLessons} 节</span>
|
||||||
|
<svg
|
||||||
|
className={`h-4 w-4 shrink-0 transition-transform duration-200 ${
|
||||||
|
isExpanded ? "rotate-180" : ""
|
||||||
|
}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 展开后:二级 section + 三级 lesson */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-slate-100 bg-slate-50/40">
|
||||||
|
{ch.sections.map((sec) => (
|
||||||
|
<div key={sec.name} className="border-b border-slate-100 last:border-b-0">
|
||||||
|
{/* 二级标题 */}
|
||||||
|
<div className="flex items-center gap-3 border-b border-slate-100/80 bg-slate-50/60 px-5 py-3.5 sm:px-6">
|
||||||
|
<span className="flex h-6 w-1 shrink-0 rounded-full bg-gradient-to-b from-sky-400 to-sky-500" />
|
||||||
|
<h4 className="text-sm font-semibold text-slate-700">
|
||||||
|
{sec.name}
|
||||||
|
</h4>
|
||||||
|
<span className="rounded-full bg-slate-200/60 px-2 py-0.5 text-xs font-medium text-slate-500">
|
||||||
|
{sec.lessons.length} 节
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* 三级:视频卡片网格 */}
|
||||||
|
<div className="grid gap-5 p-5 sm:grid-cols-2 sm:p-6 lg:grid-cols-3">
|
||||||
|
{sec.lessons.map((lesson) => (
|
||||||
|
<VideoCard
|
||||||
|
key={lesson.id}
|
||||||
|
lesson={lesson as { id: string; title: string; duration: string; videoUrl?: string }}
|
||||||
|
completed={isCompleted(lesson.id)}
|
||||||
|
onComplete={() => markCompleted(lesson.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -391,7 +510,13 @@ export default function CoursePage() {
|
|||||||
开源免费 · 社区驱动
|
开源免费 · 社区驱动
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{/* ── Modal ── */}
|
<AuthModal
|
||||||
|
isOpen={authOpen}
|
||||||
|
onClose={() => setAuthOpen(false)}
|
||||||
|
onSuccess={(email) => setUserEmail(email)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── 报名 Modal ── */}
|
||||||
{modalOpen && (
|
{modalOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
|
||||||
|
|||||||
56
app/course/useVideoProgress.ts
Normal file
56
app/course/useVideoProgress.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "digital-course-video-completed";
|
||||||
|
|
||||||
|
function loadCompleted(): Set<string> {
|
||||||
|
if (typeof window === "undefined") return new Set();
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return new Set();
|
||||||
|
const arr = JSON.parse(raw) as string[];
|
||||||
|
return new Set(Array.isArray(arr) ? arr : []);
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCompleted(ids: Set<string>) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify([...ids]));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVideoProgress() {
|
||||||
|
const [completed, setCompleted] = useState<Set<string>>(() => new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCompleted(loadCompleted());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const markCompleted = useCallback((lessonId: string) => {
|
||||||
|
setCompleted((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(lessonId);
|
||||||
|
saveCompleted(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isCompleted = useCallback(
|
||||||
|
(lessonId: string) => completed.has(lessonId),
|
||||||
|
[completed]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isPartCompleted = useCallback(
|
||||||
|
(lessonIds: string[]) =>
|
||||||
|
lessonIds.length > 0 && lessonIds.every((id) => completed.has(id)),
|
||||||
|
[completed]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { completed, markCompleted, isCompleted, isPartCompleted };
|
||||||
|
}
|
||||||
184
app/course/videoPoster.ts
Normal file
184
app/course/videoPoster.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* 生成视频封面图(数字游民插画风格,无序号)
|
||||||
|
* 返回 data URL,可直接用作 video poster
|
||||||
|
*/
|
||||||
|
export function generateVideoPoster(
|
||||||
|
title: string,
|
||||||
|
_lessonId: string,
|
||||||
|
options?: {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
bgGradient?: [string, string];
|
||||||
|
accentColor?: string;
|
||||||
|
}
|
||||||
|
): string {
|
||||||
|
const width = options?.width ?? 640;
|
||||||
|
const height = options?.height ?? 360;
|
||||||
|
const [from, to] = options?.bgGradient ?? ["#0ea5e9", "#06b6d4"];
|
||||||
|
const accent = options?.accentColor ?? "#ffffff";
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return "";
|
||||||
|
|
||||||
|
// 数字游民插画:天空渐变背景
|
||||||
|
const skyGrad = ctx.createLinearGradient(0, 0, 0, height);
|
||||||
|
skyGrad.addColorStop(0, "#87CEEB");
|
||||||
|
skyGrad.addColorStop(0.5, "#B8E0F0");
|
||||||
|
skyGrad.addColorStop(0.75, "#E8F4F8");
|
||||||
|
skyGrad.addColorStop(1, "#F5E6D3");
|
||||||
|
ctx.fillStyle = skyGrad;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// 海洋
|
||||||
|
ctx.fillStyle = "rgba(14, 165, 233, 0.25)";
|
||||||
|
ctx.fillRect(0, height * 0.65, width, height * 0.35);
|
||||||
|
ctx.strokeStyle = "rgba(255,255,255,0.4)";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
ctx.beginPath();
|
||||||
|
const x = (i * width) / 5;
|
||||||
|
ctx.moveTo(x, height * 0.72);
|
||||||
|
ctx.quadraticCurveTo(x + 50, height * 0.68, x + 100, height * 0.72);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 太阳
|
||||||
|
const sunGrad = ctx.createRadialGradient(width * 0.85, height * 0.15, 0, width * 0.85, height * 0.15, 50);
|
||||||
|
sunGrad.addColorStop(0, "#FEF3C7");
|
||||||
|
sunGrad.addColorStop(0.6, "#FCD34D");
|
||||||
|
sunGrad.addColorStop(1, "rgba(252, 211, 77, 0)");
|
||||||
|
ctx.fillStyle = sunGrad;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(width * 0.85, height * 0.15, 50, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = "#FCD34D";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(width * 0.85, height * 0.15, 18, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 椰子树(简化插画)
|
||||||
|
const palmX = width * 0.12;
|
||||||
|
const palmY = height * 0.55;
|
||||||
|
ctx.fillStyle = "#8B7355";
|
||||||
|
ctx.fillRect(palmX - 4, palmY, 8, height * 0.35);
|
||||||
|
ctx.fillStyle = "#22C55E";
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const a = (i / 5) * Math.PI * 0.8 - Math.PI * 0.2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(palmX, palmY);
|
||||||
|
ctx.quadraticCurveTo(palmX + Math.cos(a) * 35, palmY - 25, palmX + Math.cos(a) * 55, palmY - 45);
|
||||||
|
ctx.strokeStyle = "#22C55E";
|
||||||
|
ctx.lineWidth = 6;
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 地球/ globe
|
||||||
|
const globeX = width * 0.78;
|
||||||
|
const globeY = height * 0.35;
|
||||||
|
ctx.strokeStyle = "rgba(255,255,255,0.6)";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(globeX, globeY, 45, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(globeX, globeY, 45, 15, 0.3, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(globeX, globeY, 15, 45, -0.2, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = "rgba(14, 165, 233, 0.15)";
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 笔记本电脑(数字游民工作象征)
|
||||||
|
const lapX = width * 0.25;
|
||||||
|
const lapY = height * 0.5;
|
||||||
|
ctx.fillStyle = "#E5E7EB";
|
||||||
|
ctx.fillRect(lapX - 35, lapY - 20, 70, 45);
|
||||||
|
ctx.fillStyle = "#94A3B8";
|
||||||
|
ctx.fillRect(lapX - 32, lapY - 18, 64, 35);
|
||||||
|
ctx.fillStyle = "#0ea5e9";
|
||||||
|
ctx.globalAlpha = 0.6;
|
||||||
|
ctx.fillRect(lapX - 28, lapY - 14, 56, 27);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.fillStyle = "#64748B";
|
||||||
|
ctx.fillRect(lapX - 38, lapY + 22, 76, 6);
|
||||||
|
ctx.fillRect(lapX - 2, lapY + 18, 4, 8);
|
||||||
|
|
||||||
|
// 云朵
|
||||||
|
ctx.fillStyle = "rgba(255,255,255,0.7)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(width * 0.5, height * 0.2, 25, 0, Math.PI * 2);
|
||||||
|
ctx.arc(width * 0.55, height * 0.18, 20, 0, Math.PI * 2);
|
||||||
|
ctx.arc(width * 0.6, height * 0.22, 22, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 底部半透明遮罩,让标题更清晰
|
||||||
|
const overlay = ctx.createLinearGradient(0, height * 0.5, 0, height);
|
||||||
|
overlay.addColorStop(0, "rgba(0,0,0,0)");
|
||||||
|
overlay.addColorStop(0.5, "rgba(0,0,0,0.1)");
|
||||||
|
overlay.addColorStop(1, "rgba(0,0,0,0.4)");
|
||||||
|
ctx.fillStyle = overlay;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// 标题:多行自动换行
|
||||||
|
ctx.fillStyle = accent;
|
||||||
|
ctx.font = `600 ${Math.min(width * 0.045, 28)}px system-ui, "PingFang SC", sans-serif`;
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
ctx.textBaseline = "top";
|
||||||
|
|
||||||
|
const maxWidth = width * 0.9;
|
||||||
|
const lineHeight = Math.min(width * 0.05, 32);
|
||||||
|
const paddingX = width * 0.06;
|
||||||
|
const paddingY = height * 0.68;
|
||||||
|
|
||||||
|
const words = title.split("");
|
||||||
|
let line = "";
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
for (const char of words) {
|
||||||
|
const testLine = line + char;
|
||||||
|
const metrics = ctx.measureText(testLine);
|
||||||
|
if (metrics.width > maxWidth && line) {
|
||||||
|
lines.push(line);
|
||||||
|
line = char;
|
||||||
|
} else {
|
||||||
|
line = testLine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (line) lines.push(line);
|
||||||
|
|
||||||
|
ctx.shadowColor = "rgba(0,0,0,0.35)";
|
||||||
|
ctx.shadowBlur = 6;
|
||||||
|
ctx.shadowOffsetY = 1;
|
||||||
|
|
||||||
|
const maxLines = 2;
|
||||||
|
const startY = paddingY;
|
||||||
|
lines.slice(0, maxLines).forEach((ln, i) => {
|
||||||
|
ctx.fillText(ln, paddingX, startY + i * lineHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.shadowColor = "transparent";
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
|
// 播放图标(右下角)
|
||||||
|
const playSize = Math.min(width, height) * 0.1;
|
||||||
|
const playX = width - width * 0.12;
|
||||||
|
const playY = height - height * 0.15;
|
||||||
|
ctx.fillStyle = "rgba(255,255,255,0.9)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(playX, playY, playSize, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = from;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(playX - playSize * 0.3, playY - playSize * 0.5);
|
||||||
|
ctx.lineTo(playX - playSize * 0.3, playY + playSize * 0.5);
|
||||||
|
ctx.lineTo(playX + playSize * 0.5, playY);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
return canvas.toDataURL("image/jpeg", 0.92);
|
||||||
|
}
|
||||||
@@ -19,9 +19,9 @@ interface FormData {
|
|||||||
phone: string;
|
phone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MediaFile {
|
interface MediaInfo {
|
||||||
file: File;
|
|
||||||
preview: string;
|
preview: string;
|
||||||
|
url: string;
|
||||||
type: "image" | "video";
|
type: "image" | "video";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,16 +37,36 @@ export default function JoinPage() {
|
|||||||
graduationYear: "",
|
graduationYear: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
});
|
});
|
||||||
const [media, setMedia] = useState<MediaFile | null>(null);
|
const [media, setMedia] = useState<MediaInfo | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const [submitted, setSubmitted] = useState(false);
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const set = (key: keyof FormData, value: string) =>
|
const set = (key: keyof FormData, value: string) =>
|
||||||
setForm((prev) => ({ ...prev, [key]: value }));
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
const handleSubmit = (e: FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSubmitted(true);
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/join", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ...form, media: media?.url }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || !data?.ok) {
|
||||||
|
throw new Error(data?.error || "提交失败,请稍后重试");
|
||||||
|
}
|
||||||
|
setSubmitted(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "网络错误,请重试");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (submitted) {
|
if (submitted) {
|
||||||
@@ -290,7 +310,7 @@ export default function JoinPage() {
|
|||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime"
|
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
onChange={async (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
if (file.size > 20 * 1024 * 1024) {
|
if (file.size > 20 * 1024 * 1024) {
|
||||||
@@ -299,12 +319,38 @@ export default function JoinPage() {
|
|||||||
}
|
}
|
||||||
const isVideo = file.type.startsWith("video/");
|
const isVideo = file.type.startsWith("video/");
|
||||||
const preview = URL.createObjectURL(file);
|
const preview = URL.createObjectURL(file);
|
||||||
setMedia({ file, preview, type: isVideo ? "video" : "image" });
|
setMedia({ preview, url: "", type: isVideo ? "video" : "image" });
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const res = await fetch("/api/upload-media", {
|
||||||
|
method: "POST",
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || !data?.ok) {
|
||||||
|
throw new Error(data?.error || "上传失败");
|
||||||
|
}
|
||||||
|
setMedia((m) => (m ? { ...m, url: data.url } : null));
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : "上传失败");
|
||||||
|
URL.revokeObjectURL(preview);
|
||||||
|
setMedia(null);
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{media ? (
|
{media ? (
|
||||||
<div className="relative inline-block">
|
<div className="relative inline-block">
|
||||||
|
{uploading && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xl bg-black/40 text-sm font-medium text-white">
|
||||||
|
上传中…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{media.type === "image" ? (
|
{media.type === "image" ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
@@ -323,12 +369,13 @@ export default function JoinPage() {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={uploading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
URL.revokeObjectURL(media.preview);
|
URL.revokeObjectURL(media.preview);
|
||||||
setMedia(null);
|
setMedia(null);
|
||||||
if (fileRef.current) fileRef.current.value = "";
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
}}
|
}}
|
||||||
className="absolute -top-2 -right-2 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-xs text-white shadow-md transition-colors hover:bg-red-600"
|
className="absolute -top-2 -right-2 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-xs text-white shadow-md transition-colors hover:bg-red-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
@@ -336,24 +383,31 @@ export default function JoinPage() {
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={uploading}
|
||||||
onClick={() => fileRef.current?.click()}
|
onClick={() => fileRef.current?.click()}
|
||||||
className="flex h-32 w-32 flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-slate-200 text-slate-400 transition-colors hover:border-sky-300 hover:text-sky-500 sm:h-40 sm:w-40"
|
className="flex h-32 w-32 flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-slate-200 text-slate-400 transition-colors hover:border-sky-300 hover:text-sky-500 disabled:opacity-50 sm:h-40 sm:w-40"
|
||||||
>
|
>
|
||||||
<svg className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4v16m8-8H4" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4v16m8-8H4" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-xs">点击上传</span>
|
<span className="text-xs">{uploading ? "上传中…" : "点击上传"}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Submit ── */}
|
{/* ── Submit ── */}
|
||||||
<div className="mt-8 sm:mt-10">
|
<div className="mt-8 sm:mt-10">
|
||||||
|
{error && (
|
||||||
|
<p className="mb-4 rounded-lg bg-red-50 px-4 py-3 text-center text-sm text-red-600">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full rounded-full bg-[#1aad19] py-3.5 text-lg font-semibold text-white shadow-md shadow-emerald-500/20 transition-all hover:bg-[#179b16] hover:shadow-lg hover:shadow-emerald-500/25 active:scale-[0.98] sm:py-4"
|
disabled={submitting || uploading}
|
||||||
|
className="w-full rounded-full bg-[#1aad19] py-3.5 text-lg font-semibold text-white shadow-md shadow-emerald-500/20 transition-all hover:bg-[#179b16] hover:shadow-lg hover:shadow-emerald-500/25 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-70 sm:py-4"
|
||||||
>
|
>
|
||||||
提交申请
|
{uploading ? "媒体上传中…" : submitting ? "提交中…" : "提交申请"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="mt-4 whitespace-pre-line text-center text-sm leading-relaxed text-amber-500">
|
<p className="mt-4 whitespace-pre-line text-center text-sm leading-relaxed text-amber-500">
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
experimental: {
|
||||||
|
serverActions: { bodySizeLimit: "25mb" },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -9,7 +9,10 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1004.0",
|
||||||
|
"minio": "^8.0.7",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
|
"pocketbase": "^0.26.8",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
|||||||
1383
pnpm-lock.yaml
generated
1383
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user