New FAQ article style

This commit is contained in:
headpatsyou 2026-01-07 20:07:18 +00:00
parent a4eae6f169
commit 39623235f8
5 changed files with 174 additions and 35 deletions

View file

@ -2,6 +2,8 @@
title: Como funcionam as classes de peso?
readTime: 5 min
intendedFor: Atletas
author: João Silva
authorImage: https://via.placeholder.com/60
---
As classes de peso determinam em que categoria o atleta compete. Consulte as regras da IPF para a lista atualizada de classes.

View file

@ -118,7 +118,19 @@ p { font-size: 1rem; line-height: 1.6; color: var(--foreground); }
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3 { margin-top: 1.25rem; margin-bottom: 0.75rem; }
.markdown-content h3 {
margin-top: 1.25rem;
margin-bottom: 0.75rem;
scroll-margin-top: 80px; /* Account for header + subnav on mobile */
}
@media (min-width: 768px) {
.markdown-content h1,
.markdown-content h2,
.markdown-content h3 {
scroll-margin-top: 120px; /* More space on desktop */
}
}
.markdown-content p, .markdown-content ul, .markdown-content ol { margin-bottom: 0.75rem; }
.markdown-content ul { padding-left: 1.25rem; list-style: disc; }
.markdown-content ol { padding-left: 1.25rem; list-style: decimal; }

View file

