refactor: Phase 14 UI/UX polish pass
CI / typecheck + build (turbo) (push) Canceled after 0s

- Section index: doc-title-based tree, folder doc counts, active+parent highlighting, recent list with dates, doc counter badge
- Folder index: rich card listing with doc titles (not path slugs), dates, tags, subfolder grid with doc counts, breadcrumb
- Doc page: metadata card (bg-[#0f1011] border), structured header with author/date/tags, badge title tooltips, improved FolderIndex breadcrumb
- Sidebar: recursive hierarchical tree with depth indentation + 16px/level, folder doc counts, section icons, active parent highlighting, alphabetical sort
- DocForm: clean 2-column grid layout, no duplicate slug field, edit-mode loads existing custom fields
- Homepage: inline folder tree per section card
- Header nav: added Writeups/Research/Notes links
- Deleted duplicate apps/web/app/docs/page.tsx (superseded by generic [section]/page.tsx)
- Added CSS vars: --color-border-subtle, --color-border-standard, --color-text-tertiary, --color-text-quaternary
This commit is contained in:
asepharyana committed 2026-08-21 09:56:40 +07:00
1 parent b8d4fc97da
commit 26a900a030
5 files changed
+505 -334

No files matched your search

+148 -85
View File
@@ -7,6 +7,7 @@ import Markdown from "@/components/Markdown";
import DocForm from "@/components/DocForm";
import TOC from "@/components/TOC";
import { classifyPath, extractFoldersForSection } from "@mcpedia/core";
import type { DocumentMeta } from "@mcpedia/core";
// Render at request time (content in Postgres, not available at build time).
export const dynamic = "force-dynamic";
@@ -31,10 +32,10 @@ const SECTION_ICON: Record<string, string> = {
};
/**
* Render any extra/custom frontmatter fields as badges.
* Render any extra/custom frontmatter fields as labeled badges.
* This makes the system dynamic — the content creator decides what metadata
* to include, not the UI template. Standard fields (title, author, tags, etc.)
* are handled explicitly; any OTHER keys in frontmatter become badges here.
* to include. Standard fields (title, author, tags, etc.) are handled
* explicitly; any OTHER keys in frontmatter become badges here.
*/
function CustomFieldBadges({
doc,
@@ -43,7 +44,6 @@ function CustomFieldBadges({
doc: Record<string, unknown>;
standardKeys: Set<string>;
}) {
// Convert doc to a plain record to get index signature
const docRecord: Record<string, unknown> = { ...doc } as Record<string, unknown>;
const customEntries = Object.entries(docRecord).filter(
([k, v]) =>
@@ -53,7 +53,7 @@ function CustomFieldBadges({
if (customEntries.length === 0) return null;
return (
<div className="flex flex-wrap items-center gap-2 mt-3">
<div className="flex flex-wrap items-center gap-2 mt-4 pt-3 border-t border-[#1f2022]">
{customEntries.map(([key, value]) => {
const label = key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " ");
let colorClass = "bg-[#191a1b] border-[#23252a] text-[#d0d6e0]";
@@ -64,30 +64,24 @@ function CustomFieldBadges({
// not by using specific key names)
if (typeof value === "number") {
// Numeric values → purple "points"-style badge with + prefix
colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#7170ff]";
displayValue = `${value}`;
} else if (typeof value === "boolean") {
// Boolean values → green check/X
colorClass = value
? "bg-green-500/15 border-green-500/25 text-green-400"
: "bg-red-500/15 border-red-500/25 text-red-400";
displayValue = value ? "Yes" : "No";
} else if (Array.isArray(value)) {
// Arrays → purple badge, joined values
colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#7170ff]";
displayValue = value.join(", ");
} else if (typeof value === "object" && value !== null) {
// Objects → neutral badge, truncated JSON
displayValue = JSON.stringify(value).slice(0, 40) +
(JSON.stringify(value).length > 40 ? "…" : "");
} else {
// String values — auto-detect content type for styling
const v = String(value).toLowerCase();
// Difficulty-like values (easy/medium/hard/etc) OR numeric with suffix
const diffMatch = v.match(
/^(easy|simple|beginner)\b|^(medium|intermediate)\b|^(hard|expert|advanced)\b/i
/^(easy|simple|beginner)\b|^(medium|intermediate)\b|^(hard|expert|advanced)\b/i,
);
if (diffMatch) {
if (/^easy|simple|beginner/i.test(v))
@@ -97,33 +91,24 @@ function CustomFieldBadges({
else if (/^hard|expert|advanced/i.test(v))
colorClass = "bg-red-500/10 border-red-500/30 text-red-400";
displayValue = diffMatch[0];
}
// Event-like values (contains CTF, DEF CON, hack, etc.)
else if (/ctf|def.?con|hack|game|competition|tournament|qualifier/i.test(v)) {
} else if (/ctf|def.?con|hack|game|competition|tournament|qualifier/i.test(v)) {
colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#7170ff]";
displayValue = String(value);
}
// Points-like values (number + "pts" suffix)
else if (/(\d+)\s*pts?$/i.test(v)) {
} else if (/(\d+)\s*pts?$/i.test(v)) {
const match = v.match(/(\d+)\s*pts?$/i);
colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#7170ff]";
displayValue = match ? `${match[1]} pts` : String(value);
}
// Category-like values (pwn/web/crypto/etc)
else if (/^(pwn|web|crypto|misc|forensic|reverse|pwnable|binary|webexploit)$/i.test(v)) {
} else if (/^(pwn|web|crypto|misc|forensic|reverse|pwnable|binary|webexploit)$/i.test(v)) {
colorClass = "bg-orange-500/15 border-orange-500/25 text-orange-400";
displayValue = v.charAt(0).toUpperCase() + v.slice(1);
}
// Status-like values (solved/pending/wip)
else if (/^(solved|pending|wip|in.?progress|completed|todo)$/i.test(v)) {
} else if (/^(solved|pending|wip|in.?progress|completed|todo)$/i.test(v)) {
colorClass = v.includes("solved") || v.includes("completed")
? "bg-green-500/10 border-green-500/30 text-green-400"
: v.includes("wip") || v.includes("progress")
? "bg-yellow-500/10 border-yellow-500/30 text-yellow-400"
: "bg-red-500/10 border-red-500/30 text-red-400";
displayValue = v.charAt(0).toUpperCase() + v.slice(1);
}
else {
} else {
displayValue = String(value);
}
}
@@ -132,6 +117,7 @@ function CustomFieldBadges({
<span
key={key}
className={`px-2 py-0.5 border rounded text-xs ${colorClass}`}
title={label}
>
{displayValue}
</span>
@@ -148,80 +134,113 @@ function CustomFieldBadges({
function FolderIndexPage({
section,
slug,
docPaths,
allDocs,
}: {
section: string;
slug: string;
docPaths: string[];
allDocs: DocumentMeta[];
}) {
const prefix = `${section}/${slug}/`;
const folderDocs = docPaths
.filter((p) => p.startsWith(prefix))
.map((p) => p.slice(prefix.length).replace(/\.md$/, ""))
.sort();
const folderDocPaths = allDocs
.filter((d) => d.path.startsWith(prefix))
.map((d) => ({
rel: d.path.slice(prefix.length).replace(/\.md$/, ""),
doc: d,
}))
.sort((a, b) => a.rel.localeCompare(b.rel));
// Group into immediate children: folders vs leaf docs
const immediateFolders = new Set<string>();
const immediateDocs: { name: string; slug: string }[] = [];
const immediateFolders = new Map<string, { docCount: number }>();
const immediateDocs: DocumentMeta[] = [];
for (const relPath of folderDocs) {
const parts = relPath.split("/");
for (const { rel, doc } of folderDocPaths) {
const parts = rel.split("/");
if (parts.length === 1) {
// Leaf doc directly in this folder
immediateDocs.push({ name: parts[0], slug: `${section}/${slug}/${parts[0]}`.replace(/\/+/g, "/") });
immediateDocs.push(doc);
} else {
// Deeper — register the immediate folder
immediateFolders.add(parts[0]);
const folder = parts[0];
const existing = immediateFolders.get(folder) || { docCount: 0 };
existing.docCount += 1;
immediateFolders.set(folder, existing);
}
}
const folders = [...immediateFolders].sort();
const folders = [...immediateFolders.entries()].sort(([a], [b]) => a.localeCompare(b));
const sectionInfo = {
icon: SECTION_ICON[section] || "📁",
label: SECTION_LABEL[section] || section,
};
return (
<div>
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-xs text-[#62666d] mb-6">
<Link href="/" className="hover:text-[#d0d6e0] transition-colors">
MCPedia
</Link>
<span>/</span>
<Link
href={`/${section}`}
className="hover:text-[#d0d6e0] transition-colors"
>
{sectionInfo.label}
</Link>
{slug.split("/").map((part, i) => {
const path = `${section}/${slug.split("/").slice(0, i + 1).join("/")}`;
const isLast = i === slug.split("/").length - 1;
const label = part
.split(/[-_]/)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
return (
<>
<Link
<span>/</span>
<span
key={path}
href={`/${path}`}
className={isLast ? "text-[#8a8f98]" : "hover:text-[#d0d6e0] transition-colors"}
className={isLast ? "text-[#8a8f98]" : "hover:text-[#d0d6e0]"}
>
{part}
</Link>
{!isLast && <span>/</span>}
{label}
</span>
</>
);
})}
</nav>
<div className="flex items-center gap-2 mb-6">
<span className="text-xl">📁</span>
<h1 className="text-3xl font-medium text-[#f7f8f8]">{slug.split("/").pop()}</h1>
{/* Header */}
<div className="flex items-center gap-2 mb-8">
<span className="text-2xl">📁</span>
<h1 className="text-3xl font-medium text-[#f7f8f8]">
{slug.split("/").pop()!.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")}
</h1>
<span className="text-xs text-[#62666d] bg-[#191a1b] px-2 py-0.5 rounded">
{immediateDocs.length + folders.length} item{folders.length + immediateDocs.length !== 1 ? "s" : ""}
</span>
</div>
{(folders.length > 0 || immediateDocs.length > 0) ? (
<div>
{folders.length > 0 && (
<div className="mb-6">
<h2 className="text-sm font-medium text-[#d0d6e0] uppercase mb-3">
<div className="mb-8">
<h2 className="text-xs font-medium text-[#8a8f98] uppercase mb-3 tracking-wider">
Subfolders
</h2>
<div className="space-y-2">
{folders.map((folder) => (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{folders.map(([folder, info]) => (
<Link
key={folder}
href={`/${section}/${slug}/${folder}`.replace(/\/+/g, "/")}
className="flex items-center gap-2 text-sm text-[#d0d6e0] hover:text-[#7170ff] transition-colors"
className="group flex items-center gap-3 p-3 bg-[#0f1011] border border-[#1f2022] rounded-lg hover:border-[#5e6ad2]/40 hover:bg-[#131415] transition-all"
>
<span>📁</span> {folder}
<span className="text-2xl">📁</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-[#f7f8f8] group-hover:text-[#7170ff] transition-colors">
{folder.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")}
</div>
<div className="text-xs text-[#62666d] mt-0.5">
{info.docCount} document{info.docCount !== 1 ? "s" : ""}
</div>
</div>
</Link>
))}
</div>
@@ -229,7 +248,7 @@ function FolderIndexPage({
)}
{immediateDocs.length > 0 && (
<div>
<h2 className="text-sm font-medium text-[#d0d6e0] uppercase mb-3">
<h2 className="text-xs font-medium text-[#8a8f98] uppercase mb-3 tracking-wider">
Documents
</h2>
<div className="space-y-2">
@@ -237,9 +256,34 @@ function FolderIndexPage({
<Link
key={doc.slug}
href={`/${doc.slug}`}
className="flex items-center gap-2 text-sm text-[#d0d6e0] hover:text-[#7170ff] transition-colors line-clamp-1"
className="group flex items-center justify-between gap-3 p-3 bg-[#0f1011] border border-[#1f2022] rounded-lg hover:border-[#5e6ad2]/40 hover:bg-[#131415] transition-all"
>
<span>📄</span> {doc.name}
<div className="flex items-center gap-3 min-w-0">
<span className="text-xl">📄</span>
<div className="min-w-0">
<div className="text-sm font-medium text-[#f7f8f8] group-hover:text-[#7170ff] transition-colors line-clamp-1">
{doc.title}
</div>
{doc.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{doc.tags.slice(0, 3).map((t) => (
<span
key={t}
className="text-xs px-1.5 py-0.25 bg-[#191a1b] border border-[#23252a] rounded text-[#8a8f98]"
>
#{t}
</span>
))}
</div>
)}
</div>
</div>
<time className="text-xs text-[#62666d] flex-shrink-0">
{new Date(doc.updatedAt).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})}
</time>
</Link>
))}
</div>
@@ -266,7 +310,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
// If the slug path is a folder (not a leaf doc), render the folder index
if (classification === "folder") {
return <FolderIndexPage section={section} slug={slug.join("/")} docPaths={docPaths} />;
return <FolderIndexPage section={section} slug={slug.join("/")} allDocs={allDocs} />;
}
// Otherwise, treat as a document (existing behavior)
@@ -285,7 +329,6 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
// Edit mode: inline form
if (edit === "1" && canEdit) {
// Load existing folders for the folder picker in edit mode
const existingFolders = extractFoldersForSection(docPaths, doc.section);
return (
<div>
@@ -361,38 +404,58 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
})}
</nav>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-2 mb-3">
<span className="text-xl">
{/* Metadata Card */}
<div className="bg-[#0f1011] border border-[#1f2022] rounded-lg p-6 mb-8">
<div className="flex items-start gap-3 mb-4">
<span className="text-2xl">
{SECTION_ICON[doc.section] || "📄"}
</span>
<span className="text-xs font-medium text-[#7170ff] uppercase">
{SECTION_LABEL[doc.section] || doc.section}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium text-[#7170ff] uppercase">
{SECTION_LABEL[doc.section] || doc.section}
</span>
<span className="text-xs text-[#62666d]">·</span>
<span className="text-xs text-[#62666d]">
{doc.type}
</span>
</div>
<h1 className="text-3xl font-medium text-[#f7f8f8] leading-tight">
{doc.title}
</h1>
</div>
</div>
<h1 className="text-4xl font-medium text-[#f7f8f8] mb-4 leading-tight">
{doc.title}
</h1>
{/* Author + date + tags */}
<div className="flex flex-wrap items-center gap-3 text-xs text-[#62666d] mb-3">
<span className="text-[#d0d6e0]">{doc.author || "unknown"}</span>
<span>·</span>
<time dateTime={doc.updatedAt}>
Updated {new Date(doc.updatedAt).toLocaleDateString(undefined, {
{doc.author && (
<>
<span className="text-[#d0d6e0]">by {doc.author}</span>
<span>·</span>
</>
)}
<span className="text-[#d0d6e0]">
updated {new Date(doc.updatedAt).toLocaleDateString(undefined, {
month: "long",
day: "numeric",
year: "numeric",
})}
</time>
{doc.tags.map((t) => (
<span
key={t}
className="px-2 py-0.5 bg-[#191a1b] border border-[#23252a] rounded text-[#d0d6e0]"
>
#{t}
</span>
))}
</span>
{doc.tags.length > 0 && (
<>
<span>·</span>
<div className="flex flex-wrap gap-1">
{doc.tags.map((t) => (
<span
key={t}
className="px-2 py-0.5 bg-[#191a1b] border border-[#23252a] rounded text-[#d0d6e0]"
>
#{t}
</span>
))}
</div>
</>
)}
</div>
{/* Dynamic custom field badges — content creator controls what shows */}
@@ -400,7 +463,7 @@ export default async function DocPage({ params, searchParams }: DocPageProps) {
</div>
{/* Content + TOC */}
<div className="mb-8">
<div className="mb-12">
<TOC source={doc.body} />
<Markdown source={doc.body} />
</div>
+97 -64
View File
@@ -1,10 +1,18 @@
import Link from "next/link";
import { listDocuments, type DocumentMeta } from "@mcpedia/core";
import type { DocSection } from "@mcpedia/core";
import { listDocuments } from "@mcpedia/core";
export const dynamic = "force-dynamic";
const SECTIONS: { id: DocSection; label: string; icon: string; desc: string }[] = [
interface DocMeta {
slug: string;
title: string;
section: string;
tags: string[];
updatedAt: string;
path: string;
}
const SECTIONS: { id: string; label: string; icon: string; desc: string }[] = [
{
id: "docs",
label: "Documentation",
@@ -17,88 +25,102 @@ const SECTIONS: { id: DocSection; label: string; icon: string; desc: string }[]
];
/**
* Group documents into a folder tree. Returns nodes where each node is either:
* - a folder (has `children`) or
* - a leaf doc (has `doc`)
* The tree is built from the path segments of each document's `path`.
* Build a hierarchical folder tree from a flat list of documents.
* Each node is either a folder (has children) or a leaf doc.
*/
interface TreeNode {
name: string;
slug: string;
doc?: DocumentMeta;
children?: TreeNode[];
doc?: DocMeta;
children: TreeNode[];
docCount: number; // total docs in subtree
}
function buildFolderTree(docs: DocumentMeta[], section: string): TreeNode[] {
function buildFolderTree(docs: DocMeta[], section: string): TreeNode[] {
const root: TreeNode[] = [];
const rootMap = new Map<string, TreeNode>();
for (const doc of docs.filter((d) => d.section === section)) {
// path is like "docs/websocket/contract.md" → strip section prefix + .md
const rel = doc.path.startsWith(`${section}/`)
? doc.path.slice(section.length + 1)
: doc.path;
const cleanPath = rel.replace(/\.md$/, "");
const parts = cleanPath.split("/");
let currentLevel = root;
let current = root;
let currentPath = section;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
currentPath = `${currentPath}/${part}`;
const isLeaf = i === parts.length - 1;
currentPath = `${currentPath}/${part}`;
let node = currentLevel.find((n) => n.name === part);
let node = current.find((n) => n.name === part);
if (!node) {
node = {
name: part === "" ? section : part,
name: part,
slug: currentPath,
children: isLeaf ? undefined : [],
children: [],
docCount: 0,
};
currentLevel.push(node);
current.push(node);
}
if (isLeaf) {
node.doc = doc;
node.docCount = 1;
} else {
if (!node.children) node.children = [];
currentLevel = node.children;
// This is a folder — count will be accumulated below
}
current = node.children;
}
}
// Recursively compute docCount for folder nodes
function countDocs(node: TreeNode): number {
if (node.doc) return 1;
return node.children.reduce((sum, child) => sum + countDocs(child), 0);
}
for (const node of root) {
node.docCount = countDocs(node);
}
return root;
}
function renderTree(nodes: TreeNode[], section: DocSection) {
function renderTree(nodes: TreeNode[], pathname: string) {
return (
<ul className="space-y-0.5">
{nodes.map((node) => {
const isFolder = !!node.children;
const isDoc = !!node.doc;
const isFolder = node.children.length > 0;
const isActive = pathname === `/${node.slug}`;
const hasActiveChild = pathname.startsWith(`/${node.slug}/`);
return (
<li key={node.slug}>
{isFolder ? (
<Link
href={`/${node.slug}`}
className="flex items-center gap-1.5 text-xs py-0.5 rounded hover:bg-[#191a1b] text-[#d0d6e0] hover:text-[#f7f8f8]"
>
<span>📁</span>
{node.name}
</Link>
) : isDoc ? (
<Link
href={`/${node.slug}`}
className="flex items-center gap-1.5 text-xs py-0.5 rounded hover:bg-[#191a1b] text-[#d0d6e0] hover:text-[#f7f8f8] line-clamp-1"
>
<span>📄</span>
<Link
href={`/${node.slug}`}
className={`flex items-center justify-between gap-2 text-xs py-1 px-2 rounded transition-all ${
isActive
? "text-[#7170ff] bg-[#191a1b]"
: hasActiveChild
? "text-[#d0d6e0]"
: "text-[#8a8f98] hover:text-[#d0d6e0] hover:bg-[#191a1b]"
}`}
>
<span className="flex items-center gap-1.5">
<span>{isFolder ? "📁" : "📄"}</span>
{node.doc?.title || node.name}
</Link>
) : null}
{node.children && node.children.length > 0 && (
<div className="ml-3 mt-0.5">
{renderTree(node.children, section)}
</span>
{isFolder && (
<span className="text-[#62666d] bg-[#191a1b] px-1.5 py-0.25 rounded">
{node.docCount}
</span>
)}
</Link>
{node.children.length > 0 && (
<div className="ml-4 mt-0.5">
{renderTree(node.children, pathname)}
</div>
)}
</li>
@@ -115,7 +137,8 @@ export default async function SectionIndexPage({
}) {
const { section } = await params;
if (!SECTIONS.some((s) => s.id === section)) {
const sectionInfo = SECTIONS.find((s) => s.id === section);
if (!sectionInfo) {
return (
<div>
<h1 className="text-2xl font-medium text-[#f7f8f8]">Unknown section</h1>
@@ -123,39 +146,42 @@ export default async function SectionIndexPage({
);
}
const all = await listDocuments();
const sectionDocs = all.filter((d) => d.section === section);
const tree = buildFolderTree(all, section as DocSection);
const sectionInfo = SECTIONS.find((s) => s.id === section)!;
const allDocs = await listDocuments();
const sectionDocs = allDocs.filter((d) => d.section === section);
const tree = buildFolderTree(allDocs, section);
return (
<div>
<div className="flex items-center gap-2 mb-6">
<span className="text-2xl">{sectionInfo.icon}</span>
<h1 className="text-3xl font-medium text-[#f7f8f8]">{sectionInfo.label}</h1>
<span className="text-xs text-[#62666d] bg-[#191a1b] px-2 py-0.5 rounded">
{sectionDocs.length} document{sectionDocs.length !== 1 ? "s" : ""}
</span>
</div>
<p className="text-sm text-[#8a8f98] mb-6 max-w-2xl">
<p className="text-sm text-[#8a8f98] mb-8 max-w-2xl">
{sectionInfo.desc}
</p>
<div className="mb-8">
<h2 className="text-sm font-medium text-[#d0d6e0] uppercase mb-3">
Folder structure
</h2>
{tree.length > 0 ? (
renderTree(tree, section as DocSection)
) : (
<p className="text-xs text-[#62666d]">No documents found.</p>
)}
</div>
{tree.length > 0 ? (
<div className="mb-12">
<h2 className="text-xs font-medium text-[#8a8f98] uppercase mb-3 tracking-wider">
Folder structure
</h2>
{renderTree(tree, section)}
</div>
) : (
<p className="text-sm text-[#62666d] mb-8">No documents found in this section.</p>
)}
{sectionDocs.length > 0 && (
<div>
<h2 className="text-sm font-medium text-[#d0d6e0] uppercase mb-3">
All documents ({sectionDocs.length})
<h2 className="text-xs font-medium text-[#8a8f98] uppercase mb-3 tracking-wider">
Recently updated
</h2>
<div className="space-y-2">
{sectionDocs
{[...sectionDocs]
.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
@@ -164,10 +190,17 @@ export default async function SectionIndexPage({
<Link
key={d.slug}
href={`/${d.slug}`}
className="block text-sm text-[#d0d6e0] hover:text-[#7170ff] transition-colors line-clamp-1"
title={d.title}
className="group flex items-center justify-between gap-3 py-2 border-b border-[#141516] last:border-0"
>
{d.title}
<span className="text-sm text-[#d0d6e0] group-hover:text-[#7170ff] transition-colors line-clamp-1">
{d.title}
</span>
<time className="text-xs text-[#62666d] flex-shrink-0">
{new Date(d.updatedAt).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})}
</time>
</Link>
))}
</div>