mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-03-21 17:54:09 -05:00
Add central Markdown component with spoiler support
This commit is contained in:
parent
23297cb4d9
commit
ace34c4408
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -35,6 +35,7 @@
|
|||
"rom-patcher-js": "github:Hackdex-App/RomPatcher.js",
|
||||
"schema-dts": "^1.1.5",
|
||||
"serialize-javascript": "^7.0.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
"rom-patcher-js": "github:Hackdex-App/RomPatcher.js",
|
||||
"schema-dts": "^1.1.5",
|
||||
"serialize-javascript": "^7.0.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import React from "react";
|
||||
import type { Metadata } from "next";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeSlug from "rehype-slug";
|
||||
import Markdown from "@/components/Markdown/Markdown";
|
||||
import entriesMd from "./entries.md";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -18,7 +16,7 @@ export default function FAQPage() {
|
|||
<div className="mx-auto max-w-screen-lg px-6 py-6 sm:py-12">
|
||||
<h1 className="text-3xl font-bold">FAQ for Hackdex</h1>
|
||||
<div className="mt-6 prose prose-invert max-w-none faq-prose">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSlug]}>{entriesMd}</ReactMarkdown>
|
||||
<Markdown>{entriesMd}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeSlug from "rehype-slug";
|
||||
import Markdown from "@/components/Markdown/Markdown";
|
||||
import Link from "next/link";
|
||||
import { FaChevronLeft } from "react-icons/fa6";
|
||||
|
||||
|
|
@ -79,20 +77,7 @@ export default async function ChangelogPage({ params }: ChangelogPageProps) {
|
|||
</div>
|
||||
</div>
|
||||
<div className="prose prose-sm max-w-none text-foreground/80">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSlug]}
|
||||
components={{
|
||||
h1: 'h2',
|
||||
h2: 'h3',
|
||||
h3: 'h4',
|
||||
h4: 'h5',
|
||||
h5: 'h6',
|
||||
h6: 'h6',
|
||||
}}
|
||||
>
|
||||
{patch.changelog || ""}
|
||||
</ReactMarkdown>
|
||||
<Markdown headingLevelOffset={1}>{patch.changelog || ""}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,7 @@ import Link from "next/link";
|
|||
import { notFound } from "next/navigation";
|
||||
import Gallery from "@/components/Hack/Gallery";
|
||||
import HackActions from "@/components/Hack/HackActions";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeSlug from "rehype-slug";
|
||||
import Markdown from "@/components/Markdown/Markdown";
|
||||
import Image from "next/image";
|
||||
import { FaDiscord, FaTwitter, FaGithub, FaTriangleExclamation, FaArrowUpRightFromSquare } from "react-icons/fa6";
|
||||
import PokeCommunityIcon from "@/components/Icons/PokeCommunityIcon";
|
||||
|
|
@ -376,20 +374,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
|
|||
>
|
||||
{patchChangelog && patchChangelog.trim().length > 0 ? (
|
||||
<div className="prose prose-sm max-w-none text-foreground/80">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSlug]}
|
||||
components={{
|
||||
h1: 'h2',
|
||||
h2: 'h3',
|
||||
h3: 'h4',
|
||||
h4: 'h5',
|
||||
h5: 'h6',
|
||||
h6: 'h6',
|
||||
}}
|
||||
>
|
||||
{patchChangelog}
|
||||
</ReactMarkdown>
|
||||
<Markdown headingLevelOffset={1}>{patchChangelog}</Markdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="italic text-foreground/60">No changelog provided</p>
|
||||
|
|
@ -400,20 +385,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
|
|||
<div className="card-simple p-5">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">About this hack</h2>
|
||||
<div className="prose prose-sm mt-3 max-w-none text-foreground/80">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSlug]}
|
||||
components={{
|
||||
h1: 'h2',
|
||||
h2: 'h3',
|
||||
h3: 'h4',
|
||||
h4: 'h5',
|
||||
h5: 'h6',
|
||||
h6: 'h6',
|
||||
}}
|
||||
>
|
||||
{hack.description}
|
||||
</ReactMarkdown>
|
||||
<Markdown headingLevelOffset={1}>{hack.description}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import React from "react";
|
||||
import type { Metadata } from "next";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeSlug from "rehype-slug";
|
||||
import Markdown from "@/components/Markdown/Markdown";
|
||||
import privacyMd from "@/../docs/legal/PRIVACY.md";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -16,7 +14,7 @@ export default function PrivacyPage() {
|
|||
return (
|
||||
<div className="mx-auto max-w-screen-lg px-6 py-6 sm:py-12">
|
||||
<div className="prose prose-invert max-w-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSlug]}>{privacyMd}</ReactMarkdown>
|
||||
<Markdown>{privacyMd}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import React from "react";
|
||||
import type { Metadata } from "next";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeSlug from "rehype-slug";
|
||||
import Markdown from "@/components/Markdown/Markdown";
|
||||
import termsMd from "@/../docs/legal/TERMS.md";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -16,7 +14,7 @@ export default function TermsPage() {
|
|||
return (
|
||||
<div className="mx-auto max-w-screen-lg px-6 py-6 sm:py-12">
|
||||
<div className="prose prose-invert max-w-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSlug]}>{termsMd}</ReactMarkdown>
|
||||
<Markdown>{termsMd}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeSlug from "rehype-slug";
|
||||
import Markdown from "@/components/Markdown/Markdown";
|
||||
import TagSelector from "@/components/Submit/TagSelector";
|
||||
import { baseRoms } from "@/data/baseRoms";
|
||||
import Image from "next/image";
|
||||
|
|
@ -346,7 +344,7 @@ export default function HackEditForm({ slug, initial }: HackEditFormProps) {
|
|||
/>
|
||||
) : (
|
||||
<div className={`prose max-w-none rounded-md min-h-[14rem] px-3 py-2 ring-1 ring-inset ${descriptionChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'} ${description ? "" : "text-foreground/60 text-sm"}`}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSlug]}>{description || "Nothing to preview yet."}</ReactMarkdown>
|
||||
<Markdown headingLevelOffset={1}>{description || "Nothing to preview yet."}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,9 +10,7 @@ import { presignCoverUpload } from "@/app/hack/actions";
|
|||
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core";
|
||||
import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeSlug from "rehype-slug";
|
||||
import Markdown from "@/components/Markdown/Markdown";
|
||||
import { RxDragHandleDots2 } from "react-icons/rx";
|
||||
import { FaDiscord, FaTwitter, FaGithub } from "react-icons/fa6";
|
||||
import PokeCommunityIcon from "@/components/Icons/PokeCommunityIcon";
|
||||
|
|
@ -900,7 +898,7 @@ export default function HackSubmitForm({
|
|||
</div>
|
||||
{isDummy ? (
|
||||
<div className="prose max-w-none h-36 rounded-md bg-[var(--surface-2)] px-3 py-2 ring-1 ring-inset ring-[var(--border)] text-foreground/60 select-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSlug]}>{description || "Write a longer markdown description here."}</ReactMarkdown>
|
||||
<Markdown headingLevelOffset={1}>{description || "Write a longer markdown description here."}</Markdown>
|
||||
</div>
|
||||
) : !showMdPreview ? (
|
||||
<textarea
|
||||
|
|
@ -912,7 +910,7 @@ export default function HackSubmitForm({
|
|||
/>
|
||||
) : (
|
||||
<div className={`prose max-w-none rounded-md bg-[var(--surface-2)] min-h-[14rem] px-3 py-2 ring-1 ring-inset ring-[var(--border)] ${description ? "" : "text-foreground/60 text-sm"}`}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSlug]}>{description || "Nothing to preview yet."}</ReactMarkdown>
|
||||
<Markdown headingLevelOffset={1}>{description || "Nothing to preview yet."}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeSlug from "rehype-slug";
|
||||
import Markdown from "@/components/Markdown/Markdown";
|
||||
import { FaChevronDown, FaChevronUp, FaStar, FaDownload, FaTrash, FaRotateLeft, FaUpload, FaCheck, FaPlus } from "react-icons/fa6";
|
||||
import { FiEdit2, FiEdit, FiX } from "react-icons/fi";
|
||||
import VersionActions from "@/components/Hack/VersionActions";
|
||||
|
|
@ -262,20 +260,7 @@ export default function VersionList({ patches, currentPatchId, canEdit, hackSlug
|
|||
/>
|
||||
) : (
|
||||
<div className="prose prose-sm max-w-none text-foreground/80">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSlug]}
|
||||
components={{
|
||||
h1: 'h2',
|
||||
h2: 'h3',
|
||||
h3: 'h4',
|
||||
h4: 'h5',
|
||||
h5: 'h6',
|
||||
h6: 'h6',
|
||||
}}
|
||||
>
|
||||
{patch.changelog || ""}
|
||||
</ReactMarkdown>
|
||||
<Markdown headingLevelOffset={1}>{patch.changelog || ""}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
50
src/components/Markdown/Markdown.tsx
Normal file
50
src/components/Markdown/Markdown.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeSlug from "rehype-slug";
|
||||
import remarkSpoiler from "@/utils/markdown/remark-spoiler";
|
||||
import Spoiler from "./Spoiler";
|
||||
|
||||
interface MarkdownProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
headingLevelOffset?: number;
|
||||
}
|
||||
|
||||
export default function Markdown({
|
||||
children,
|
||||
headingLevelOffset = 0,
|
||||
}: MarkdownProps) {
|
||||
// Build heading demotion components if offset is provided
|
||||
const headingComponents: Record<string, string> = {};
|
||||
if (headingLevelOffset > 0) {
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const targetLevel = Math.min(i + headingLevelOffset, 6);
|
||||
headingComponents[`h${i}`] = `h${targetLevel}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkSpoiler]}
|
||||
rehypePlugins={[rehypeSlug]}
|
||||
components={{
|
||||
...headingComponents,
|
||||
span: ({ node, children, className, ...props }: any) => {
|
||||
// Check if this span has the markdown-spoiler class (from our remark plugin)
|
||||
if (
|
||||
className === "markdown-spoiler" ||
|
||||
(Array.isArray(node?.properties?.className) &&
|
||||
node.properties.className.includes("markdown-spoiler"))
|
||||
) {
|
||||
return <Spoiler>{children}</Spoiler>;
|
||||
}
|
||||
// Default span rendering
|
||||
return <span className={className} {...props}>{children}</span>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
25
src/components/Markdown/Spoiler.tsx
Normal file
25
src/components/Markdown/Spoiler.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
interface SpoilerProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Spoiler({ children }: SpoilerProps) {
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={() => setRevealed(true)}
|
||||
className={`inline rounded px-1 transition-colors duration-150 ${
|
||||
revealed
|
||||
? "bg-foreground/20 text-foreground cursor-text"
|
||||
: "bg-foreground/50 text-transparent cursor-pointer hover:bg-foreground/60"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
91
src/utils/markdown/remark-spoiler.ts
Normal file
91
src/utils/markdown/remark-spoiler.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { visit } from "unist-util-visit";
|
||||
import type { Root, Text } from "mdast";
|
||||
|
||||
type SpoilerNode = {
|
||||
type: "spoiler";
|
||||
children: Text[];
|
||||
};
|
||||
|
||||
export default function remarkSpoiler() {
|
||||
return (tree: Root) => {
|
||||
// Collect all modifications first
|
||||
const modifications: Array<{
|
||||
parent: any;
|
||||
index: number;
|
||||
replacement: (Text | SpoilerNode)[];
|
||||
}> = [];
|
||||
|
||||
visit(tree, (node, index, parent) => {
|
||||
// Skip code blocks and code spans - don't process spoilers inside code
|
||||
if (node.type === "code" || node.type === "inlineCode") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Process text nodes
|
||||
if (node.type === "text" && parent && typeof index === "number") {
|
||||
const textNode = node as Text;
|
||||
const text = textNode.value;
|
||||
const spoilerRegex = /\|\|([^|]+)\|\|/g;
|
||||
|
||||
let match;
|
||||
let lastIndex = 0;
|
||||
const newNodes: (Text | SpoilerNode)[] = [];
|
||||
let hasSpoilers = false;
|
||||
|
||||
while ((match = spoilerRegex.exec(text)) !== null) {
|
||||
hasSpoilers = true;
|
||||
// Add text before the spoiler
|
||||
if (match.index > lastIndex) {
|
||||
newNodes.push({
|
||||
type: "text",
|
||||
value: text.slice(lastIndex, match.index),
|
||||
} as Text);
|
||||
}
|
||||
|
||||
// Add the spoiler node with data to help mdast-to-hast conversion
|
||||
// The spoiler node contains a text node as a child so the content is preserved
|
||||
newNodes.push({
|
||||
type: "spoiler",
|
||||
children: [
|
||||
{
|
||||
type: "text",
|
||||
value: match[1],
|
||||
} as Text,
|
||||
],
|
||||
data: {
|
||||
hName: "span",
|
||||
hProperties: {
|
||||
className: "markdown-spoiler",
|
||||
},
|
||||
},
|
||||
} as SpoilerNode & { data?: { hName: string; hProperties: Record<string, string> } });
|
||||
|
||||
lastIndex = spoilerRegex.lastIndex;
|
||||
}
|
||||
|
||||
// Add remaining text after the last spoiler
|
||||
if (lastIndex < text.length) {
|
||||
newNodes.push({
|
||||
type: "text",
|
||||
value: text.slice(lastIndex),
|
||||
} as Text);
|
||||
}
|
||||
|
||||
// Store modification if we found spoilers
|
||||
if (hasSpoilers) {
|
||||
modifications.push({
|
||||
parent,
|
||||
index,
|
||||
replacement: newNodes,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Apply modifications in reverse order to maintain indices
|
||||
modifications.reverse().forEach(({ parent, index, replacement }) => {
|
||||
parent.children.splice(index, 1, ...replacement);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user