Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 30 additions & 23 deletions app/components/Contribute.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import React, { useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
Dialog,
Expand Down Expand Up @@ -51,6 +52,7 @@ Write your content here.

export function Contribute() {
const router = useRouter();
const t = useTranslations("contribute");
const [open, setOpen] = useState(false);
const [tree, setTree] = useState<DirNode[]>([]);
const [loading, setLoading] = useState(false);
Expand All @@ -71,18 +73,17 @@ export function Contribute() {
if (!normalizedArticleFile) {
return {
isFileNameValid: false,
fileNameError: "请填写文件名。",
fileNameError: t("dialog.filenameError.empty"),
};
}
if (!FILENAME_PATTERN.test(normalizedArticleFile)) {
return {
isFileNameValid: false,
fileNameError:
"文件名仅支持字母、数字、连字符或下划线,并需以字母或数字开头。",
fileNameError: t("dialog.filenameError.invalid"),
};
}
return { isFileNameValid: true, fileNameError: "" };
}, [normalizedArticleFile]);
}, [normalizedArticleFile, t]);

useEffect(() => {
let mounted = true;
Expand Down Expand Up @@ -179,7 +180,7 @@ export function Contribute() {
>
<span className="relative z-10 flex items-center gap-4">
<Sparkles className="h-6 w-6" />
<span>Submit Contribution / 投稿 </span>
<span>{t("button")}</span>
</span>
</Button>
</DialogTrigger>
Expand All @@ -188,25 +189,24 @@ export function Contribute() {
href="https://github.com/InvolutionHell/involutionhell?tab=contributing-ov-file#%E6%8A%95%E7%A8%BF%E6%8C%87%E5%8D%97"
target="_blank"
rel="noopener noreferrer"
aria-label="查看投稿指南"
title="查看投稿指南"
aria-label={t("guideAriaLabel")}
title={t("guideAriaLabel")}
className="absolute top-0 right-0 flex h-10 w-10 translate-x-1/2 -translate-y-1/2 items-center justify-center border border-[var(--foreground)] bg-[var(--background)] text-[var(--foreground)] font-mono hover:bg-[#CC0000] hover:text-white transition-colors z-20"
>
<span className="text-sm font-bold">?</span>
<span className="sr-only">查看投稿指南</span>
<span className="sr-only">{t("guideAriaLabel")}</span>
</a>
</div>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>我要投稿</DialogTitle>
<DialogDescription>
选择栏目(单选、可搜索;一级仅用于展开),或在一级下新建二级子栏目,然后跳转到
GitHub 新建文章。
</DialogDescription>
<DialogTitle>{t("dialog.title")}</DialogTitle>
<DialogDescription>{t("dialog.description")}</DialogDescription>
</DialogHeader>

<div className="space-y-2">
<label className="text-sm font-medium">选择栏目</label>
<label className="text-sm font-medium">
{t("dialog.selectLabel")}
</label>
<TreeSelect
className="w-full"
treeData={options as DataNode[]}
Expand All @@ -231,37 +231,42 @@ export function Contribute() {
getPopupContainer={(trigger) =>
trigger?.parentElement ?? document.body
}
placeholder="请选择(可搜索)"
placeholder={t("dialog.selectPlaceholder")}
allowClear
treeLine
/>
</div>

{selectedKey.endsWith(CREATE_SUBDIR_SUFFIX) && (
<div className="space-y-1">
<label className="text-sm font-medium">新建二级子栏目名称</label>
<label className="text-sm font-medium">
{t("dialog.subdirLabel")}
</label>
<Input
placeholder="e.g. foundation-models"
value={newSub}
onChange={(e) => setNewSub(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
将创建路径:{selectedKey.split("/")[0]} /{" "}
{sanitizedSubdir || "<未填写>"}
{t("dialog.subdirPathPrefix")}
{selectedKey.split("/")[0]} /{" "}
{sanitizedSubdir || t("dialog.subdirEmpty")}
</p>
</div>
)}

<div className="grid gap-2">
<label className="text-sm font-medium">
文章标题(front-matter)
{t("dialog.articleTitleLabel")}
</label>
<Input
placeholder="e.g. A Gentle Intro to Transformers"
value={articleTitle}
onChange={(e) => setArticleTitle(e.target.value)}
/>
<label className="text-sm font-medium">文件名(必填)</label>
<label className="text-sm font-medium">
{t("dialog.filenameLabel")}
</label>
<Input
placeholder="e.g. intro-to-transformers"
value={articleFile}
Expand All @@ -278,11 +283,13 @@ export function Contribute() {

<DialogFooter className="flex items-center justify-between">
<div className="text-xs text-muted-foreground">
路径预览:
<code className="font-mono">{finalDirPath || "(未选择)"}</code>
{t("dialog.pathPreview")}
<code className="font-mono">
{finalDirPath || t("dialog.pathEmpty")}
</code>
</div>
<Button onClick={handleOpenGithub} disabled={!canProceed}>
继续在 GitHub 新建页面 <ExternalLink className="ml-2 h-4 w-4" />
{t("dialog.submit")} <ExternalLink className="ml-2 h-4 w-4" />
</Button>
</DialogFooter>
</DialogContent>
Expand Down
55 changes: 21 additions & 34 deletions app/components/DocHistoryPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,16 @@

import { useEffect, useReducer } from "react";
import Image from "next/image";
import { useTranslations } from "next-intl";
import type { HistoryItem } from "@/app/types/docs-history";

// author 缺失时用 1x1 透明占位图,避免 <Image> 收到空 src 报错
const FALLBACK_AVATAR =
"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><rect width='24' height='24' fill='%23e5e7eb'/></svg>";

interface DocHistoryPanelProps {
path: string;
}

// 将 items / error / loading 合并成一个 discriminated union,
// 避免 effect 里多次同步 setState 触发 react-hooks/set-state-in-effect
// 同时天然保证三种状态互斥(不会同时出现"错误提示 + 旧列表")
type State =
| { status: "loading" }
| { status: "ok"; items: HistoryItem[] }
Expand All @@ -31,22 +28,6 @@ function reducer(_: State, action: Action): State {
return { status: "error", message: action.message };
}

// 将 ISO 日期转为相对时间描述(中文)
function relativeTime(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60_000);
if (minutes < 1) return "刚刚";
if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} 小时前`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days} 天前`;
const months = Math.floor(days / 30);
if (months < 12) return `${months} 个月前`;
return `${Math.floor(months / 12)} 年前`;
}

// 骨架屏占位行
function SkeletonRow() {
return (
<div className="flex items-center gap-3 py-2.5 animate-pulse">
Expand All @@ -61,10 +42,23 @@ function SkeletonRow() {

export function DocHistoryPanel({ path }: DocHistoryPanelProps) {
const [state, dispatch] = useReducer(reducer, { status: "loading" });
const t = useTranslations("docHistory");

function relativeTime(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60_000);
if (minutes < 1) return t("timeAgo.justNow");
if (minutes < 60) return t("timeAgo.minutesAgo", { n: minutes });
const hours = Math.floor(minutes / 60);
if (hours < 24) return t("timeAgo.hoursAgo", { n: hours });
const days = Math.floor(hours / 24);
if (days < 30) return t("timeAgo.daysAgo", { n: days });
const months = Math.floor(days / 30);
if (months < 12) return t("timeAgo.monthsAgo", { n: months });
return t("timeAgo.yearsAgo", { n: Math.floor(months / 12) });
}

useEffect(() => {
// 用 dispatch 而不是多次 setState,规避 react-hooks/set-state-in-effect lint;
// path 变化时立刻回到 loading,避免"错误提示 + 旧列表"并存
dispatch({ type: "fetch" });
let cancelled = false;
fetch(`/api/docs/history?path=${encodeURIComponent(path)}`)
Expand All @@ -76,28 +70,27 @@ export function DocHistoryPanel({ path }: DocHistoryPanelProps) {
} else {
dispatch({
type: "error",
message: json.error ?? "无法加载历史",
message: json.error ?? t("loadError"),
});
}
})
.catch(() => {
if (!cancelled) {
dispatch({ type: "error", message: "无法加载历史" });
dispatch({ type: "error", message: t("loadError") });
}
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [path]);

return (
<div className="font-serif">
{/* 报纸风格标题 */}
<h2 className="text-xs font-mono uppercase tracking-widest text-neutral-400 dark:text-neutral-500 mb-3 border-b border-neutral-200 dark:border-neutral-700 pb-2">
最近更新
{t("heading")}
</h2>

{/* 加载中 */}
{state.status === "loading" && (
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
<SkeletonRow />
Expand All @@ -106,21 +99,18 @@ export function DocHistoryPanel({ path }: DocHistoryPanelProps) {
</div>
)}

{/* 错误 */}
{state.status === "error" && (
<p className="text-xs font-mono text-neutral-400 dark:text-neutral-500 py-2">
{state.message}
</p>
)}

{/* 空结果 */}
{state.status === "ok" && state.items.length === 0 && (
<p className="text-xs font-mono text-neutral-400 dark:text-neutral-500 py-2">
暂无更新记录
{t("empty")}
</p>
)}

{/* 历史列表 */}
{state.status === "ok" && state.items.length > 0 && (
<ol className="divide-y divide-neutral-100 dark:divide-neutral-800">
{state.items.map((item) => (
Expand All @@ -131,7 +121,6 @@ export function DocHistoryPanel({ path }: DocHistoryPanelProps) {
rel="noopener noreferrer"
className="flex items-start gap-3 py-2.5 group hover:bg-neutral-50 dark:hover:bg-neutral-900 transition-colors px-1 -mx-1"
>
{/* 头像 */}
<Image
src={item.avatarUrl || FALLBACK_AVATAR}
alt={item.authorLogin}
Expand All @@ -142,11 +131,9 @@ export function DocHistoryPanel({ path }: DocHistoryPanelProps) {
/>

<div className="flex-1 min-w-0">
{/* commit message,截断超长内容 */}
<p className="text-sm leading-snug text-neutral-800 dark:text-neutral-200 truncate group-hover:text-[#CC0000] transition-colors">
{item.message}
</p>
{/* 作者 + 时间,monospace 风格 */}
<p className="text-[11px] font-mono text-neutral-400 dark:text-neutral-500 mt-0.5">
{item.authorName}
<span className="mx-1 opacity-40">·</span>
Expand Down
10 changes: 4 additions & 6 deletions app/components/DocShareButton.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { trackEvent } from "@/lib/analytics";

/**
Expand All @@ -9,7 +10,7 @@ import { trackEvent } from "@/lib/analytics";
*/
export function DocShareButton() {
const [copied, setCopied] = useState(false);
// timer ref:每次新点击 / 组件卸载时清掉旧 timer,避免 setState on unmounted
const t = useTranslations("docShare");
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
Expand All @@ -23,14 +24,12 @@ export function DocShareButton() {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
// 旧 timer 先清掉,避免连点两次后提前恢复文案
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
resetTimerRef.current = setTimeout(() => setCopied(false), 2000);
} catch {
// clipboard 不可用时静默失败
}

// 埋点在复制动作发生后立即上报,不依赖 clipboard 是否成功
trackEvent("doc_share", { path: window.location.pathname, url });
};

Expand All @@ -39,7 +38,7 @@ export function DocShareButton() {
type="button"
onClick={handleCopy}
className="inline-flex items-center gap-2 px-3 py-1.5 font-mono text-xs uppercase tracking-widest border border-[var(--foreground)] hover:bg-[var(--foreground)] hover:text-[var(--background)] transition-colors"
aria-label="复制页面链接"
aria-label={t("ariaLabel")}
>
<svg
aria-hidden="true"
Expand All @@ -51,11 +50,10 @@ export function DocShareButton() {
strokeLinecap="round"
strokeLinejoin="round"
>
{/* link 图标 */}
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
{copied ? "已复制" : "复制链接"}
{copied ? t("copied") : t("copy")}
</button>
);
}
Loading
Loading