demo 20-4-2026

This commit is contained in:
taDuc
2026-04-19 23:43:31 +07:00
parent 57a7843d80
commit 2508172489
10 changed files with 1443 additions and 98 deletions

View File

@@ -0,0 +1,349 @@
"use client";
import { useEffect, useMemo, type ComponentProps } from "react";
import Tree from "react-d3-tree";
export type CommitTreeItem = {
id: string;
parent_commit_id: string | null;
restored_from_commit_id: string | null;
commit_no: number;
kind: string;
created_by: string;
created_at: string;
title: string | null;
};
type CommitTreeNode = {
commit: CommitTreeItem;
children: CommitTreeNode[];
};
type CommitTreeDatum = {
name: string;
commit: CommitTreeItem;
isHead: boolean;
detail: string;
restoredFromLabel: string | null;
children?: CommitTreeDatum[];
};
type Props = {
open: boolean;
commits: CommitTreeItem[];
headCommitId: string | null;
onClose: () => void;
};
type TreeRenderNode = NonNullable<ComponentProps<typeof Tree>["renderCustomNodeElement"]>;
export default function CommitTreePopup({
open,
commits,
headCommitId,
onClose,
}: Props) {
const { roots, commitById } = useMemo(() => buildCommitTree(commits), [commits]);
const treeData = useMemo(
() => roots.map((node) => toTreeDatum(node, commitById, headCommitId)),
[roots, commitById, headCommitId]
);
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [open, onClose]);
if (!open) return null;
return (
<div
role="presentation"
onClick={onClose}
style={{
position: "fixed",
inset: 0,
zIndex: 1000,
background: "rgba(2, 6, 23, 0.72)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "24px",
}}
>
<section
role="dialog"
aria-modal="true"
aria-label="Commit tree"
onClick={(event) => event.stopPropagation()}
style={{
width: "min(1120px, calc(100vw - 48px))",
maxHeight: "min(720px, calc(100vh - 48px))",
overflow: "hidden",
border: "1px solid #334155",
borderRadius: "8px",
background: "#0f172a",
color: "#e2e8f0",
boxShadow: "0 24px 80px rgba(0, 0, 0, 0.45)",
display: "flex",
flexDirection: "column",
}}
>
<style>
{`
.commit-tree-link {
fill: none;
stroke: #ffffff;
stroke-width: 4px;
stroke-opacity: 1;
filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.75));
}
`}
</style>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
padding: "14px 16px",
borderBottom: "1px solid #1f2937",
}}
>
<div>
<div style={{ fontSize: "16px", fontWeight: 700, color: "#f8fafc" }}>
Commit tree
</div>
<div style={{ marginTop: "3px", fontSize: "12px", color: "#94a3b8" }}>
{commits.length} commit{commits.length === 1 ? "" : "s"}
</div>
</div>
<button
type="button"
onClick={onClose}
style={{
padding: "7px 10px",
border: "1px solid #475569",
borderRadius: "4px",
background: "#111827",
color: "#f8fafc",
cursor: "pointer",
}}
>
Close
</button>
</div>
<div
style={{
padding: "16px",
overflow: "auto",
}}
>
{treeData.length === 0 ? (
<div style={{ color: "#94a3b8", fontSize: "14px" }}>
Chưa commit.
</div>
) : (
<div
style={{
width: "100%",
minWidth: "640px",
height: "540px",
border: "1px solid #64748b",
borderRadius: "6px",
background: "#111827",
overflow: "hidden",
}}
>
<Tree
data={treeData}
orientation="vertical"
translate={{ x: 520, y: 56 }}
nodeSize={{ x: 300, y: 165 }}
separation={{ siblings: 1.15, nonSiblings: 1.45 }}
pathFunc="step"
collapsible={false}
zoomable
draggable
scaleExtent={{ min: 0.45, max: 1.4 }}
renderCustomNodeElement={renderCommitTreeNode}
pathClassFunc={() => "commit-tree-link"}
/>
</div>
)}
</div>
</section>
</div>
);
}
const renderCommitTreeNode: TreeRenderNode = function renderCommitTreeNode({ nodeDatum }) {
const datum = nodeDatum as unknown as CommitTreeDatum;
const commit = datum.commit;
const isHead = datum.isHead;
return (
<g>
<circle
r={8}
fill={isHead ? "#16a34a" : "#111827"}
stroke={isHead ? "#bbf7d0" : "#f8fafc"}
strokeWidth={3}
/>
<foreignObject x={-115} y={18} width={230} height={96}>
<div
style={{
width: "220px",
minHeight: "78px",
padding: "8px 9px",
border: isHead ? "2px solid #86efac" : "2px solid #e2e8f0",
borderRadius: "6px",
background: isHead ? "#14532d" : "#1f2937",
color: "#f8fafc",
fontSize: "12px",
lineHeight: 1.35,
boxSizing: "border-box",
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "6px",
minWidth: 0,
}}
>
<span style={{ color: "#f8fafc", fontWeight: 700 }}>
#{commit.commit_no}
</span>
{isHead ? (
<span
style={{
padding: "1px 5px",
border: "1px solid #22c55e",
borderRadius: "4px",
color: "#bbf7d0",
fontSize: "10px",
fontWeight: 700,
}}
>
HEAD
</span>
) : null}
</div>
<div
title={formatCommitTitle(commit)}
style={{
marginTop: "4px",
color: "#f8fafc",
fontWeight: 700,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{formatCommitTitle(commit)}
</div>
<div style={{ marginTop: "4px", color: "#94a3b8" }}>
{datum.detail}
</div>
{datum.restoredFromLabel ? (
<div
title={datum.restoredFromLabel}
style={{
marginTop: "3px",
color: "#93c5fd",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{datum.restoredFromLabel}
</div>
) : null}
</div>
</foreignObject>
</g>
);
};
function buildCommitTree(commits: CommitTreeItem[]) {
const commitById = new Map<string, CommitTreeItem>();
const nodeById = new Map<string, CommitTreeNode>();
for (const commit of commits) {
commitById.set(commit.id, commit);
nodeById.set(commit.id, { commit, children: [] });
}
const roots: CommitTreeNode[] = [];
for (const node of nodeById.values()) {
const parentId = getDisplayParentCommitId(node.commit);
const parent = parentId ? nodeById.get(parentId) : null;
if (parent) {
parent.children.push(node);
} else {
roots.push(node);
}
}
const sortNodes = (nodes: CommitTreeNode[]) => {
nodes.sort((a, b) => a.commit.commit_no - b.commit.commit_no);
for (const node of nodes) {
sortNodes(node.children);
}
};
sortNodes(roots);
return { roots, commitById };
}
function toTreeDatum(
node: CommitTreeNode,
commitById: Map<string, CommitTreeItem>,
headCommitId: string | null
): CommitTreeDatum {
const commit = node.commit;
const restoredFromCommit = commit.restored_from_commit_id
? commitById.get(commit.restored_from_commit_id) || null
: null;
const children = node.children.map((child) => toTreeDatum(child, commitById, headCommitId));
return {
name: formatCommitTitle(commit),
commit,
isHead: headCommitId === commit.id,
detail: `${commit.kind} by ${commit.created_by} - ${formatDateTime(commit.created_at)}`,
restoredFromLabel: restoredFromCommit
? `restored from #${restoredFromCommit.commit_no} ${formatCommitTitle(restoredFromCommit)}`
: null,
children: children.length ? children : undefined,
};
}
function getDisplayParentCommitId(commit: CommitTreeItem): string | null {
if (commit.kind === "restore" && commit.restored_from_commit_id) {
return commit.restored_from_commit_id;
}
return commit.parent_commit_id;
}
function formatCommitTitle(commit: CommitTreeItem): string {
return commit.title?.trim() || `Commit #${commit.commit_no}`;
}
function formatDateTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}