New FAQ article style
This commit is contained in:
parent
a4eae6f169
commit
39623235f8
5 changed files with 174 additions and 35 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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$/, ""),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue