site: initial implementation and footer fix

This commit is contained in:
Fonz Beato 2026-01-06 20:51:48 +00:00
parent 2ada7468fa
commit 19d092ea23
39 changed files with 3497 additions and 119 deletions

View file

@ -1,36 +1,52 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
APP Website — Associação Portuguesa de Powerlifting
## Getting Started
Overview
First, run the development server:
- Next.js (App Router) + TypeScript + Tailwind
- Strict accessibility (WCAG 2.1 AA)
- Bilingual support: Portuguese (default) and English
- Markdown content with sticky Table of Contents
- OpenPowerlifting integration (meets scraping + results CSV parsing)
- Cloudflare Turnstile on Contatos page
Getting Started
1. Install dependencies:
```bash
npm install
```
2. Set environment variables in `.env.local`:
```bash
NEXT_PUBLIC_TURNSTILE_SITE_KEY=your_site_key_here
```
3. Run the dev server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Open http://localhost:3000 — you will be redirected to /pt.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
Key Paths
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
- Portuguese: [src/app/pt](src/app/pt)
- English: [src/app/en](src/app/en)
- Content: [content](content)
- Markdown API: [src/app/api/content/route.ts](src/app/api/content/route.ts)
- Meets API: [src/app/api/openpowerlifting/meets/route.ts](src/app/api/openpowerlifting/meets/route.ts)
- Results API: [src/app/api/openpowerlifting/results/route.ts](src/app/api/openpowerlifting/results/route.ts)
## Learn More
Accessibility Notes
To learn more about Next.js, take a look at the following resources:
- Skip link and clear focus indicators present
- Semantic headings and ARIA labels on navigation
- Tables include headers and caption
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
Next Steps
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
- Add comprehensive English pages and translations
- Expand accessibility testing with Lighthouse and axe DevTools

View file

@ -0,0 +1,14 @@
# Portuguese Powerlifting Association
The Portuguese Powerlifting Association (APP) promotes and organizes powerlifting in Portugal, aligned with international standards.
## Mission
Develop powerlifting in Portugal inclusively, safely and accessibly, complying with IPF and WADA norms.
## Values
- Integrity
- Inclusion
- Safety
- Transparency

View file

@ -0,0 +1,16 @@
{
"events": [
{
"title": "Campeonato Nacional de Powerlifting",
"date": "2026-03-15",
"location": "Lisboa",
"signupUrl": "https://example.com/signup/nacional"
},
{
"title": "Taça APP",
"date": "2026-05-20",
"location": "Porto",
"signupUrl": "https://example.com/signup/taca-app"
}
]
}

View file

@ -0,0 +1,7 @@
---
title: Como funcionam as classes de peso?
readTime: 5 min
intendedFor: Atletas
---
As classes de peso determinam em que categoria o atleta compete. Consulte as regras da IPF para a lista atualizada de classes.

View file

@ -0,0 +1,11 @@
# Anti-Doping
Conteúdo placeholder. Substitua com diretrizes da WADA e IPF.
## Política
Compromisso da APP com a luta contra o doping.
## Procedimentos
Controlo, educação e sanções.

View file

@ -0,0 +1,11 @@
# IPF Rule Book
Conteúdo placeholder. Substitua com o texto do livro de regras da IPF.
## Competições
Diretrizes para organização e participação.
## Equipamento
Regras para equipamento aprovado.

View file

@ -0,0 +1,22 @@
---
# frontmatter can be added later if needed
---
# Associação Portuguesa de Powerlifting
A Associação Portuguesa de Powerlifting (APP) é a entidade que promove e organiza o powerlifting em Portugal, seguindo as regras e padrões internacionais.
## Missão
Promover o desenvolvimento do powerlifting em Portugal de forma inclusiva, segura e acessível, garantindo o cumprimento das normas da IPF e da WADA.
## Valores
- Integridade
- Inclusão
- Segurança
- Transparência
## Contacto
Consulte a página de Contatos para obter informação de contacto.

1984
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,13 +9,21 @@
"lint": "eslint"
},
"dependencies": {
"cheerio": "^1.1.2",
"gray-matter": "^4.0.3",
"next": "16.1.1",
"papaparse": "^5.5.3",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/papaparse": "^5.5.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View file

@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { readFile } from "node:fs/promises";
import { extname, join } from "node:path";
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const p = url.searchParams.get("path");
if (!p) return NextResponse.json({ error: "Missing path" }, { status: 400 });
try {
const root = process.cwd();
const full = join(root, "content", p);
const buf = await readFile(full);
const ext = extname(full).toLowerCase();
if (ext === ".json") {
try {
const json = JSON.parse(buf.toString());
return NextResponse.json(json);
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 500 });
}
}
return new NextResponse(buf.toString(), {
status: 200,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
} catch (e) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
}

View file

@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { load } from "cheerio";
export const revalidate = 3600; // cache for 1 hour
export async function GET() {
try {
const res = await fetch("https://www.openpowerlifting.org/mlist/apportugal", {
headers: { "User-Agent": "APP-Website/1.0" },
next: { revalidate },
});
const html = await res.text();
const $ = load(html);
const meets: { id: string; name: string; href: string; date?: string; location?: string }[] = [];
$("a").each((_, el) => {
const href = $(el).attr("href") || "";
const text = $(el).text().trim();
const match = href.match(/\/m\/apportugal\/(\d+)/);
if (match) {
meets.push({ id: match[1], name: text, href });
}
});
return NextResponse.json({ meets });
} catch (e) {
return NextResponse.json({ error: "Failed to scrape meets" }, { status: 500 });
}
}

View file

