新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
480 lines
17 KiB
TypeScript
480 lines
17 KiB
TypeScript
"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);
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
}
|