site: initial implementation and footer fix
This commit is contained in:
parent
2ada7468fa
commit
19d092ea23
39 changed files with 3497 additions and 119 deletions
62
README.md
62
README.md
|
|
@ -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
|
||||
|
|
|
|||
14
content/en/sobre/quem-somos.md
Normal file
14
content/en/sobre/quem-somos.md
Normal 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
|
||||
16
content/pt/competicoes/future.json
Normal file
16
content/pt/competicoes/future.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
7
content/pt/perguntas/faq-exemplo.md
Normal file
7
content/pt/perguntas/faq-exemplo.md
Normal 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.
|
||||
11
content/pt/regras/anti-doping.md
Normal file
11
content/pt/regras/anti-doping.md
Normal 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.
|
||||
11
content/pt/regras/ipf-rule-book.md
Normal file
11
content/pt/regras/ipf-rule-book.md
Normal 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.
|
||||
22
content/pt/sobre/quem-somos.md
Normal file
22
content/pt/sobre/quem-somos.md
Normal 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
1984
package-lock.json
generated
File diff suppressed because it is too large
Load diff
10
package.json
10
package.json
|
|
@ -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
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
29
src/app/api/content/route.ts
Normal file
29
src/app/api/content/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
27
src/app/api/openpowerlifting/meets/route.ts
Normal file
27
src/app/api/openpowerlifting/meets/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
19
src/app/api/openpowerlifting/results/route.ts
Normal file
19
src/app/api/openpowerlifting/results/route.ts
Normal 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
31
src/app/en/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/app/en/sobre/quem-somos/page.tsx
Normal file
19
src/app/en/sobre/quem-somos/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
53
src/app/pt/acessibilidade/page.tsx
Normal file
53
src/app/pt/acessibilidade/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
src/app/pt/avisos-legais/page.tsx
Normal file
58
src/app/pt/avisos-legais/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/app/pt/competicoes/calendario/page.tsx
Normal file
34
src/app/pt/competicoes/calendario/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
src/app/pt/competicoes/fotos/page.tsx
Normal file
6
src/app/pt/competicoes/fotos/page.tsx
Normal 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");
|
||||
}
|
||||
5
src/app/pt/competicoes/resultados/page.tsx
Normal file
5
src/app/pt/competicoes/resultados/page.tsx
Normal 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
31
src/app/pt/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/app/pt/perguntas/[slug]/page.tsx
Normal file
31
src/app/pt/perguntas/[slug]/page.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
36
src/app/pt/perguntas/page.tsx
Normal file
36
src/app/pt/perguntas/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/app/pt/privacidade/page.tsx
Normal file
38
src/app/pt/privacidade/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/app/pt/regras/anti-doping/page.tsx
Normal file
19
src/app/pt/regras/anti-doping/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/app/pt/regras/ipf-rule-book/page.tsx
Normal file
19
src/app/pt/regras/ipf-rule-book/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/app/pt/sobre/contatos/page.tsx
Normal file
55
src/app/pt/sobre/contatos/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
src/app/pt/sobre/quem-somos/page.tsx
Normal file
27
src/app/pt/sobre/quem-somos/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
src/components/competitions/CalendarView.tsx
Normal file
50
src/components/competitions/CalendarView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
src/components/competitions/ListView.tsx
Normal file
45
src/components/competitions/ListView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
src/components/competitions/ResultsTable.tsx
Normal file
141
src/components/competitions/ResultsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
src/components/layout/Navigation.tsx
Normal file
98
src/components/layout/Navigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/components/layout/SubnavBar.tsx
Normal file
55
src/components/layout/SubnavBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/components/markdown/MarkdownRenderer.tsx
Normal file
37
src/components/markdown/MarkdownRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/components/markdown/TableOfContents.tsx
Normal file
46
src/components/markdown/TableOfContents.tsx
Normal 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
25
src/lib/markdown.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue