Fixed table of contents issues

This commit is contained in:
headpatsyou 2026-01-08 22:13:28 +00:00
parent 450ac1b7e8
commit 0bf0ba54a8
11 changed files with 495 additions and 123 deletions

View file

@ -1,10 +1,12 @@
## Sobre o Anti-Doping/Clean Sport # Anti-Doping
> Conteúdo traduzido do site da IPF. Pode consultar a página da IPF [aqui.](https://www.powerlifting.sport/anti-doping/about-anti-doping-/-clean-sport). Informação foi adicionada a esta página. > Conteúdo traduzido do site da IPF. Pode consultar a página da IPF [aqui.](https://www.powerlifting.sport/anti-doping/about-anti-doping-/-clean-sport). Informação foi adicionada a esta página.
## Sobre o Anti-Doping/Clean Sport
### Introdução ### Introdução
**A IPF ESTÁ COMPROMETIDA COM O SEU PROGRAMA E COM O CUMPRIMENTO DAS SUAS OBRIGAÇÕES DE CONFORMIDADE COM O CÓDIGO MUNDIAL DE ANTI-DOPING** **A IPF ESTÁ COMPROMETIDA COM O SEU PROGRAMA E COM O CUMPRIMENTO DAS SUAS OBRIGAÇÕES DE CONFORMIDADE COM O CÓDIGO MUNDIAL DE ANTI-DOPING**
O *doping* pode ser prejudicial para a saúde de um atleta, prejudica a integridade do desporto e é moral e eticamente errado. Todos os atletas da IPF e qualquer atleta que pretenda participar em competições sob a jurisdição da IPF devem cumprir as Regras Anti-Doping da IPF. O *doping* pode ser prejudicial para a saúde de um atleta, prejudica a integridade do desporto e é moral e eticamente errado. Todos os atletas da IPF e qualquer atleta que pretenda participar em competições sob a jurisdição da IPF devem cumprir as Regras Anti-Doping da IPF.
O texto abaixo oferece respostas a questões importantes relacionadas com o antidoping. O texto abaixo oferece respostas a questões importantes relacionadas com o antidoping.
@ -48,4 +50,164 @@ Ao educar os nossos atletas, treinadores, pessoal de apoio, equipas médicas e a
É também o seu papel denunciar o doping se o presenciar: pode ajudar a proteger o atleta que está a tomar uma substância proibida, bem como o seu desporto, ao defender os valores do desporto limpo. É também o seu papel denunciar o doping se o presenciar: pode ajudar a proteger o atleta que está a tomar uma substância proibida, bem como o seu desporto, ao defender os valores do desporto limpo.
### O que é o doping? ### O que é o *doping*?
A dopagem (*doping*) não é apenas um teste positivo que mostra a presença de uma substância proibida numa amostra de urina de um atleta. A dopagem é definida como a ocorrência de uma ou mais das 11 Violações das Regras Antidopagem (VRA) descritas no [Código Mundial Antidopagem](https://www.wada-ama.org/en/resources/the-code/world-anti-doping-code) e nas [Regras Antidopagem da IPF IPF Anti-Doping Rules - International Powerlifting Federation IPF](https://www.powerlifting.sport/anti-doping/ipf-anti-doping-rules)
Estas são:
1. Presença de uma substância proibida, seus metabolitos ou marcadores na amostra de um atleta
2. Uso ou tentativa de uso de uma substância ou método proibido por um atleta
3. Recusa, evasão ou não submissão à recolha de amostras por um atleta
4. Falha no fornecimento de informações de paradeiro e/ou testes falhados por um atleta
5. Adulteração ou tentativa de adulteração do processo de controlo de dopagem por um atleta ou outra pessoa
6. Posse de uma substância ou método proibido por um atleta ou pessoal de apoio ao atleta
7. Tráfico ou tentativa de tráfico de uma substância ou método proibido por um atleta ou outra pessoa
8. Administração ou tentativa de administração de uma substância ou método proibido a um atleta
9. Cumplicidade ou tentativa de cumplicidade numa VRA por um atleta ou outra pessoa
10. Associação Proibida por um atleta ou outra pessoa com pessoal de apoio ao atleta sancionado
11. Atos para desencorajar ou retaliar contra denúncias às autoridades
### Porque é que a dopagem no desporto é proibida?
O uso de [substâncias de dopagem ou métodos de dopagem](https://www.wada-ama.org/en/prohibited-list#search-anchor) para melhorar o desempenho é fundamentalmente errado e é prejudicial ao espírito geral do desporto. O uso indevido de substâncias pode ser prejudicial para a saúde de um atleta e para outros atletas que competem no desporto. Prejudica severamente a integridade, imagem e valor do desporto, quer a motivação para usar substâncias seja ou não melhorar o desempenho. Para alcançar integridade e justiça no desporto, um compromisso com o desporto limpo é fundamental.
### O que significa 'Responsabilidade Estrita'?
* O princípio da responsabilidade estrita aplica-se a todos os atletas que competem em qualquer desporto com um programa antidopagem. Significa que os atletas são responsáveis por qualquer substância proibida, ou seus metabolitos ou marcadores, que sejam encontrados presentes na sua amostra de urina e/ou sangue recolhida durante o controlo de dopagem, independentemente de o atleta ter usado intencional ou não intencionalmente uma substância ou método proibido. Portanto, é importante lembrar que é responsabilidade última de cada atleta saber o que entra no seu corpo.
* A regra que estabelece esse princípio, ao abrigo do [Artigo 2.1 e Artigo 2.2](https://www.wada-ama.org/en/resources/the-code/world-anti-doping-code) do Código, declara que não é necessário que a intenção, culpa, negligência ou uso consciente por parte do atleta seja demonstrado pela Organização Antidopagem para estabelecer uma violação das regras antidopagem.
### Porque é que a dopagem é perigosa?
A dopagem pode resultar em graves consequências para a saúde, mas também acarreta consequências desportivas, sociais, financeiras e legais. Para um atleta, a dopagem pode significar o fim da sua carreira desportiva, reputação e perspetivas tanto dentro como fora do desporto.
#### Consequências Desportivas
As sanções por uma Violação das Regras Antidopagem (VRA) podem incluir:
* Suspensão Provisória. O atleta ou outra pessoa é temporariamente proibido de participar em qualquer competição ou atividade enquanto aguarda a conclusão do processo de gestão de resultados ou até que a decisão final seja proferida.
* Inelegibilidade. O atleta ou outra pessoa não é autorizado a competir ou participar em qualquer outra atividade, como treino, coaching, ou mesmo acesso a financiamento devido a uma VRA. Este período de inelegibilidade pode ser de até 4 anos ou mesmo vitalício, dependendo das circunstâncias da VRA.
* Desqualificação de resultados. Os resultados do atleta durante um determinado período, competição ou evento são invalidados, o que implica a perda de quaisquer medalhas, pontos e prémios.
* Divulgação Pública. A Organização Antidopagem (OAD) informa o público em geral da VRA.
* Multas.
#### Consequências para a Saúde
As consequências para a saúde de um atleta podem incluir:
* Saúde física. Medicamentos e intervenções médicas foram desenvolvidos para tratar uma condição ou doença particular. Não um atleta saudável. Dependendo da substância, da dosagem e da frequência de consumo, os produtos de dopagem podem ter efeitos secundários particularmente negativos na saúde.
* Saúde psicológica. Algumas substâncias de dopagem podem ter um impacto na saúde mental do atleta. Ansiedade, perturbações obsessivo-compulsivas ou psicose são consequências diretas da dopagem.
#### Consequências Sociais
Algumas das consequências sociais da dopagem incluem:
* Danos à reputação e imagem, que podem ser permanentes com a atenção dos media, e futuros desempenhos limpos podem ser recebidos com ceticismo.
* Danos às perspetivas de carreira futura.
* Isolamento dos pares e do desporto.
* Relações danificadas com amigos e família.
* Efeitos no bem-estar emocional e psicológico.
* Perda de estatuto, fama, respeito e credibilidade.
#### Consequências Financeiras
As consequências financeiras da dopagem podem incluir:
* Multas que uma Organização Antidopagem (OAD) pode ter incluído nas suas regras antidopagem, incluindo custos associados a uma Violação das Regras Antidopagem (VRA).
* Perda de rendimento/apoio financeiro, tal como financiamento governamental, outras formas de apoio financeiro e por não participar nas competições.
* Perda de apoio financeiro devido à retirada de patrocinador.
* Requisito de reembolsar patrocinador, se incluído no contrato.
* Reembolso de prémios monetários.
* Impacto da reputação danificada nas perspetivas de carreira futura.
#### Consequências Legais
Para além das consequências desportivas, de saúde, sociais e financeiras listadas acima, a dopagem pode acarretar outras consequências legais, tais como:
* Alguns países foram além do Código Mundial Antidopagem e tornaram o uso de uma substância proibida um crime (por exemplo, Áustria, Itália, França).
* Em alguns países, VRA relacionadas com tráfico, posse ou administração de uma substância proibida ou algumas substâncias na Lista de Substâncias Proibidas são consideradas um crime.
### O que os atletas e o pessoal de apoio ao atleta precisam de saber sobre antidopagem?
Os atletas, o seu pessoal de apoio e outros que estão sujeitos às regras antidopagem têm todos direitos e responsabilidades ao abrigo do Código Mundial Antidopagem (Código). A Parte Três do Código descreve todos os papéis e responsabilidades de cada parte interessada no sistema antidopagem.
#### Direitos dos Atletas
"Cada atleta tem o direito ao desporto limpo!"
Garantir que os atletas estão conscientes dos seus direitos e que estes direitos são respeitados é vital para o sucesso do desporto limpo. O Comité de Atletas da AMA (agora Conselho de Atletas) redigiu a Lei dos Direitos Antidopagem dos Atletas (Lei). Esta Lei é composta por duas partes. A primeira parte estabelece direitos que se encontram no Código e nas Normas Internacionais. A segunda parte estabelece direitos de atletas recomendados que não se encontram no Código ou nas Normas Internacionais, mas são direitos que os atletas recomendam que as Organizações Antidopagem (OAD) adotem como melhores práticas.
Os direitos dos atletas descritos no Código incluem:
Oportunidades iguais na sua busca pelo desporto, livre da participação de outros atletas que fazem dopagem
Programas de testes equitativos e justos
Um processo de candidatura para Autorização de Uso Terapêutico (AUT)
Ser ouvido, ter uma audiência justa dentro de um prazo razoável por um painel de audiência justo, imparcial e operacionalmente independente, com uma decisão fundamentada oportuna que inclua especificamente uma explicação das razões da decisão
Direito de recorrer da decisão da audiência
Qualquer OAD que tenha jurisdição sobre eles será responsável pelas suas ações e um atleta terá a capacidade de denunciar qualquer questão de conformidade
Capacidade de denunciar Violações das Regras Antidopagem (VRA) através de um mecanismo anónimo e não ser sujeito a ameaças ou intimidação
Receber educação antidopagem
Tratamento justo das suas informações pessoais pelas OAD de acordo com a Norma Internacional para a Proteção da Privacidade e Informação Pessoal (NIPPIP) e qualquer lei local aplicável
Procurar indemnização de outro atleta cujas ações tenham prejudicado esse atleta através da prática de uma VRA
Durante o processo de recolha de amostras, direito a:
Ver a identificação do Oficial de Controlo de Dopagem (OCD)
Solicitar informações adicionais sobre o processo de recolha de amostras, sobre a autoridade sob a qual será realizado e sobre o tipo de recolha de amostras
Hidratar-se
Ser acompanhado por um representante e, se disponível, um intérprete
Solicitar um atraso na apresentação na estação de controlo de dopagem por razões válidas (Norma Internacional para Testes e Investigações Art. 5.4.4)
Solicitar modificações para atletas com deficiências (se aplicável)
Ser informado dos seus direitos e responsabilidades
Receber uma cópia dos registos do processo
Ter proteções adicionais para "pessoas protegidas" devido à sua idade ou falta de capacidade legal
Solicitar e assistir à análise da amostra B (no caso de um Resultado Analítico Adverso)
Responsabilidades dos Atletas
Os direitos dos atletas ao desporto limpo vêm com responsabilidades correspondentes, e os atletas podem ser testados dentro e fora de competição, a qualquer momento, em qualquer lugar e sem aviso prévio.
As suas responsabilidades no desporto limpo incluem (mas não se limitam a):
Cumprir as Regras Antidopagem da IPF IPF Anti-Doping Rules - International Powerlifting Federation IPF (em conformidade com o Código Mundial Antidopagem)
Estar disponível para recolha de amostras (urina, sangue ou mancha de sangue seco (MSS)), seja dentro ou fora de competição
Permanecer sob observação direta do Oficial de Controlo de Dopagem (OCD) ou acompanhante em todos os momentos desde a notificação até à conclusão do processo de recolha de amostras
Fornecer identificação mediante solicitação durante o processo de recolha de amostras
Garantir que nenhuma substância proibida entra no seu corpo e que nenhum método proibido é usado neles
Garantir que qualquer tratamento não é proibido de acordo com a 2026 Prohibited List | World Anti Doping Agency em vigor e verificar isto com os médicos prescritores, ou diretamente com a IPF se necessário
Candidatar-se à IPF ou à sua ONAD se nenhum tratamento alternativo permitido for possível e uma Autorização de Uso Terapêutico (AUT) for necessária (ver TUEs - International Powerlifting Federation IPF)
Apresentar-se imediatamente para recolha de amostras após ser notificado de ter sido selecionado para controlo de dopagem
Garantir a exatidão das informações inseridas no Formulário de Controlo de Dopagem (FCD)
Cooperar com OAD que investigam VRA
Não trabalhar com treinadores, preparadores, médicos ou outro pessoal de apoio ao atleta que estejam inelegíveis por conta de uma VRA ou que tenham sido condenados criminalmente ou disciplinados profissionalmente em relação à dopagem (ver Lista de Associação Proibida da AMA)
Direitos do Pessoal de Apoio ao Atleta
O pessoal de apoio ao atleta e outras pessoas também têm direitos e responsabilidades ao abrigo do Código. Estes incluem:
Direito a uma audiência justa, perante um painel de audiência independente
Direito de recorrer da decisão da audiência
Direitos relativos à proteção de dados, de acordo com a NIPPIP e qualquer lei local aplicável
Responsabilidades do Pessoal de Apoio ao Atleta
As responsabilidades do pessoal de apoio ao atleta ao abrigo do Código incluem:
Usar a sua influência nos valores e comportamentos dos atletas para promover comportamentos de desporto limpo
Conhecer e cumprir todas as políticas e regras antidopagem aplicáveis, incluindo as Regras Antidopagem da IPF (em conformidade com o Código)
Cooperar com o programa de controlo de dopagem dos atletas
Cooperar com OAD que investigam Violações das Regras Antidopagem (VRA)
Informar a FI relevante e/ou ONAD se cometeram uma VRA nos últimos 10 anos
Abster-se de possuir uma substância proibida (ou um método proibido)*, administrar tal substância ou método a um atleta, traficar, encobrir uma violação das regras antidopagem (VRA) ou outras formas de cumplicidade e associar-se com uma pessoa condenada por dopagem (associação proibida). Estas são VRA aplicáveis ao pessoal de apoio ao atleta ao abrigo do Artigo 2 do Código Mundial Antidopagem e Artigo 2 das Regras Antidopagem da IPF.
* A menos que o pessoal de apoio ao atleta possa estabelecer que a posse é consistente com uma AUT concedida a um atleta ou outra justificação aceitável. Justificação aceitável incluiria, por exemplo, um médico de equipa que transporta substâncias proibidas para lidar com situações agudas e de emergência.
Recomendação da IPF ao Pessoal de Apoio ao Atleta
Aqui estão algumas formas como o pessoal de apoio ao atleta pode apoiar os seus atletas na sua educação sobre desporto limpo:
Partilhar a Lei dos Direitos Antidopagem dos Atletas com o seu atleta
Registar-se e fazer um curso adequado para si na plataforma ADEL da AMA
Contactar ipfantidoping@cces.ca para quaisquer questões que possa ter

View file

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

View file

@ -115,26 +115,46 @@ p { font-size: 1rem; line-height: 1.6; color: var(--foreground); }
/* Markdown content */ /* Markdown content */
.markdown-content { .markdown-content {
display: block; display: block;
max-width: 100%;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
hyphens: auto;
} }
.markdown-content h1, .markdown-content h1,
.markdown-content h2, .markdown-content h2,
.markdown-content h3 { .markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
margin-top: 1.25rem; margin-top: 1.25rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
scroll-margin-top: 80px; /* Account for header + subnav on mobile */ scroll-margin-top: 100px; /* Account for header + subnav on mobile */
overflow-wrap: break-word;
word-wrap: break-word;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.markdown-content h1, .markdown-content h1,
.markdown-content h2, .markdown-content h2,
.markdown-content h3 { .markdown-content h3,
scroll-margin-top: 120px; /* More space on desktop */ .markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
scroll-margin-top: 140px; /* More space on desktop */
} }
} }
.markdown-content p, .markdown-content ul, .markdown-content ol { margin-bottom: 0.75rem; } .markdown-content p, .markdown-content ul, .markdown-content ol {
margin-bottom: 0.75rem;
overflow-wrap: break-word;
word-wrap: break-word;
}
.markdown-content ul { padding-left: 1.25rem; list-style: disc; } .markdown-content ul { padding-left: 1.25rem; list-style: disc; }
.markdown-content ol { padding-left: 1.25rem; list-style: decimal; } .markdown-content ol { padding-left: 1.25rem; list-style: decimal; }
.markdown-content a { text-decoration: underline; } .markdown-content a {
text-decoration: underline;
word-break: break-all;
}
/* Markdown blockquote */ /* Markdown blockquote */
.markdown-blockquote { .markdown-blockquote {
@ -157,6 +177,7 @@ p { font-size: 1rem; line-height: 1.6; color: var(--foreground); }
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.9em; font-size: 0.9em;
color: var(--color-red); color: var(--color-red);
word-break: break-word;
} }
.markdown-pre { .markdown-pre {
background: #f5f5f5; background: #f5f5f5;
@ -165,29 +186,36 @@ p { font-size: 1rem; line-height: 1.6; color: var(--foreground); }
padding: 1rem; padding: 1rem;
overflow-x: auto; overflow-x: auto;
margin: 1rem 0; margin: 1rem 0;
max-width: 100%;
} }
.markdown-pre code { .markdown-pre code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.5; line-height: 1.5;
color: var(--foreground); color: var(--foreground);
white-space: pre-wrap;
word-break: break-word;
} }
/* Markdown table */ /* Markdown table */
.markdown-table-wrapper { .markdown-table-wrapper {
overflow-x: auto; overflow-x: auto;
margin: 1.25rem 0; margin: 1.25rem 0;
max-width: 100%;
} }
.markdown-table { .markdown-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
table-layout: auto;
} }
.markdown-table th, .markdown-table th,
.markdown-table td { .markdown-table td {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
text-align: left; text-align: left;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
word-wrap: break-word;
overflow-wrap: break-word;
} }
.markdown-table th { .markdown-table th {
background: rgba(0, 106, 61, 0.08); background: rgba(0, 106, 61, 0.08);
@ -522,6 +550,9 @@ p { font-size: 1rem; line-height: 1.6; color: var(--foreground); }
/* Subnav bar */ /* Subnav bar */
.subnav-bar { .subnav-bar {
position: sticky;
top: 66px; /* Below main header */
z-index: 40;
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
background: #fff; background: #fff;
} }
@ -540,6 +571,116 @@ p { font-size: 1rem; line-height: 1.6; color: var(--foreground); }
background: rgba(0,0,0,0.06); background: rgba(0,0,0,0.06);
} }
/* Table of Contents */
.toc-container {
position: sticky;
top: 110px; /* Below header + subnav */
align-self: start;
}
.toc-toggle {
width: 100%;
margin-bottom: 1rem;
padding: 0.75rem 1rem;
background: #f0f7ff;
color: #1a73e8;
border: 1px solid #c2d9f7;
border-radius: 8px;
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: background 150ms ease, border-color 150ms ease;
}
.toc-toggle:hover {
background: #e0efff;
border-color: #a0c4f0;
}
.toc-toggle-icon {
font-size: 1rem;
}
.toc-nav {
display: none;
padding: 1rem;
background: #fff;
border: 1px solid var(--color-border);
border-radius: 8px;
margin-bottom: 1.5rem;
}
.toc-nav--open {
display: block;
}
@media (min-width: 768px) {
.toc-toggle {
display: none;
}
.toc-nav {
display: block;
max-height: calc(100vh - 140px);
overflow-y: auto;
padding: 1rem;
background: #fafafa;
border: 1px solid var(--color-border);
border-radius: 8px;
margin-bottom: 0;
}
}
.toc-title {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-muted);
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border);
}
.toc-list {
list-style: none;
padding: 0;
margin: 0;
}
.toc-item {
margin: 0;
}
.toc-link {
display: block;
padding: 0.375rem 0.625rem;
border-radius: 6px;
font-size: 0.875rem;
line-height: 1.4;
color: var(--foreground);
text-decoration: none;
transition: background 150ms ease, color 150ms ease;
border-left: 2px solid transparent;
margin-left: -2px;
}
.toc-link:hover {
background: rgba(0, 0, 0, 0.04);
color: var(--foreground);
}
.toc-link--active {
background: rgba(26, 115, 232, 0.08);
color: #1a73e8;
font-weight: 600;
border-left-color: #1a73e8;
}
/* Animations */ /* Animations */
@keyframes fade-in-up { @keyframes fade-in-up {
from { opacity: 0; transform: translateY(8px); } from { opacity: 0; transform: translateY(8px); }

View file

@ -6,12 +6,12 @@ import { TableOfContents } from "@/components/markdown/TableOfContents";
export default function AcessibilidadePage() { export default function AcessibilidadePage() {
const [headings, setHeadings] = useState([] as { id: string; text: string; level: number }[]); const [headings, setHeadings] = useState([] as { id: string; text: string; level: number }[]);
return ( return (
<div className="grid grid-cols-1 md:grid-cols-[1fr_300px] gap-8"> <div className="grid grid-cols-1 md:grid-cols-[1fr_280px] gap-6 lg:gap-8">
<div> <div className="min-w-0">
<h1 className="text-2xl font-bold">Declaração de Acessibilidade</h1> <h1 className="text-2xl font-bold">Declaração de Acessibilidade</h1>
<MarkdownRenderer contentPath={"pt/acessibilidade.md"} onHeadings={setHeadings} /> <MarkdownRenderer contentPath={"pt/acessibilidade.md"} onHeadings={setHeadings} />
</div> </div>
<aside> <aside className="min-w-0">
<TableOfContents headings={headings} /> <TableOfContents headings={headings} />
</aside> </aside>
</div> </div>

View file

@ -6,12 +6,12 @@ import { TableOfContents } from "@/components/markdown/TableOfContents";
export default function AvisosLegaisPage() { export default function AvisosLegaisPage() {
const [headings, setHeadings] = useState([] as { id: string; text: string; level: number }[]); const [headings, setHeadings] = useState([] as { id: string; text: string; level: number }[]);
return ( return (
<div className="grid grid-cols-1 md:grid-cols-[1fr_300px] gap-8"> <div className="grid grid-cols-1 md:grid-cols-[1fr_280px] gap-6 lg:gap-8">
<div> <div className="min-w-0">
<h1 className="text-2xl font-bold">Avisos Legais</h1> <h1 className="text-2xl font-bold">Avisos Legais</h1>
<MarkdownRenderer contentPath={"pt/avisos-legais.md"} onHeadings={setHeadings} /> <MarkdownRenderer contentPath={"pt/avisos-legais.md"} onHeadings={setHeadings} />
</div> </div>
<aside> <aside className="min-w-0">
<TableOfContents headings={headings} /> <TableOfContents headings={headings} />
</aside> </aside>
</div> </div>

View file

@ -6,11 +6,11 @@ import { TableOfContents } from "@/components/markdown/TableOfContents";
export default function AntiDopingPage() { export default function AntiDopingPage() {
const [headings, setHeadings] = useState([] as { id: string; text: string; level: number }[]); const [headings, setHeadings] = useState([] as { id: string; text: string; level: number }[]);
return ( return (
<div className="grid grid-cols-1 md:grid-cols-[1fr_300px] gap-8"> <div className="grid grid-cols-1 md:grid-cols-[1fr_280px] gap-6 lg:gap-8">
<div> <div className="min-w-0">
<MarkdownRenderer contentPath={"pt/regras/anti-doping.md"} onHeadings={setHeadings} /> <MarkdownRenderer contentPath={"pt/regras/anti-doping.md"} onHeadings={setHeadings} />
</div> </div>
<aside> <aside className="min-w-0">
<TableOfContents headings={headings} /> <TableOfContents headings={headings} />
</aside> </aside>
</div> </div>

View file

@ -6,11 +6,11 @@ import { TableOfContents } from "@/components/markdown/TableOfContents";
export default function IpfRuleBookPage() { export default function IpfRuleBookPage() {
const [headings, setHeadings] = useState([] as { id: string; text: string; level: number }[]); const [headings, setHeadings] = useState([] as { id: string; text: string; level: number }[]);
return ( return (
<div className="grid grid-cols-1 md:grid-cols-[1fr_300px] gap-8"> <div className="grid grid-cols-1 md:grid-cols-[1fr_280px] gap-6 lg:gap-8">
<div> <div className="min-w-0">
<MarkdownRenderer contentPath={"pt/regras/ipf-rule-book.md"} onHeadings={setHeadings} /> <MarkdownRenderer contentPath={"pt/regras/ipf-rule-book.md"} onHeadings={setHeadings} />
</div> </div>
<aside> <aside className="min-w-0">
<TableOfContents headings={headings} /> <TableOfContents headings={headings} />
</aside> </aside>
</div> </div>

View file

@ -11,15 +11,15 @@ export default function QuemSomosPage() {
}[]>([]); }[]>([]);
return ( return (
<div className="grid grid-cols-1 md:grid-cols-[1fr_300px] gap-8"> <div className="grid grid-cols-1 md:grid-cols-[1fr_280px] gap-6 lg:gap-8">
<div> <div className="min-w-0">
<h1 className="text-2xl font-bold">Quem Somos</h1> <h1 className="text-2xl font-bold">Quem Somos</h1>
<MarkdownRenderer <MarkdownRenderer
contentPath={"pt/sobre/quem-somos.md"} contentPath={"pt/sobre/quem-somos.md"}
onHeadings={setHeadings} onHeadings={setHeadings}
/> />
</div> </div>
<aside aria-label="Tabela de conteúdos" className="md:block"> <aside className="min-w-0">
<TableOfContents headings={headings} /> <TableOfContents headings={headings} />
</aside> </aside>
</div> </div>

View file

@ -32,7 +32,7 @@ export function SubnavBar() {
label: locale === "en" ? "Rules" : "Regras", label: locale === "en" ? "Rules" : "Regras",
path: `${base}/regras`, path: `${base}/regras`,
items: [ items: [
{ label: "IPF Rule Book", href: `${base}/regras/ipf-rule-book` }, { label: "Regras/Info. IPF", href: `${base}/regras/ipf-rule-book` },
{ label: locale === "en" ? "Anti-Doping" : "Anti-Doping", href: `${base}/regras/anti-doping` }, { label: locale === "en" ? "Anti-Doping" : "Anti-Doping", href: `${base}/regras/anti-doping` },
], ],
}, },

View file

@ -1,11 +1,10 @@
"use client"; "use client";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown"; import type { Components } from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug"; import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings"; import rehypeAutolinkHeadings from "rehype-autolink-headings";
import { parseMarkdownWithFrontmatter } from "@/lib/markdown";
type MarkdownRendererProps = { type MarkdownRendererProps = {
contentPath: string; contentPath: string;
@ -14,16 +13,49 @@ type MarkdownRendererProps = {
export function MarkdownRenderer({ contentPath, onHeadings }: MarkdownRendererProps) { export function MarkdownRenderer({ contentPath, onHeadings }: MarkdownRendererProps) {
const [source, setSource] = useState<string>(""); const [source, setSource] = useState<string>("");
const articleRef = useRef<HTMLElement>(null);
const hasExtractedHeadings = useRef(false);
// Fetch content
useEffect(() => { useEffect(() => {
hasExtractedHeadings.current = false;
(async () => { (async () => {
const res = await fetch(`/api/content?path=${encodeURIComponent(contentPath)}`); const res = await fetch(`/api/content?path=${encodeURIComponent(contentPath)}`);
const text = await res.text(); const text = await res.text();
const { content, headings } = parseMarkdownWithFrontmatter(text); // Just get the content without frontmatter
const frontmatterMatch = text.match(/^---\n[\s\S]*?\n---\n/);
const content = frontmatterMatch ? text.slice(frontmatterMatch[0].length) : text;
setSource(content); setSource(content);
onHeadings?.(headings);
})(); })();
}, [contentPath, onHeadings]); }, [contentPath]);
// Extract headings from rendered DOM after content is rendered
useEffect(() => {
if (!source || !articleRef.current || !onHeadings || hasExtractedHeadings.current) return;
// Use a small delay to ensure rehype-slug has processed the headings
const timeoutId = setTimeout(() => {
if (!articleRef.current) return;
const headingElements = articleRef.current.querySelectorAll("h1, h2, h3, h4, h5, h6");
const headings: { id: string; text: string; level: number }[] = [];
headingElements.forEach((el) => {
const id = el.getAttribute("id");
const text = el.textContent?.trim() || "";
const level = parseInt(el.tagName[1], 10);
if (id && text) {
headings.push({ id, text, level });
}
});
hasExtractedHeadings.current = true;
onHeadings(headings);
}, 50);
return () => clearTimeout(timeoutId);
}, [source, onHeadings]);
const components: Components = { const components: Components = {
blockquote: ({ children, ...props }) => ( blockquote: ({ children, ...props }) => (
@ -57,7 +89,7 @@ export function MarkdownRenderer({ contentPath, onHeadings }: MarkdownRendererPr
}; };
return ( return (
<article className="markdown-content" aria-label="Conteúdo"> <article ref={articleRef} className="markdown-content" aria-label="Conteúdo">
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSlug, [rehypeAutolinkHeadings, { behavior: "wrap" }]]} rehypePlugins={[rehypeSlug, [rehypeAutolinkHeadings, { behavior: "wrap" }]]}

View file

@ -1,123 +1,160 @@
"use client"; "use client";
import React, { useEffect, useRef, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
type Heading = { id: string; text: string; level: number }; type Heading = { id: string; text: string; level: number };
export function TableOfContents({ headings }: { headings: Heading[] }) { export function TableOfContents({ headings }: { headings: Heading[] }) {
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const observerRef = useRef<IntersectionObserver | null>(null); const isManualScrollRef = useRef(false);
const manualScrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Get offset for fixed headers
const getOffset = useCallback(() => {
const header = document.querySelector(".app-header");
const subnav = document.querySelector(".subnav-bar");
let offset = 32;
if (header) offset += header.getBoundingClientRect().height;
if (subnav) offset += subnav.getBoundingClientRect().height;
return offset;
}, []);
// Scroll spy - track which heading is currently visible
useEffect(() => { useEffect(() => {
// Disconnect old observer if it exists if (headings.length === 0) return;
if (observerRef.current) {
observerRef.current.disconnect();
}
// Only set up observer if we have headings const updateActiveHeading = () => {
if (headings.length === 0) { if (isManualScrollRef.current) return;
setActiveId(null);
return;
}
const visibleHeadings = new Map<string, number>(); const offset = getOffset();
let found: string | null = null;
const callback: IntersectionObserverCallback = (entries) => {
entries.forEach((entry) => { // Go through headings and find which one we're at
if (entry.isIntersecting) { for (let i = 0; i < headings.length; i++) {
visibleHeadings.set(entry.target.id, entry.boundingClientRect.top); const el = document.getElementById(headings[i].id);
if (!el) continue;
const rect = el.getBoundingClientRect();
// If this heading is at or above our threshold, it's the current one
if (rect.top <= offset + 10) {
found = headings[i].id;
} else { } else {
visibleHeadings.delete(entry.target.id); // First heading that's below threshold - stop here
break;
} }
}); }
// Find the topmost visible heading // If at very top of page, select first heading
if (visibleHeadings.size > 0) { if (!found && window.scrollY < 100 && headings.length > 0) {
const sortedHeadings = Array.from(visibleHeadings.entries()) found = headings[0].id;
.sort((a, b) => a[1] - b[1]); }
setActiveId(sortedHeadings[0][0]);
} else { setActiveId(found);
setActiveId(null); };
// Run on mount
updateActiveHeading();
// Throttle scroll events
let ticking = false;
const onScroll = () => {
if (!ticking) {
requestAnimationFrame(() => {
updateActiveHeading();
ticking = false;
});
ticking = true;
} }
}; };
const observer = new IntersectionObserver(callback, {
rootMargin: "-80px 0px -55% 0px",
threshold: 0.1,
});
observerRef.current = observer;
headings.forEach((h) => {
const el = document.getElementById(h.id);
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [headings]);
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, headingId: string) => { window.addEventListener("scroll", onScroll, { passive: true });
e.preventDefault(); return () => window.removeEventListener("scroll", onScroll);
const element = document.getElementById(headingId); }, [headings, getOffset]);
if (element) {
// Calculate the scroll offset to account for fixed header // Scroll to a heading when clicked
// Mobile: 80px, Desktop: 120px const scrollToHeading = useCallback((id: string) => {
const isMobile = window.innerWidth < 768; const el = document.getElementById(id);
const scrollOffset = isMobile ? 80 : 120; if (!el) return;
const elementPosition = element.getBoundingClientRect().top + window.scrollY; // Mark as manual scroll to prevent scroll spy interference
const targetPosition = elementPosition - scrollOffset; isManualScrollRef.current = true;
if (manualScrollTimeoutRef.current) {
window.scrollTo({ clearTimeout(manualScrollTimeoutRef.current);
top: targetPosition,
behavior: "smooth",
});
// Update URL without causing a page reload
window.history.pushState(null, "", `#${headingId}`);
// Close mobile menu after click
setIsOpen(false);
} }
};
// Set active immediately for responsive feel
setActiveId(id);
setIsOpen(false);
// Calculate position
const offset = getOffset();
const top = el.getBoundingClientRect().top + window.scrollY - offset;
// Scroll
window.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
// Update URL
history.replaceState(null, "", `#${id}`);
// Re-enable scroll spy after scroll completes
manualScrollTimeoutRef.current = setTimeout(() => {
isManualScrollRef.current = false;
}, 1000);
}, [getOffset]);
const handleClick = useCallback((e: React.MouseEvent, id: string) => {
e.preventDefault();
scrollToHeading(id);
}, [scrollToHeading]);
// Handle hash on page load
useEffect(() => {
const hash = window.location.hash.slice(1);
if (hash && headings.some(h => h.id === hash)) {
setTimeout(() => scrollToHeading(hash), 200);
}
}, [headings, scrollToHeading]);
// Cleanup
useEffect(() => {
return () => {
if (manualScrollTimeoutRef.current) {
clearTimeout(manualScrollTimeoutRef.current);
}
};
}, []);
if (headings.length === 0) return null;
return ( return (
<> <div className="toc-container">
{/* Mobile Toggle Button */}
<button <button
className="md:hidden w-full mb-4 px-4 py-2 bg-blue-50 text-blue-600 border border-blue-200 rounded-lg font-medium text-sm transition hover:bg-blue-100" className="toc-toggle md:hidden"
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen} aria-expanded={isOpen}
aria-label="Alternar tabela de conteúdos" aria-controls="toc-nav"
> >
{isOpen ? "✕ Fechar conteúdos" : "☰ Mostrar conteúdos"} <span className="toc-toggle-icon">{isOpen ? "✕" : "☰"}</span>
<span>{isOpen ? "Fechar conteúdos" : "Mostrar conteúdos"}</span>
</button> </button>
{/* Desktop Sidebar + Mobile Collapsible */}
<nav <nav
id="toc-nav"
aria-label="Tabela de conteúdos" aria-label="Tabela de conteúdos"
className={` className={`toc-nav ${isOpen ? "toc-nav--open" : ""}`}
md:sticky md:max-h-[calc(100vh-140px)] md:overflow-auto md:block
${isOpen ? "block" : "hidden md:block"}
mb-6 md:mb-0 p-4 md:p-0
bg-white md:bg-transparent
border md:border-none border-gray-200 md:border-0
rounded-lg md:rounded-none
`}
style={{ top: "120px" }}
> >
<ul className="space-y-1"> <h2 className="toc-title">Conteúdos</h2>
<ul className="toc-list">
{headings.map((h) => ( {headings.map((h) => (
<li key={h.id} style={{ paddingLeft: (h.level - 1) * 12 }}> <li
key={h.id}
className="toc-item"
style={{ paddingLeft: `${(h.level - 1) * 0.75}rem` }}
>
<a <a
href={`#${h.id}`} href={`#${h.id}`}
onClick={(e) => handleClick(e, h.id)} onClick={(e) => handleClick(e, h.id)}
className={` className={`toc-link ${activeId === h.id ? "toc-link--active" : ""}`}
block py-1 px-2 rounded transition
${
activeId === h.id
? "font-semibold text-blue-600 bg-blue-50"
: "text-gray-700 hover:bg-gray-100"
}
`}
> >
{h.text} {h.text}
</a> </a>
@ -125,6 +162,6 @@ export function TableOfContents({ headings }: { headings: Heading[] }) {
))} ))}
</ul> </ul>
</nav> </nav>
</> </div>
); );
} }