feat: fully dynamic custom field badges (auto-style by value, not key name)
CI / typecheck + build (turbo) (push) Canceled after 0s

- CustomFieldBadges now auto-detects styling based on VALUE TYPE+CONTENT
  (number→purple points style, difficulty strings→color-coded, event-like
  strings→purple, categories→orange, status→color-coded)
- DocForm help text made generic (no hardcoded field examples)
- Removed typed CTF fields from DocumentMeta (event/challenge/etc) —
  everything flows through extraFields JSONB for true dynamic behavior
This commit is contained in:
asepharyana
2026-08-21 00:31:36 +07:00
parent b31948faba
commit edbd812c30
2 changed files with 73 additions and 31 deletions
+69 -23
View File
@@ -45,7 +45,8 @@ function CustomFieldBadges({
// 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]) => !standardKeys.has(k) && v !== undefined && v !== null && v !== "" && !k.startsWith("_"),
([k, v]) =>
!standardKeys.has(k) && v !== undefined && v !== null && v !== "" && !k.startsWith("_"),
);
if (customEntries.length === 0) return null;
@@ -55,38 +56,83 @@ function CustomFieldBadges({
{customEntries.map(([key, value]) => {
const label = key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " ");
let colorClass = "bg-[#191a1b] border-[#23252a] text-[#d0d6e0]";
let prefix = "";
let displayValue: string;
// Auto-style common CTF fields based on key name
if (key === "points") {
prefix = "pts";
// --- Auto-detect styling based on VALUE TYPE + VALUE CONTENTS ---
// (NOT key name — content creators determine appearance via values,
// 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]";
} else if (key === "difficulty" || key === "level") {
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();
if (v === "easy")
colorClass = "bg-green-500/10 border-green-500/30 text-green-400";
else if (v === "medium")
colorClass = "bg-yellow-500/10 border-yellow-500/30 text-yellow-400";
else if (v === "hard")
colorClass = "bg-red-500/10 border-red-500/30 text-red-400";
else
colorClass = "bg-[#191a1b] border-[#23252a] text-[#d0d6e0]";
} else if (key === "event" || key === "category") {
colorClass = "bg-[#5e6ad2]/10 border-[#5e6ad2]/30 text-[#7170ff]";
}
const displayValue = Array.isArray(value)
? value.join(", ")
: typeof value === "object"
? JSON.stringify(value)
: String(value);
// 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
);
if (diffMatch) {
if (/^easy|simple|beginner/i.test(v))
colorClass = "bg-green-500/10 border-green-500/30 text-green-400";
else if (/^medium|intermediate/i.test(v))
colorClass = "bg-yellow-500/10 border-yellow-500/30 text-yellow-400";
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)) {
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)) {
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)) {
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)) {
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 {
displayValue = String(value);
}
}
return (
<span
key={key}
className={`px-2 py-0.5 border rounded text-xs ${colorClass}`}
>
{prefix ? `${displayValue} ${prefix}` : `${label}: ${displayValue}`}
{displayValue}
</span>
);
})}
+4 -8
View File
@@ -244,14 +244,10 @@ export default function DocForm({ mode, slug, secret, initial }: DocFormProps) {
Custom metadata fields
</label>
<p className="text-xs text-[#62666d] mb-3">
Add extra frontmatter fields. These will be stored in the document's
YAML frontmatter and rendered as badges on the doc page. Examples for
CTF writeups: <code className="text-[#7170ff]">event</code>,{" "}
<code className="text-[#7170ff]">challenge</code>,{" "}
<code className="text-[#7170ff]">category</code>,{" "}
<code className="text-[#7170ff]">difficulty</code>,{" "}
<code className="text-[#7170ff]">points</code>.
</p>
Add extra frontmatter fields. Content creators name and define their own
metadata here — the system auto-renders any field as a badge on the doc
page based on its value (e.g. numbers → points style, "easy" → green
difficulty badge, etc.).{" "}</p>
{customFields.length > 0 && (
<div className="space-y-3 mb-3">
{customFields.map((field, i) => (