Files
jiang13-bbs/frontend/components/TimelineEditor.tsx
freefire 4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

480 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useState } from "react";
import {
ArrowDown,
ArrowUp,
GitBranch,
Loader2,
Plus,
Tag,
Trash2,
} from "lucide-react";
import Modal from "@/components/Modal";
import ConfirmDialog from "@/components/ConfirmDialog";
import {
isLikelyMergeCommit,
parseTimelineBody,
sanitizeTimelineText,
sanitizeTimelineTitle,
serializeTimelineBlock,
type TimelineItem,
} from "@/lib/timelineBlocks";
import { apiTimelineFromGit, apiTimelineFromReleases, type TimelineGitItem } from "@/lib/api";
import { toast } from "@/lib/toast";
type ImportMode = "replace" | "append";
type TimelineEditorProps = {
open: boolean;
/** 编辑已有块时传入 body;新建为空 */
initialBody?: string;
onInsert: (block: string) => void;
onClose: () => void;
};
function todayISO(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function fromGitItem(it: TimelineGitItem): TimelineItem {
const title = sanitizeTimelineTitle(it.title || "提交");
return {
date: /^\d{4}-\d{2}-\d{2}$/.test(it.date) ? it.date : todayISO(),
title,
body: sanitizeTimelineText(it.body || "", 2000),
sourceUrl: it.source_url,
selected: !isLikelyMergeCommit(title),
};
}
function emptyItem(): TimelineItem {
return {
date: todayISO(),
title: "",
body: "",
selected: true,
};
}
function seedItems(initialBody: string): TimelineItem[] {
const parsed = parseTimelineBody(initialBody);
return parsed.length > 0 ? parsed : [emptyItem()];
}
export default function TimelineEditor({
open,
initialBody = "",
onInsert,
onClose,
}: TimelineEditorProps) {
const [items, setItems] = useState<TimelineItem[]>(() => seedItems(initialBody));
const [urlsText, setUrlsText] = useState("");
const [followPages, setFollowPages] = useState(true);
const [importMode, setImportMode] = useState<ImportMode>("replace");
const [importing, setImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [deleteIdx, setDeleteIdx] = useState<number | null>(null);
useEffect(() => {
if (!open) return;
setItems(seedItems(initialBody));
setUrlsText("");
setImportError(null);
setFollowPages(true);
setImportMode(initialBody.trim() ? "append" : "replace");
}, [open, initialBody]);
const updateItem = useCallback((idx: number, patch: Partial<TimelineItem>) => {
setItems((prev) => prev.map((it, i) => (i === idx ? { ...it, ...patch } : it)));
}, []);
const moveItem = (idx: number, dir: -1 | 1) => {
setItems((prev) => {
const j = idx + dir;
if (j < 0 || j >= prev.length) return prev;
const next = [...prev];
[next[idx], next[j]] = [next[j], next[idx]];
return next;
});
};
const sortByDate = () => {
setItems((prev) =>
[...prev].sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0))
);
};
const collectURLs = () =>
urlsText
.split(/\n/)
.map((l) => l.trim())
.filter(Boolean);
const applyImported = (mapped: TimelineItem[], errorHint: string) => {
if (mapped.length === 0) {
setImportError(errorHint);
return false;
}
setItems((prev) => {
if (importMode === "append") {
const seen = new Set(
prev.map((p) => p.sourceUrl || `${p.date}|${p.title}`).filter(Boolean)
);
const extra = mapped.filter(
(m) => !m.sourceUrl || !seen.has(m.sourceUrl)
);
return [...prev.filter((p) => p.title.trim() || p.body.trim()), ...extra];
}
return mapped;
});
return true;
};
const runImportCommits = async () => {
const urls = collectURLs();
if (urls.length === 0) {
setImportError("请粘贴至少一条提交记录页面 URL");
return;
}
setImporting(true);
setImportError(null);
try {
const res = await apiTimelineFromGit({ urls, follow_pages: followPages });
const mapped = (res.items || []).map(fromGitItem);
if (!applyImported(mapped, res.error || "未能解析出提交;请检查 URL 或改为手填")) {
if (res.warning) toast(res.warning);
return;
}
if (res.warning) toast(res.warning, "ok");
else toast(`已导入 ${mapped.length} 条,请点「插入/更新时间线」写入正文`, "ok");
if (res.error) setImportError(res.error);
} catch (e) {
setImportError(e instanceof Error ? e.message : "导入失败,请手填时间线");
} finally {
setImporting(false);
}
};
const runImportReleases = async () => {
const urls = collectURLs();
if (urls.length === 0) {
setImportError("请粘贴至少一条 Release 页面 URL");
return;
}
setImporting(true);
setImportError(null);
try {
const res = await apiTimelineFromReleases({ urls });
const mapped = (res.items || []).map(fromGitItem);
if (!applyImported(mapped, res.error || "未能解析出 Release;请检查 URL 或改为手填")) {
if (res.warning) toast(res.warning);
return;
}
if (res.warning) toast(res.warning, "ok");
else toast(`已从 Release 导入 ${mapped.length} 条,请点「插入/更新时间线」写入正文`, "ok");
if (res.error) setImportError(res.error);
} catch (e) {
setImportError(e instanceof Error ? e.message : "导入失败,请手填时间线");
} finally {
setImporting(false);
}
};
const handleInsert = () => {
const kept = items.filter((it) => it.selected !== false);
if (kept.length === 0) {
toast("请至少保留一条时间线项目", "error");
return;
}
for (const it of kept) {
if (!it.title.trim()) {
toast("每条时间线需要标题", "error");
return;
}
}
onInsert(serializeTimelineBlock(kept));
onClose();
};
const keptCount = items.filter((it) => it.selected !== false).length;
const urlCount = urlsText
.split(/\n/)
.map((l) => l.trim())
.filter(Boolean).length;
const hasExistingContent = items.some(
(it) => it.title.trim() || it.body.trim()
);
const showReplaceWarning = importMode === "replace" && hasExistingContent;
return (
<>
<Modal
open={open}
title="编辑时间线"
onClose={onClose}
zIndexClass="z-[1200]"
maxWidthClass="max-w-2xl"
description="从提交记录批量导入,或手动编辑后插入正文。"
panelClassName="p-5 sm:p-6 max-h-[88vh] flex flex-col overflow-hidden"
bodyClassName="flex-1 min-h-0 flex flex-col"
footer={
<div className="w-full flex items-center justify-between gap-2">
<span
className="text-[12.5px]"
style={{ color: keptCount > 0 ? "var(--ink-3)" : "var(--danger)" }}
>
{keptCount > 0
? `将${initialBody.trim() ? "更新" : "插入"} ${keptCount} 条`
: "没有可保留的条目"}
</span>
<div className="flex items-center gap-2">
<button type="button" className="btn btn-line btn-sm" onClick={onClose}>
取消
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleInsert}
disabled={keptCount === 0}
>
{initialBody.trim() ? "更新时间线" : "插入时间线"}
</button>
</div>
</div>
}
>
<div className="flex-1 min-h-0 overflow-y-auto space-y-4">
{/* 导入区 */}
<section className="j13-tl-import" aria-label="从提交记录导入">
<div className="flex items-center gap-2 mb-2">
<GitBranch size={15} style={{ color: "var(--accent)" }} />
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
从提交记录导入
</p>
</div>
<p className="meta text-[12px] mb-2 leading-relaxed">
粘贴 Gitea / GitHub 的 commits 或 releases 列表页 URL,每行一条。点「导入提交」拉取提交记录,点「导入 Release」拉取发布日志。
</p>
<textarea
value={urlsText}
onChange={(e) => {
setUrlsText(e.target.value);
setImportError(null);
}}
rows={3}
placeholder={"https://github.com/org/repo/commits/main\nhttps://git.iioio.com/freefire/jiang13-bbs/releases"}
className="j13-tl-urls"
disabled={importing}
/>
{urlCount > 0 ? (
<p className="j13-tl-hint">已识别 {urlCount} 条 URL</p>
) : null}
<div className="mt-2 flex flex-wrap items-center gap-3">
<label className="inline-flex items-center gap-1.5 text-[12.5px]" style={{ color: "var(--ink-2)" }}>
<input
type="checkbox"
checked={followPages}
onChange={(e) => setFollowPages(e.target.checked)}
disabled={importing}
/>
自动拉取后续页
</label>
<div className="flex items-center gap-1 text-[12.5px]" style={{ color: "var(--ink-2)" }}>
<span>导入方式</span>
<select
className="j13-tl-select"
value={importMode}
onChange={(e) => setImportMode(e.target.value as ImportMode)}
disabled={importing}
>
<option value="replace">替换当前</option>
<option value="append">追加</option>
</select>
</div>
<div className="ml-auto flex items-center gap-1.5">
<button
type="button"
className="btn btn-line btn-sm"
disabled={importing}
onClick={() => void runImportCommits()}
>
{importing ? (
<>
<Loader2 size={14} className="animate-spin" /> 导入中…
</>
) : (
<>
<GitBranch size={14} /> 导入提交
</>
)}
</button>
<button
type="button"
className="btn btn-line btn-sm"
disabled={importing}
onClick={() => void runImportReleases()}
>
{importing ? (
<>
<Loader2 size={14} className="animate-spin" /> 导入中…
</>
) : (
<>
<Tag size={14} /> 导入 Release
</>
)}
</button>
</div>
</div>
{showReplaceWarning ? (
<p className="j13-tl-banner is-warn">
替换模式会丢弃当前已编辑的 {items.length} 条内容;如需保留请改选「追加」。
</p>
) : null}
{importError ? (
<p className="j13-tl-banner is-err">{importError}</p>
) : null}
</section>
{/* 条目列表 */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
时间线条目
</p>
<span className="j13-tl-count is-muted">{items.length} 项</span>
</div>
<div className="flex items-center gap-1.5">
<button type="button" className="btn btn-line btn-sm" onClick={sortByDate}>
按日期整理
</button>
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setItems((prev) => [emptyItem(), ...prev])}
>
<Plus size={14} /> 添加
</button>
</div>
</div>
{items.length === 0 ? (
<div className="j13-tl-empty">
<p>还没有时间线条目</p>
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setItems([emptyItem()])}
>
<Plus size={14} /> 添加一条
</button>
</div>
) : (
<ul className="space-y-3">
{items.map((it, idx) => (
<li
key={idx}
className={`j13-tl-edit-card${it.selected === false ? " is-excluded" : ""}`}
>
<div className="flex items-start gap-2">
<label className="mt-2 shrink-0">
<input
type="checkbox"
checked={it.selected !== false}
onChange={(e) => updateItem(idx, { selected: e.target.checked })}
aria-label="保留此条"
/>
</label>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
{it.selected === false ? (
<span className="j13-tl-tag">已排除</span>
) : null}
<input
type="date"
value={it.date}
onChange={(e) => updateItem(idx, { date: e.target.value })}
className="j13-tl-field w-[9.5rem]"
/>
<input
type="text"
value={it.title}
maxLength={200}
placeholder="标题"
onChange={(e) => updateItem(idx, { title: e.target.value })}
className="j13-tl-field flex-1 min-w-[10rem]"
/>
</div>
<textarea
value={it.body}
rows={2}
placeholder="说明(可选,支持 Markdown)"
onChange={(e) => updateItem(idx, { body: e.target.value })}
className="j13-tl-field w-full"
/>
{it.sourceUrl ? (
<p className="meta text-[11.5px] truncate" title={it.sourceUrl}>
来自提交 · {it.sourceUrl}
</p>
) : null}
</div>
<div className="flex flex-col gap-0.5 shrink-0">
<button
type="button"
className="j13-md-tool"
aria-label="上移"
disabled={idx === 0}
onClick={() => moveItem(idx, -1)}
>
<ArrowUp size={14} />
</button>
<button
type="button"
className="j13-md-tool"
aria-label="下移"
disabled={idx === items.length - 1}
onClick={() => moveItem(idx, 1)}
>
<ArrowDown size={14} />
</button>
<button
type="button"
className="j13-md-tool"
aria-label="删除"
onClick={() => setDeleteIdx(idx)}
>
<Trash2 size={14} />
</button>
</div>
</div>
</li>
))}
</ul>
)}
</div>
</Modal>
<ConfirmDialog
open={deleteIdx != null}
title="删除条目"
message="确定删除这条时间线项目?"
danger
zIndexClass="z-[1300]"
confirmLabel="删除"
onCancel={() => setDeleteIdx(null)}
onConfirm={() => {
if (deleteIdx != null) {
setItems((prev) => prev.filter((_, i) => i !== deleteIdx));
}
setDeleteIdx(null);
}}
/>
</>
);
}