From e42b1479c3e85e603f6e5f956e9d31fffce3bde2 Mon Sep 17 00:00:00 2001 From: Crokily Date: Wed, 17 Sep 2025 08:57:20 +1000 Subject: [PATCH 1/3] Merge pull request #49 from InvolutionHell/feat/contributeButton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: 添加工作模块,并且用route路由读取文件结构,发送到前端自动生成Tree Selector结构,用于一键Contribute --- app/api/docs-tree/route.ts | 138 ++ app/components/Community.tsx | 4 +- app/components/Contribute.module.css | 59 + app/components/Contribute.tsx | 291 ++++ app/components/Features.tsx | 4 +- app/components/Header.tsx | 2 +- app/components/Hero.tsx | 10 +- app/components/ThemeToggle.tsx | 2 +- .../HelloWorld.md" | 11 + .../HelloWorld.md" | 11 + .../HelloWorld.md" | 11 + app/layout.tsx | 4 + {app/components => components}/ui/badge.tsx | 0 {app/components => components}/ui/button.tsx | 0 {app/components => components}/ui/card.tsx | 0 components/ui/command.tsx | 184 +++ components/ui/dialog.tsx | 143 ++ components/ui/input.tsx | 21 + next.config.mjs | 1 - package.json | 6 +- pnpm-lock.yaml | 1335 +++++++++++++++-- 21 files changed, 2133 insertions(+), 104 deletions(-) create mode 100644 app/api/docs-tree/route.ts create mode 100644 app/components/Contribute.module.css create mode 100644 app/components/Contribute.tsx create mode 100644 "app/docs/jobs/\344\270\232\345\212\241\346\265\201/HelloWorld.md" create mode 100644 "app/docs/jobs/\346\212\200\346\234\257\346\240\210/HelloWorld.md" create mode 100644 "app/docs/jobs/\347\254\224\350\257\225\351\235\242\347\273\217/HelloWorld.md" rename {app/components => components}/ui/badge.tsx (100%) rename {app/components => components}/ui/button.tsx (100%) rename {app/components => components}/ui/card.tsx (100%) create mode 100644 components/ui/command.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/input.tsx diff --git a/app/api/docs-tree/route.ts b/app/api/docs-tree/route.ts new file mode 100644 index 0000000..a0e9c1d --- /dev/null +++ b/app/api/docs-tree/route.ts @@ -0,0 +1,138 @@ +import { NextResponse } from "next/server"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +type DirNode = { + name: string; + path: string; // relative to docs root + children?: DirNode[]; +}; + +type Diag = { + cwd: string; + node: string; + hasFs: boolean; + candidates: string[]; + envHints: { + NEXT_RUNTIME: string | null; + NODE_ENV: string | null; + }; +}; + +function hasFs() { + try { + return ( + typeof fs.readdirSync === "function" && + typeof fs.existsSync === "function" + ); + } catch { + return false; + } +} + +function safeListDir(dir: string): { entries: fs.Dirent[]; error?: string } { + try { + return { entries: fs.readdirSync(dir, { withFileTypes: true }) }; + } catch (e) { + return { entries: [], error: String(e) }; + } +} + +function buildTree(root: string, maxDepth = 2, rel = ""): DirNode[] { + const { entries, error } = safeListDir(root); + if (error) throw new Error(`readdir failed at ${root}: ${error}`); + + const dirs = entries.filter((d) => d.isDirectory()); + const nodes: DirNode[] = []; + + for (const e of dirs) { + if (e.name.startsWith(".") || e.name.startsWith("[")) continue; + const abs = path.join(root, e.name); + const nodeRel = rel ? `${rel}/${e.name}` : e.name; + const node: DirNode = { name: e.name, path: nodeRel }; + if (maxDepth > 1) node.children = buildTree(abs, maxDepth - 1, nodeRel); + nodes.push(node); + } + + try { + nodes.sort((a, b) => a.name.localeCompare(b.name, "zh-Hans")); + } catch { + nodes.sort((a, b) => a.name.localeCompare(b.name)); + } + return nodes; +} + +export async function GET() { + const cwd = process.cwd(); + const candidates = [ + path.join(cwd, "app", "docs"), + path.join(cwd, "src", "app", "docs"), + ]; + + const diag: Diag = { + cwd, + node: process.version, + hasFs: hasFs(), + candidates, + envHints: { + NEXT_RUNTIME: process.env.NEXT_RUNTIME ?? null, + NODE_ENV: process.env.NODE_ENV ?? null, + }, + }; + + try { + if (!diag.hasFs) { + return NextResponse.json( + { ok: false, reason: "fs-unavailable", diag }, + { status: 500 }, + ); + } + + // pick the first existing candidate + const docsRoot = candidates.find((p) => fs.existsSync(p)); + if (!docsRoot) { + return NextResponse.json( + { + ok: false, + reason: "docs-root-not-found", + diag: { + ...diag, + exists: Object.fromEntries( + candidates.map((p) => [p, fs.existsSync(p)]), + ), + }, + }, + { status: 500 }, + ); + } + + // try to list + let tree: DirNode[] = []; + try { + tree = buildTree(docsRoot, 2); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + return NextResponse.json( + { + ok: false, + reason: "buildTree-failed", + error: msg, + diag: { ...diag, docsRoot }, + }, + { status: 500 }, + ); + } + + return NextResponse.json({ ok: true, docsRoot, tree, diag }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return NextResponse.json( + { ok: false, reason: "unhandled", error: msg, diag }, + { status: 500 }, + ); + } +} diff --git a/app/components/Community.tsx b/app/components/Community.tsx index e90a073..cac6887 100644 --- a/app/components/Community.tsx +++ b/app/components/Community.tsx @@ -1,5 +1,5 @@ -import { Button } from "./ui/button"; -import { Card, CardContent } from "./ui/card"; +import { Button } from "../../components/ui/button"; +import { Card, CardContent } from "../../components/ui/card"; import { ExternalLink, MessageCircle, diff --git a/app/components/Contribute.module.css b/app/components/Contribute.module.css new file mode 100644 index 0000000..15402ea --- /dev/null +++ b/app/components/Contribute.module.css @@ -0,0 +1,59 @@ +@keyframes drift { + 0% { + transform: translateX(-10%); + } + 100% { + transform: translateX(110%); + } +} + +@keyframes twinkle { + 0% { + opacity: 0.35; + transform: scale(1); + } + 50% { + opacity: 1; + transform: scale(1.15); + } + 100% { + opacity: 0.35; + transform: scale(1); + } +} + +.driftSlow { + animation: drift 42s linear infinite; +} + +.driftFast { + animation: drift 26s linear infinite; +} + +.twinkle { + animation: twinkle 2.8s ease-in-out infinite; +} + +.twinkleDelay1 { + animation: twinkle 3.2s ease-in-out infinite; + animation-delay: 0.4s; +} + +.twinkleDelay2 { + animation: twinkle 3.6s ease-in-out infinite; + animation-delay: 0.8s; +} + +.twinkleDelay3 { + animation: twinkle 4s ease-in-out infinite; + animation-delay: 1.2s; +} + +.textGlow { + /* 多层阴影增强对比度:深色描边 + 柔和外晕 */ + text-shadow: + 0 1px 2px rgba(0, 0, 0, 0.55), + 0 2px 6px rgba(0, 0, 0, 0.35), + 0 0 12px rgba(255, 255, 255, 0.12); + -webkit-text-stroke: 0.35px rgba(0, 0, 0, 0.35); +} diff --git a/app/components/Contribute.tsx b/app/components/Contribute.tsx new file mode 100644 index 0000000..02254b4 --- /dev/null +++ b/app/components/Contribute.tsx @@ -0,0 +1,291 @@ +"use client"; + +import React, { useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { ExternalLink, Plus, Sparkles } from "lucide-react"; +import styles from "./Contribute.module.css"; + +// --- antd +import { TreeSelect } from "antd"; +import type { DefaultOptionType } from "antd/es/select"; +import { DataNode } from "antd/es/tree"; + +const REPO_OWNER = "InvolutionHell"; +const REPO_NAME = "involutionhell.github.io"; +const DEFAULT_BRANCH = "main"; +const DOCS_BASE = "app/docs"; + +type DirNode = { name: string; path: string; children?: DirNode[] }; + +function buildGithubNewUrl(dirPath: string, filename: string, title: string) { + const file = filename.endsWith(".mdx") ? filename : `${filename}.mdx`; + const fullDir = `${DOCS_BASE}/${dirPath}`.replace(/\/+/g, "/"); + const frontMatter = `--- +title: ${title || "New Article"} +description: +date: ${new Date().toISOString().slice(0, 10)} +tags: [] +--- + +# ${title || "New Article"} + +Write your content here. +`; + const params = new URLSearchParams({ filename: file, value: frontMatter }); + return `https://github.com/${REPO_OWNER}/${REPO_NAME}/new/${DEFAULT_BRANCH}/${encodeURIComponent(fullDir)}?${params.toString()}`; +} + +// ✅ 用纯文本 label + 一级节点 selectable:false +function toTreeSelectData(tree: DirNode[]): DefaultOptionType[] { + return tree.map((l1) => ({ + key: l1.path, + value: l1.path, + label: l1.name, + selectable: false, // ✅ 一级不可选 + children: [ + ...(l1.children || []).map((l2) => ({ + key: l2.path, + value: l2.path, + label: `${l1.name} / ${l2.name}`, // 纯文本,方便搜索 + isLeaf: true, + })), + { + key: `${l1.path}/__create__`, + value: `${l1.path}/__create__`, + label: ( + + + 在「{l1.name}」下新建二级子栏目… + + ), + }, + ], + })); +} + +export function Contribute() { + const [open, setOpen] = useState(false); + const [tree, setTree] = useState([]); + const [loading, setLoading] = useState(false); + + // ✅ Hooks 必须在组件内部 + const [expandedKeys, setExpandedKeys] = useState([]); // 受控展开状态 + const [selectedKey, setSelectedKey] = useState(""); + const [newSub, setNewSub] = useState(""); + const [articleTitle, setArticleTitle] = useState(""); + const [articleFile, setArticleFile] = useState(""); + + useEffect(() => { + let mounted = true; + (async () => { + setLoading(true); + try { + const res = await fetch("/api/docs-tree", { cache: "no-store" }); + const data = await res.json(); + if (mounted && data?.ok) setTree(data.tree || []); + } catch { + const res = await fetch("/docs-tree.json").catch(() => null); + const data = await res?.json(); + if (mounted && data?.ok) setTree(data.tree || []); + } finally { + setLoading(false); + } + })(); + return () => { + mounted = false; + }; + }, []); + + const options = useMemo(() => toTreeSelectData(tree), [tree]); + + const finalDirPath = useMemo(() => { + if (!selectedKey) return ""; + if (selectedKey.endsWith("/__create__")) { + const l1 = selectedKey.split("/")[0]; + if (!newSub.trim()) return ""; + return `${l1}/${newSub.trim().replace(/\s+/g, "-")}`; + } + return selectedKey; + }, [selectedKey, newSub]); + + const canProceed = !!finalDirPath && (articleTitle || articleFile); + + const handleOpenGithub = () => { + if (!canProceed) return; + const filename = (articleFile || articleTitle || "new-article") + .trim() + .replace(/\s+/g, "-") + .toLowerCase(); + const title = articleTitle || filename; + window.open( + buildGithubNewUrl(finalDirPath, filename, title), + "_blank", + "noopener,noreferrer", + ); + }; + + return ( + { + setOpen(v); + if (!v) { + setSelectedKey(""); + setNewSub(""); + } + }} + > + + + + + + 我要投稿 + + 选择栏目(单选、可搜索;一级仅用于展开),或在一级下新建二级子栏目,然后跳转到 + GitHub 新建文章。 + + + +
+ + setSelectedKey((val as string) ?? "")} + showSearch + // 用 label 做过滤(label 都是可读文本) + treeNodeFilterProp="label" + filterTreeNode={(input, node) => + String(node.label ?? "") + .toLowerCase() + .includes(input.toLowerCase()) + } + // ✅ 默认折叠;点标题即可展开/收起 + treeExpandedKeys={expandedKeys} + onTreeExpand={(keys) => setExpandedKeys(keys as string[])} + treeExpandAction="click" + // 下拉不顶满,挂到触发元素父节点内,避免被 Dialog 裁剪 + popupMatchSelectWidth={false} + listHeight={360} + getPopupContainer={(trigger) => + trigger?.parentElement ?? document.body + } + placeholder="请选择(可搜索)" + allowClear + treeLine + /> +
+ + {selectedKey.endsWith("/__create__") && ( +
+ + setNewSub(e.target.value)} + /> +

+ 将创建路径:{selectedKey.split("/")[0]} / {newSub || "<未填写>"} +

+
+ )} + +
+ + setArticleTitle(e.target.value)} + /> + + setArticleFile(e.target.value)} + /> +
+ + +
+ 路径预览: + {finalDirPath || "(未选择)"} +
+ +
+
+
+ ); +} diff --git a/app/components/Features.tsx b/app/components/Features.tsx index 450f139..6357339 100644 --- a/app/components/Features.tsx +++ b/app/components/Features.tsx @@ -1,5 +1,5 @@ -import { Card, CardContent } from "./ui/card"; -import { Badge } from "./ui/badge"; +import { Card, CardContent } from "../../components/ui/card"; +import { Badge } from "../../components/ui/badge"; import { Users, Zap, Heart } from "lucide-react"; import { Github as GithubIcon } from "./icons/Github"; diff --git a/app/components/Header.tsx b/app/components/Header.tsx index effdfbd..e751934 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -1,5 +1,5 @@ import { ThemeToggle } from "./ThemeToggle"; -import { Button } from "./ui/button"; +import { Button } from "../../components/ui/button"; import { MessageCircle } from "lucide-react"; import { Github as GithubIcon } from "./icons/Github"; diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx index 3453908..52fbefe 100644 --- a/app/components/Hero.tsx +++ b/app/components/Hero.tsx @@ -1,8 +1,9 @@ import Link from "next/link"; -import { Button } from "./ui/button"; +import { Button } from "../../components/ui/button"; import { ExternalLink } from "lucide-react"; import { Github as GithubIcon } from "./icons/Github"; import { ZoteroFeed } from "@/app/components/ZoteroFeed"; +import { Contribute } from "@/app/components/Contribute"; export function Hero() { const categories: { title: string; desc: string; href: string }[] = [ @@ -16,6 +17,11 @@ export function Hero() { desc: "数据结构、算法与基础计算机科学知识", href: "/docs/computer-science", }, + { + title: "笔试面经", + desc: "可以给我一份工作吗?我什么都可以做!", + href: "/docs/jobs", + }, { title: "群友分享", desc: "群友写的捏", @@ -67,6 +73,8 @@ export function Hero() { + {/* 投稿按钮 */} + {/* Top-level directories */}
目录
diff --git a/app/components/ThemeToggle.tsx b/app/components/ThemeToggle.tsx index 6adce57..1afc9d5 100644 --- a/app/components/ThemeToggle.tsx +++ b/app/components/ThemeToggle.tsx @@ -1,6 +1,6 @@ "use client"; import { Moon, Sun } from "lucide-react"; -import { Button } from "./ui/button"; +import { Button } from "../../components/ui/button"; import { useTheme } from "./ThemeProvider"; export function ThemeToggle() { diff --git "a/app/docs/jobs/\344\270\232\345\212\241\346\265\201/HelloWorld.md" "b/app/docs/jobs/\344\270\232\345\212\241\346\265\201/HelloWorld.md" new file mode 100644 index 0000000..a665e78 --- /dev/null +++ "b/app/docs/jobs/\344\270\232\345\212\241\346\265\201/HelloWorld.md" @@ -0,0 +1,11 @@ +--- +title: Hello World +description: First page +date: "2025-09-11" +tags: + - intro +--- + +# Hello World + +期待您的投稿 diff --git "a/app/docs/jobs/\346\212\200\346\234\257\346\240\210/HelloWorld.md" "b/app/docs/jobs/\346\212\200\346\234\257\346\240\210/HelloWorld.md" new file mode 100644 index 0000000..a665e78 --- /dev/null +++ "b/app/docs/jobs/\346\212\200\346\234\257\346\240\210/HelloWorld.md" @@ -0,0 +1,11 @@ +--- +title: Hello World +description: First page +date: "2025-09-11" +tags: + - intro +--- + +# Hello World + +期待您的投稿 diff --git "a/app/docs/jobs/\347\254\224\350\257\225\351\235\242\347\273\217/HelloWorld.md" "b/app/docs/jobs/\347\254\224\350\257\225\351\235\242\347\273\217/HelloWorld.md" new file mode 100644 index 0000000..a665e78 --- /dev/null +++ "b/app/docs/jobs/\347\254\224\350\257\225\351\235\242\347\273\217/HelloWorld.md" @@ -0,0 +1,11 @@ +--- +title: Hello World +description: First page +date: "2025-09-11" +tags: + - intro +--- + +# Hello World + +期待您的投稿 diff --git a/app/layout.tsx b/app/layout.tsx index ff5479e..c609f04 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,6 +4,7 @@ import { RootProvider } from "fumadocs-ui/provider"; import Script from "next/script"; import "./globals.css"; import { ThemeProvider } from "@/app/components/ThemeProvider"; +import { SpeedInsights } from "@vercel/speed-insights/next"; const geistSans = localFont({ src: "./fonts/GeistVF.woff", @@ -51,6 +52,7 @@ export default function RootLayout({
{children}
+ {/* 谷歌分析 */}