@ -1,32 +1,91 @@
import { notFound } from "next/navigation";
import { readFile } from "node:fs/promises";
import { readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import { parseMarkdownWithFrontmatter } from "@/lib/markdown";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
type Params = { params: { slug: string } };
type Params = { params: Promise<{ slug: string }> };
async function getAvailableSlugs(): Promise<string[]> {
try {
const root = process.cwd();
const dir = join(root, "content", "pt", "perguntas");
const files = await readdir(dir);
return files
.filter((f) => f.endsWith(".md"))
.map((f) => f.replace(/\.md$/, ""));
} catch {
return [];
}
}
export async function generateStaticParams() {
const slugs = await getAvailableSlugs();
return slugs.map((slug) => ({
slug,
}));
}
export default async function FaqArticlePage({ params }: Params) {
const { slug } = await params;
const root = process.cwd();
const file = join(root, "content", "pt", "perguntas", `${params.slug}.md`);
const file = join(root, "content", "pt", "perguntas", `${slug}.md`);
try {
const raw = await readFile(file);
const { content, frontmatter } = parseMarkdownWithFrontmatter(raw.toString());
return (
<article aria-labelledby="article-title" className="prose max-w-none">
<header className="mb-4">
<h1 id="article-title" className="text-2xl font-bold">{String(frontmatter.title ?? "FAQ")}</h1>
<p className="text-sm text-gray-700">
{frontmatter.readTime ? (
<span>Tempo de leitura: {String(frontmatter.readTime)}</span>
) : null}
{frontmatter.intendedFor ? (
<span className="ml-2"> Público: {String(frontmatter.intendedFor)}</span>
) : null}
</p>
<article aria-labelledby="article-title" className="max-w-3xl mx-auto">
<header className="mb-8 pb-6 border-b border-gray-200">
<h1 id="article-title" className="text-4xl font-bold mb-6 text-gray-900">
{String(frontmatter.title ?? "FAQ")}
</h1>
{/* Metadata Grid */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
{frontmatter.readTime && (
<div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Tempo de Leitura
</p>
<p className="text-sm text-gray-700 mt-1">{String(frontmatter.readTime)}</p>
</div>
)}
{frontmatter.intendedFor && (
<div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Quem?
</p>
<p className="text-sm text-gray-700 mt-1">{String(frontmatter.intendedFor)}</p>
</div>
)}
</div>
{/* Author Section */}
{frontmatter.author && (
<div className="flex items-center gap-4 pt-4">
{frontmatter.authorImage && (
<img
src={String(frontmatter.authorImage)}
alt={String(frontmatter.author)}
className="w-16 h-16 rounded-full object-cover border-2 border-gray-200"
/>
)}
<div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Autor
</p>
<p className="text-sm font-semibold text-gray-900 mt-1">
{String(frontmatter.author)}
</p>
</div>
</div>
)}
</header>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
<div className="prose prose-sm md:prose md:max-w-none text-gray-700">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
</article>
);
} catch (e) {

View file

@ -1,15 +1,24 @@
import Link from "next/link";
import { readFile } from "node:fs/promises";
import { readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import matter from "gray-matter";
export default async function FaqIndexPage() {
// Simple server component that lists available FAQ markdown files
// Dynamically read all FAQ markdown files
const root = process.cwd();
const files = ["faq-exemplo.md"]; // In a real app, glob directory
const faqDir = join(root, "content", "pt", "perguntas");
let files: string[] = [];
try {
files = await readdir(faqDir);
files = files.filter((f) => f.endsWith(".md")).sort();
} catch {
files = [];
}
const entries: { slug: string; title: string; readTime?: string; intendedFor?: string }[] = [];
for (const f of files) {
const raw = await readFile(join(root, "content", "pt", "perguntas", f));
const raw = await readFile(join(faqDir, f));
const { data } = matter(raw.toString());
entries.push({
slug: f.replace(/\.md$/, ""),

View file

@ -5,9 +5,21 @@ type Heading = { id: string; text: string; level: number };
export function TableOfContents({ headings }: { headings: Heading[] }) {
const [activeId, setActiveId] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false);
const observerRef = useRef<IntersectionObserver | null>(null);
useEffect(() => {
// Disconnect old observer if it exists
if (observerRef.current) {
observerRef.current.disconnect();
}
// Only set up observer if we have headings
if (headings.length === 0) {
setActiveId(null);
return;
}
const visibleHeadings = new Map<string, number>();
const callback: IntersectionObserverCallback = (entries) => {
@ -30,31 +42,76 @@ export function TableOfContents({ headings }: { headings: Heading[] }) {
};
const observer = new IntersectionObserver(callback, {
rootMargin: "-40% 0px -55% 0px",
rootMargin: "-80px 0px -55% 0px",
threshold: 0.1,
});
observerRef.current = observer;
headings.forEach((h) => {
const el = document.getElementById(h.id);
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [headings]);
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, headingId: string) => {
e.preventDefault();
const element = document.getElementById(headingId);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
// Update URL without causing a page reload
window.history.pushState(null, "", `#${headingId}`);
// Close mobile menu after click
setIsOpen(false);
}
};
return (
<nav aria-label="Tabela de conteúdos" className="sticky top-4 max-h-[80vh] overflow-auto">
<ul className="space-y-1">
{headings.map((h) => (
<li key={h.id} style={{ paddingLeft: (h.level - 1) * 12 }}>
<a
href={`#${h.id}`}
className={activeId === h.id ? "font-semibold text-blue-600" : ""}
>
{h.text}
</a>
</li>
))}
</ul>
</nav>
<>
{/* Mobile Toggle Button */}
<button
className="md:hidden w-full mb-4 px-4 py-2 bg-blue-50 text-blue-600 border border-blue-200 rounded-lg font-medium text-sm transition hover:bg-blue-100"
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-label="Alternar tabela de conteúdos"
>
{isOpen ? "✕ Fechar conteúdos" : "☰ Mostrar conteúdos"}
</button>
{/* Desktop Sidebar + Mobile Collapsible */}
<nav
aria-label="Tabela de conteúdos"
className={`
md:sticky md:top-4 md:max-h-[80vh] md:overflow-auto md:block
${isOpen ? "block" : "hidden md:block"}
mb-6 md:mb-0 p-4 md:p-0
bg-white md:bg-transparent
border md:border-none border-gray-200 md:border-0
rounded-lg md:rounded-none
`}
>
<ul className="space-y-1">
{headings.map((h) => (
<li key={h.id} style={{ paddingLeft: (h.level - 1) * 12 }}>
<a
href={`#${h.id}`}
onClick={(e) => handleClick(e, h.id)}
className={`
block py-1 px-2 rounded transition
${
activeId === h.id
? "font-semibold text-blue-600 bg-blue-50"
: "text-gray-700 hover:bg-gray-100"
}
`}
>
{h.text}
</a>
</li>
))}
</ul>
</nav>
</>
);
}