Files
lage-chat-control/frontend/src/routes/outils/+page.svelte
T
sucupira b13a1e7a31 feat(oki): intro publique, chimen, PWA offline, contribuer, kréyòl, ton OKI
- Landing : intro « Pourquoi ce site ? » (Chat Control expliqué au grand
  public) + sommaire-chimen en 3 arcs (Fondations 🟢 / Reprendre le
  contrôle 🟡 / Autodéfense avancée 🔴) avec compte d'outils par catégorie
- PWA : service worker offline-first (précache coquille, network-first
  avec repli cache pour les fiches consultées), badge hors-ligne discret
- Page /contribuer (3 voies + forge labola) + template d'issue Forgejo
  .gitea/ISSUE_TEMPLATE/nouvo-zouti.yaml ; liens GitHub → labola.o-k-i.net
- Fiches jumelles créoles : chargeur content/outils/gcf/, affichage selon
  la langue avec bandeaux « an chantyé » / « póko tradui », 6 pilotes
  (signal, protonmail, brave, bitwarden, ublock, quad9) — À FAIRE RELIRE
  par un locuteur natif
- Ton OKI : les 6 mêmes fiches réécrites (tutoiement, registre
  militant-chaleureux) ; le script de migration n'écrase plus les fiches
  retravaillées (--force pour tout régénérer)
- UI : View Transitions (respect reduced-motion), aide clavier « ? »
  (dialog natif, raccourcis / h a), progression locale « jès fèt »
  (localStorage uniquement, compteur visible après le premier geste)
- docs/DEPLOYMENT.md : guide VPS (Caddy, nginx, YunoHost, Docker, rsync)
- Audits : axe-core 0 violation (5 pages), Lighthouse 99-98/100/100/100 ;
  corrections contraste monogrammes + ordre des titres
- Budgets : JS 46 Ko gzip, CSS 3,5 Ko, 59 pages prérendues

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:38:43 -04:00

140 lines
3.8 KiB
Svelte

<script lang="ts">
import { browser } from '$app/environment'
import { page } from '$app/state'
import CategoryNav from '$lib/components/CategoryNav.svelte'
import SearchBar from '$lib/components/SearchBar.svelte'
import ToolCard from '$lib/components/ToolCard.svelte'
import EndMarker from '$lib/components/EndMarker.svelte'
import DifficultyBadge from '$lib/components/DifficultyBadge.svelte'
import { t } from '$lib/i18n/index.svelte'
let { data } = $props()
let query = $state('')
const DIFFICULTIES = ['beginner', 'intermediate', 'advanced'] as const
// Chaque filtre vit dans l'URL (query param) : partageable (mission §5.1).
// Le HTML prérendu montre la liste complète ; le filtrage est une
// amélioration progressive côté client (ADR-001). Les searchParams ne
// sont pas lisibles au prerender, d'où la garde `browser`.
let cat = $derived(browser ? page.url.searchParams.get('cat') : null)
let diff = $derived(browser ? page.url.searchParams.get('diff') : null)
let filtered = $derived(
data.tools.filter(
(tool) =>
(!cat || tool.category === cat) &&
(!diff || tool.difficulty === diff) &&
(!query ||
`${tool.name} ${tool.tags.join(' ')} ${tool.excerpt}`
.toLocaleLowerCase('fr')
.includes(query.toLocaleLowerCase('fr')))
)
)
function diffHref(d: string | null): string {
const params = new URLSearchParams(browser ? page.url.searchParams : undefined)
if (cat) params.set('cat', cat)
if (d) params.set('diff', d)
else params.delete('diff')
const qs = params.toString()
return qs ? `/outils?${qs}` : '/outils'
}
</script>
<svelte:head>
<title>{t('directory.title')}{t('site.name')}</title>
<meta name="description" content={t('directory.intro')} />
</svelte:head>
<header class="stack">
<h1>{t('directory.title')}</h1>
<p>{t('directory.intro')}</p>
</header>
<div class="stack filters">
<SearchBar bind:value={query} />
<CategoryNav categories={data.categories} />
<nav aria-label={t('directory.filterDifficulty')}>
<ul class="cluster">
<li>
<a href={diffHref(null)} aria-current={diff === null ? 'page' : undefined}>
{t('directory.allDifficulties')}
</a>
</li>
{#each DIFFICULTIES as d (d)}
<li>
<a href={diffHref(d)} aria-current={diff === d ? 'page' : undefined}>
<DifficultyBadge difficulty={d} />
</a>
</li>
{/each}
</ul>
</nav>
</div>
<section class="stack" aria-live="polite">
<!-- h2 : préserve l'ordre des titres (h1 → h2 → h3 des cartes) -->
<h2 class="count">
{filtered.length}
{filtered.length === 1 ? t('directory.resultOne') : t('directory.results')}
</h2>
{#if filtered.length === 0}
<p class="empty">{t('directory.empty')}</p>
{:else}
<div class="card-grid">
<ul>
{#each filtered as tool (tool.slug)}
<li><ToolCard {tool} /></li>
{/each}
</ul>
</div>
{/if}
<EndMarker />
</section>
<style>
.filters {
--gap: var(--space-3);
}
nav ul {
list-style: none;
padding: 0;
}
nav a {
display: inline-flex;
align-items: center;
min-height: var(--tap-target);
padding-inline: var(--space-3);
border: 1px solid var(--oki-line);
border-radius: var(--radius-2);
text-decoration: none;
color: var(--oki-ink);
}
nav a[aria-current='page'] {
background: var(--oki-surface-2);
border-color: var(--oki-ink);
font-weight: 700;
}
.count {
color: var(--oki-ink-2);
font-size: var(--text-0);
font-family: var(--font-text);
font-weight: 400;
}
.empty {
padding: var(--space-5);
border: 1px dashed var(--oki-line);
border-radius: var(--radius-2);
text-align: center;
color: var(--oki-ink-2);
}
</style>