Phases 6 et 7 : application SvelteKit, PWA, systemd, documentation
Application — rendu serveur sur adapter-node, conforme au §3bis : - tableau de bord, recherche à facettes, fiche détaillée, calendrier - /a-propos et /methode prérendues ; tout le reste en rendu serveur - API JSON ouverte : /api/textes, /api/textes/[slug], /api/echeances - recherche FTS5 avec surlignage et classement bm25 pondéré (le titre pèse dix fois plus que les points clés) - facettes recalculées sur le résultat filtré par les AUTRES facettes : cocher un thème doit recompter les statuts encore disponibles, sinon les compteurs mentent Identité OKI sobrifiée : tokens en CSS vanilla, thème sombre par défaut et clair opt-in, or comme seule couleur d'action, flag-bar une fois par écran, polices Archivo/Inter auto-hébergées. Politique de sécurité de contenu interdisant toute requête tierce. Les statuts ne reposent jamais sur la seule couleur : libellé en toutes lettres et pastille de forme distincte. Budget respecté très largement : 6,6 Ko de JavaScript sur l'accueil (3 Ko compressés) pour un plafond de 100 Ko. Les faits clés — AFD à 500 €, saisine 2026-915 DC, statut — sont lisibles sans JavaScript. Trois défauts trouvés en testant l'application, pas en relisant le code : - v_textes est une vue, et une vue SQLite n'a pas de rowid : la jointure avec l'index plein texte échouait (migration 005) - ppl-montagne remontait en « pertinence forte » alors que le corpus dit « sans portée pour la Guadeloupe » — la cotation comptait le mot sans lire la négation. Idem pour pjl-logement et accord-globe. Corrigé par une lecture du voisinage, avec quatre tests de non-régression. - les libellés d'affichage étaient importés depuis /server dans un composant, ce que SvelteKit interdit à raison PWA : service worker réseau-d'abord pour les pages, cache-d'abord pour le coffre du build. Une veille législative ne doit pas servir une page périmée quand le réseau répond — une date de promulgation change tout. Les réponses issues du cache portent un en-tête qui le dit. systemd : timer du pipeline (6 h, 18 h, dimanche 9 h, avec dispersion et rattrapage), application activée par socket avec mise en sommeil à 300 s. Unités durcies, validées par systemd-analyze. Documentation : README d'installation sur Debian/Ubuntu vierge, README-pipeline avec le piège des deux couples d'identifiants PISTE, et scripts/verifier-conformite.sh qui contrôle mécaniquement le §3bis et le §8. 172 tests, npm run check à 0 erreur, make verifier au vert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
feb5544599
commit
34d81c66a8
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.svelte-kit/
|
||||
build/
|
||||
static/polices/
|
||||
@@ -0,0 +1,3 @@
|
||||
engine-strict=false
|
||||
fund=false
|
||||
audit=false
|
||||
Generated
+2304
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "veille-legislative-971-web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Interface de la veille législative Guadeloupe — SvelteKit, rendu serveur",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 5971",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "node build",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"polices": "node scripts/copier-polices.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource/archivo": "5.1.0",
|
||||
"@fontsource/inter": "5.1.0",
|
||||
"@sveltejs/adapter-node": "5.2.12",
|
||||
"@sveltejs/kit": "2.17.2",
|
||||
"@sveltejs/vite-plugin-svelte": "5.0.3",
|
||||
"@types/better-sqlite3": "7.6.12",
|
||||
"svelte": "5.20.2",
|
||||
"svelte-check": "4.1.4",
|
||||
"typescript": "5.7.3",
|
||||
"vite": "6.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "11.8.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Copie les polices Archivo et Inter depuis node_modules vers static/polices/.
|
||||
*
|
||||
* Le self-hébergement n'est pas une préférence : la politique de sécurité de
|
||||
* contenu déclarée dans svelte.config.js interdit toute requête vers un hôte
|
||||
* tiers, Google Fonts compris.
|
||||
*/
|
||||
import { copyFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const DESTINATION = 'static/polices';
|
||||
mkdirSync(DESTINATION, { recursive: true });
|
||||
|
||||
const familles = [
|
||||
{ paquet: '@fontsource/archivo', graisses: ['400', '600', '700', '900'] },
|
||||
{ paquet: '@fontsource/inter', graisses: ['400', '500', '600', '700'] }
|
||||
];
|
||||
|
||||
let copiees = 0;
|
||||
for (const { paquet, graisses } of familles) {
|
||||
const source = join('node_modules', paquet, 'files');
|
||||
for (const fichier of readdirSync(source)) {
|
||||
const estLatin = fichier.includes('-latin-') && fichier.endsWith('.woff2');
|
||||
const estNormal = fichier.includes('-normal.woff2');
|
||||
const graisseVoulue = graisses.some((g) => fichier.includes(`-${g}-`));
|
||||
if (estLatin && estNormal && graisseVoulue) {
|
||||
copyFileSync(join(source, fichier), join(DESTINATION, fichier));
|
||||
copiees += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`${copiees} fichiers de police copiés dans ${DESTINATION}`);
|
||||
@@ -0,0 +1,35 @@
|
||||
<!doctype html>
|
||||
<html lang="fr" %sveltekit.theme%>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<meta name="theme-color" content="#0D0D0D" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
|
||||
<link
|
||||
rel="preload"
|
||||
href="%sveltekit.assets%/polices/archivo-latin-700-normal.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="%sveltekit.assets%/polices/inter-latin-400-normal.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<!--
|
||||
Restauration du thème avant le premier rendu, pour éviter le flash de
|
||||
thème clair. Le script est externe : la politique de sécurité de contenu
|
||||
interdit l'inline.
|
||||
-->
|
||||
<script src="%sveltekit.assets%/theme.js"></script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
/** Cotation de pertinence guadeloupéenne, avec son motif quand il existe. */
|
||||
interface Props {
|
||||
pertinence: string | null;
|
||||
note?: string | null;
|
||||
aVerifier?: boolean;
|
||||
}
|
||||
|
||||
const { pertinence, note = null, aVerifier = false }: Props = $props();
|
||||
|
||||
const LIBELLES: Record<string, string> = {
|
||||
forte: 'Gwadloup : forte',
|
||||
moyenne: 'Gwadloup : moyenne',
|
||||
faible: 'Gwadloup : faible'
|
||||
};
|
||||
const OPACITES: Record<string, string> = { forte: '100%', moyenne: '70%', faible: '45%' };
|
||||
</script>
|
||||
|
||||
{#if pertinence}
|
||||
<span
|
||||
class="badge"
|
||||
style="--intensite: {OPACITES[pertinence] ?? '50%'}"
|
||||
title={note ?? undefined}
|
||||
>
|
||||
{LIBELLES[pertinence] ?? pertinence}{#if aVerifier}<abbr title="Cotation déduite automatiquement, non relue">*</abbr>{/if}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
padding: 0.16em 0.6em;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid color-mix(in srgb, var(--turquoise-caraibes) var(--intensite), transparent);
|
||||
background: color-mix(in srgb, var(--turquoise-caraibes) 12%, transparent);
|
||||
color: var(--blanc-creme);
|
||||
white-space: nowrap;
|
||||
}
|
||||
abbr {
|
||||
text-decoration: none;
|
||||
color: var(--or-oki);
|
||||
margin-left: 0.15em;
|
||||
cursor: help;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Badge de statut.
|
||||
*
|
||||
* L'accessibilité daltonienne interdit de faire porter l'information par la
|
||||
* seule couleur : chaque statut porte donc son libellé en toutes lettres et
|
||||
* un motif de bordure distinct. La couleur ne fait que renforcer.
|
||||
*/
|
||||
import { LIBELLES_STATUT } from '$lib/libelles';
|
||||
|
||||
interface Props {
|
||||
statut: string;
|
||||
devantCc?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const { statut, devantCc = false, compact = false }: Props = $props();
|
||||
|
||||
const COULEURS: Record<string, string> = {
|
||||
promulguee: 'var(--statut-promulguee)',
|
||||
validee_cc: 'var(--statut-attente)',
|
||||
adoptee_non_promulguee: 'var(--statut-attente)',
|
||||
saisie_cc: 'var(--statut-cc)',
|
||||
censuree_partiellement: 'var(--statut-cc)',
|
||||
navette: 'var(--statut-navette)',
|
||||
deposee_non_examinee: 'var(--statut-projet)',
|
||||
annoncee: 'var(--statut-projet)',
|
||||
rejetee: 'var(--statut-rejetee)'
|
||||
};
|
||||
|
||||
const couleur = $derived(COULEURS[statut] ?? 'var(--muted-fort)');
|
||||
const libelle = $derived(LIBELLES_STATUT[statut] ?? statut);
|
||||
</script>
|
||||
|
||||
<span class="badge" class:compact style="--teinte: {couleur}">
|
||||
<span class="pastille" aria-hidden="true"></span>
|
||||
{libelle}
|
||||
</span>
|
||||
{#if devantCc && statut !== 'saisie_cc'}
|
||||
<span class="badge cc" style="--teinte: var(--statut-cc)">
|
||||
<span class="pastille" aria-hidden="true"></span>
|
||||
Devant le Conseil constitutionnel
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
padding: 0.16em 0.6em;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid color-mix(in srgb, var(--teinte) 65%, transparent);
|
||||
background: color-mix(in srgb, var(--teinte) 12%, transparent);
|
||||
color: var(--blanc-creme);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge.compact {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.pastille {
|
||||
width: 0.5em;
|
||||
height: 0.5em;
|
||||
background: var(--teinte);
|
||||
border-radius: 1px;
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
/** Carte compacte de résultat : titre, badges, date clé, résumé deux lignes. */
|
||||
import BadgeGuadeloupe from './BadgeGuadeloupe.svelte';
|
||||
import BadgeStatut from './BadgeStatut.svelte';
|
||||
import { LIBELLES_THEME, LIBELLES_TYPE } from '$lib/libelles';
|
||||
import { dateLongue, extrait } from '$lib/format';
|
||||
import type { Texte } from '$lib/types';
|
||||
|
||||
const { texte }: { texte: Texte } = $props();
|
||||
|
||||
const dateCle = $derived(
|
||||
texte.date_promulgation ?? texte.date_adoption ?? texte.statut_date ?? null
|
||||
);
|
||||
const libelleDate = $derived(
|
||||
texte.date_promulgation
|
||||
? `Promulguée le ${dateLongue(texte.date_promulgation)}`
|
||||
: texte.date_adoption
|
||||
? `Adoptée le ${dateLongue(texte.date_adoption)}`
|
||||
: dateCle
|
||||
? dateLongue(dateCle)
|
||||
: ''
|
||||
);
|
||||
</script>
|
||||
|
||||
<article class="carte resultat">
|
||||
<h3>
|
||||
<a href="/textes/{texte.id}">
|
||||
{#if texte.numero_officiel}<span class="numero">n° {texte.numero_officiel}</span>{/if}
|
||||
{texte.titre_court}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<div class="ligne badges">
|
||||
<BadgeStatut statut={texte.statut} devantCc={texte.devant_cc === 1} compact />
|
||||
<BadgeGuadeloupe
|
||||
pertinence={texte.guadeloupe_pertinence}
|
||||
note={texte.guadeloupe_note}
|
||||
aVerifier={texte.a_verifier === 1}
|
||||
/>
|
||||
<span class="type">{LIBELLES_TYPE[texte.type] ?? texte.type}</span>
|
||||
</div>
|
||||
|
||||
{#if libelleDate}<p class="date">{libelleDate}</p>{/if}
|
||||
|
||||
{#if texte.extrait}
|
||||
<p class="resume">{@html texte.extrait}</p>
|
||||
{:else if texte.resume}
|
||||
<p class="resume">{extrait(texte.resume, 200)}</p>
|
||||
{:else if texte.points_cles.length}
|
||||
<p class="resume">{extrait(texte.points_cles[0], 200)}</p>
|
||||
{/if}
|
||||
|
||||
{#if texte.themes.length}
|
||||
<div class="ligne themes">
|
||||
{#each texte.themes as theme (theme)}
|
||||
<span class="tag">{LIBELLES_THEME[theme] ?? theme}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="meta">
|
||||
{texte.nb_sources}
|
||||
{texte.nb_sources > 1 ? 'sources' : 'source'}
|
||||
{#if texte.a_verifier === 1}
|
||||
· <span class="verifier">à vérifier</span>
|
||||
{/if}
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.resultat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1.3;
|
||||
text-transform: none;
|
||||
}
|
||||
h3 a {
|
||||
color: var(--blanc-creme);
|
||||
text-decoration: none;
|
||||
}
|
||||
h3 a:hover {
|
||||
color: var(--or-oki);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.numero {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82em;
|
||||
color: var(--or-oki);
|
||||
margin-right: 0.35em;
|
||||
}
|
||||
.badges {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.type {
|
||||
font-size: 0.76rem;
|
||||
color: var(--muted-fort);
|
||||
}
|
||||
.date {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.resume {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.themes {
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.meta {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted-fort);
|
||||
}
|
||||
.verifier {
|
||||
color: var(--or-oki);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,159 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Panneau de filtres à facettes.
|
||||
*
|
||||
* Chaque facette est un lien : la page fonctionne sans JavaScript, et l'état
|
||||
* des filtres vit dans l'URL, donc se partage et se recharge à l'identique.
|
||||
* Les compteurs sont recalculés sur le résultat filtré par les *autres*
|
||||
* facettes — cocher un thème doit recompter les statuts encore disponibles.
|
||||
*/
|
||||
import type { Facette, Filtres, Resultats } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
facettes: Resultats['facettes'];
|
||||
filtres: Filtres;
|
||||
lien: (champ: keyof Filtres, valeur: string) => string;
|
||||
}
|
||||
|
||||
const { facettes, filtres, lien }: Props = $props();
|
||||
|
||||
const GROUPES: { champ: keyof Filtres; titre: string; cle: keyof Resultats['facettes'] }[] = [
|
||||
{ champ: 'statut', titre: 'Statut', cle: 'statut' },
|
||||
{ champ: 'guadeloupe', titre: 'Pertinence Guadeloupe', cle: 'guadeloupe' },
|
||||
{ champ: 'impact', titre: 'Impact sur les libertés', cle: 'impact' },
|
||||
{ champ: 'theme', titre: 'Thème', cle: 'theme' },
|
||||
{ champ: 'type', titre: 'Type de texte', cle: 'type' },
|
||||
{ champ: 'confiance', titre: 'Niveau de confiance', cle: 'confiance' }
|
||||
];
|
||||
|
||||
function ouvertParDefaut(champ: keyof Filtres, liste: Facette[]): boolean {
|
||||
return (filtres[champ] as string[]).length > 0 || liste.some((f) => f.actif) ||
|
||||
champ === 'statut' || champ === 'guadeloupe';
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="panneau-facettes" aria-label="Filtres">
|
||||
{#each GROUPES as groupe (groupe.champ)}
|
||||
{@const liste = facettes[groupe.cle]}
|
||||
{#if liste.length}
|
||||
<details class="groupe" open={ouvertParDefaut(groupe.champ, liste)}>
|
||||
<summary>
|
||||
{groupe.titre}
|
||||
{#if (filtres[groupe.champ] as string[]).length}
|
||||
<span class="compteur-actifs">{(filtres[groupe.champ] as string[]).length}</span>
|
||||
{/if}
|
||||
</summary>
|
||||
<ul>
|
||||
{#each liste as facette (facette.valeur)}
|
||||
<li>
|
||||
<!--
|
||||
Ce sont des liens, pas des cases à cocher : `aria-pressed` n'est pas
|
||||
valide sur le rôle `link`. L'état est donc porté par un texte
|
||||
réservé aux lecteurs d'écran, qui dit aussi ce que fera le clic.
|
||||
-->
|
||||
<a
|
||||
href={lien(groupe.champ, facette.valeur)}
|
||||
class="facette"
|
||||
class:actif={facette.actif}
|
||||
rel="nofollow"
|
||||
>
|
||||
<span class="case" aria-hidden="true">{facette.actif ? '×' : '+'}</span>
|
||||
<span class="libelle">{facette.libelle}</span>
|
||||
<span class="nombre">{facette.nombre}</span>
|
||||
<span class="visuellement-cache">
|
||||
{facette.actif
|
||||
? 'filtre actif, activer pour le retirer'
|
||||
: `${facette.nombre} résultats, activer pour filtrer`}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
{/each}
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.panneau-facettes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.groupe {
|
||||
border: var(--border-card);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
padding: 0.6rem 0.8rem;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
summary::marker {
|
||||
color: var(--or-oki);
|
||||
}
|
||||
|
||||
.compteur-actifs {
|
||||
background: var(--or-oki);
|
||||
color: var(--noir-oki);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.7rem;
|
||||
padding: 0 0.4em;
|
||||
min-width: 1.3em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0.5rem 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.facette {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.32rem 0.45rem;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--blanc-creme);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.facette:hover {
|
||||
background: var(--card-bg-survol);
|
||||
border-color: var(--line);
|
||||
}
|
||||
.facette.actif {
|
||||
background: color-mix(in srgb, var(--or-oki) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--or-oki) 60%, transparent);
|
||||
}
|
||||
|
||||
.case {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--or-oki);
|
||||
width: 1ch;
|
||||
flex: none;
|
||||
}
|
||||
.libelle {
|
||||
flex: 1;
|
||||
}
|
||||
.nombre {
|
||||
color: var(--muted-fort);
|
||||
font-size: 0.78rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Formatage des dates et des libellés, en français. */
|
||||
|
||||
const MOIS = [
|
||||
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
||||
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'
|
||||
];
|
||||
|
||||
/** « 2026-06-12 » → « 12 juin 2026 ». */
|
||||
export function dateLongue(iso: string | null | undefined): string {
|
||||
if (!iso) return '';
|
||||
const [a, m, j] = iso.slice(0, 10).split('-').map(Number);
|
||||
if (!a || !m || !j) return iso;
|
||||
return `${j === 1 ? '1ᵉʳ' : j} ${MOIS[m - 1]} ${a}`;
|
||||
}
|
||||
|
||||
/** « 2026-06-12 » → « 12 juin ». */
|
||||
export function dateCourte(iso: string | null | undefined): string {
|
||||
if (!iso) return '';
|
||||
const [, m, j] = iso.slice(0, 10).split('-').map(Number);
|
||||
if (!m || !j) return iso;
|
||||
return `${j === 1 ? '1ᵉʳ' : j} ${MOIS[m - 1]}`;
|
||||
}
|
||||
|
||||
export function mois(iso: string): string {
|
||||
const [a, m] = iso.slice(0, 10).split('-').map(Number);
|
||||
return `${MOIS[m - 1]} ${a}`;
|
||||
}
|
||||
|
||||
/** Nombre de jours entre deux dates ISO, positif dans le futur. */
|
||||
export function joursRestants(iso: string, depuis = '2026-07-25'): number {
|
||||
const cible = new Date(iso.slice(0, 10)).getTime();
|
||||
const base = new Date(depuis).getTime();
|
||||
return Math.round((cible - base) / 86_400_000);
|
||||
}
|
||||
|
||||
export function pluriel(n: number, singulier: string, plurielMot?: string): string {
|
||||
return n > 1 ? (plurielMot ?? `${singulier}s`) : singulier;
|
||||
}
|
||||
|
||||
/** Tronque sur une frontière de mot, sans couper au milieu. */
|
||||
export function extrait(texte: string | null | undefined, taille = 180): string {
|
||||
if (!texte) return '';
|
||||
if (texte.length <= taille) return texte;
|
||||
const coupe = texte.slice(0, taille);
|
||||
const espace = coupe.lastIndexOf(' ');
|
||||
return `${coupe.slice(0, espace > 0 ? espace : taille)}…`;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Libellés d'affichage du vocabulaire métier.
|
||||
*
|
||||
* Ce module est partagé serveur et client : il ne touche ni à la base ni au
|
||||
* système de fichiers. Les composants d'interface en ont besoin, et SvelteKit
|
||||
* interdit — à raison — d'importer `$lib/server` depuis du code client.
|
||||
*/
|
||||
|
||||
export const LIBELLES_STATUT: Record<string, string> = {
|
||||
promulguee: 'Promulguée',
|
||||
adoptee_non_promulguee: 'Adoptée, non promulguée',
|
||||
saisie_cc: 'Devant le Conseil constitutionnel',
|
||||
validee_cc: 'Validée, en attente de promulgation',
|
||||
censuree_partiellement: 'Censurée partiellement',
|
||||
navette: 'En navette',
|
||||
deposee_non_examinee: 'Déposée, non examinée',
|
||||
annoncee: 'Annoncée',
|
||||
rejetee: 'Rejetée'
|
||||
};
|
||||
|
||||
export const LIBELLES_THEME: Record<string, string> = {
|
||||
justice: 'Justice',
|
||||
securite: 'Sécurité',
|
||||
numerique: 'Numérique',
|
||||
social: 'Social',
|
||||
fiscal: 'Fiscal',
|
||||
environnement: 'Environnement',
|
||||
agriculture: 'Agriculture',
|
||||
sante: 'Santé',
|
||||
memoire_patrimoine: 'Mémoire et patrimoine',
|
||||
outre_mer: 'Outre-mer',
|
||||
economie: 'Économie',
|
||||
migration: 'Migration',
|
||||
institutions: 'Institutions'
|
||||
};
|
||||
|
||||
export const LIBELLES_TYPE: Record<string, string> = {
|
||||
loi: 'Loi',
|
||||
loi_organique: 'Loi organique',
|
||||
pjl: 'Projet de loi',
|
||||
ppl: 'Proposition de loi',
|
||||
ordonnance: 'Ordonnance',
|
||||
decision_cc: 'Décision du Conseil constitutionnel',
|
||||
decret: 'Décret',
|
||||
accord_international: 'Accord international'
|
||||
};
|
||||
|
||||
export const LIBELLES_PERTINENCE: Record<string, string> = {
|
||||
forte: 'Forte',
|
||||
moyenne: 'Moyenne',
|
||||
faible: 'Faible'
|
||||
};
|
||||
|
||||
export const LIBELLES_CONFIANCE: Record<string, string> = {
|
||||
high: 'Élevée',
|
||||
medium: 'Moyenne',
|
||||
low: 'Faible'
|
||||
};
|
||||
|
||||
export const LIBELLES_IMPACT: Record<string, string> = {
|
||||
'individus:positif': 'Individus — droits étendus',
|
||||
'individus:negatif': 'Individus — droits restreints',
|
||||
'individus:mixte': 'Individus — effet mixte',
|
||||
'associations:positif': 'Associations — droits étendus',
|
||||
'associations:negatif': 'Associations — droits restreints',
|
||||
'associations:mixte': 'Associations — effet mixte',
|
||||
'entreprises:positif': 'Entreprises — obligations allégées',
|
||||
'entreprises:negatif': 'Entreprises — obligations renforcées',
|
||||
'entreprises:mixte': 'Entreprises — effet mixte'
|
||||
};
|
||||
|
||||
export const LIBELLES_ETAPE: Record<string, string> = {
|
||||
depot: 'Dépôt',
|
||||
adoption_1re_lecture: 'Adoption en première lecture',
|
||||
adoption_definitive: 'Adoption définitive',
|
||||
commission_mixte_paritaire: 'Commission mixte paritaire',
|
||||
transmission: 'Transmission',
|
||||
saisine_cc: 'Saisine du Conseil constitutionnel',
|
||||
decision_cc: 'Décision du Conseil constitutionnel',
|
||||
promulgation: 'Promulgation',
|
||||
publication_jo: 'Publication au Journal officiel',
|
||||
entree_vigueur: 'Entrée en vigueur',
|
||||
rejet: 'Rejet',
|
||||
annonce: 'Annonce',
|
||||
autre: 'Étape'
|
||||
};
|
||||
|
||||
export const LIBELLES_RESULTAT_CC: Record<string, string> = {
|
||||
conforme: 'Conforme',
|
||||
conforme_avec_reserves: 'Conforme avec réserves',
|
||||
non_conformite_partielle: 'Non-conformité partielle',
|
||||
non_conformite_totale: 'Non-conformité totale',
|
||||
en_instance: 'En instance'
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Connexion SQLite partagée.
|
||||
*
|
||||
* La base est ouverte une seule fois, en lecture seule : l'application web ne
|
||||
* modifie jamais les données, c'est le rôle du pipeline. Le mode WAL permet au
|
||||
* pipeline d'écrire pendant que l'application lit, sans blocage.
|
||||
*/
|
||||
import Database from 'better-sqlite3';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ICI = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Emplacements possibles de la base, du plus explicite au plus conventionnel. */
|
||||
function trouverLaBase(): string {
|
||||
const candidats = [
|
||||
process.env.VEILLE_DB,
|
||||
resolve(process.cwd(), '../data/veille.db'),
|
||||
resolve(process.cwd(), 'data/veille.db'),
|
||||
resolve(ICI, '../../../../data/veille.db')
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
for (const candidat of candidats) {
|
||||
if (existsSync(candidat)) return candidat;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Base introuvable. Essayés : ${candidats.join(', ')}. ` +
|
||||
'Lancer `make seed` à la racine du dépôt, ou définir VEILLE_DB.'
|
||||
);
|
||||
}
|
||||
|
||||
let instance: Database.Database | null = null;
|
||||
|
||||
export function base(): Database.Database {
|
||||
if (instance) return instance;
|
||||
|
||||
instance = new Database(trouverLaBase(), { readonly: true, fileMustExist: true });
|
||||
instance.pragma('journal_mode = WAL');
|
||||
// Les requêtes de facettes lisent beaucoup et écrivent rien : un cache
|
||||
// généreux évite de relire les mêmes pages à chaque requête HTTP.
|
||||
instance.pragma('cache_size = -16000');
|
||||
return instance;
|
||||
}
|
||||
|
||||
export function fermer(): void {
|
||||
instance?.close();
|
||||
instance = null;
|
||||
}
|
||||
|
||||
/** Date d'arrêté du corpus de recherche, affichée partout où c'est utile. */
|
||||
export const DATE_ARRETE_CORPUS = '2026-07-25';
|
||||
@@ -0,0 +1,573 @@
|
||||
/**
|
||||
* Requêtes de consultation : recherche plein texte, facettes, fiches, calendrier.
|
||||
*
|
||||
* Tout se joue en SQL. La recherche plein texte passe par l'index FTS5 à
|
||||
* tokenizer `unicode61 remove_diacritics 2` — « chlordecone » retrouve
|
||||
* « chlordécone » — et le classement par `bm25()` pondère les colonnes : un mot
|
||||
* dans le titre pèse dix fois plus que dans les points clés.
|
||||
*
|
||||
* Les facettes sont calculées sur le résultat filtré **par les autres
|
||||
* facettes**, pas sur la table entière : cocher « santé » doit recompter les
|
||||
* statuts disponibles parmi les textes de santé, sinon les compteurs mentent.
|
||||
*/
|
||||
import { base } from '$serveur/db';
|
||||
import {
|
||||
LIBELLES_CONFIANCE,
|
||||
LIBELLES_IMPACT,
|
||||
LIBELLES_PERTINENCE,
|
||||
LIBELLES_STATUT,
|
||||
LIBELLES_THEME,
|
||||
LIBELLES_TYPE
|
||||
} from '$lib/libelles';
|
||||
import type {
|
||||
Echeance,
|
||||
Facette,
|
||||
Filtres,
|
||||
Insight,
|
||||
Resultats,
|
||||
Texte,
|
||||
TexteDetaille
|
||||
} from '$lib/types';
|
||||
|
||||
const PAR_PAGE = 20;
|
||||
|
||||
/** Pondération bm25 : titre court, titre officiel, résumé, points clés, note, numéro. */
|
||||
const POIDS_BM25 = '10.0, 5.0, 3.0, 2.0, 2.0, 1.0';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Lecture des filtres depuis l'URL
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export function lireFiltres(parametres: URLSearchParams): Filtres {
|
||||
const multiple = (nom: string) =>
|
||||
parametres
|
||||
.getAll(nom)
|
||||
.flatMap((valeur) => valeur.split(','))
|
||||
.map((valeur) => valeur.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const page = Number.parseInt(parametres.get('page') ?? '1', 10);
|
||||
|
||||
return {
|
||||
q: (parametres.get('q') ?? '').trim(),
|
||||
statut: multiple('statut'),
|
||||
theme: multiple('theme'),
|
||||
type: multiple('type'),
|
||||
impact: multiple('impact'),
|
||||
guadeloupe: multiple('guadeloupe'),
|
||||
confiance: multiple('confiance'),
|
||||
from: parametres.get('from'),
|
||||
to: parametres.get('to'),
|
||||
tri: parametres.get('tri') === 'date' ? 'date' : 'pertinence',
|
||||
page: Number.isFinite(page) && page > 0 ? page : 1
|
||||
};
|
||||
}
|
||||
|
||||
/** Reconstruit une URL de recherche en basculant une valeur de facette. */
|
||||
export function basculerFacette(
|
||||
filtres: Filtres,
|
||||
champ: keyof Filtres,
|
||||
valeur: string
|
||||
): string {
|
||||
const parametres = new URLSearchParams();
|
||||
if (filtres.q) parametres.set('q', filtres.q);
|
||||
|
||||
for (const nom of ['statut', 'theme', 'type', 'impact', 'guadeloupe', 'confiance'] as const) {
|
||||
let valeurs = [...filtres[nom]];
|
||||
if (nom === champ) {
|
||||
valeurs = valeurs.includes(valeur)
|
||||
? valeurs.filter((v) => v !== valeur)
|
||||
: [...valeurs, valeur];
|
||||
}
|
||||
for (const v of valeurs) parametres.append(nom, v);
|
||||
}
|
||||
|
||||
if (filtres.from) parametres.set('from', filtres.from);
|
||||
if (filtres.to) parametres.set('to', filtres.to);
|
||||
if (filtres.tri !== 'pertinence') parametres.set('tri', filtres.tri);
|
||||
|
||||
const chaine = parametres.toString();
|
||||
return chaine ? `/recherche?${chaine}` : '/recherche';
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Construction du SQL
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
interface Clause {
|
||||
sql: string;
|
||||
parametres: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditions issues des filtres, hors recherche plein texte.
|
||||
*
|
||||
* `exclure` permet de recalculer les facettes d'un champ en ignorant ses
|
||||
* propres sélections — c'est ce qui rend les compteurs honnêtes.
|
||||
*/
|
||||
function conditions(filtres: Filtres, exclure?: string): Clause {
|
||||
const morceaux: string[] = [];
|
||||
const parametres: unknown[] = [];
|
||||
|
||||
if (filtres.statut.length && exclure !== 'statut') {
|
||||
// `saisie_cc` n'est pas seulement un statut : c'est aussi l'état des
|
||||
// textes dont une affaire est en instance devant le Conseil. La vue
|
||||
// `v_textes` expose cette lecture combinée sous `devant_cc`.
|
||||
const statutsSimples = filtres.statut.filter((s) => s !== 'saisie_cc');
|
||||
const clauses: string[] = [];
|
||||
if (statutsSimples.length) {
|
||||
clauses.push(`statut IN (${statutsSimples.map(() => '?').join(', ')})`);
|
||||
parametres.push(...statutsSimples);
|
||||
}
|
||||
if (filtres.statut.includes('saisie_cc')) clauses.push('devant_cc = 1');
|
||||
morceaux.push(`(${clauses.join(' OR ')})`);
|
||||
}
|
||||
|
||||
if (filtres.theme.length && exclure !== 'theme') {
|
||||
const clauses = filtres.theme.map(
|
||||
() => `EXISTS (SELECT 1 FROM json_each(themes) WHERE json_each.value = ?)`
|
||||
);
|
||||
morceaux.push(`(${clauses.join(' OR ')})`);
|
||||
parametres.push(...filtres.theme);
|
||||
}
|
||||
|
||||
if (filtres.type.length && exclure !== 'type') {
|
||||
morceaux.push(`type IN (${filtres.type.map(() => '?').join(', ')})`);
|
||||
parametres.push(...filtres.type);
|
||||
}
|
||||
|
||||
if (filtres.guadeloupe.length && exclure !== 'guadeloupe') {
|
||||
morceaux.push(
|
||||
`guadeloupe_pertinence IN (${filtres.guadeloupe.map(() => '?').join(', ')})`
|
||||
);
|
||||
parametres.push(...filtres.guadeloupe);
|
||||
}
|
||||
|
||||
if (filtres.confiance.length && exclure !== 'confiance') {
|
||||
morceaux.push(`confiance IN (${filtres.confiance.map(() => '?').join(', ')})`);
|
||||
parametres.push(...filtres.confiance);
|
||||
}
|
||||
|
||||
if (filtres.impact.length && exclure !== 'impact') {
|
||||
// Un filtre d'impact s'écrit « public:sens », par exemple
|
||||
// « associations:negatif ». Les deux moitiés doivent correspondre
|
||||
// ensemble, d'où l'accès direct par clé JSON.
|
||||
const clauses = filtres.impact.map(() => `json_extract(impacts, ?) = ?`);
|
||||
morceaux.push(`(${clauses.join(' OR ')})`);
|
||||
for (const valeur of filtres.impact) {
|
||||
const [pub, sens] = valeur.split(':');
|
||||
parametres.push(`$.${pub}.sens`, sens);
|
||||
}
|
||||
}
|
||||
|
||||
if (filtres.from) {
|
||||
morceaux.push(`COALESCE(date_promulgation, date_adoption, statut_date) >= ?`);
|
||||
parametres.push(filtres.from);
|
||||
}
|
||||
if (filtres.to) {
|
||||
morceaux.push(`COALESCE(date_promulgation, date_adoption, statut_date) <= ?`);
|
||||
parametres.push(filtres.to);
|
||||
}
|
||||
|
||||
return { sql: morceaux.length ? morceaux.join(' AND ') : '1 = 1', parametres };
|
||||
}
|
||||
|
||||
/**
|
||||
* Traduit une saisie libre en requête FTS5.
|
||||
*
|
||||
* Chaque terme est cité pour neutraliser la syntaxe FTS (`NEAR`, `*`, `:`)
|
||||
* qu'un visiteur ne saisit pas volontairement, et suffixé d'une étoile pour
|
||||
* accepter les préfixes : « chlord » trouve « chlordécone ».
|
||||
*/
|
||||
function requeteFts(saisie: string): string {
|
||||
const termes = saisie
|
||||
.replace(/["^*:()]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.map((terme) => terme.trim())
|
||||
.filter((terme) => terme.length > 1);
|
||||
|
||||
if (!termes.length) return '';
|
||||
return termes.map((terme) => `"${terme}"*`).join(' AND ');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Recherche
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export function rechercher(filtres: Filtres): Resultats {
|
||||
const db = base();
|
||||
const where = conditions(filtres);
|
||||
const fts = filtres.q ? requeteFts(filtres.q) : '';
|
||||
|
||||
const source = fts
|
||||
? `v_textes t JOIN textes_fts f ON f.rowid = t.rowid AND textes_fts MATCH ?`
|
||||
: `v_textes t`;
|
||||
const parametresSource = fts ? [fts] : [];
|
||||
|
||||
const ordre = fts
|
||||
? filtres.tri === 'date'
|
||||
? `COALESCE(t.date_promulgation, t.date_adoption, t.statut_date) DESC`
|
||||
: `bm25(textes_fts, ${POIDS_BM25})`
|
||||
: `COALESCE(t.date_promulgation, t.date_adoption, t.statut_date) DESC, t.id`;
|
||||
|
||||
const total = (
|
||||
db
|
||||
.prepare(`SELECT COUNT(*) AS n FROM ${source} WHERE ${where.sql}`)
|
||||
.get(...parametresSource, ...where.parametres) as { n: number }
|
||||
).n;
|
||||
|
||||
const extrait = fts
|
||||
? `snippet(textes_fts, -1, '<mark>', '</mark>', '…', 24) AS extrait`
|
||||
: `NULL AS extrait`;
|
||||
|
||||
const lignes = db
|
||||
.prepare(
|
||||
`SELECT t.*, ${extrait}
|
||||
FROM ${source}
|
||||
WHERE ${where.sql}
|
||||
ORDER BY ${ordre}
|
||||
LIMIT ? OFFSET ?`
|
||||
)
|
||||
.all(
|
||||
...parametresSource,
|
||||
...where.parametres,
|
||||
PAR_PAGE,
|
||||
(filtres.page - 1) * PAR_PAGE
|
||||
) as Record<string, unknown>[];
|
||||
|
||||
return {
|
||||
textes: lignes.map(hydrater),
|
||||
total,
|
||||
page: filtres.page,
|
||||
pages: Math.max(1, Math.ceil(total / PAR_PAGE)),
|
||||
facettes: {
|
||||
statut: facetteStatut(filtres, fts),
|
||||
theme: facetteTheme(filtres, fts),
|
||||
type: facetteSimple(filtres, fts, 'type', LIBELLES_TYPE),
|
||||
guadeloupe: facetteSimple(
|
||||
filtres,
|
||||
fts,
|
||||
'guadeloupe_pertinence',
|
||||
LIBELLES_PERTINENCE,
|
||||
'guadeloupe'
|
||||
),
|
||||
impact: facetteImpact(filtres, fts),
|
||||
confiance: facetteSimple(filtres, fts, 'confiance', LIBELLES_CONFIANCE)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function sourceEtParametres(fts: string): [string, unknown[]] {
|
||||
return fts
|
||||
? [`v_textes t JOIN textes_fts f ON f.rowid = t.rowid AND textes_fts MATCH ?`, [fts]]
|
||||
: [`v_textes t`, []];
|
||||
}
|
||||
|
||||
function facetteSimple(
|
||||
filtres: Filtres,
|
||||
fts: string,
|
||||
colonne: string,
|
||||
libelles: Record<string, string>,
|
||||
champFiltre?: keyof Filtres
|
||||
): Facette[] {
|
||||
const champ = (champFiltre ?? colonne) as keyof Filtres;
|
||||
const where = conditions(filtres, champ as string);
|
||||
const [source, parametresSource] = sourceEtParametres(fts);
|
||||
|
||||
const lignes = base()
|
||||
.prepare(
|
||||
`SELECT ${colonne} AS valeur, COUNT(*) AS nombre
|
||||
FROM ${source}
|
||||
WHERE ${where.sql} AND ${colonne} IS NOT NULL
|
||||
GROUP BY ${colonne}
|
||||
ORDER BY nombre DESC`
|
||||
)
|
||||
.all(...parametresSource, ...where.parametres) as { valeur: string; nombre: number }[];
|
||||
|
||||
const selection = filtres[champ] as string[];
|
||||
return lignes.map((ligne) => ({
|
||||
valeur: ligne.valeur,
|
||||
libelle: libelles[ligne.valeur] ?? ligne.valeur,
|
||||
nombre: ligne.nombre,
|
||||
actif: selection.includes(ligne.valeur)
|
||||
}));
|
||||
}
|
||||
|
||||
function facetteStatut(filtres: Filtres, fts: string): Facette[] {
|
||||
const facettes = facetteSimple(filtres, fts, 'statut', LIBELLES_STATUT);
|
||||
|
||||
// Facette dérivée : « devant le Conseil constitutionnel » recouvre à la
|
||||
// fois le statut `saisie_cc` et les textes ayant une affaire en instance.
|
||||
const where = conditions(filtres, 'statut');
|
||||
const [source, parametresSource] = sourceEtParametres(fts);
|
||||
const nombre = (
|
||||
base()
|
||||
.prepare(`SELECT COUNT(*) AS n FROM ${source} WHERE ${where.sql} AND devant_cc = 1`)
|
||||
.get(...parametresSource, ...where.parametres) as { n: number }
|
||||
).n;
|
||||
|
||||
if (nombre > 0 && !facettes.some((f) => f.valeur === 'saisie_cc')) {
|
||||
facettes.unshift({
|
||||
valeur: 'saisie_cc',
|
||||
libelle: LIBELLES_STATUT.saisie_cc,
|
||||
nombre,
|
||||
actif: filtres.statut.includes('saisie_cc')
|
||||
});
|
||||
}
|
||||
|
||||
return facettes;
|
||||
}
|
||||
|
||||
function facetteTheme(filtres: Filtres, fts: string): Facette[] {
|
||||
const where = conditions(filtres, 'theme');
|
||||
const [source, parametresSource] = sourceEtParametres(fts);
|
||||
|
||||
const lignes = base()
|
||||
.prepare(
|
||||
`SELECT json_each.value AS valeur, COUNT(*) AS nombre
|
||||
FROM ${source}, json_each(t.themes)
|
||||
WHERE ${where.sql}
|
||||
GROUP BY json_each.value
|
||||
ORDER BY nombre DESC`
|
||||
)
|
||||
.all(...parametresSource, ...where.parametres) as { valeur: string; nombre: number }[];
|
||||
|
||||
return lignes.map((ligne) => ({
|
||||
valeur: ligne.valeur,
|
||||
libelle: LIBELLES_THEME[ligne.valeur] ?? ligne.valeur,
|
||||
nombre: ligne.nombre,
|
||||
actif: filtres.theme.includes(ligne.valeur)
|
||||
}));
|
||||
}
|
||||
|
||||
function facetteImpact(filtres: Filtres, fts: string): Facette[] {
|
||||
const where = conditions(filtres, 'impact');
|
||||
const [source, parametresSource] = sourceEtParametres(fts);
|
||||
|
||||
const lignes = base()
|
||||
.prepare(
|
||||
`SELECT json_each.key || ':' || json_extract(json_each.value, '$.sens') AS valeur,
|
||||
COUNT(*) AS nombre
|
||||
FROM ${source}, json_each(t.impacts)
|
||||
WHERE ${where.sql}
|
||||
GROUP BY valeur
|
||||
ORDER BY nombre DESC`
|
||||
)
|
||||
.all(...parametresSource, ...where.parametres) as { valeur: string; nombre: number }[];
|
||||
|
||||
return lignes
|
||||
.filter((ligne) => !ligne.valeur.endsWith(':neutre'))
|
||||
.map((ligne) => ({
|
||||
valeur: ligne.valeur,
|
||||
libelle: LIBELLES_IMPACT[ligne.valeur] ?? ligne.valeur,
|
||||
nombre: ligne.nombre,
|
||||
actif: filtres.impact.includes(ligne.valeur)
|
||||
}));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Fiche détaillée
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export function texte(slug: string): TexteDetaille | null {
|
||||
const db = base();
|
||||
const ligne = db.prepare(`SELECT * FROM v_textes WHERE id = ?`).get(slug) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!ligne) return null;
|
||||
|
||||
const detaille = hydrater(ligne) as TexteDetaille;
|
||||
|
||||
detaille.sources = db
|
||||
.prepare(
|
||||
`SELECT url, titre, editeur, date_publication, tier, extrait_verbatim,
|
||||
contexte, confiance, marqueur, fichier_origine
|
||||
FROM sources WHERE texte_id = ?
|
||||
ORDER BY tier ASC, date_publication DESC NULLS LAST, id`
|
||||
)
|
||||
.all(slug) as TexteDetaille['sources'];
|
||||
|
||||
detaille.evenements = db
|
||||
.prepare(
|
||||
`SELECT date_evenement, type_etape, description, source_url, previsionnel
|
||||
FROM evenements WHERE texte_id = ?
|
||||
ORDER BY date_evenement ASC, id`
|
||||
)
|
||||
.all(slug) as TexteDetaille['evenements'];
|
||||
|
||||
detaille.decisions_cc = db
|
||||
.prepare(`SELECT * FROM decisions_cc WHERE texte_id = ? ORDER BY numero_affaire`)
|
||||
.all(slug) as TexteDetaille['decisions_cc'];
|
||||
|
||||
return detaille;
|
||||
}
|
||||
|
||||
export function tousLesSlugs(): string[] {
|
||||
return (base().prepare(`SELECT id FROM textes ORDER BY id`).all() as { id: string }[]).map(
|
||||
(l) => l.id
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tableau de bord
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export interface Compteurs {
|
||||
total: number;
|
||||
parStatut: { valeur: string; libelle: string; nombre: number }[];
|
||||
devantCc: number;
|
||||
guadeloupeForte: number;
|
||||
aVerifier: number;
|
||||
sources: number;
|
||||
}
|
||||
|
||||
export function compteurs(): Compteurs {
|
||||
const db = base();
|
||||
const un = (sql: string) => (db.prepare(sql).get() as { n: number }).n;
|
||||
|
||||
return {
|
||||
total: un(`SELECT COUNT(*) AS n FROM textes`),
|
||||
parStatut: (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT statut AS valeur, COUNT(*) AS nombre FROM textes
|
||||
GROUP BY statut ORDER BY nombre DESC`
|
||||
)
|
||||
.all() as { valeur: string; nombre: number }[]
|
||||
).map((l) => ({ ...l, libelle: LIBELLES_STATUT[l.valeur] ?? l.valeur })),
|
||||
devantCc: un(`SELECT COUNT(*) AS n FROM v_textes WHERE devant_cc = 1`),
|
||||
guadeloupeForte: un(
|
||||
`SELECT COUNT(*) AS n FROM textes WHERE guadeloupe_pertinence = 'forte'`
|
||||
),
|
||||
aVerifier: un(`SELECT COUNT(*) AS n FROM textes WHERE a_verifier = 1`),
|
||||
sources: un(`SELECT COUNT(*) AS n FROM sources`)
|
||||
};
|
||||
}
|
||||
|
||||
/** Échéances des `jours` prochains jours, textes et repères nationaux mêlés. */
|
||||
export function prochainesEcheances(jours = 60, aPartirDe?: string): Echeance[] {
|
||||
const depart = aPartirDe ?? '2026-07-25';
|
||||
const fin = new Date(depart);
|
||||
fin.setDate(fin.getDate() + jours);
|
||||
const limite = fin.toISOString().slice(0, 10);
|
||||
|
||||
const db = base();
|
||||
|
||||
const nationales = db
|
||||
.prepare(
|
||||
`SELECT * FROM echeances
|
||||
WHERE date_echeance >= ? AND date_echeance <= ?
|
||||
ORDER BY date_echeance`
|
||||
)
|
||||
.all(depart, limite) as Echeance[];
|
||||
|
||||
const desTextes = db
|
||||
.prepare(
|
||||
`SELECT NULL AS id, 'texte-' || id AS code, prochaine_echeance AS date_echeance,
|
||||
titre_court AS libelle, prochaine_echeance_label AS description,
|
||||
'texte' AS portee, id AS texte_id,
|
||||
CASE WHEN guadeloupe_pertinence = 'forte' THEN 1 ELSE NULL END
|
||||
AS concerne_guadeloupe,
|
||||
guadeloupe_note AS note_guadeloupe, 1 AS previsionnel,
|
||||
NULL AS source_url, NULL AS source_extrait
|
||||
FROM textes
|
||||
WHERE prochaine_echeance IS NOT NULL
|
||||
AND prochaine_echeance >= ? AND prochaine_echeance <= ?
|
||||
ORDER BY prochaine_echeance`
|
||||
)
|
||||
.all(depart, limite) as Echeance[];
|
||||
|
||||
return [...nationales, ...desTextes].sort((a, b) =>
|
||||
a.date_echeance.localeCompare(b.date_echeance)
|
||||
);
|
||||
}
|
||||
|
||||
/** Toutes les échéances, pour la page calendrier. */
|
||||
export function calendrier(): Echeance[] {
|
||||
return prochainesEcheances(400, '2026-07-01');
|
||||
}
|
||||
|
||||
export function decisionsAttendues(): (Echeance & { numero_affaire: string })[] {
|
||||
return base()
|
||||
.prepare(
|
||||
`SELECT d.numero_affaire, d.date_decision_attendue AS date_echeance,
|
||||
COALESCE(t.titre_court, d.resume) AS libelle, d.resume AS description,
|
||||
d.texte_id, t.guadeloupe_pertinence
|
||||
FROM decisions_cc d
|
||||
LEFT JOIN textes t ON t.id = d.texte_id
|
||||
WHERE d.date_decision IS NULL
|
||||
ORDER BY COALESCE(d.date_decision_attendue, '9999'), d.numero_affaire`
|
||||
)
|
||||
.all() as (Echeance & { numero_affaire: string })[];
|
||||
}
|
||||
|
||||
export function insights(): Insight[] {
|
||||
return (
|
||||
base().prepare(`SELECT * FROM insights ORDER BY numero`).all() as Record<
|
||||
string,
|
||||
unknown
|
||||
>[]
|
||||
).map((ligne) => ({
|
||||
numero: ligne.numero as number,
|
||||
titre: ligne.titre as string,
|
||||
corps: ligne.corps as string,
|
||||
implications: ligne.implications as string | null,
|
||||
confiance: ligne.confiance as string,
|
||||
derive_de: JSON.parse((ligne.derive_de as string) || '[]')
|
||||
}));
|
||||
}
|
||||
|
||||
export interface Changement {
|
||||
horodatage: string;
|
||||
mode: string;
|
||||
texte_id: string | null;
|
||||
titre: string | null;
|
||||
nature: string;
|
||||
champ: string | null;
|
||||
ancienne_valeur: string | null;
|
||||
nouvelle_valeur: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
/** Derniers changements détectés par le pipeline, pour l'accueil. */
|
||||
export function derniersChangements(limite = 12): Changement[] {
|
||||
return base()
|
||||
.prepare(
|
||||
`SELECT v.horodatage, v.mode, c.texte_id, t.titre_court AS titre,
|
||||
c.nature, c.champ, c.ancienne_valeur, c.nouvelle_valeur, c.description
|
||||
FROM veille_changements c
|
||||
JOIN veille_log v ON v.id = c.run_id
|
||||
LEFT JOIN textes t ON t.id = c.texte_id
|
||||
ORDER BY v.horodatage DESC, c.id DESC
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(limite) as Changement[];
|
||||
}
|
||||
|
||||
export interface Run {
|
||||
horodatage: string;
|
||||
mode: string;
|
||||
duree_s: number | null;
|
||||
ajouts: number;
|
||||
modifications: number;
|
||||
echecs: number;
|
||||
rapport: string | null;
|
||||
}
|
||||
|
||||
export function dernierRun(): Run | null {
|
||||
return (
|
||||
(base()
|
||||
.prepare(
|
||||
`SELECT horodatage, mode, duree_s, ajouts, modifications, echecs, rapport
|
||||
FROM veille_log ORDER BY horodatage DESC, id DESC LIMIT 1`
|
||||
)
|
||||
.get() as Run | undefined) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Hydratation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function hydrater(ligne: Record<string, unknown>): Texte {
|
||||
return {
|
||||
...(ligne as unknown as Texte),
|
||||
themes: JSON.parse((ligne.themes as string) || '[]'),
|
||||
impacts: JSON.parse((ligne.impacts as string) || '{}'),
|
||||
points_cles: JSON.parse((ligne.points_cles as string) || '[]')
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Socle : polices auto-hébergées, réinitialisation, éléments communs.
|
||||
*
|
||||
* Aucune requête tierce. Les `@font-face` pointent vers `static/polices/`,
|
||||
* copiées depuis npm par `npm run polices`.
|
||||
*/
|
||||
|
||||
/* ── Polices ─────────────────────────────────────────────────────────────── */
|
||||
@font-face {
|
||||
font-family: 'Archivo';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/polices/archivo-latin-400-normal.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+2000-206F, U+20AC;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Archivo';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/polices/archivo-latin-600-normal.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Archivo';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/polices/archivo-latin-700-normal.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/polices/inter-latin-400-normal.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/polices/inter-latin-500-normal.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/polices/inter-latin-600-normal.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/polices/inter-latin-700-normal.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* ── Réinitialisation ────────────────────────────────────────────────────── */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--noir-oki);
|
||||
color: var(--blanc-creme);
|
||||
font-family: var(--font-body);
|
||||
font-size: 1rem;
|
||||
line-height: 1.55;
|
||||
font-weight: 400;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.15;
|
||||
margin: 0 0 var(--space-2);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.55rem, 3.4vw, 2.3rem);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
h2 {
|
||||
font-size: clamp(1.2rem, 2.4vw, 1.55rem);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
h4 {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 var(--space-2);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--or-oki);
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 0.18em;
|
||||
}
|
||||
a:hover {
|
||||
color: var(--or-clair);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 3px solid var(--or-oki);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
mark {
|
||||
background: color-mix(in srgb, var(--or-oki) 26%, transparent);
|
||||
color: inherit;
|
||||
padding: 0 0.15em;
|
||||
border-radius: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
margin: var(--space-4) 0;
|
||||
}
|
||||
|
||||
/* ── Mise en page ────────────────────────────────────────────────────────── */
|
||||
.contenant {
|
||||
width: min(100% - 2rem, var(--container));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding-block: var(--space-4);
|
||||
}
|
||||
|
||||
.pile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.ligne {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.grille {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
|
||||
}
|
||||
|
||||
/* ── Signature de marque ─────────────────────────────────────────────────── */
|
||||
.flag-bar {
|
||||
height: 6px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#0d0d0d 0 25%,
|
||||
var(--or-oki) 25% 50%,
|
||||
var(--vert-oki) 50% 75%,
|
||||
var(--rouge-oki) 75% 100%
|
||||
);
|
||||
}
|
||||
:root.light-theme .flag-bar {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#000 0 25%,
|
||||
var(--or-oki) 25% 50%,
|
||||
var(--vert-oki) 50% 75%,
|
||||
var(--rouge-oki) 75% 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Carte canonique ─────────────────────────────────────────────────────── */
|
||||
.carte {
|
||||
background: var(--card-bg);
|
||||
border: var(--border-card);
|
||||
border-left: 4px solid var(--or-oki);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2);
|
||||
transition:
|
||||
transform var(--dur-tanbou) var(--ease-ka),
|
||||
border-color var(--dur-tanbou) var(--ease-ka),
|
||||
background var(--dur-tanbou) var(--ease-ka);
|
||||
}
|
||||
.carte:hover {
|
||||
background: var(--card-bg-survol);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ── Tag canonique ───────────────────────────────────────────────────────── */
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35em;
|
||||
background: color-mix(in srgb, var(--or-oki) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--or-oki) 55%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--blanc-creme);
|
||||
font-size: 0.78rem;
|
||||
padding: 0.15em 0.55em;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Bouton canonique ────────────────────────────────────────────────────── */
|
||||
.bouton {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 0.55em 1.1em;
|
||||
border: var(--border-btn);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--or-oki);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
transition: color var(--dur-tanbou) var(--ease-syncope);
|
||||
}
|
||||
.bouton::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--or-oki);
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
transition: transform var(--dur-tanbou) var(--ease-syncope);
|
||||
z-index: -1;
|
||||
}
|
||||
.bouton:hover {
|
||||
color: var(--noir-oki);
|
||||
}
|
||||
.bouton:hover::after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
.bouton > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.bouton-discret {
|
||||
border-color: var(--line);
|
||||
color: var(--blanc-creme);
|
||||
}
|
||||
|
||||
/* ── Accessibilité ───────────────────────────────────────────────────────── */
|
||||
.lien-evitement {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
background: var(--or-oki);
|
||||
color: var(--noir-oki);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-weight: 700;
|
||||
z-index: 100;
|
||||
}
|
||||
.lien-evitement:focus {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.visuellement-cache {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Une seule garde, au niveau global : sans mouvement, tout reste lisible. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
.carte:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Impression : la veille se transmet aussi sur papier ─────────────────── */
|
||||
@media print {
|
||||
:root {
|
||||
--noir-oki: #fff;
|
||||
--blanc-creme: #000;
|
||||
--card-bg: transparent;
|
||||
--line: #999;
|
||||
--muted: #444;
|
||||
}
|
||||
.entete-site,
|
||||
.pied-site,
|
||||
.panneau-facettes,
|
||||
.bouton {
|
||||
display: none !important;
|
||||
}
|
||||
a[href^='http']::after {
|
||||
content: ' (' attr(href) ')';
|
||||
font-size: 0.75em;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Tokens OKI — Organisation KA Internationale.
|
||||
*
|
||||
* Valeurs mesurées sur o-k-i.net, reprises telles quelles de la charte. Le
|
||||
* thème sombre est l'identité par défaut ; le clair est une variante opt-in qui
|
||||
* assombrit les accents pour rester au contraste AA.
|
||||
*
|
||||
* Trois règles ne se négocient pas :
|
||||
* · l'or porte toute l'interaction — c'est la seule couleur d'action ;
|
||||
* · rouge = signaler, vert = valider, or = agir : jamais permuter ;
|
||||
* · angles nets partout, 3 px sur les petits éléments, 6 px sur les cartes.
|
||||
*
|
||||
* Usage propre à cette application : c'est un outil de veille, pas une vitrine.
|
||||
* La densité informationnelle prime, le motion est réduit au strict minimum, et
|
||||
* la flag-bar n'apparaît qu'une fois par écran.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ── Noyau ── */
|
||||
--noir-oki: #0d0d0d;
|
||||
--noir-profond: #1a0f1a;
|
||||
--blanc-creme: #fff8e7;
|
||||
--line: rgba(255, 255, 255, 0.1);
|
||||
--or-oki: #fdb813;
|
||||
--rouge-oki: #ff1654;
|
||||
|
||||
/* ── Étendue, avec parcimonie ── */
|
||||
--vert-oki: #00d66c;
|
||||
--turquoise-caraibes: #00ced1;
|
||||
--jaune-soleil: #ffd700;
|
||||
--orange-flamme: #ff6b35;
|
||||
--violet-nuit: #6b2d5c;
|
||||
--bleu-ocean: #0077be;
|
||||
--or-clair: #ffe066;
|
||||
--gris-sombre: #2d1b2e;
|
||||
|
||||
/* ── Sémantique dérivée ── */
|
||||
--muted: color-mix(in srgb, var(--blanc-creme) 70%, transparent);
|
||||
--muted-fort: color-mix(in srgb, var(--blanc-creme) 55%, transparent);
|
||||
--card-bg: rgba(255, 255, 255, 0.03);
|
||||
--card-bg-survol: rgba(255, 255, 255, 0.06);
|
||||
|
||||
/* ── Typographie ── */
|
||||
--font-display: 'Archivo', 'Arial Black', sans-serif;
|
||||
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-mono: ui-monospace, 'SFMono-Regular', 'Menlo', monospace;
|
||||
|
||||
/* ── Formes et espacements ── */
|
||||
--radius-sm: 3px;
|
||||
--radius-md: 6px;
|
||||
--border-card: 1px solid var(--line);
|
||||
--border-btn: 2px solid var(--or-oki);
|
||||
--space-1: 0.75rem;
|
||||
--space-2: 1rem;
|
||||
--space-3: 1.5rem;
|
||||
--space-4: 2rem;
|
||||
--space-5: 4rem;
|
||||
--container: 1200px;
|
||||
|
||||
/* ── Motion : les cadences gwoka ── */
|
||||
--ease-ka: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-syncope: cubic-bezier(0.65, 0, 0.35, 1);
|
||||
--dur-tanbou: 120ms;
|
||||
--dur-mesure: 480ms;
|
||||
--dur-phrase: 960ms;
|
||||
|
||||
/* ── Sémantique métier ──
|
||||
* Les statuts sont d'abord distingués par leur libellé et leur forme, la
|
||||
* couleur ne fait que renforcer : un daltonien doit pouvoir lire la page.
|
||||
*/
|
||||
--statut-promulguee: var(--vert-oki);
|
||||
--statut-attente: var(--or-oki);
|
||||
--statut-cc: var(--orange-flamme);
|
||||
--statut-navette: var(--turquoise-caraibes);
|
||||
--statut-projet: var(--muted-fort);
|
||||
--statut-rejetee: var(--rouge-oki);
|
||||
|
||||
--impact-positif: var(--vert-oki);
|
||||
--impact-negatif: var(--rouge-oki);
|
||||
--impact-mixte: var(--jaune-soleil);
|
||||
--impact-neutre: var(--muted-fort);
|
||||
}
|
||||
|
||||
/* Thème clair — opt-in, accents assombris pour le contraste AA. */
|
||||
:root.light-theme {
|
||||
--noir-oki: #fff8e7;
|
||||
--blanc-creme: #0d0d0d;
|
||||
--noir-profond: #f5f0e8;
|
||||
--gris-sombre: #e8ddd0;
|
||||
--or-oki: #8f5c00;
|
||||
--vert-oki: #006b3d;
|
||||
--rouge-oki: #a01030;
|
||||
--turquoise-caraibes: #006b75;
|
||||
--bleu-ocean: #004b7f;
|
||||
--or-clair: #a86e00;
|
||||
--jaune-soleil: #7a5300;
|
||||
--orange-flamme: #b34700;
|
||||
--violet-nuit: #4a1f40;
|
||||
--line: rgba(0, 0, 0, 0.14);
|
||||
--card-bg: rgba(0, 0, 0, 0.025);
|
||||
--card-bg-survol: rgba(0, 0, 0, 0.05);
|
||||
--muted: color-mix(in srgb, var(--blanc-creme) 72%, transparent);
|
||||
--muted-fort: color-mix(in srgb, var(--blanc-creme) 58%, transparent);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/** Vocabulaire du domaine, aligné sur le schéma SQLite et les modèles pydantic. */
|
||||
|
||||
export type Statut =
|
||||
| 'promulguee'
|
||||
| 'adoptee_non_promulguee'
|
||||
| 'saisie_cc'
|
||||
| 'validee_cc'
|
||||
| 'censuree_partiellement'
|
||||
| 'navette'
|
||||
| 'deposee_non_examinee'
|
||||
| 'annoncee'
|
||||
| 'rejetee';
|
||||
|
||||
export type TypeTexte =
|
||||
| 'loi'
|
||||
| 'loi_organique'
|
||||
| 'pjl'
|
||||
| 'ppl'
|
||||
| 'ordonnance'
|
||||
| 'decision_cc'
|
||||
| 'decret'
|
||||
| 'accord_international';
|
||||
|
||||
export type Theme =
|
||||
| 'justice'
|
||||
| 'securite'
|
||||
| 'numerique'
|
||||
| 'social'
|
||||
| 'fiscal'
|
||||
| 'environnement'
|
||||
| 'agriculture'
|
||||
| 'sante'
|
||||
| 'memoire_patrimoine'
|
||||
| 'outre_mer'
|
||||
| 'economie'
|
||||
| 'migration'
|
||||
| 'institutions';
|
||||
|
||||
export type Public = 'individus' | 'associations' | 'entreprises';
|
||||
export type SensImpact = 'positif' | 'negatif' | 'mixte' | 'neutre';
|
||||
export type Pertinence = 'forte' | 'moyenne' | 'faible';
|
||||
export type Confiance = 'high' | 'medium' | 'low';
|
||||
export type RegimeLibertes = 'droits_plus' | 'controle_plus' | 'mixte' | 'neutre';
|
||||
|
||||
export interface Impact {
|
||||
sens: SensImpact;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface Source {
|
||||
url: string;
|
||||
titre: string | null;
|
||||
editeur: string | null;
|
||||
date_publication: string | null;
|
||||
tier: 'T1' | 'T2';
|
||||
extrait_verbatim: string | null;
|
||||
contexte: string | null;
|
||||
confiance: Confiance | null;
|
||||
marqueur: string | null;
|
||||
fichier_origine: string | null;
|
||||
}
|
||||
|
||||
export interface Evenement {
|
||||
date_evenement: string;
|
||||
type_etape: string;
|
||||
description: string;
|
||||
source_url: string | null;
|
||||
previsionnel: 0 | 1;
|
||||
}
|
||||
|
||||
export interface DecisionCC {
|
||||
numero_affaire: string;
|
||||
date_saisine: string | null;
|
||||
date_decision: string | null;
|
||||
resultat: string | null;
|
||||
saisissants: string | null;
|
||||
resume: string | null;
|
||||
url: string | null;
|
||||
date_decision_attendue: string | null;
|
||||
}
|
||||
|
||||
export interface Texte {
|
||||
id: string;
|
||||
numero_officiel: string | null;
|
||||
type: TypeTexte;
|
||||
titre_court: string;
|
||||
titre_officiel: string | null;
|
||||
statut: Statut;
|
||||
statut_date: string | null;
|
||||
date_depot: string | null;
|
||||
date_adoption: string | null;
|
||||
date_promulgation: string | null;
|
||||
date_entree_vigueur: string | null;
|
||||
prochaine_echeance: string | null;
|
||||
prochaine_echeance_label: string | null;
|
||||
themes: Theme[];
|
||||
impacts: Partial<Record<Public, Impact>>;
|
||||
regime_libertes: RegimeLibertes | null;
|
||||
regime_libertes_note: string | null;
|
||||
guadeloupe_pertinence: Pertinence | null;
|
||||
guadeloupe_note: string | null;
|
||||
resume: string | null;
|
||||
points_cles: string[];
|
||||
confiance: Confiance;
|
||||
source_seed: string | null;
|
||||
a_verifier: 0 | 1;
|
||||
motif_verification: string | null;
|
||||
derniere_verif: string | null;
|
||||
nb_sources: number;
|
||||
nb_evenements: number;
|
||||
devant_cc: 0 | 1;
|
||||
/** Extrait surligné, présent uniquement sur les résultats de recherche. */
|
||||
extrait?: string | null;
|
||||
}
|
||||
|
||||
export interface TexteDetaille extends Texte {
|
||||
sources: Source[];
|
||||
evenements: Evenement[];
|
||||
decisions_cc: DecisionCC[];
|
||||
}
|
||||
|
||||
export interface Echeance {
|
||||
id: number;
|
||||
code: string;
|
||||
date_echeance: string;
|
||||
libelle: string;
|
||||
description: string | null;
|
||||
portee: 'nationale' | 'texte';
|
||||
texte_id: string | null;
|
||||
concerne_guadeloupe: 0 | 1 | null;
|
||||
note_guadeloupe: string | null;
|
||||
previsionnel: 0 | 1;
|
||||
source_url: string | null;
|
||||
source_extrait: string | null;
|
||||
}
|
||||
|
||||
export interface Insight {
|
||||
numero: number;
|
||||
titre: string;
|
||||
corps: string;
|
||||
implications: string | null;
|
||||
confiance: string;
|
||||
derive_de: string[];
|
||||
}
|
||||
|
||||
export interface Facette {
|
||||
valeur: string;
|
||||
libelle: string;
|
||||
nombre: number;
|
||||
actif: boolean;
|
||||
}
|
||||
|
||||
export interface Filtres {
|
||||
q: string;
|
||||
statut: string[];
|
||||
theme: string[];
|
||||
type: string[];
|
||||
impact: string[];
|
||||
guadeloupe: string[];
|
||||
confiance: string[];
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
tri: 'date' | 'pertinence';
|
||||
page: number;
|
||||
}
|
||||
|
||||
export interface Resultats {
|
||||
textes: Texte[];
|
||||
total: number;
|
||||
page: number;
|
||||
pages: number;
|
||||
facettes: {
|
||||
statut: Facette[];
|
||||
theme: Facette[];
|
||||
type: Facette[];
|
||||
guadeloupe: Facette[];
|
||||
impact: Facette[];
|
||||
confiance: Facette[];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{page.status} · Veille législative Gwadloup</title>
|
||||
<meta name="robots" content="noindex" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="erreur">
|
||||
<p class="code">{page.status}</p>
|
||||
{#if page.status === 404}
|
||||
<h1>Pa ni ayen la</h1>
|
||||
<p class="explication">
|
||||
Cette page n'existe pas — ou n'existe plus. Le texte que vous cherchez a peut-être un
|
||||
autre identifiant.
|
||||
</p>
|
||||
{:else}
|
||||
<h1>Sa pa maché</h1>
|
||||
<p class="explication">{page.error?.message ?? 'Une erreur est survenue.'}</p>
|
||||
{/if}
|
||||
<div class="ligne actions">
|
||||
<a class="bouton" href="/"><span>Tableau de bord</span></a>
|
||||
<a class="bouton bouton-discret" href="/recherche"><span>Rechercher un texte</span></a>
|
||||
</div>
|
||||
<div class="flag-bar" aria-hidden="true"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.erreur {
|
||||
max-width: 48ch;
|
||||
margin: var(--space-5) auto;
|
||||
text-align: center;
|
||||
}
|
||||
.code {
|
||||
font-family: var(--font-display);
|
||||
font-size: 4rem;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
color: var(--or-oki);
|
||||
margin: 0;
|
||||
}
|
||||
h1 {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
.explication {
|
||||
color: var(--muted);
|
||||
}
|
||||
.actions {
|
||||
justify-content: center;
|
||||
margin-block: var(--space-3);
|
||||
}
|
||||
.flag-bar {
|
||||
max-width: 56px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Le rendu serveur est le défaut sur toute l'application.
|
||||
*
|
||||
* On ne pose JAMAIS `ssr = false` ici : le mode SPA est explicitement écarté
|
||||
* par l'architecture de rendu (§3bis), pour le référencement comme pour la
|
||||
* lisibilité sans JavaScript. Le prérendu est activé page par page, uniquement
|
||||
* sur celles dont le contenu est figé (`/a-propos`, `/methode`).
|
||||
*/
|
||||
export const prerender = false;
|
||||
@@ -0,0 +1,220 @@
|
||||
<script lang="ts">
|
||||
import '$lib/styles/oki-tokens.css';
|
||||
import '$lib/styles/base.css';
|
||||
import { page } from '$app/state';
|
||||
|
||||
const { children } = $props();
|
||||
|
||||
const NAVIGATION = [
|
||||
{ href: '/', libelle: 'Tableau de bord' },
|
||||
{ href: '/recherche', libelle: 'Recherche' },
|
||||
{ href: '/calendrier', libelle: 'Calendrier' },
|
||||
{ href: '/methode', libelle: 'Méthode' },
|
||||
{ href: '/a-propos', libelle: 'À propos' }
|
||||
];
|
||||
|
||||
const actif = (href: string) =>
|
||||
href === '/' ? page.url.pathname === '/' : page.url.pathname.startsWith(href);
|
||||
</script>
|
||||
|
||||
<a class="lien-evitement" href="#contenu">Aller au contenu</a>
|
||||
|
||||
<header class="entete-site">
|
||||
<div class="contenant barre">
|
||||
<a class="marque" href="/">
|
||||
<span class="monogramme" aria-hidden="true">VL</span>
|
||||
<span class="titre">
|
||||
<strong>Veille législative</strong>
|
||||
<small>Gwadloup · libertés, associations, entreprises</small>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<nav aria-label="Navigation principale">
|
||||
<ul>
|
||||
{#each NAVIGATION as entree (entree.href)}
|
||||
<li>
|
||||
<a href={entree.href} aria-current={actif(entree.href) ? 'page' : undefined}>
|
||||
{entree.libelle}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="bascule-theme"
|
||||
aria-label="Basculer entre thème sombre et thème clair"
|
||||
onclick={() => {
|
||||
const racine = document.documentElement;
|
||||
const clair = racine.classList.toggle('light-theme');
|
||||
try {
|
||||
localStorage.setItem('theme', clair ? 'clair' : 'sombre');
|
||||
} catch {
|
||||
/* stockage indisponible : le choix ne survivra pas au rechargement */
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">◐</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Signature de marque : une seule occurrence pleine largeur par écran. -->
|
||||
<div class="flag-bar"></div>
|
||||
</header>
|
||||
|
||||
<main id="contenu" class="contenant">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<footer class="pied-site">
|
||||
<div class="contenant">
|
||||
<p class="doctrine">
|
||||
Aucune donnée législative n'est inventée. Chaque fait porte sa source, sa date et son
|
||||
niveau de confiance. Ce qui n'est pas établi est marqué « à vérifier ».
|
||||
</p>
|
||||
<p class="federe">
|
||||
<span class="monogramme petit" aria-hidden="true">OKI</span>
|
||||
Un service libre opéré par
|
||||
<a href="https://o-k-i.net" rel="noopener">ORGANISATION KA INTERNATIONALE</a>
|
||||
· o-k-i.net
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.entete-site {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: var(--noir-oki);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.barre {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding-block: 0.7rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.marque {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
text-decoration: none;
|
||||
color: var(--blanc-creme);
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.monogramme {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 900;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--noir-oki);
|
||||
background: var(--or-oki);
|
||||
padding: 0.28em 0.45em;
|
||||
border-radius: var(--radius-sm);
|
||||
flex: none;
|
||||
}
|
||||
.monogramme.petit {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2em 0.35em;
|
||||
}
|
||||
|
||||
.titre {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.titre strong {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 0.98rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.titre small {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted-fort);
|
||||
}
|
||||
|
||||
nav ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
gap: 0.15rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
nav a {
|
||||
display: block;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.85rem;
|
||||
color: var(--blanc-creme);
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
nav a:hover {
|
||||
color: var(--or-oki);
|
||||
border-color: var(--line);
|
||||
}
|
||||
nav a[aria-current='page'] {
|
||||
color: var(--or-oki);
|
||||
border-color: color-mix(in srgb, var(--or-oki) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--or-oki) 10%, transparent);
|
||||
}
|
||||
|
||||
.bascule-theme {
|
||||
background: transparent;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--blanc-creme);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
padding: 0.35rem 0.55rem;
|
||||
}
|
||||
.bascule-theme:hover {
|
||||
color: var(--or-oki);
|
||||
border-color: var(--or-oki);
|
||||
}
|
||||
|
||||
main {
|
||||
min-height: 60vh;
|
||||
padding-block: var(--space-3) var(--space-5);
|
||||
}
|
||||
|
||||
.pied-site {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-block: var(--space-3);
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted-fort);
|
||||
}
|
||||
.doctrine {
|
||||
max-width: 62ch;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.federe {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.marque {
|
||||
margin-right: 0;
|
||||
}
|
||||
nav ul {
|
||||
gap: 0;
|
||||
}
|
||||
nav a {
|
||||
padding: 0.3rem 0.45rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
compteurs,
|
||||
derniersChangements,
|
||||
dernierRun,
|
||||
decisionsAttendues,
|
||||
insights,
|
||||
prochainesEcheances
|
||||
} from '$serveur/requetes';
|
||||
import { DATE_ARRETE_CORPUS } from '$serveur/db';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
/**
|
||||
* Tableau de bord — rendu serveur, sans prérendu.
|
||||
*
|
||||
* Les compteurs et les derniers changements doivent refléter le dernier
|
||||
* passage du pipeline sans qu'on ait à reconstruire le site.
|
||||
*/
|
||||
export const load: PageServerLoad = async () => ({
|
||||
compteurs: compteurs(),
|
||||
echeances: prochainesEcheances(45),
|
||||
decisionsAttendues: decisionsAttendues(),
|
||||
changements: derniersChangements(8),
|
||||
run: dernierRun(),
|
||||
insights: insights(),
|
||||
dateArrete: DATE_ARRETE_CORPUS
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
<script lang="ts">
|
||||
import BadgeStatut from '$composants/BadgeStatut.svelte';
|
||||
import { dateLongue, joursRestants } from '$lib/format';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
const { data }: { data: PageData } = $props();
|
||||
|
||||
// L'encart « point de veille » tourne sur les sept analyses transversales.
|
||||
let analyseCourante = $state(0);
|
||||
const analyse = $derived(data.insights[analyseCourante]);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Veille législative Gwadloup — tableau de bord</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Suivi des lois françaises et de leurs effets sur les libertés des individus, des associations et des entreprises, avec un zoom Guadeloupe."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<section class="hero">
|
||||
<h1>Ki lwa ka chanjé lavi an nou ?</h1>
|
||||
<p class="accroche">
|
||||
{data.compteurs.total} textes suivis, {data.compteurs.sources} sources vérifiables.
|
||||
Données arrêtées au {dateLongue(data.dateArrete)}.
|
||||
</p>
|
||||
|
||||
<form class="recherche" action="/recherche" method="get" role="search">
|
||||
<label class="visuellement-cache" for="q">Rechercher dans les textes suivis</label>
|
||||
<input
|
||||
id="q"
|
||||
name="q"
|
||||
type="search"
|
||||
placeholder="chlordécone, associations, facturation électronique…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button class="bouton" type="submit"><span>Rechercher</span></button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Où en sont les textes</h2>
|
||||
<div class="compteurs">
|
||||
{#each data.compteurs.parStatut as ligne (ligne.valeur)}
|
||||
<a class="compteur" href="/recherche?statut={ligne.valeur}">
|
||||
<strong>{ligne.nombre}</strong>
|
||||
<span>{ligne.libelle}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="ligne raccourcis">
|
||||
<a class="bouton bouton-discret" href="/recherche?statut=saisie_cc">
|
||||
<span>{data.compteurs.devantCc} devant le Conseil constitutionnel</span>
|
||||
</a>
|
||||
<a class="bouton bouton-discret" href="/recherche?guadeloupe=forte">
|
||||
<span>{data.compteurs.guadeloupeForte} de pertinence forte pour la Guadeloupe</span>
|
||||
</a>
|
||||
<a class="bouton bouton-discret" href="/recherche?confiance=low">
|
||||
<span>{data.compteurs.aVerifier} à vérifier</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="deux-colonnes">
|
||||
<section class="section">
|
||||
<h2>Prochaines échéances</h2>
|
||||
{#if data.echeances.length}
|
||||
<ol class="echeances">
|
||||
{#each data.echeances as echeance (echeance.code)}
|
||||
{@const jours = joursRestants(echeance.date_echeance, data.dateArrete)}
|
||||
<li class="carte echeance" class:gwada={echeance.concerne_guadeloupe === 1}>
|
||||
<div class="quand">
|
||||
<time datetime={echeance.date_echeance}>{dateLongue(echeance.date_echeance)}</time>
|
||||
{#if jours >= 0}<span class="dans">dans {jours} j</span>{/if}
|
||||
</div>
|
||||
<h3>
|
||||
{#if echeance.texte_id}
|
||||
<a href="/textes/{echeance.texte_id}">{echeance.libelle}</a>
|
||||
{:else}
|
||||
{echeance.libelle}
|
||||
{/if}
|
||||
</h3>
|
||||
{#if echeance.description}<p>{echeance.description}</p>{/if}
|
||||
{#if echeance.concerne_guadeloupe === 0}
|
||||
<p class="hors-gwada">Ne concerne pas la Guadeloupe.</p>
|
||||
{/if}
|
||||
{#if echeance.note_guadeloupe}
|
||||
<p class="note-gwada">{echeance.note_guadeloupe}</p>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
<p><a href="/calendrier">Voir tout le calendrier →</a></p>
|
||||
{:else}
|
||||
<p class="vide">Aucune échéance dans les 45 prochains jours.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<div class="colonne-secondaire">
|
||||
<section class="section">
|
||||
<h2>Point de veille</h2>
|
||||
{#if analyse}
|
||||
<article class="carte analyse">
|
||||
<h3>{analyse.titre}</h3>
|
||||
<p>{analyse.corps.slice(0, 460)}{analyse.corps.length > 460 ? '…' : ''}</p>
|
||||
{#if analyse.implications}
|
||||
<p class="implications"><strong>Implications —</strong> {analyse.implications}</p>
|
||||
{/if}
|
||||
<div class="ligne navigation-analyse">
|
||||
<span class="position">{analyseCourante + 1} / {data.insights.length}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="bouton bouton-discret"
|
||||
onclick={() =>
|
||||
(analyseCourante = (analyseCourante + 1) % data.insights.length)}
|
||||
>
|
||||
<span>Analyse suivante</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Décisions attendues</h2>
|
||||
<ul class="decisions">
|
||||
{#each data.decisionsAttendues as decision (decision.numero_affaire)}
|
||||
<li>
|
||||
<span class="affaire">{decision.numero_affaire}</span>
|
||||
{#if decision.texte_id}
|
||||
<a href="/textes/{decision.texte_id}">{decision.libelle}</a>
|
||||
{:else}
|
||||
<span>{decision.libelle}</span>
|
||||
{/if}
|
||||
{#if decision.date_echeance}
|
||||
<span class="quand-decision">→ {dateLongue(decision.date_echeance)}</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Derniers changements détectés</h2>
|
||||
{#if data.changements.length}
|
||||
<ul class="changements">
|
||||
{#each data.changements as changement, i (i)}
|
||||
<li>
|
||||
{#if changement.texte_id}
|
||||
<a href="/textes/{changement.texte_id}">{changement.titre ?? changement.texte_id}</a>
|
||||
{:else}
|
||||
<span>{changement.description}</span>
|
||||
{/if}
|
||||
{#if changement.champ}
|
||||
<span class="diff">
|
||||
{changement.champ} : {changement.ancienne_valeur ?? '—'} →
|
||||
<strong>{changement.nouvelle_valeur}</strong>
|
||||
</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="vide">
|
||||
Aucun changement enregistré. Le pipeline n'a pas encore tourné en mode écriture —
|
||||
lancer <code>make update</code>.
|
||||
</p>
|
||||
{/if}
|
||||
{#if data.run}
|
||||
<p class="run">
|
||||
Dernier passage : {data.run.horodatage} ({data.run.mode}),
|
||||
{data.run.ajouts} ajout(s), {data.run.modifications} modification(s).
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding-block: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.accroche {
|
||||
color: var(--muted);
|
||||
max-width: 60ch;
|
||||
}
|
||||
.recherche {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
max-width: 720px;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.recherche input {
|
||||
flex: 1;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--blanc-creme);
|
||||
font: inherit;
|
||||
padding: 0.6rem 0.8rem;
|
||||
}
|
||||
.recherche input:focus {
|
||||
border-color: var(--or-oki);
|
||||
}
|
||||
|
||||
.compteurs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.compteur {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
background: var(--card-bg);
|
||||
border: var(--border-card);
|
||||
border-left: 4px solid var(--or-oki);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--blanc-creme);
|
||||
text-decoration: none;
|
||||
}
|
||||
.compteur:hover {
|
||||
background: var(--card-bg-survol);
|
||||
}
|
||||
.compteur strong {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
color: var(--or-oki);
|
||||
}
|
||||
.compteur span {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.raccourcis {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.deux-colonnes {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.deux-colonnes {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.echeances {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.echeance {
|
||||
border-left-color: var(--muted-fort);
|
||||
}
|
||||
.echeance.gwada {
|
||||
border-left-color: var(--turquoise-caraibes);
|
||||
}
|
||||
.echeance h3 {
|
||||
font-size: 0.95rem;
|
||||
text-transform: none;
|
||||
margin: 0.2rem 0 0.3rem;
|
||||
}
|
||||
.echeance p {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
margin: 0 0 0.3rem;
|
||||
}
|
||||
.quand {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: baseline;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.quand time {
|
||||
color: var(--or-oki);
|
||||
font-weight: 600;
|
||||
}
|
||||
.dans {
|
||||
color: var(--muted-fort);
|
||||
}
|
||||
.hors-gwada {
|
||||
color: var(--rouge-oki) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
.note-gwada {
|
||||
color: var(--turquoise-caraibes) !important;
|
||||
}
|
||||
|
||||
.analyse h3 {
|
||||
text-transform: none;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.analyse p {
|
||||
font-size: 0.87rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.implications {
|
||||
border-left: 2px solid var(--or-oki);
|
||||
padding-left: 0.7rem;
|
||||
}
|
||||
.navigation-analyse {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.position {
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted-fort);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.decisions,
|
||||
.changements {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.decisions li,
|
||||
.changements li {
|
||||
padding-bottom: 0.45rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
.affaire {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--orange-flamme);
|
||||
flex: none;
|
||||
}
|
||||
.quand-decision,
|
||||
.diff {
|
||||
color: var(--muted-fort);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.vide,
|
||||
.run {
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted-fort);
|
||||
}
|
||||
code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
background: var(--card-bg);
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
<svelte:head>
|
||||
<title>À propos · Veille législative Gwadloup</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Qui édite cette veille législative, avec quels moyens, sous quelle licence, et comment la reprendre."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<h1>À propos</h1>
|
||||
|
||||
<div class="prose">
|
||||
<p class="chapeau">
|
||||
Cette plateforme suit la production législative française et ce qu'elle change aux libertés
|
||||
des individus, des associations et des entreprises — avec un zoom Guadeloupe et outre-mer.
|
||||
</p>
|
||||
|
||||
<h2>Pourquoi</h2>
|
||||
<p>
|
||||
Les textes qui restreignent ou étendent des libertés se lisent rarement dans un seul journal.
|
||||
Ils arrivent par morceaux : un article dans une loi de simplification, un cavalier censuré
|
||||
dans une loi de finances, un décret d'application six mois plus tard. Suivre cela demande du
|
||||
temps que peu d'associations et de petites structures ont.
|
||||
</p>
|
||||
<p>
|
||||
Cette veille rassemble ce travail au même endroit, avec les sources en clair, et signale
|
||||
explicitement ce qui n'est pas établi.
|
||||
</p>
|
||||
|
||||
<h2>Ce que la plateforme ne fait pas</h2>
|
||||
<ul>
|
||||
<li>Elle ne donne pas de conseil juridique. Un texte cité ici ne remplace pas un avocat.</li>
|
||||
<li>
|
||||
Elle n'invente rien. Quand une information manque, elle manque : la fiche le dit, plutôt
|
||||
que de combler le vide.
|
||||
</li>
|
||||
<li>
|
||||
Elle ne suit pas votre navigation. Aucun traceur, aucun cookie de mesure, aucune requête
|
||||
vers un service tiers — polices comprises.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Technique</h2>
|
||||
<p>
|
||||
Tout est libre et auto-hébergeable sur un serveur modeste : un pipeline Python qui lit les
|
||||
sources officielles, une base SQLite unique, une application SvelteKit rendue côté serveur.
|
||||
La recherche plein texte est assurée par SQLite lui-même. Aucun service externe n'est
|
||||
nécessaire au fonctionnement.
|
||||
</p>
|
||||
<p>
|
||||
Le code est sous licence AGPL. La sauvegarde consiste à copier un fichier.
|
||||
</p>
|
||||
|
||||
<h2>Reprendre les données</h2>
|
||||
<p>
|
||||
Une interface de programmation en lecture est ouverte, sans clé ni inscription :
|
||||
</p>
|
||||
<ul class="api">
|
||||
<li><code>GET /api/textes</code> — liste filtrable</li>
|
||||
<li><code>GET /api/textes/[identifiant]</code> — fiche complète avec sources</li>
|
||||
<li><code>GET /api/echeances</code> — calendrier</li>
|
||||
</ul>
|
||||
<p>
|
||||
Si vous réutilisez ces données, citez la source primaire plutôt que cette plateforme : elle
|
||||
n'est qu'un intermédiaire.
|
||||
</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
Une erreur, un texte manquant, une source cassée ? Signalez-le — une veille se corrige à
|
||||
plusieurs. <a href="mailto:cyber.mawonaj@gmail.com">cyber.mawonaj@gmail.com</a>
|
||||
</p>
|
||||
|
||||
<p class="lien-methode">
|
||||
<a class="bouton" href="/methode"><span>Lire la méthode en détail</span></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.prose {
|
||||
max-width: 68ch;
|
||||
}
|
||||
.chapeau {
|
||||
font-size: 1.05rem;
|
||||
color: var(--blanc-creme);
|
||||
}
|
||||
.prose h2 {
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--or-oki);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 0.35rem;
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.prose p,
|
||||
.prose li {
|
||||
color: var(--muted);
|
||||
}
|
||||
.prose ul {
|
||||
padding-left: 1.1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.prose li::marker {
|
||||
color: var(--or-oki);
|
||||
}
|
||||
code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.88em;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--line);
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.api {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.lien-methode {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Page figée : aucun contenu dynamique, donc exclue du manifeste du rendu
|
||||
* serveur et servie en statique (§3bis de l'architecture de rendu).
|
||||
*/
|
||||
export const prerender = true;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { calendrier, decisionsAttendues, prochainesEcheances } from '$serveur/requetes';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* `GET /api/echeances` — calendrier.
|
||||
*
|
||||
* `?jours=N` restreint aux N prochains jours ; sans paramètre, tout le
|
||||
* calendrier est rendu, décisions attendues comprises.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const jours = Number.parseInt(url.searchParams.get('jours') ?? '', 10);
|
||||
|
||||
return json(
|
||||
{
|
||||
echeances: Number.isFinite(jours) && jours > 0 ? prochainesEcheances(jours) : calendrier(),
|
||||
decisions_attendues: decisionsAttendues()
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'cache-control': 'public, max-age=300',
|
||||
'access-control-allow-origin': '*'
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { lireFiltres, rechercher } from '$serveur/requetes';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* `GET /api/textes` — liste filtrable.
|
||||
*
|
||||
* Mêmes paramètres que la page de recherche : `q`, `statut`, `theme`, `type`,
|
||||
* `impact`, `guadeloupe`, `confiance`, `from`, `to`, `tri`, `page`. Les
|
||||
* paramètres multivalués acceptent la répétition (`?theme=sante&theme=social`)
|
||||
* comme la virgule (`?theme=sante,social`).
|
||||
*
|
||||
* Ouverte sans clé : ces données sont publiques et proviennent de sources
|
||||
* publiques. Le cache est court — le pipeline peut écrire à tout moment.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const filtres = lireFiltres(url.searchParams);
|
||||
const resultats = rechercher(filtres);
|
||||
|
||||
return json(
|
||||
{
|
||||
filtres,
|
||||
total: resultats.total,
|
||||
page: resultats.page,
|
||||
pages: resultats.pages,
|
||||
textes: resultats.textes,
|
||||
facettes: resultats.facettes
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'cache-control': 'public, max-age=300',
|
||||
'access-control-allow-origin': '*'
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { texte } from '$serveur/requetes';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/** `GET /api/textes/[slug]` — fiche complète, sources et timeline comprises. */
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const fiche = texte(params.slug);
|
||||
if (!fiche) error(404, { message: `Texte inconnu : ${params.slug}` });
|
||||
|
||||
return json(fiche, {
|
||||
headers: {
|
||||
'cache-control': 'public, max-age=300',
|
||||
'access-control-allow-origin': '*'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { calendrier, decisionsAttendues } from '$serveur/requetes';
|
||||
import { DATE_ARRETE_CORPUS } from '$serveur/db';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
/** Calendrier — rendu serveur : les échéances vivent avec la base. */
|
||||
export const load: PageServerLoad = async () => {
|
||||
const echeances = calendrier();
|
||||
|
||||
// Regroupement par mois, dans l'ordre chronologique.
|
||||
const parMois = new Map<string, typeof echeances>();
|
||||
for (const echeance of echeances) {
|
||||
const cle = echeance.date_echeance.slice(0, 7);
|
||||
if (!parMois.has(cle)) parMois.set(cle, []);
|
||||
parMois.get(cle)!.push(echeance);
|
||||
}
|
||||
|
||||
return {
|
||||
mois: [...parMois.entries()].map(([cle, liste]) => ({ cle, echeances: liste })),
|
||||
decisions: decisionsAttendues(),
|
||||
dateArrete: DATE_ARRETE_CORPUS
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
<script lang="ts">
|
||||
import { dateLongue, joursRestants, mois as libelleMois } from '$lib/format';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
const { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Calendrier des échéances · Veille législative Gwadloup</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Décisions attendues du Conseil constitutionnel, entrées en vigueur du 1er septembre 2026, sénatoriales du 27 septembre, projet de loi de finances 2027."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<h1>Calendrier</h1>
|
||||
<p class="intro">
|
||||
Échéances de la fenêtre de veille. Les dates postérieures au
|
||||
{dateLongue(data.dateArrete)} sont <strong>prévisionnelles</strong> : elles indiquent ce qui est
|
||||
attendu, jamais ce qui est acquis.
|
||||
</p>
|
||||
|
||||
{#if data.decisions.length}
|
||||
<section class="section">
|
||||
<h2>Décisions attendues du Conseil constitutionnel</h2>
|
||||
<ul class="decisions">
|
||||
{#each data.decisions as decision (decision.numero_affaire)}
|
||||
<li class="carte">
|
||||
<span class="affaire">{decision.numero_affaire}</span>
|
||||
<div>
|
||||
{#if decision.texte_id}
|
||||
<a href="/textes/{decision.texte_id}">{decision.libelle}</a>
|
||||
{:else}
|
||||
<span>{decision.libelle}</span>
|
||||
{/if}
|
||||
{#if decision.date_echeance}
|
||||
<p class="quand">Attendue vers le {dateLongue(decision.date_echeance)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#each data.mois as bloc (bloc.cle)}
|
||||
<section class="section mois">
|
||||
<h2>{libelleMois(bloc.cle + '-01')}</h2>
|
||||
<ol class="liste">
|
||||
{#each bloc.echeances as echeance (echeance.code)}
|
||||
{@const jours = joursRestants(echeance.date_echeance, data.dateArrete)}
|
||||
<li
|
||||
class="carte entree"
|
||||
class:gwada={echeance.concerne_guadeloupe === 1}
|
||||
class:hors-gwada={echeance.concerne_guadeloupe === 0}
|
||||
class:passe={jours < 0}
|
||||
>
|
||||
<div class="date">
|
||||
<time datetime={echeance.date_echeance}>{dateLongue(echeance.date_echeance)}</time>
|
||||
{#if echeance.previsionnel === 1}
|
||||
<span class="prevu">prévisionnel</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="contenu">
|
||||
<h3>
|
||||
{#if echeance.texte_id}
|
||||
<a href="/textes/{echeance.texte_id}">{echeance.libelle}</a>
|
||||
{:else}
|
||||
{echeance.libelle}
|
||||
{/if}
|
||||
</h3>
|
||||
{#if echeance.description}<p>{echeance.description}</p>{/if}
|
||||
{#if echeance.concerne_guadeloupe === 0}
|
||||
<p class="mention-gwada negative">Ne concerne pas la Guadeloupe</p>
|
||||
{:else if echeance.concerne_guadeloupe === 1}
|
||||
<p class="mention-gwada positive">Concerne la Guadeloupe</p>
|
||||
{/if}
|
||||
{#if echeance.note_guadeloupe}<p class="note">{echeance.note_guadeloupe}</p>{/if}
|
||||
{#if echeance.source_extrait}
|
||||
<blockquote>{echeance.source_extrait}</blockquote>
|
||||
{/if}
|
||||
{#if echeance.source_url}
|
||||
<a class="source" href={echeance.source_url} rel="noopener nofollow" target="_blank">
|
||||
Source
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
<style>
|
||||
.intro {
|
||||
max-width: 70ch;
|
||||
color: var(--muted);
|
||||
}
|
||||
.mois h2,
|
||||
.section h2 {
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--or-oki);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 0.35rem;
|
||||
}
|
||||
.liste,
|
||||
.decisions {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.decisions li {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
align-items: baseline;
|
||||
border-left-color: var(--orange-flamme);
|
||||
}
|
||||
.affaire {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
color: var(--orange-flamme);
|
||||
flex: none;
|
||||
}
|
||||
.quand {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted-fort);
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
|
||||
.entree {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, auto) 1fr;
|
||||
gap: var(--space-2);
|
||||
border-left-color: var(--muted-fort);
|
||||
}
|
||||
.entree.gwada {
|
||||
border-left-color: var(--turquoise-caraibes);
|
||||
}
|
||||
.entree.hors-gwada {
|
||||
border-left-color: var(--rouge-oki);
|
||||
}
|
||||
.entree.passe {
|
||||
opacity: 0.65;
|
||||
}
|
||||
.date {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
time {
|
||||
color: var(--or-oki);
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.prevu {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted-fort);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0 0.35em;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.contenu h3 {
|
||||
font-size: 0.95rem;
|
||||
text-transform: none;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.contenu p {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
.mention-gwada {
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem !important;
|
||||
}
|
||||
.mention-gwada.negative {
|
||||
color: var(--rouge-oki) !important;
|
||||
}
|
||||
.mention-gwada.positive {
|
||||
color: var(--turquoise-caraibes) !important;
|
||||
}
|
||||
.note {
|
||||
color: var(--turquoise-caraibes) !important;
|
||||
}
|
||||
blockquote {
|
||||
margin: 0.4rem 0;
|
||||
padding-left: 0.8rem;
|
||||
border-left: 2px solid var(--line);
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted-fort);
|
||||
font-style: italic;
|
||||
}
|
||||
.source {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.entree {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,206 @@
|
||||
<svelte:head>
|
||||
<title>Méthode · Veille législative Gwadloup</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Comment cette veille distingue promulgué, adopté et en navette ; d'où viennent les données ; ce qu'elles ne disent pas."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<h1>Méthode</h1>
|
||||
|
||||
<div class="prose">
|
||||
<p class="chapeau">
|
||||
Une veille législative ne vaut que par ce qu'elle refuse d'affirmer. Cette page dit d'où
|
||||
viennent les données, comment elles sont qualifiées, et où sont leurs limites.
|
||||
</p>
|
||||
|
||||
<h2>La règle d'or : le statut prime sur l'intitulé</h2>
|
||||
<p>
|
||||
Un texte « voté » n'est pas un texte applicable. Entre le vote et l'entrée en vigueur, il
|
||||
peut être censuré, amputé, ou attendre des décrets pendant des mois. La confusion entre ces
|
||||
états est la première source d'erreur dans le débat public — et elle change tout pour qui
|
||||
veut savoir ce qui s'applique aujourd'hui.
|
||||
</p>
|
||||
|
||||
<dl class="statuts">
|
||||
<dt>Promulguée</dt>
|
||||
<dd>
|
||||
Le texte porte un numéro de loi et a été publié au Journal officiel. Il produit ses
|
||||
effets, sous réserve de ses propres dates d'entrée en vigueur différées.
|
||||
</dd>
|
||||
|
||||
<dt>Adoptée, non promulguée</dt>
|
||||
<dd>
|
||||
Le Parlement a voté définitivement, mais le texte n'est pas signé. Il n'a pas de numéro.
|
||||
Il ne s'applique pas. Une saisine du Conseil constitutionnel suspend la promulgation
|
||||
pendant un mois.
|
||||
</dd>
|
||||
|
||||
<dt>Devant le Conseil constitutionnel</dt>
|
||||
<dd>
|
||||
Une affaire est enregistrée et la décision n'est pas rendue. Le texte peut en sortir
|
||||
intact, amputé, ou annulé. Tant que la décision n'est pas rendue, son contenu applicable
|
||||
est <em>inconnu</em>.
|
||||
</dd>
|
||||
|
||||
<dt>Validée, en attente de promulgation</dt>
|
||||
<dd>
|
||||
Le Conseil a statué favorablement ; il ne manque que la signature. La promulgation est
|
||||
très probable mais pas encore intervenue.
|
||||
</dd>
|
||||
|
||||
<dt>En navette</dt>
|
||||
<dd>
|
||||
Le texte fait l'aller-retour entre l'Assemblée et le Sénat. Son contenu bouge encore.
|
||||
Rien de ce qu'il contient n'est acquis.
|
||||
</dd>
|
||||
|
||||
<dt>Déposée, non examinée · Annoncée</dt>
|
||||
<dd>
|
||||
Le texte existe sur le papier — ou seulement dans une déclaration. Il documente une
|
||||
intention politique, pas le droit positif.
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<h2>D'où viennent les données</h2>
|
||||
<p>
|
||||
Le socle est un corpus de recherche arrêté au <strong>25 juillet 2026</strong>, couvrant la
|
||||
production législative du 1<sup>er</sup> mai au 30 septembre 2026 : un rapport consolidé de
|
||||
onze chapitres, douze rapports de dimension thématique, une bibliographie de 481 références,
|
||||
et un fichier d'arbitrage des désaccords tranchés sur sources primaires.
|
||||
</p>
|
||||
<p>
|
||||
Chaque fait importé conserve sa citation d'origine — adresse, date, extrait verbatim — et son
|
||||
niveau de confiance. Les marqueurs de citation du rapport sont résolus vers leur source :
|
||||
aucune affirmation n'entre en base sans que l'on puisse remonter à ce qui l'atteste.
|
||||
</p>
|
||||
|
||||
<h2>Comment la base se met à jour</h2>
|
||||
<p>
|
||||
Un pipeline interroge régulièrement les sources officielles : Légifrance par son interface de
|
||||
programmation publique, la liste chronologique des lois promulguées du Sénat, et le registre
|
||||
des affaires du Conseil constitutionnel. Chaque source est interrogée séparément ; si l'une
|
||||
est indisponible, les autres continuent.
|
||||
</p>
|
||||
<p>
|
||||
Un texte collecté est rapproché de la base par son numéro officiel, à défaut par similarité
|
||||
de titre. Entre deux seuils, le rapprochement est <strong>signalé mais pas appliqué</strong> :
|
||||
mieux vaut une vérification humaine qu'une fusion erronée. En cas de contradiction entre
|
||||
sources, la source primaire l'emporte — Journal officiel et Légifrance devant les assemblées,
|
||||
les assemblées devant la presse.
|
||||
</p>
|
||||
|
||||
<h2>Ce qui est déduit, et signalé comme tel</h2>
|
||||
<p>
|
||||
Certaines informations ne figurent nulle part explicitement et sont déduites. Elles portent
|
||||
alors la mention <strong>« à vérifier »</strong> et un motif lisible sur la fiche :
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Pertinence Guadeloupe des textes non promulgués.</strong> Le rapport ne cote que
|
||||
les lois promulguées ; pour les autres, la cotation vient d'une détection de vocabulaire.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Sens des impacts par public.</strong> Le rapport consacre un chapitre aux
|
||||
individus, un aux associations, un aux entreprises : le placement d'un texte dans l'un de
|
||||
ces chapitres est un choix éditorial explicite, et sert de base à la ventilation. Le sens
|
||||
— favorable, défavorable, mixte — vient de la cotation manuelle quand elle existe, du
|
||||
vocabulaire sinon.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Thèmes.</strong> Déduits par mots-clés sur l'intitulé et le résumé.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Textes ajoutés par le pipeline.</strong> Ils entrent avec une confiance basse et
|
||||
le drapeau de revue : la machine propose, un humain valide.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Les échéances sont des prévisions</h2>
|
||||
<p>
|
||||
Toute date postérieure au 25 juillet 2026 est prévisionnelle, et affichée comme telle. Une
|
||||
décision « attendue vers le 24 août » peut tomber avant, après, ou être reportée. Les
|
||||
libellés du corpus — « fin août », « automne 2026 » — sont conservés tels quels ; la date
|
||||
associée ne sert qu'au tri et au filtrage.
|
||||
</p>
|
||||
|
||||
<h2>Limites connues</h2>
|
||||
<ul>
|
||||
<li>
|
||||
Le corpus s'arrête au 25 juillet 2026. Tout ce qui suit dépend des passages du pipeline.
|
||||
</li>
|
||||
<li>
|
||||
Certaines références du corpus n'ont pas d'adresse consultable — surtout dans la
|
||||
dimension consacrée aux associations. Elles sont conservées comme références
|
||||
bibliographiques, sans lien.
|
||||
</li>
|
||||
<li>
|
||||
Le contenu du titre « adaptation outre-mer » de la loi « Riposte » n'était pas public à
|
||||
la date d'arrêté. Les effets ultramarins de ce texte sont donc incomplets.
|
||||
</li>
|
||||
<li>
|
||||
La veille suit les lois, pas les décrets d'application. Or c'est souvent au niveau
|
||||
réglementaire que se jouent les effets concrets — les décrets chlordécone en sont
|
||||
l'exemple.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Corriger une erreur</h2>
|
||||
<p>
|
||||
Si une fiche est fausse, elle doit être corrigée, pas défendue.
|
||||
<a href="mailto:cyber.mawonaj@gmail.com">Signalez-la</a> en indiquant l'identifiant du texte
|
||||
et la source qui vous donne raison.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.prose {
|
||||
max-width: 70ch;
|
||||
}
|
||||
.chapeau {
|
||||
font-size: 1.05rem;
|
||||
color: var(--blanc-creme);
|
||||
}
|
||||
.prose h2 {
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--or-oki);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 0.35rem;
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.prose p,
|
||||
.prose li,
|
||||
.prose dd {
|
||||
color: var(--muted);
|
||||
}
|
||||
.prose ul {
|
||||
padding-left: 1.1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.prose li::marker {
|
||||
color: var(--or-oki);
|
||||
}
|
||||
.statuts {
|
||||
display: grid;
|
||||
gap: 0.3rem 0;
|
||||
margin: var(--space-2) 0;
|
||||
}
|
||||
.statuts dt {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--blanc-creme);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.statuts dd {
|
||||
margin: 0;
|
||||
padding-left: 0.9rem;
|
||||
border-left: 2px solid var(--line);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Page figée, prérendue au build — voir a-propos/+page.ts. */
|
||||
export const prerender = true;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { basculerFacette, lireFiltres, rechercher } from '$serveur/requetes';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
/**
|
||||
* Recherche à facettes — rendu serveur, jamais prérendue.
|
||||
*
|
||||
* Les facettes vivent dans `url.searchParams` : une URL de recherche est
|
||||
* partageable et se recharge à l'identique. Le prérendu interdit d'y accéder,
|
||||
* c'est pourquoi cette route reste en rendu serveur (§3bis).
|
||||
*/
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const filtres = lireFiltres(url.searchParams);
|
||||
const resultats = rechercher(filtres);
|
||||
|
||||
return {
|
||||
filtres,
|
||||
resultats,
|
||||
// Les liens de facette sont calculés côté serveur : la page fonctionne
|
||||
// entièrement sans JavaScript.
|
||||
liens: Object.fromEntries(
|
||||
(['statut', 'theme', 'type', 'impact', 'guadeloupe', 'confiance'] as const).map(
|
||||
(champ) => [
|
||||
champ,
|
||||
Object.fromEntries(
|
||||
resultats.facettes[champ === 'guadeloupe' ? 'guadeloupe' : champ].map((f) => [
|
||||
f.valeur,
|
||||
basculerFacette(filtres, champ, f.valeur)
|
||||
])
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,215 @@
|
||||
<script lang="ts">
|
||||
import CarteTexte from '$composants/CarteTexte.svelte';
|
||||
import PanneauFacettes from '$composants/PanneauFacettes.svelte';
|
||||
import { pluriel } from '$lib/format';
|
||||
import type { Filtres } from '$lib/types';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
const { data }: { data: PageData } = $props();
|
||||
|
||||
const lien = (champ: keyof Filtres, valeur: string) =>
|
||||
(data.liens as Record<string, Record<string, string>>)[champ as string]?.[valeur] ??
|
||||
'/recherche';
|
||||
|
||||
const chipsActifs = $derived(
|
||||
(['statut', 'guadeloupe', 'impact', 'theme', 'type', 'confiance'] as const).flatMap(
|
||||
(champ) =>
|
||||
data.resultats.facettes[champ]
|
||||
.filter((f) => f.actif)
|
||||
.map((f) => ({ champ, ...f }))
|
||||
)
|
||||
);
|
||||
|
||||
function urlPage(numero: number): string {
|
||||
const parametres = new URLSearchParams(
|
||||
typeof window === 'undefined' ? '' : window.location.search
|
||||
);
|
||||
parametres.set('page', String(numero));
|
||||
return `/recherche?${parametres.toString()}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>
|
||||
{data.filtres.q ? `« ${data.filtres.q} » — recherche` : 'Recherche'} · Veille législative
|
||||
</title>
|
||||
<meta name="robots" content="noindex, follow" />
|
||||
</svelte:head>
|
||||
|
||||
<h1>Recherche</h1>
|
||||
|
||||
<form class="recherche" action="/recherche" method="get" role="search">
|
||||
<label class="visuellement-cache" for="q">Rechercher</label>
|
||||
<input id="q" name="q" type="search" value={data.filtres.q} placeholder="Mot-clé, numéro de loi…" />
|
||||
{#each ['statut', 'theme', 'type', 'impact', 'guadeloupe', 'confiance'] as champ (champ)}
|
||||
{#each data.filtres[champ as keyof Filtres] as string[] as valeur (valeur)}
|
||||
<input type="hidden" name={champ} value={valeur} />
|
||||
{/each}
|
||||
{/each}
|
||||
<button class="bouton" type="submit"><span>Chercher</span></button>
|
||||
</form>
|
||||
|
||||
{#if chipsActifs.length}
|
||||
<div class="ligne chips">
|
||||
<span class="etiquette">Filtres actifs :</span>
|
||||
{#each chipsActifs as chip (chip.champ + chip.valeur)}
|
||||
<a class="chip" href={lien(chip.champ as keyof Filtres, chip.valeur)} rel="nofollow">
|
||||
{chip.libelle}
|
||||
<span aria-hidden="true">×</span>
|
||||
<span class="visuellement-cache">Retirer ce filtre</span>
|
||||
</a>
|
||||
{/each}
|
||||
<a class="chip effacer" href={data.filtres.q ? `/recherche?q=${encodeURIComponent(data.filtres.q)}` : '/recherche'}>
|
||||
Tout effacer
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mise-en-page">
|
||||
<PanneauFacettes facettes={data.resultats.facettes} filtres={data.filtres} {lien} />
|
||||
|
||||
<div class="resultats">
|
||||
<p class="total" role="status">
|
||||
<strong>{data.resultats.total}</strong>
|
||||
{pluriel(data.resultats.total, 'texte')}
|
||||
{#if data.filtres.q}pour « {data.filtres.q} »{/if}
|
||||
</p>
|
||||
|
||||
{#if data.resultats.textes.length}
|
||||
<div class="liste">
|
||||
{#each data.resultats.textes as texte (texte.id)}
|
||||
<CarteTexte {texte} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if data.resultats.pages > 1}
|
||||
<nav class="pagination" aria-label="Pagination">
|
||||
{#if data.resultats.page > 1}
|
||||
<a class="bouton bouton-discret" href={urlPage(data.resultats.page - 1)}>
|
||||
<span>← Précédent</span>
|
||||
</a>
|
||||
{/if}
|
||||
<span class="position">Page {data.resultats.page} sur {data.resultats.pages}</span>
|
||||
{#if data.resultats.page < data.resultats.pages}
|
||||
<a class="bouton bouton-discret" href={urlPage(data.resultats.page + 1)}>
|
||||
<span>Suivant →</span>
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="carte vide">
|
||||
<h2>Aucun résultat</h2>
|
||||
<p>
|
||||
Aucun texte ne correspond à cette combinaison de filtres. Essayez de retirer un
|
||||
critère, ou de chercher sur un mot plus général.
|
||||
</p>
|
||||
<a class="bouton" href="/recherche"><span>Repartir de zéro</span></a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.recherche {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: var(--space-2);
|
||||
max-width: 720px;
|
||||
}
|
||||
.recherche input[type='search'] {
|
||||
flex: 1;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--blanc-creme);
|
||||
font: inherit;
|
||||
padding: 0.55rem 0.8rem;
|
||||
}
|
||||
.recherche input:focus {
|
||||
border-color: var(--or-oki);
|
||||
}
|
||||
|
||||
.chips {
|
||||
margin-bottom: var(--space-2);
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.etiquette {
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted-fort);
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
background: color-mix(in srgb, var(--or-oki) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--or-oki) 55%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--blanc-creme);
|
||||
font-size: 0.78rem;
|
||||
padding: 0.18em 0.6em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.chip:hover {
|
||||
background: color-mix(in srgb, var(--or-oki) 22%, transparent);
|
||||
}
|
||||
.chip.effacer {
|
||||
background: transparent;
|
||||
border-color: var(--line);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.mise-en-page {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(210px, 260px) minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 820px) {
|
||||
.mise-en-page {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.total {
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.total strong {
|
||||
color: var(--or-oki);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.liste {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
.position {
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted-fort);
|
||||
}
|
||||
|
||||
.vide {
|
||||
text-align: center;
|
||||
padding: var(--space-4) var(--space-2);
|
||||
border-left-color: var(--muted-fort);
|
||||
}
|
||||
.vide h2 {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.vide p {
|
||||
color: var(--muted);
|
||||
max-width: 46ch;
|
||||
margin-inline: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { texte } from '$serveur/requetes';
|
||||
import { DATE_ARRETE_CORPUS } from '$serveur/db';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
/**
|
||||
* Fiche détaillée — rendu serveur.
|
||||
*
|
||||
* Pas de prérendu : la fiche doit être fraîche dès que le pipeline écrit dans
|
||||
* SQLite, sans reconstruction du site. C'est aussi la page la plus utile au
|
||||
* référencement, donc celle qui gagne le plus au rendu serveur.
|
||||
*/
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const fiche = texte(params.slug);
|
||||
if (!fiche) {
|
||||
error(404, `Aucun texte suivi ne porte l'identifiant « ${params.slug} ».`);
|
||||
}
|
||||
return { texte: fiche, dateArrete: DATE_ARRETE_CORPUS };
|
||||
};
|
||||
@@ -0,0 +1,571 @@
|
||||
<script lang="ts">
|
||||
import BadgeGuadeloupe from '$composants/BadgeGuadeloupe.svelte';
|
||||
import BadgeStatut from '$composants/BadgeStatut.svelte';
|
||||
import { dateLongue } from '$lib/format';
|
||||
import {
|
||||
LIBELLES_CONFIANCE,
|
||||
LIBELLES_ETAPE,
|
||||
LIBELLES_IMPACT,
|
||||
LIBELLES_RESULTAT_CC,
|
||||
LIBELLES_THEME,
|
||||
LIBELLES_TYPE
|
||||
} from '$lib/libelles';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
const { data }: { data: PageData } = $props();
|
||||
const t = $derived(data.texte);
|
||||
|
||||
const PUBLICS = ['individus', 'associations', 'entreprises'] as const;
|
||||
|
||||
let copie = $state(false);
|
||||
|
||||
function citation(): string {
|
||||
const nom = t.numero_officiel ? `Loi n° ${t.numero_officiel}` : t.titre_court;
|
||||
const date = t.date_promulgation
|
||||
? `, ${dateLongue(t.date_promulgation)}`
|
||||
: t.date_adoption
|
||||
? `, adoptée le ${dateLongue(t.date_adoption)}`
|
||||
: '';
|
||||
const url =
|
||||
typeof window === 'undefined' ? `/textes/${t.id}` : window.location.href;
|
||||
return `${nom} — ${t.titre_court}${date}. Veille législative Gwadloup, consulté le ${dateLongue(
|
||||
new Date().toISOString()
|
||||
)}. ${url}`;
|
||||
}
|
||||
|
||||
async function copierCitation() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(citation());
|
||||
copie = true;
|
||||
setTimeout(() => (copie = false), 2500);
|
||||
} catch {
|
||||
/* presse-papiers refusé : la citation reste sélectionnable à la main */
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{t.titre_court} · Veille législative Gwadloup</title>
|
||||
<meta name="description" content={(t.resume ?? t.titre_court).slice(0, 180)} />
|
||||
</svelte:head>
|
||||
|
||||
<nav class="fil" aria-label="Fil d'Ariane">
|
||||
<a href="/">Tableau de bord</a> ›
|
||||
<a href="/recherche">Recherche</a> ›
|
||||
<span aria-current="page">{t.numero_officiel ? `Loi n° ${t.numero_officiel}` : t.titre_court}</span>
|
||||
</nav>
|
||||
|
||||
<article class="fiche">
|
||||
<header>
|
||||
{#if t.numero_officiel}<p class="numero">Loi n° {t.numero_officiel}</p>{/if}
|
||||
<h1>{t.titre_court}</h1>
|
||||
{#if t.titre_officiel && t.titre_officiel !== t.titre_court}
|
||||
<p class="titre-officiel">{t.titre_officiel}</p>
|
||||
{/if}
|
||||
|
||||
<div class="ligne badges">
|
||||
<BadgeStatut statut={t.statut} devantCc={t.devant_cc === 1} />
|
||||
<BadgeGuadeloupe
|
||||
pertinence={t.guadeloupe_pertinence}
|
||||
note={t.guadeloupe_note}
|
||||
aVerifier={t.a_verifier === 1}
|
||||
/>
|
||||
<span class="tag">{LIBELLES_TYPE[t.type] ?? t.type}</span>
|
||||
<span class="tag confiance" data-niveau={t.confiance}>
|
||||
Confiance : {LIBELLES_CONFIANCE[t.confiance] ?? t.confiance}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if t.a_verifier === 1 && t.motif_verification}
|
||||
<aside class="avertissement" role="note">
|
||||
<strong>À vérifier —</strong>
|
||||
{t.motif_verification}
|
||||
</aside>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<div class="corps">
|
||||
<div class="principal">
|
||||
{#if t.resume}
|
||||
<section>
|
||||
<h2>Résumé</h2>
|
||||
<p class="resume">{t.resume}</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if t.points_cles.length}
|
||||
<section>
|
||||
<h2>Points clés</h2>
|
||||
<ul class="points">
|
||||
{#each t.points_cles as point (point)}
|
||||
<li>{point}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if Object.keys(t.impacts).length}
|
||||
<section>
|
||||
<h2>Effets sur les libertés</h2>
|
||||
<div class="impacts">
|
||||
{#each PUBLICS as pub (pub)}
|
||||
{@const impact = t.impacts[pub]}
|
||||
{#if impact}
|
||||
<div class="impact" data-sens={impact.sens}>
|
||||
<h3>{pub}</h3>
|
||||
<p class="sens">{LIBELLES_IMPACT[`${pub}:${impact.sens}`] ?? impact.sens}</p>
|
||||
{#if impact.note}<p class="note">{impact.note}</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if t.evenements.length}
|
||||
<section>
|
||||
<h2>Parcours du texte</h2>
|
||||
<ol class="timeline">
|
||||
{#each t.evenements as evenement (evenement.date_evenement + evenement.type_etape)}
|
||||
<li class:previsionnel={evenement.previsionnel === 1}>
|
||||
<time datetime={evenement.date_evenement}>
|
||||
{dateLongue(evenement.date_evenement)}
|
||||
</time>
|
||||
<div>
|
||||
<strong>{LIBELLES_ETAPE[evenement.type_etape] ?? evenement.type_etape}</strong>
|
||||
{#if evenement.previsionnel === 1}
|
||||
<span class="prevu">prévisionnel</span>
|
||||
{/if}
|
||||
<p>{evenement.description}</p>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if t.decisions_cc.length}
|
||||
<section>
|
||||
<h2>Conseil constitutionnel</h2>
|
||||
{#each t.decisions_cc as decision (decision.numero_affaire)}
|
||||
<div class="carte decision">
|
||||
<h3>Affaire n° {decision.numero_affaire}</h3>
|
||||
<dl>
|
||||
{#if decision.date_saisine}
|
||||
<dt>Saisine</dt>
|
||||
<dd>{dateLongue(decision.date_saisine)}</dd>
|
||||
{/if}
|
||||
{#if decision.date_decision}
|
||||
<dt>Décision</dt>
|
||||
<dd>{dateLongue(decision.date_decision)}</dd>
|
||||
{:else if decision.date_decision_attendue}
|
||||
<dt>Décision attendue</dt>
|
||||
<dd>{dateLongue(decision.date_decision_attendue)}</dd>
|
||||
{/if}
|
||||
{#if decision.resultat}
|
||||
<dt>Résultat</dt>
|
||||
<dd>{LIBELLES_RESULTAT_CC[decision.resultat] ?? decision.resultat}</dd>
|
||||
{/if}
|
||||
{#if decision.saisissants}
|
||||
<dt>Saisissants</dt>
|
||||
<dd>{decision.saisissants}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
{#if decision.resume}<p>{decision.resume}</p>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
<h2>Sources ({t.sources.length})</h2>
|
||||
<p class="avant-sources">
|
||||
Chaque source est citée telle qu'elle a été consultée. Les sources primaires
|
||||
(Journal officiel, assemblées, Conseil constitutionnel, Conseil d'État) sont
|
||||
marquées T1, la presse et les analyses T2.
|
||||
</p>
|
||||
<ol class="sources">
|
||||
{#each t.sources as source (source.url + (source.marqueur ?? ''))}
|
||||
<li class="carte source" data-tier={source.tier}>
|
||||
<div class="ligne entete-source">
|
||||
<span class="tier">{source.tier}</span>
|
||||
{#if source.editeur}<span class="editeur">{source.editeur}</span>{/if}
|
||||
{#if source.date_publication}
|
||||
<time datetime={source.date_publication}>
|
||||
{dateLongue(source.date_publication)}
|
||||
</time>
|
||||
{/if}
|
||||
</div>
|
||||
{#if source.titre}<p class="titre-source">{source.titre}</p>{/if}
|
||||
{#if source.extrait_verbatim}
|
||||
<blockquote>{source.extrait_verbatim}</blockquote>
|
||||
{/if}
|
||||
{#if source.contexte}<p class="contexte">{source.contexte}</p>{/if}
|
||||
<a class="url" href={source.url} rel="noopener nofollow" target="_blank">
|
||||
{source.url}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="lateral">
|
||||
<div class="carte encart">
|
||||
<h2>Repères</h2>
|
||||
<dl>
|
||||
{#if t.date_depot}
|
||||
<dt>Dépôt</dt>
|
||||
<dd>{dateLongue(t.date_depot)}</dd>
|
||||
{/if}
|
||||
{#if t.date_adoption}
|
||||
<dt>Adoption</dt>
|
||||
<dd>{dateLongue(t.date_adoption)}</dd>
|
||||
{/if}
|
||||
{#if t.date_promulgation}
|
||||
<dt>Promulgation</dt>
|
||||
<dd>{dateLongue(t.date_promulgation)}</dd>
|
||||
{/if}
|
||||
{#if t.date_entree_vigueur}
|
||||
<dt>Entrée en vigueur</dt>
|
||||
<dd>{dateLongue(t.date_entree_vigueur)}</dd>
|
||||
{/if}
|
||||
{#if t.prochaine_echeance_label}
|
||||
<dt>Prochaine étape</dt>
|
||||
<dd>{t.prochaine_echeance_label}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{#if t.guadeloupe_note}
|
||||
<div class="carte encart gwada">
|
||||
<h2>Guadeloupe</h2>
|
||||
<p>{t.guadeloupe_note}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if t.themes.length}
|
||||
<div class="carte encart">
|
||||
<h2>Thèmes</h2>
|
||||
<div class="ligne">
|
||||
{#each t.themes as theme (theme)}
|
||||
<a class="tag" href="/recherche?theme={theme}">
|
||||
{LIBELLES_THEME[theme] ?? theme}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="carte encart">
|
||||
<h2>Citer cette fiche</h2>
|
||||
<button type="button" class="bouton" onclick={copierCitation}>
|
||||
<span>{copie ? 'Copié' : 'Copier la citation'}</span>
|
||||
</button>
|
||||
{#if t.source_seed}
|
||||
<p class="provenance">Origine : <code>{t.source_seed}</code></p>
|
||||
{/if}
|
||||
{#if t.derniere_verif}
|
||||
<p class="provenance">Dernière vérification : {t.derniere_verif}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.fil {
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted-fort);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.fil a {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.numero {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--or-oki);
|
||||
margin: 0 0 0.2rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.titre-officiel {
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
max-width: 72ch;
|
||||
}
|
||||
.badges {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.confiance[data-niveau='low'] {
|
||||
border-color: color-mix(in srgb, var(--rouge-oki) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--rouge-oki) 10%, transparent);
|
||||
}
|
||||
|
||||
.avertissement {
|
||||
border: 1px solid color-mix(in srgb, var(--or-oki) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--or-oki) 9%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.7rem 0.9rem;
|
||||
font-size: 0.86rem;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.corps {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(240px, 300px);
|
||||
gap: var(--space-3);
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.corps {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
section h2 {
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--or-oki);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 0.35rem;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.resume {
|
||||
font-size: 1rem;
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
.points {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.92rem;
|
||||
max-width: 74ch;
|
||||
}
|
||||
.points li::marker {
|
||||
color: var(--or-oki);
|
||||
}
|
||||
|
||||
.impacts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 0.7rem;
|
||||
}
|
||||
.impact {
|
||||
border: var(--border-card);
|
||||
border-left: 4px solid var(--impact-neutre);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.7rem 0.85rem;
|
||||
background: var(--card-bg);
|
||||
}
|
||||
.impact[data-sens='positif'] {
|
||||
border-left-color: var(--impact-positif);
|
||||
}
|
||||
.impact[data-sens='negatif'] {
|
||||
border-left-color: var(--impact-negatif);
|
||||
}
|
||||
.impact[data-sens='mixte'] {
|
||||
border-left-color: var(--impact-mixte);
|
||||
}
|
||||
.impact h3 {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted-fort);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.impact .sens {
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.impact .note {
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0 0 1rem;
|
||||
border-left: 2px solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.timeline li {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, auto) 1fr;
|
||||
gap: 0.8rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.timeline li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -1.35rem;
|
||||
top: 0.4rem;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--or-oki);
|
||||
border-radius: 1px;
|
||||
}
|
||||
.timeline li.previsionnel::before {
|
||||
background: transparent;
|
||||
border: 2px solid var(--or-oki);
|
||||
}
|
||||
.timeline time {
|
||||
color: var(--or-oki);
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.timeline p {
|
||||
margin: 0.15rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.prevu {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted-fort);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0 0.35em;
|
||||
margin-left: 0.35em;
|
||||
}
|
||||
|
||||
.decision {
|
||||
border-left-color: var(--orange-flamme);
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.decision h3 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9rem;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.25rem 0.8rem;
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
dt {
|
||||
color: var(--muted-fort);
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.avant-sources {
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted-fort);
|
||||
max-width: 68ch;
|
||||
}
|
||||
.sources {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.source {
|
||||
border-left-color: var(--muted-fort);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
.source[data-tier='T1'] {
|
||||
border-left-color: var(--vert-oki);
|
||||
}
|
||||
.entete-source {
|
||||
gap: 0.5rem;
|
||||
font-size: 0.76rem;
|
||||
color: var(--muted-fort);
|
||||
}
|
||||
.tier {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
color: var(--vert-oki);
|
||||
}
|
||||
.source[data-tier='T2'] .tier {
|
||||
color: var(--muted-fort);
|
||||
}
|
||||
.editeur {
|
||||
font-weight: 600;
|
||||
color: var(--blanc-creme);
|
||||
}
|
||||
.titre-source {
|
||||
margin: 0.3rem 0;
|
||||
}
|
||||
blockquote {
|
||||
margin: 0.4rem 0;
|
||||
padding-left: 0.8rem;
|
||||
border-left: 2px solid var(--or-oki);
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.contexte {
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted-fort);
|
||||
margin: 0.3rem 0;
|
||||
}
|
||||
.url {
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.lateral {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
position: sticky;
|
||||
top: 5.5rem;
|
||||
}
|
||||
.encart h2 {
|
||||
font-size: 0.75rem;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.encart.gwada {
|
||||
border-left-color: var(--turquoise-caraibes);
|
||||
}
|
||||
.encart p {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
.provenance {
|
||||
font-size: 0.74rem !important;
|
||||
color: var(--muted-fort) !important;
|
||||
margin-top: 0.5rem !important;
|
||||
}
|
||||
code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.lateral {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.timeline li {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
/**
|
||||
* Service worker minimal — consultation hors ligne des dernières données.
|
||||
*
|
||||
* Deux stratégies, et pas une de plus :
|
||||
*
|
||||
* - **Le coffre du build** (JS, CSS, polices, icônes) est mis en cache à
|
||||
* l'installation et servi depuis le cache. Ces fichiers portent une empreinte
|
||||
* dans leur nom : ils ne changent jamais sous le même nom.
|
||||
* - **Les pages et l'API** passent par le réseau d'abord, avec repli sur le
|
||||
* cache. Une veille législative doit montrer l'état le plus récent qu'elle
|
||||
* peut atteindre ; servir une page périmée alors que le réseau répond serait
|
||||
* trompeur sur un sujet où une date de promulgation change tout.
|
||||
*
|
||||
* Le hors-ligne n'est donc pas un mode de consultation normal : c'est un filet
|
||||
* pour les connexions instables, fréquentes en zone rurale guadeloupéenne. La
|
||||
* page servie depuis le cache le signale à l'utilisateur via l'en-tête ajouté
|
||||
* `X-Depuis-Cache`.
|
||||
*/
|
||||
import { build, files, version } from '$service-worker';
|
||||
|
||||
const sw = self as unknown as ServiceWorkerGlobalScope;
|
||||
|
||||
const CACHE_COFFRE = `coffre-${version}`;
|
||||
const CACHE_PAGES = `pages-${version}`;
|
||||
|
||||
// Les polices et le manifeste valent d'être précachés ; pas les gros binaires.
|
||||
const A_PRECACHER = [...build, ...files.filter((f) => !f.endsWith('.map'))];
|
||||
|
||||
sw.addEventListener('install', (evenement) => {
|
||||
evenement.waitUntil(
|
||||
caches.open(CACHE_COFFRE).then((cache) => cache.addAll(A_PRECACHER))
|
||||
);
|
||||
// Le nouveau worker prend la main sans attendre la fermeture des onglets :
|
||||
// une correction de données ne doit pas rester bloquée derrière un onglet
|
||||
// ouvert depuis une semaine.
|
||||
sw.skipWaiting();
|
||||
});
|
||||
|
||||
sw.addEventListener('activate', (evenement) => {
|
||||
evenement.waitUntil(
|
||||
(async () => {
|
||||
const noms = await caches.keys();
|
||||
await Promise.all(
|
||||
noms
|
||||
.filter((nom) => nom !== CACHE_COFFRE && nom !== CACHE_PAGES)
|
||||
.map((nom) => caches.delete(nom))
|
||||
);
|
||||
await sw.clients.claim();
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
sw.addEventListener('fetch', (evenement) => {
|
||||
const requete = evenement.request;
|
||||
if (requete.method !== 'GET') return;
|
||||
|
||||
const url = new URL(requete.url);
|
||||
// Rien qui ne vienne de cette origine : la politique de sécurité de contenu
|
||||
// interdit déjà les tiers, le worker ne les mettra pas en cache non plus.
|
||||
if (url.origin !== location.origin) return;
|
||||
|
||||
// Coffre du build : cache d'abord, sans condition.
|
||||
if (A_PRECACHER.includes(url.pathname)) {
|
||||
evenement.respondWith(
|
||||
caches
|
||||
.open(CACHE_COFFRE)
|
||||
.then((cache) => cache.match(url.pathname))
|
||||
.then((reponse) => reponse ?? fetch(requete))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pages et API : réseau d'abord, cache en repli.
|
||||
evenement.respondWith(reseauPuisCache(requete));
|
||||
});
|
||||
|
||||
async function reseauPuisCache(requete: Request): Promise<Response> {
|
||||
const cache = await caches.open(CACHE_PAGES);
|
||||
|
||||
try {
|
||||
const reponse = await fetch(requete);
|
||||
if (reponse.ok) {
|
||||
cache.put(requete, reponse.clone());
|
||||
}
|
||||
return reponse;
|
||||
} catch {
|
||||
const enCache = await cache.match(requete);
|
||||
if (enCache) {
|
||||
// On signale explicitement que la réponse peut être périmée.
|
||||
const entetes = new Headers(enCache.headers);
|
||||
entetes.set('X-Depuis-Cache', 'true');
|
||||
return new Response(enCache.body, {
|
||||
status: enCache.status,
|
||||
statusText: enCache.statusText,
|
||||
headers: entetes
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(
|
||||
`<!doctype html><html lang="fr"><head><meta charset="utf-8">
|
||||
<title>Hors ligne</title>
|
||||
<style>
|
||||
body{background:#0D0D0D;color:#FFF8E7;font-family:system-ui,sans-serif;
|
||||
display:grid;place-items:center;min-height:100vh;margin:0;text-align:center;padding:2rem}
|
||||
h1{font-size:1.4rem;text-transform:uppercase;letter-spacing:-0.01em}
|
||||
p{color:#B6B1A6;max-width:40ch}
|
||||
.bar{height:6px;width:56px;margin:1.5rem auto 0;background:linear-gradient(to right,
|
||||
#0D0D0D 0 25%,#FDB813 25% 50%,#00D66C 50% 75%,#FF1654 75% 100%)}
|
||||
</style></head><body><div>
|
||||
<h1>Pa ni rézo</h1>
|
||||
<p>Cette page n'a pas encore été consultée et le réseau est indisponible.
|
||||
Les pages déjà visitées restent accessibles.</p>
|
||||
<div class="bar"></div>
|
||||
</div></body></html>`,
|
||||
{ status: 503, headers: { 'content-type': 'text/html; charset=utf-8' } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Veille législative Gwadloup">
|
||||
<rect width="64" height="64" fill="#0D0D0D"/>
|
||||
<rect x="0" y="52" width="16" height="12" fill="#0D0D0D"/>
|
||||
<rect x="16" y="52" width="16" height="12" fill="#FDB813"/>
|
||||
<rect x="32" y="52" width="16" height="12" fill="#00D66C"/>
|
||||
<rect x="48" y="52" width="16" height="12" fill="#FF1654"/>
|
||||
<path d="M14 12 L24 44 L34 12" fill="none" stroke="#FDB813" stroke-width="7" stroke-linejoin="miter"/>
|
||||
<rect x="40" y="12" width="7" height="32" fill="#FDB813"/>
|
||||
<rect x="40" y="37" width="14" height="7" fill="#FDB813"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 643 B |
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Veille législative Gwadloup",
|
||||
"short_name": "Vèy Lwa",
|
||||
"description": "Lois françaises et effets sur les libertés des individus, des associations et des entreprises — zoom Guadeloupe.",
|
||||
"lang": "fr",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"background_color": "#0D0D0D",
|
||||
"theme_color": "#0D0D0D",
|
||||
"icons": [
|
||||
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
|
||||
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "maskable" }
|
||||
],
|
||||
"categories": ["news", "government", "reference"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Applique le thème choisi avant le premier rendu.
|
||||
*
|
||||
* Le thème sombre est l'identité par défaut : on n'ajoute la classe que si le
|
||||
* visiteur a explicitement demandé le clair, ou si son système le préfère sans
|
||||
* qu'il ait exprimé de choix.
|
||||
*/
|
||||
(function () {
|
||||
try {
|
||||
var choix = localStorage.getItem('theme');
|
||||
var systemeClair =
|
||||
window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
|
||||
if (choix === 'clair' || (choix === null && systemeClair)) {
|
||||
document.documentElement.classList.add('light-theme');
|
||||
}
|
||||
} catch (e) {
|
||||
/* stockage indisponible : on garde le thème sombre par défaut */
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,39 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/**
|
||||
* Architecture de rendu : « transitional app » en rendu serveur sur
|
||||
* adapter-node, avec prérendu ciblé des seules pages figées.
|
||||
*
|
||||
* Le rendu serveur est le défaut partout : les fiches doivent être fraîches dès
|
||||
* que le pipeline écrit dans SQLite, et les facettes de recherche vivent dans
|
||||
* `url.searchParams`, ce que le prérendu interdit.
|
||||
*
|
||||
* `precompress` génère les variantes .gz et .br au build ; la négociation est
|
||||
* faite par le reverse proxy.
|
||||
*/
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({ precompress: true }),
|
||||
alias: {
|
||||
$composants: 'src/lib/composants',
|
||||
$serveur: 'src/lib/server'
|
||||
},
|
||||
csp: {
|
||||
// Aucune ressource tierce : ni police, ni script, ni image externe.
|
||||
directives: {
|
||||
'default-src': ['self'],
|
||||
'img-src': ['self', 'data:'],
|
||||
'style-src': ['self', 'unsafe-inline'],
|
||||
'font-src': ['self'],
|
||||
'connect-src': ['self'],
|
||||
'frame-ancestors': ['none'],
|
||||
'base-uri': ['self'],
|
||||
'form-action': ['self']
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
// better-sqlite3 est un module natif : il ne doit jamais être empaqueté.
|
||||
ssr: { external: ['better-sqlite3'] },
|
||||
build: { target: 'es2022' }
|
||||
});
|
||||
Reference in New Issue
Block a user