2026-07-10 12:51:17 -04:00
|
|
|
<script lang="ts">
|
|
|
|
|
import { browser } from '$app/environment'
|
2026-07-21 13:41:10 -04:00
|
|
|
import { base } from '$app/paths'
|
2026-07-10 12:51:17 -04:00
|
|
|
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()
|
2026-07-21 13:41:10 -04:00
|
|
|
return qs ? `${base}/outils?${qs}` : `${base}/outils`
|
2026-07-10 12:51:17 -04:00
|
|
|
}
|
|
|
|
|
</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">
|
2026-07-10 13:38:43 -04:00
|
|
|
<!-- h2 : préserve l'ordre des titres (h1 → h2 → h3 des cartes) -->
|
|
|
|
|
<h2 class="count">
|
2026-07-10 12:51:17 -04:00
|
|
|
{filtered.length}
|
|
|
|
|
{filtered.length === 1 ? t('directory.resultOne') : t('directory.results')}
|
2026-07-10 13:38:43 -04:00
|
|
|
</h2>
|
2026-07-10 12:51:17 -04:00
|
|
|
|
|
|
|
|
{#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);
|
2026-07-10 13:38:43 -04:00
|
|
|
font-family: var(--font-text);
|
|
|
|
|
font-weight: 400;
|
2026-07-10 12:51:17 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.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>
|