@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import Papa from "papaparse";
export const revalidate = 3600; // 1 hour
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const id = url.searchParams.get("id");
if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
try {
const apiUrl = `https://www.openpowerlifting.org/api/meetcsv/apportugal/${id}`;
const res = await fetch(apiUrl, { next: { revalidate } });
const csv = await res.text();
const parsed = Papa.parse(csv, { header: true, skipEmptyLines: true });
return NextResponse.json({ data: parsed.data });
} catch (e) {
return NextResponse.json({ error: "Failed to fetch meet CSV" }, { status: 500 });
}
}

31
src/app/en/page.tsx Normal file
View file

@ -0,0 +1,31 @@
export default function EnHome() {
return (
<section className="hero" aria-labelledby="home-title">
<div className="container">
<h1 id="home-title" className="title">Portuguese Powerlifting Association</h1>
<p className="subtitle">Accessible. Transparent. For all athletes.</p>
<div className="mt-4 flex gap-2">
<a className="btn btn-primary" href="/en/competicoes/calendario">View Calendar</a>
<a className="btn btn-outline" href="/en/competicoes/resultados">View Results</a>
</div>
<div className="mt-8 grid gap-4 md:grid-cols-3">
<div className="border p-4 rounded-md">
<h3 className="font-semibold">About</h3>
<p className="muted">Learn about APP and our mission.</p>
<a className="underline mt-2 inline-block" href="/en/sobre/quem-somos">Read more</a>
</div>
<div className="border p-4 rounded-md">
<h3 className="font-semibold">Rules</h3>
<p className="muted">IPF Rule Book and Anti-Doping.</p>
<a className="underline mt-2 inline-block" href="/en/regras/ipf-rule-book">See rules</a>
</div>
<div className="border p-4 rounded-md">
<h3 className="font-semibold">FAQ</h3>
<p className="muted">Frequently asked questions.</p>
<a className="underline mt-2 inline-block" href="/en/perguntas">Explore</a>
</div>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,19 @@
"use client";
import { useState } from "react";
import { MarkdownRenderer } from "@/components/markdown/MarkdownRenderer";
import { TableOfContents } from "@/components/markdown/TableOfContents";
export default function AboutPage() {
const [headings, setHeadings] = useState([] as { id: string; text: string; level: number }[]);
return (
<div className="grid grid-cols-1 md:grid-cols-[1fr_300px] gap-8">
<div>
<h1 className="text-2xl font-bold">About</h1>
<MarkdownRenderer contentPath={"en/sobre/quem-somos.md"} onHeadings={setHeadings} />
</div>
<aside>
<TableOfContents headings={headings} />
</aside>
</div>
);
}

View file

@ -2,25 +2,314 @@
:root {
--background: #ffffff;
--foreground: #171717;
--foreground: #111111;
--color-red: #c00021; /* Portuguese flag red (high contrast) */
--color-green: #006a3d; /* Portuguese flag green (high contrast) */
--color-focus: #1a73e8; /* Accessible focus color */
--color-muted: #666666;
--color-border: #e5e7eb;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-sans: var(--font-inter);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
/* Force light mode; remove automatic dark scheme */
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
font-family: var(--font-inter), system-ui, -apple-system, Segoe UI, Roboto,
Ubuntu, Cantarell, Noto Sans, sans-serif;
transition: background-color 200ms ease, color 200ms ease;
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* Typography baseline */
h1 { font-size: 2rem; line-height: 1.25; }
h2 { font-size: 1.5rem; line-height: 1.3; }
h3 { font-size: 1.25rem; line-height: 1.3; }
p { font-size: 1rem; line-height: 1.6; color: var(--foreground); }
.muted { color: var(--color-muted); }
/* Markdown content */
.markdown-content {
display: block;
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3 { margin-top: 1.25rem; margin-bottom: 0.75rem; }
.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; }
.markdown-content a { text-decoration: underline; }
/* Layout utilities */
.container {
width: 100%;
max-width: 72rem; /* ~1152px */
margin-left: auto;
margin-right: auto;
padding-left: 1rem;
padding-right: 1rem;
}
/* Hero */
.hero {
padding: 3rem 0;
animation: fade-in-up 400ms ease both;
}
.hero .title {
font-size: 2.25rem;
font-weight: 800;
}
.hero .subtitle {
margin-top: 0.5rem;
color: var(--color-muted);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
padding: 0.5rem 1rem;
border: 1px solid var(--color-border);
transition: opacity 150ms ease, transform 150ms ease, background-color 150ms ease, color 150ms ease;
}
.btn-primary {
background-color: var(--color-green);
color: #fff;
border-color: var(--color-green);
}
.btn-primary:hover, .btn-primary:focus-visible { opacity: 0.92; transform: translateY(-1px); }
.btn-outline { background-color: #fff; color: var(--foreground); }
.btn-outline:hover, .btn-outline:focus-visible { transform: translateY(-1px); }
/* Skip link for keyboard users */
.skip-link {
position: absolute;
left: -999px;
top: -999px;
background: #fff;
color: #000;
border: 2px solid var(--color-focus);
padding: 0.5rem 0.75rem;
z-index: 1000;
}
.skip-link:focus {
left: 0.75rem;
top: 0.75rem;
}
/* Header & navigation */
.app-header {
position: sticky;
top: 0;
z-index: 50;
background-color: #ffffff;
box-shadow: 0 2px 10px rgba(0,0,0,0.06);
}
.top-accent {
height: 6px;
background-color: var(--color-red);
}
.logo {
display: inline-flex;
align-items: center;
}
.nav-toggle {
display: none;
}
.nav-list {
display: flex;
gap: 1rem;
align-items: center;
}
.nav-list a,
.nav-label {
padding: 0.5rem 0.75rem;
border-radius: 999px;
transition: background-color 150ms ease, transform 150ms ease;
display: inline-flex;
align-items: center;
}
.nav-label {
cursor: pointer;
}
.nav-list a:hover,
.nav-list a:focus-visible,
.nav-label:hover {
background-color: rgba(0,0,0,0.06);
transform: translateY(-1px);
}
.nav-list a.active,
.nav-label.active {
background-color: rgba(0,0,0,0.06);
font-weight: 700;
}
.nav-item {
position: relative;
display: flex;
align-items: center;
}
.nav-item.has-sub > .submenu {
display: none;
position: absolute;
top: 100%;
left: 0;
min-width: 220px;
background: #fff;
border: 1px solid var(--color-border);
box-shadow: 0 8px 20px rgba(0,0,0,0.08);
padding: 0.5rem;
border-radius: 10px;
z-index: 60;
}
.nav-item.has-sub:hover > .submenu,
.nav-item.has-sub:focus-within > .submenu {
display: block;
}
.submenu a { display: block; padding: 0.5rem 0.75rem; border-radius: 8px; }
.submenu a:hover, .submenu a:focus-visible { background: rgba(0,0,0,0.06); }
.lang-switcher {
display: inline-flex;
gap: 0.25rem;
background: var(--color-red);
color: #fff;
border-radius: 999px;
padding: 0.25rem;
}
.lang-switcher a {
font-weight: 700;
color: #fff;
padding: 0.375rem 0.625rem;
border-radius: 999px;
}
.lang-switcher a.current {
background: rgba(255,255,255,0.2);
}
/* Footer */
.app-footer {
border-top: 4px solid var(--color-red);
margin-top: auto;
}
.footer-main {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.footer-left {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.footer-left > p {
margin: 0;
}
.footer-links {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-left: auto;
}
.footer-links a {
text-decoration: underline;
font-size: 0.875rem;
color: var(--color-muted);
}
.footer-links a:hover,
.footer-links a:focus-visible {
color: var(--foreground);
}
.footer-attribution {
font-size: 0.75rem;
color: var(--color-muted);
}
.footer-attribution a {
color: var(--color-muted);
text-decoration: underline;
}
.footer-attribution a:hover,
.footer-attribution a:focus-visible {
color: var(--foreground);
}
/* Subnav bar */
.subnav-bar {
border-top: 1px solid var(--color-border);
background: #fff;
}
.subnav {
display: flex;
gap: 0.5rem;
padding: 0.5rem 0;
}
.subnav a {
padding: 0.375rem 0.625rem;
border-radius: 999px;
}
.subnav a.active,
.subnav a:hover,
.subnav a:focus-visible {
background: rgba(0,0,0,0.06);
}
/* Animations */
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
.hero, .markdown-content { animation: none; }
.nav-list a, .btn { transition: none; }
}
/* Focus styles */
:where(a, button, input, select, textarea, summary, [role="button"]):focus,
:where(a, button, input, select, textarea, summary, [role="button"]):focus-visible,
:where(a, button, input, select, textarea, summary, [role="button"]):focus-within {
outline: 3px solid var(--color-focus);
outline-offset: 2px;
}
/* Responsive navigation */
@media (max-width: 768px) {
.nav-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: 2px solid var(--foreground);
border-radius: 6px;
background: #fff;
}
.nav-list {
display: none;
}
.nav-toggle[aria-expanded="true"] + .nav-list {
display: grid;
grid-template-columns: 1fr;
gap: 0.5rem;
margin-top: 0.75rem;
}
.nav-item.has-sub > .submenu {
position: static;
display: grid;
grid-template-columns: 1fr;
gap: 0.25rem;
box-shadow: none;
border: none;
padding: 0;
margin-top: 0.25rem;
}
}

View file

@ -1,20 +1,17 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Inter } from "next/font/google";
import "./globals.css";
import Link from "next/link";
import Image from "next/image";
import { Navigation } from "@/components/layout/Navigation";
import { SubnavBar } from "@/components/layout/SubnavBar";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Associação Portuguesa de Powerlifting (APP)",
description:
"Website da APP com acessibilidade WCAG 2.1 AA e suporte bilingue",
};
export default function RootLayout({
@ -23,11 +20,52 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<html lang="pt" data-theme="light">
<body className={`${inter.variable} font-sans antialiased`}>
{/* Skip to main content link */}
<a href="#main-content" className="skip-link">Saltar para o conteúdo</a>
{/* Header Navigation */}
<header className="app-header" role="banner">
<div className="top-accent" aria-hidden="true"></div>
<div className="container flex items-center justify-between py-3">
<Link href="/pt" className="logo" aria-label="Página inicial APP">
<Image
src="/logo.png"
alt="APP — Associação Portuguesa de Powerlifting"
width={240}
height={48}
priority
sizes="(max-width: 768px) 180px, 240px"
/>
</Link>
<Navigation />
</div>
{/* Persistent sub navigation for active section */}
{/* Rendered below via SubnavBar for full-width bar */}
<SubnavBar />
</header>
<main id="main-content" className="container py-6" role="main">
{children}
</main>
<footer className="app-footer" role="contentinfo">
<div className="container py-4">
<div className="footer-main">
<div className="footer-left">
<p>© {new Date().getFullYear()} APP Associação Portuguesa de Powerlifting</p>
<p className="footer-attribution">feito por <a href="https://comfy.solutions" target="_blank" rel="noopener noreferrer">comfy.solutions</a> com &lt;3</p>
</div>
<nav className="footer-links" aria-label="Footer links">
<Link href="/pt/privacidade">Privacidade</Link>
<Link href="/pt/acessibilidade">Declaração de Acessibilidade</Link>
<Link href="/pt/avisos-legais">Avisos Legais</Link>
</nav>
</div>
</div>
</footer>
</body>
</html>
);

View file

@ -1,65 +1,5 @@
import Image from "next/image";
import { redirect } from "next/navigation";
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
redirect("/pt");
}

View file

@ -0,0 +1,53 @@
export default function AcessibilidadePage() {
return (
<section aria-labelledby="accessibility-title" className="markdown-content">
<h1 id="accessibility-title">Declaração de Acessibilidade</h1>
<p>Última atualização: 6 de janeiro de 2026</p>
<h2>Compromisso com a Acessibilidade</h2>
<p>
A APP está empenhada em garantir a acessibilidade digital para pessoas com deficiência.
Melhoramos continuamente a experiência do utilizador para todos e aplicamos as normas
de acessibilidade relevantes.
</p>
<h2>Conformidade</h2>
<p>
Este website está em conformidade com as seguintes normas:
</p>
<ul>
<li>EN 301 549 - Requisitos de acessibilidade para produtos e serviços TIC</li>
<li>WCAG 2.1 Nível AA (Web Content Accessibility Guidelines)</li>
</ul>
<h2>Medidas de Acessibilidade</h2>
<ul>
<li>Estrutura semântica HTML adequada</li>
<li>Navegação por teclado completa</li>
<li>Indicadores de foco visíveis</li>
<li>Rótulos ARIA apropriados</li>
<li>Contraste de cores adequado (mínimo 4.5:1 para texto normal)</li>
<li>Texto alternativo para todas as imagens</li>
<li>Link "Saltar para o conteúdo" para navegação rápida</li>
<li>Compatibilidade com leitores de ecrã</li>
</ul>
<h2>Feedback</h2>
<p>
Agradecemos o seu feedback sobre a acessibilidade deste website.
Se encontrar barreiras de acessibilidade, por favor contacte-nos através da página de Contatos.
</p>
<h2>Testes e Avaliação</h2>
<p>
Este website foi testado com as seguintes tecnologias de apoio:
</p>
<ul>
<li>NVDA (Windows)</li>
<li>JAWS (Windows)</li>
<li>VoiceOver (macOS, iOS)</li>
<li>Navegação apenas por teclado</li>
</ul>
</section>
);
}

View file

@ -0,0 +1,58 @@
export default function AvisosLegaisPage() {
return (
<section aria-labelledby="legal-title" className="markdown-content">
<h1 id="legal-title">Avisos Legais</h1>
<p>Última atualização: 6 de janeiro de 2026</p>
<h2>Informação Legal</h2>
<p>
Este website é propriedade e operado pela Associação Portuguesa de Powerlifting (APP),
uma associação sem fins lucrativos registada em Portugal.
</p>
<h2>Propriedade Intelectual</h2>
<p>
Todos os conteúdos deste website, incluindo mas não limitado a textos, gráficos, logótipos,
imagens e software, são propriedade da APP ou dos seus licenciadores e estão protegidos
pelas leis de direitos de autor portuguesas e internacionais.
</p>
<h2>Utilização do Website</h2>
<p>
É permitida a visualização e impressão de conteúdos deste website para uso pessoal
e não comercial. Qualquer outra utilização, incluindo reprodução, distribuição,
modificação ou republicação, requer autorização prévia por escrito da APP.
</p>
<h2>Links Externos</h2>
<p>
Este website pode conter links para websites externos. A APP não é responsável
pelo conteúdo ou práticas de privacidade desses websites.
</p>
<h2>Isenção de Responsabilidade</h2>
<p>
A APP esforça-se por garantir que a informação neste website seja precisa e atualizada.
No entanto, não podemos garantir a exatidão, completude ou adequação de qualquer informação.
A APP não se responsabiliza por quaisquer perdas ou danos decorrentes da utilização deste website.
</p>
<h2>Modificações</h2>
<p>
A APP reserva-se o direito de alterar estes avisos legais a qualquer momento.
As alterações entrarão em vigor imediatamente após a publicação neste website.
</p>
<h2>Lei Aplicável</h2>
<p>
Estes avisos legais são regidos pela lei portuguesa. Qualquer disputa será submetida
à jurisdição exclusiva dos tribunais portugueses.
</p>
<h2>Contacto</h2>
<p>
Para questões legais, por favor contacte-nos através da página de Contatos.
</p>
</section>
);
}

View file

@ -0,0 +1,34 @@
"use client";
import { useState } from "react";
import { ListView } from "@/components/competitions/ListView";
import { CalendarView } from "@/components/competitions/CalendarView";
export default function CalendarioPage() {
const [tab, setTab] = useState<"lista" | "calendario">("lista");
return (
<section aria-labelledby="cal-title">
<h1 id="cal-title" className="text-2xl font-bold">Calendário de Competições</h1>
<div className="mt-4" role="tablist" aria-label="Selecionar vista">
<button
role="tab"
aria-selected={tab === "lista"}
className="mr-2 border px-3 py-2"
onClick={() => setTab("lista")}
>
Lista
</button>
<button
role="tab"
aria-selected={tab === "calendario"}
className="border px-3 py-2"
onClick={() => setTab("calendario")}
>
Calendário
</button>
</div>
<div className="mt-4">
{tab === "lista" ? <ListView /> : <CalendarView />}
</div>
</section>
);
}

View file

@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
export default function FotosPage() {
// Provide messaging before redirect on server
redirect("https://fotos.pavaogenial.pt");
}

View file

@ -0,0 +1,5 @@
import { ResultsTable } from "@/components/competitions/ResultsTable";
export default function ResultadosPage() {
return <ResultsTable />;
}

31
src/app/pt/page.tsx Normal file
View file

@ -0,0 +1,31 @@
export default function PtHome() {
return (
<section className="hero" aria-labelledby="home-title">
<div className="container">
<h1 id="home-title" className="title">Associação Portuguesa de Powerlifting</h1>
<p className="subtitle">Acessível. Transparente. Para todos os atletas.</p>
<div className="mt-4 flex gap-2">
<a className="btn btn-primary" href="/pt/competicoes/calendario">Ver Calendário</a>
<a className="btn btn-outline" href="/pt/competicoes/resultados">Ver Resultados</a>
</div>
<div className="mt-8 grid gap-4 md:grid-cols-3">
<div className="border p-4 rounded-md">
<h3 className="font-semibold">Quem Somos</h3>
<p className="muted">Conheça a APP e a nossa missão.</p>
<a className="underline mt-2 inline-block" href="/pt/sobre/quem-somos">Ler mais</a>
</div>
<div className="border p-4 rounded-md">
<h3 className="font-semibold">Regras</h3>
<p className="muted">IPF Rule Book e Anti-Doping.</p>
<a className="underline mt-2 inline-block" href="/pt/regras/ipf-rule-book">Consultar</a>
</div>
<div className="border p-4 rounded-md">
<h3 className="font-semibold">FAQ</h3>
<p className="muted">Perguntas frequentes para atletas e treinadores.</p>
<a className="underline mt-2 inline-block" href="/pt/perguntas">Explorar</a>
</div>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,31 @@
import { notFound } from "next/navigation";
import { readFile } 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 } };
export default async function FaqArticlePage({ params }: Params) {
const root = process.cwd();
const file = join(root, "content", "pt", "perguntas", `${params.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>}
{frontmatter.intendedFor && <span className="ml-2"> Público: {String(frontmatter.intendedFor)}</span>}
</p>
</header>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</article>
);
} catch (e) {
notFound();
}
}

View file

@ -0,0 +1,36 @@
import Link from "next/link";
import { readFile } 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
const root = process.cwd();
const files = ["faq-exemplo.md"]; // In a real app, glob directory
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 { data } = matter(raw.toString());
entries.push({
slug: f.replace(/\.md$/, ""),
title: String(data.title ?? "FAQ"),
readTime: data.readTime as string | undefined,
intendedFor: data.intendedFor as string | undefined,
});
}
return (
<section aria-labelledby="faq-title">
<h1 id="faq-title" className="text-2xl font-bold">Perguntas Frequentes</h1>
<ul className="mt-4 space-y-3">
{entries.map((e) => (
<li key={e.slug} className="border p-3">
<Link href={`/pt/perguntas/${e.slug}`} className="underline font-semibold">{e.title}</Link>
{e.readTime && <span className="ml-2"> {e.readTime}</span>}
{e.intendedFor && <span className="ml-2"> {e.intendedFor}</span>}
</li>
))}
</ul>
</section>
);
}

View file

@ -0,0 +1,38 @@
export default function PrivacidadePage() {
return (
<section aria-labelledby="privacy-title" className="markdown-content">
<h1 id="privacy-title">Política de Privacidade</h1>
<p>Última atualização: 6 de janeiro de 2026</p>
<h2>Recolha e Utilização de Dados</h2>
<p>
A Associação Portuguesa de Powerlifting (APP) compromete-se a proteger a privacidade dos utilizadores
do seu website. Esta política descreve como recolhemos, utilizamos e protegemos os seus dados pessoais.
</p>
<h2>Dados Recolhidos</h2>
<p>
Recolhemos apenas os dados essenciais para o funcionamento do website e para responder às suas solicitações.
Estes dados podem incluir nome, email e informações de contacto quando submetidas voluntariamente.
</p>
<h2>Proteção de Dados</h2>
<p>
Implementamos medidas de segurança adequadas para proteger os seus dados pessoais contra acesso não autorizado,
alteração, divulgação ou destruição.
</p>
<h2>Direitos dos Utilizadores</h2>
<p>
De acordo com o RGPD, tem o direito de aceder, retificar ou eliminar os seus dados pessoais.
Para exercer estes direitos, contacte-nos através da página de Contatos.
</p>
<h2>Cookies</h2>
<p>
Este website utiliza apenas cookies essenciais para o seu funcionamento. Não utilizamos cookies
de rastreamento ou publicidade.
</p>
</section>
);
}

View file

@ -0,0 +1,19 @@
"use client";
import { useState } from "react";
import { MarkdownRenderer } from "@/components/markdown/MarkdownRenderer";
import { TableOfContents } from "@/components/markdown/TableOfContents";
export default function AntiDopingPage() {
const [headings, setHeadings] = useState([] as { id: string; text: string; level: number }[]);
return (
<div className="grid grid-cols-1 md:grid-cols-[1fr_300px] gap-8">
<div>
<h1 className="text-2xl font-bold">Anti-Doping</h1>
<MarkdownRenderer contentPath={"pt/regras/anti-doping.md"} onHeadings={setHeadings} />
</div>
<aside>
<TableOfContents headings={headings} />
</aside>
</div>
);
}

View file

@ -0,0 +1,19 @@
"use client";
import { useState } from "react";
import { MarkdownRenderer } from "@/components/markdown/MarkdownRenderer";
import { TableOfContents } from "@/components/markdown/TableOfContents";
export default function IpfRuleBookPage() {
const [headings, setHeadings] = useState([] as { id: string; text: string; level: number }[]);
return (
<div className="grid grid-cols-1 md:grid-cols-[1fr_300px] gap-8">
<div>
<h1 className="text-2xl font-bold">IPF Rule Book</h1>
<MarkdownRenderer contentPath={"pt/regras/ipf-rule-book.md"} onHeadings={setHeadings} />
</div>
<aside>
<TableOfContents headings={headings} />
</aside>
</div>
);
}

View file

@ -0,0 +1,55 @@
"use client";
import { useEffect, useRef, useState } from "react";
declare global {
interface Window {
turnstile?: {
render: (el: HTMLElement, options: any) => void;
};
}
}
export default function ContatosPage() {
const [verified, setVerified] = useState(false);
const widgetRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const script = document.createElement("script");
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
script.async = true;
script.defer = true;
script.onload = () => {
if (widgetRef.current && window.turnstile) {
window.turnstile.render(widgetRef.current, {
sitekey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
callback: () => setVerified(true),
});
}
};
document.body.appendChild(script);
return () => {
document.body.removeChild(script);
};
}, []);
return (
<section aria-labelledby="contatos-title">
<h1 id="contatos-title" className="text-2xl font-bold">Contatos</h1>
{!verified ? (
<div>
<p className="mb-3">Complete o desafio para revelar os contatos:</p>
<div ref={widgetRef} className="cf-turnstile" />
</div>
) : (
<div aria-live="polite">
<h2 className="text-xl font-semibold">Informação de contato</h2>
<ul className="mt-2">
<li>Email: <a href="mailto:info@app.pt">info@app.pt</a></li>
<li>Telefone: <a href="tel:+351123456789">+351 123 456 789</a></li>
<li>Morada: Rua Exemplo, 100, Lisboa, Portugal</li>
</ul>
</div>
)}
</section>
);
}

View file

@ -0,0 +1,27 @@
"use client";
import { useState } from "react";
import { MarkdownRenderer } from "@/components/markdown/MarkdownRenderer";
import { TableOfContents } from "@/components/markdown/TableOfContents";
export default function QuemSomosPage() {
const [headings, setHeadings] = useState<{
id: string;
text: string;
level: number;
}[]>([]);
return (
<div className="grid grid-cols-1 md:grid-cols-[1fr_300px] gap-8">
<div>
<h1 className="text-2xl font-bold">Quem Somos</h1>
<MarkdownRenderer
contentPath={"pt/sobre/quem-somos.md"}
onHeadings={setHeadings}
/>
</div>
<aside aria-label="Tabela de conteúdos" className="md:block">
<TableOfContents headings={headings} />
</aside>
</div>
);
}

View file

@ -0,0 +1,50 @@
"use client";
import React, { useEffect, useState } from "react";
type Event = { title: string; date: string; location?: string; signupUrl?: string };
export function CalendarView() {
const [events, setEvents] = useState<Event[]>([]);
useEffect(() => {
(async () => {
try {
const res = await fetch("/api/content?path=pt/competicoes/future.json");
const json = await res.json();
setEvents(json.events ?? []);
} catch {}
})();
}, []);
// Simple month grid from provided future events
const byMonth = events.reduce<Record<string, Event[]>>((acc, e) => {
const month = new Date(e.date).toLocaleString("pt-PT", { month: "long", year: "numeric" });
acc[month] ??= [];
acc[month].push(e);
return acc;
}, {});
return (
<section aria-labelledby="calendar-title">
<h2 id="calendar-title" className="text-xl font-semibold">Calendário</h2>
<div className="grid gap-4 md:grid-cols-2 mt-2">
{Object.entries(byMonth).map(([m, items]) => (
<div key={m} className="border p-3">
<h3 className="font-bold">{m}</h3>
<ul className="mt-2 space-y-1">
{items.map((e, idx) => (
<li key={idx}>
<strong>{e.title}</strong> {new Date(e.date).toLocaleDateString("pt-PT")}
{e.location && <span className="ml-2">({e.location})</span>}
{e.signupUrl && (
<a className="ml-2 underline" href={e.signupUrl} target="_blank" rel="noopener noreferrer">Inscrição</a>
)}
</li>
))}
</ul>
</div>
))}
</div>
</section>
);
}

View file

@ -0,0 +1,45 @@
"use client";
import React, { useEffect, useState } from "react";
type Meet = { id: string; name: string; date?: string; location?: string; signupUrl?: string };
export function ListView() {
const [meets, setMeets] = useState<Meet[]>([]);
const [future, setFuture] = useState<Meet[]>([]);
useEffect(() => {
(async () => {
const res = await fetch("/api/openpowerlifting/meets");
const data = await res.json();
setMeets(data.meets ?? []);
const f = await fetch("/api/content?path=pt/competicoes/future.json");
try {
const json = await f.json();
setFuture(json.events ?? []);
} catch {}
})();
}, []);
return (
<section aria-labelledby="list-title">
<h2 id="list-title" className="text-xl font-semibold">Lista de Competições</h2>
<ul className="mt-2 space-y-2">
{future.map((m) => (
<li key={`future-${m.name}`} className="border p-3">
<strong>{m.name}</strong>
{m.date && <span className="ml-2">{m.date}</span>}
{m.location && <span className="ml-2"> {m.location}</span>}
{m.signupUrl && (
<a className="ml-3 underline" href={m.signupUrl} target="_blank" rel="noopener noreferrer">Inscrição</a>
)}
</li>
))}
{meets.map((m) => (
<li key={m.id} className="border p-3">
<strong>{m.name}</strong>
</li>
))}
</ul>
</section>
);
}

View file

@ -0,0 +1,141 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
type Row = Record<string, string>;
export function ResultsTable() {
const [meets, setMeets] = useState<{ id: string; name: string }[]>([]);
const [selectedMeet, setSelectedMeet] = useState<string>("");
const [rows, setRows] = useState<Row[]>([]);
const [query, setQuery] = useState<string>("");
const [squatCol, setSquatCol] = useState<string>("Squat3Kg");
const [benchCol, setBenchCol] = useState<string>("Bench3Kg");
const [deadliftCol, setDeadliftCol] = useState<string>("Deadlift3Kg");
useEffect(() => {
(async () => {
const res = await fetch("/api/openpowerlifting/meets");
const data = await res.json();
setMeets(data.meets ?? []);
})();
}, []);
useEffect(() => {
if (!selectedMeet) return;
(async () => {
const res = await fetch(`/api/openpowerlifting/results?id=${selectedMeet}`);
const json = await res.json();
setRows(json.data ?? []);
})();
}, [selectedMeet]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return rows.filter((r) => (r["Name"] || "").toLowerCase().includes(q));
}, [rows, query]);
const attemptOptions = (prefix: string) => {
const labelPrefix = prefix === "Squat" ? "Agachamento" : prefix === "Bench" ? "Supino" : "Levantamento terra";
const opts = [1, 2, 3, 4]
.map((n) => ({ key: `${prefix}${n}Kg`, label: `${labelPrefix} ${n}` }))
.filter((o) => rows.some((r) => o.key in r));
return opts;
};
return (
<section aria-labelledby="results-title">
<h1 id="results-title" className="text-2xl font-bold">Resultados</h1>
<div className="grid gap-4 md:grid-cols-3 mt-4" role="region" aria-label="Controlo de pesquisa e seleção">
<label className="block">
<span className="block font-semibold">Competição</span>
<select
className="mt-1 w-full border p-2"
aria-label="Selecionar competição"
value={selectedMeet}
onChange={(e) => setSelectedMeet(e.target.value)}
>
<option value="">Escolha uma competição</option>
{meets.map((m) => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
</label>
<label className="block">
<span className="block font-semibold">Pesquisar atleta</span>
<input
type="search"
className="mt-1 w-full border p-2"
placeholder="Nome do atleta"
aria-label="Pesquisar por nome"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</label>
<div className="grid grid-cols-3 gap-2" aria-label="Seleção de tentativas">
<label>
<span className="block font-semibold">Agachamento</span>
<select className="mt-1 w-full border p-2" value={squatCol} onChange={(e) => setSquatCol(e.target.value)}>
{attemptOptions("Squat").map((o) => (
<option key={o.key} value={o.key}>{o.label}</option>
))}
</select>
</label>
<label>
<span className="block font-semibold">Supino</span>
<select className="mt-1 w-full border p-2" value={benchCol} onChange={(e) => setBenchCol(e.target.value)}>
{attemptOptions("Bench").map((o) => (
<option key={o.key} value={o.key}>{o.label}</option>
))}
</select>
</label>
<label>
<span className="block font-semibold">Deadlift</span>
<select className="mt-1 w-full border p-2" value={deadliftCol} onChange={(e) => setDeadliftCol(e.target.value)}>
{attemptOptions("Deadlift").map((o) => (
<option key={o.key} value={o.key}>{o.label}</option>
))}
</select>
</label>
</div>
</div>
<div className="mt-6 overflow-x-auto" role="region" aria-label="Tabela de resultados">
<table className="min-w-full border" role="table">
<caption className="sr-only">Resultados da competição selecionada</caption>
<thead>
<tr>
{(["Place","Name","Sex","Age","Equipment","Division","Bodyweight (kg)"] as const).map((h) => (
<th key={h} scope="col" className="border p-2 text-left">{h}</th>
))}
<th scope="col" className="border p-2 text-left">Agachamento</th>
<th scope="col" className="border p-2 text-left">Supino</th>
<th scope="col" className="border p-2 text-left">Deadlift</th>
{(["Total (kg)","GLPoints"] as const).map((h) => (
<th key={h} scope="col" className="border p-2 text-left">{h}</th>
))}
</tr>
</thead>
<tbody>
{filtered.map((r, idx) => (
<tr key={idx} className="even:bg-gray-50">
<td className="border p-2">{r["Place"]}</td>
<td className="border p-2">{r["Name"]}</td>
<td className="border p-2">{r["Sex"]}</td>
<td className="border p-2">{r["Age"]}</td>
<td className="border p-2">{r["Equipment"]}</td>
<td className="border p-2">{r["Division"]}</td>
<td className="border p-2">{r["BodyweightKg"]}</td>
<td className="border p-2">{r[squatCol]}</td>
<td className="border p-2">{r[benchCol]}</td>
<td className="border p-2">{r[deadliftCol]}</td>
<td className="border p-2">{r["TotalKg"]}</td>
<td className="border p-2">{r["GLPoints"]}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}

View file

@ -0,0 +1,98 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { usePathname } from "next/navigation";
export function Navigation() {
const [expanded, setExpanded] = useState(false);
const pathname = usePathname();
const locale = pathname.startsWith("/en") ? "en" : "pt";
const base = `/${locale}`;
const isActive = (href: string) => pathname.startsWith(href);
const sections = useMemo(() => [
{
key: "home",
label: locale === "en" ? "Home" : "Início",
path: base,
items: [] as { label: string; href: string }[],
},
{
key: "sobre",
label: locale === "en" ? "About APP" : "Sobre a APP",
path: `${base}/sobre`,
items: [
{ label: locale === "en" ? "Quem Somos" : "Quem Somos", href: `${base}/sobre/quem-somos` },
{ label: locale === "en" ? "Contatos" : "Contatos", href: `${base}/sobre/contatos` },
],
},
{
key: "competicoes",
label: locale === "en" ? "Competitions" : "Competições",
path: `${base}/competicoes`,
items: [
{ label: locale === "en" ? "Calendar" : "Calendário", href: `${base}/competicoes/calendario` },
{ label: locale === "en" ? "Results" : "Resultados", href: `${base}/competicoes/resultados` },
{ label: locale === "en" ? "Photos" : "Fotos", href: `${base}/competicoes/fotos` },
],
},
{
key: "regras",
label: locale === "en" ? "Rules" : "Regras",
path: `${base}/regras`,
items: [
{ label: "IPF Rule Book", href: `${base}/regras/ipf-rule-book` },
{ label: locale === "en" ? "Anti-Doping" : "Anti-Doping", href: `${base}/regras/anti-doping` },
],
},
{
key: "perguntas",
label: locale === "en" ? "FAQ" : "Perguntas",
path: `${base}/perguntas`,
items: [] as { label: string; href: string }[],
},
], [locale, base, pathname]);
const currentSection = sections.find(
(s) => s.key !== "home" && pathname.startsWith(s.path)
);
return (
<nav aria-label="Principal" className="nav">
<button
className="nav-toggle"
aria-expanded={expanded}
aria-controls="primary-menu"
onClick={() => setExpanded((e) => !e)}
>
<span className="sr-only">{expanded ? (locale === "en" ? "Close menu" : "Fechar menu") : (locale === "en" ? "Open menu" : "Abrir menu")}</span>
</button>
<ul id="primary-menu" className="nav-list">
{sections.map((s) => (
<li key={s.key} className={s.items.length ? "nav-item has-sub" : "nav-item"}>
{s.items.length > 0 ? (
<>
<span className={isActive(s.path) ? "active nav-label" : "nav-label"}>{s.label}</span>
<ul className="submenu" role="menu" aria-label={s.label}>
{s.items.map((it) => (
<li key={it.href} role="none">
<Link role="menuitem" href={it.href} className={isActive(it.href) ? "active" : ""}>{it.label}</Link>
</li>
))}
</ul>
</>
) : (
<Link className={isActive(s.path) ? "active" : ""} href={s.path}>{s.label}</Link>
)}
</li>
))}
<li className="lang-switcher" role="group" aria-label={locale === "en" ? "Language" : "Idioma"}>
<Link className={locale === "pt" ? "current" : ""} href="/pt" aria-label="Português">PT</Link>
<Link className={locale === "en" ? "current" : ""} href="/en" aria-label="English">EN</Link>
</li>
</ul>
</nav>
);
}

View file

@ -0,0 +1,55 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
export function SubnavBar() {
const pathname = usePathname();
const locale = pathname.startsWith("/en") ? "en" : "pt";
const base = `/${locale}`;
const sections = [
{
key: "sobre",
label: locale === "en" ? "About APP" : "Sobre a APP",
path: `${base}/sobre`,
items: [
{ label: locale === "en" ? "About" : "Quem Somos", href: `${base}/sobre/quem-somos` },
{ label: locale === "en" ? "Contacts" : "Contatos", href: `${base}/sobre/contatos` },
],
},
{
key: "competicoes",
label: locale === "en" ? "Competitions" : "Competições",
path: `${base}/competicoes`,
items: [
{ label: locale === "en" ? "Calendar" : "Calendário", href: `${base}/competicoes/calendario` },
{ label: locale === "en" ? "Results" : "Resultados", href: `${base}/competicoes/resultados` },
{ label: locale === "en" ? "Photos" : "Fotos", href: `${base}/competicoes/fotos` },
],
},
{
key: "regras",
label: locale === "en" ? "Rules" : "Regras",
path: `${base}/regras`,
items: [
{ label: "IPF Rule Book", href: `${base}/regras/ipf-rule-book` },
{ label: locale === "en" ? "Anti-Doping" : "Anti-Doping", href: `${base}/regras/anti-doping` },
],
},
];
const current = sections.find((s) => pathname.startsWith(s.path));
if (!current) return null;
const isActive = (href: string) => pathname.startsWith(href);
return (
<div className="subnav-bar" role="navigation" aria-label={`${current.label} submenus`}>
<div className="container subnav">
{current.items.map((it) => (
<Link key={it.href} href={it.href} className={isActive(it.href) ? "active" : ""}>{it.label}</Link>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,37 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import { parseMarkdownWithFrontmatter } from "@/lib/markdown";
type MarkdownRendererProps = {
contentPath: string;
onHeadings?: (headings: { id: string; text: string; level: number }[]) => void;
};
export function MarkdownRenderer({ contentPath, onHeadings }: MarkdownRendererProps) {
const [source, setSource] = useState<string>("");
useEffect(() => {
(async () => {
const res = await fetch(`/api/content?path=${encodeURIComponent(contentPath)}`);
const text = await res.text();
const { content, headings } = parseMarkdownWithFrontmatter(text);
setSource(content);
onHeadings?.(headings);
})();
}, [contentPath, onHeadings]);
return (
<article className="markdown-content" aria-label="Conteúdo">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSlug, [rehypeAutolinkHeadings, { behavior: "wrap" }]]}
>
{source}
</ReactMarkdown>
</article>
);
}

View file

@ -0,0 +1,46 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
type Heading = { id: string; text: string; level: number };
export function TableOfContents({ headings }: { headings: Heading[] }) {
const [activeId, setActiveId] = useState<string | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
useEffect(() => {
const callback: IntersectionObserverCallback = (entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
});
};
const observer = new IntersectionObserver(callback, {
rootMargin: "-40% 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]);
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>
);
}

25
src/lib/markdown.ts Normal file
View file

@ -0,0 +1,25 @@
import matter from "gray-matter";
export type Frontmatter = Record<string, unknown>;
export function parseMarkdownWithFrontmatter(source: string) {
const { content, data } = matter(source);
const headings = extractHeadings(content);
return { content, frontmatter: data as Frontmatter, headings };
}
function extractHeadings(content: string) {
const regex = /^(#{1,6})\s+(.+)$/gm;
const headings: { id: string; text: string; level: number }[] = [];
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null) {
const level = match[1].length;
const text = match[2].trim();
const id = text
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-");
headings.push({ id, text, level });
}
return headings;
}