feat(app) : phase 1 — registre, profils, moteur de recommandation, export markdown
- Schémas Zod partagés ($lib/schemas) : modèle, profil (PRD §4), null = « à vérifier », sources[] obligatoires à chaque écriture - Persistance YAML dans DATA_DIR + commits git automatiques (simple-git) ; seeds PRD au premier démarrage (1 modèle §4.1, 5 profils, 2 templates) - CRUD registre et profils : pages liste/édition/création, form actions, champs tri-état, messages d'erreur Zod, commit par écriture - Moteur de recommandation pur et testé (12 cas : nominal, licence null, seuil, statut mort, vram, modalité, fraîcheur, pénalités) : filtrage dur (requiert/exclut/obligatoire/mort) puis scoring pondéré (préférences, rang arena, fraîcheur, pénalités statut et champs null), justification et warnings (attribution, seuil, garde-fous, déclarations plateformes) - POST /api/recommander (SSO requis) + page /recommander - Exporteur markdown /exports : templates éditables, génération depuis le registre (tableaux registre + classements), commit par document - Vérifié : check/lint/test (22) /build verts, test fumée HTTP complet (seed + git init, CRUD par form action, exports générés, filtres API, 401/404), svelte-autofixer propre sur les 11 composants
This commit is contained in:
@@ -9,12 +9,13 @@
|
||||
let { data, children }: LayoutProps = $props();
|
||||
|
||||
// Navigation privée : visible uniquement avec un utilisateur SSO.
|
||||
// Les entrées "bientot" correspondent aux phases 1 et 2 du PRD.
|
||||
// L'entrée "bientot" correspond à la phase 2 du PRD.
|
||||
const entrees = [
|
||||
{ href: '/', libelle: fr.nav.accueil, icone: 'accueil', bientot: false },
|
||||
{ href: '/registre', libelle: fr.nav.registre, icone: 'registre', bientot: true },
|
||||
{ href: '/profils', libelle: fr.nav.profils, icone: 'profils', bientot: true },
|
||||
{ href: '/recommander', libelle: fr.nav.recommander, icone: 'cible', bientot: true },
|
||||
{ href: '/registre', libelle: fr.nav.registre, icone: 'registre', bientot: false },
|
||||
{ href: '/profils', libelle: fr.nav.profils, icone: 'profils', bientot: false },
|
||||
{ href: '/recommander', libelle: fr.nav.recommander, icone: 'cible', bientot: false },
|
||||
{ href: '/exports', libelle: fr.nav.exports, icone: 'registre', bientot: false },
|
||||
{ href: '/alertes', libelle: fr.nav.alertes, icone: 'alerte', bientot: true }
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
@@ -10,22 +10,26 @@
|
||||
{
|
||||
titre: fr.accueil.carte_registre_titre,
|
||||
texte: fr.accueil.carte_registre_texte,
|
||||
icone: 'registre'
|
||||
icone: 'registre',
|
||||
href: '/registre'
|
||||
},
|
||||
{
|
||||
titre: fr.accueil.carte_profils_titre,
|
||||
texte: fr.accueil.carte_profils_texte,
|
||||
icone: 'profils'
|
||||
icone: 'profils',
|
||||
href: '/profils'
|
||||
},
|
||||
{
|
||||
titre: fr.accueil.carte_recommander_titre,
|
||||
texte: fr.accueil.carte_recommander_texte,
|
||||
icone: 'cible'
|
||||
icone: 'cible',
|
||||
href: '/recommander'
|
||||
},
|
||||
{
|
||||
titre: fr.accueil.carte_alertes_titre,
|
||||
texte: fr.accueil.carte_alertes_texte,
|
||||
icone: 'alerte'
|
||||
icone: 'alerte',
|
||||
href: null
|
||||
}
|
||||
] as const;
|
||||
</script>
|
||||
@@ -44,10 +48,16 @@
|
||||
<article class="carte">
|
||||
<h2>
|
||||
<Icon nom={carte.icone} taille={22} />
|
||||
{carte.titre}
|
||||
{#if carte.href}
|
||||
<a class="carte-lien" href={resolve(carte.href)}>{carte.titre}</a>
|
||||
{:else}
|
||||
{carte.titre}
|
||||
{/if}
|
||||
</h2>
|
||||
<p>{carte.texte}</p>
|
||||
<span class="tag">{fr.accueil.phase_a_venir}</span>
|
||||
{#if !carte.href}
|
||||
<span class="tag">{fr.accueil.phase_a_venir}</span>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { modaliteSchema } from '$lib/schemas';
|
||||
import { recommander } from '$lib/server/recommandation';
|
||||
import { lireProfils, lireRegistre } from '$lib/server/stockage';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* POST /api/recommander (PRD §4.3)
|
||||
* Corps JSON : { "profil_id": "vn_renpy_adulte", "filtres": { "modalite"?, "vram_max"?, "budget"? } }
|
||||
* Authentification : SSO requis (hooks.server — route non publique).
|
||||
*/
|
||||
|
||||
const corpsSchema = z.object({
|
||||
profil_id: z.string().min(1),
|
||||
filtres: z
|
||||
.object({
|
||||
modalite: modaliteSchema.optional(),
|
||||
vram_max: z.number().positive().optional(),
|
||||
// Réservé — aucune donnée de prix n'est stockée par conception.
|
||||
budget: z.literal(0).optional()
|
||||
})
|
||||
.optional()
|
||||
.default({})
|
||||
});
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
let corpsBrut: unknown;
|
||||
try {
|
||||
corpsBrut = await request.json();
|
||||
} catch {
|
||||
error(400, 'Corps JSON invalide');
|
||||
}
|
||||
|
||||
const corps = corpsSchema.safeParse(corpsBrut);
|
||||
if (!corps.success) {
|
||||
error(400, `Requête invalide : ${corps.error.issues.map((i) => i.message).join(', ')}`);
|
||||
}
|
||||
|
||||
const profils = await lireProfils();
|
||||
const profil = profils.find((p) => p.id === corps.data.profil_id);
|
||||
if (!profil) error(404, `Profil « ${corps.data.profil_id} » introuvable`);
|
||||
|
||||
const registre = await lireRegistre();
|
||||
return json(recommander(registre, profil, corps.data.filtres));
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { genererDocument } from '$lib/server/exportateur';
|
||||
import {
|
||||
ecrireExport,
|
||||
ecrireTemplate,
|
||||
lireExport,
|
||||
lireRegistre,
|
||||
lireTemplate,
|
||||
listerExports
|
||||
} from '$lib/server/stockage';
|
||||
import { chaineOuNull } from '$lib/server/formulaires';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const NOMS_TEMPLATES = ['guide_licences.md', 'classements.md'] as const;
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const templates: Record<string, string> = {};
|
||||
for (const nom of NOMS_TEMPLATES) {
|
||||
templates[nom] = await lireTemplate(nom);
|
||||
}
|
||||
|
||||
const nomsExports = await listerExports();
|
||||
const exports: Record<string, string> = {};
|
||||
for (const nom of nomsExports) {
|
||||
exports[nom] = await lireExport(nom);
|
||||
}
|
||||
|
||||
return { templates, exports };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
enregistrerTemplate: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const nom = chaineOuNull(data.get('nom'));
|
||||
const contenu = data.get('contenu');
|
||||
if (
|
||||
!nom ||
|
||||
!(NOMS_TEMPLATES as readonly string[]).includes(nom) ||
|
||||
typeof contenu !== 'string'
|
||||
) {
|
||||
return fail(400, { erreur: 'Template invalide.' });
|
||||
}
|
||||
await ecrireTemplate(nom, contenu, `feat : mise à jour du template ${nom}`);
|
||||
return { templateEnregistre: nom };
|
||||
},
|
||||
|
||||
generer: async () => {
|
||||
const registre = await lireRegistre();
|
||||
const maintenant = new Date();
|
||||
for (const nom of NOMS_TEMPLATES) {
|
||||
const template = await lireTemplate(nom);
|
||||
const document = genererDocument(template, registre, maintenant);
|
||||
await ecrireExport(nom, document, `docs : régénération de ${nom} depuis le registre`);
|
||||
}
|
||||
return { genere: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { fr } from '$lib/i18n/fr';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
const nomsExports = $derived(Object.keys(data.exports));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{fr.exports.titre} — {fr.app.nom}</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>{fr.exports.titre}</h1>
|
||||
<p class="intro">{fr.exports.intro}</p>
|
||||
<p class="aide">{fr.exports.tokens_aide}</p>
|
||||
|
||||
<section aria-labelledby="titre-templates">
|
||||
<h2 id="titre-templates">{fr.exports.section_templates}</h2>
|
||||
{#each Object.entries(data.templates) as [nom, contenu] (nom)}
|
||||
<form method="post" action="?/enregistrerTemplate" use:enhance class="template">
|
||||
<h3>{nom}</h3>
|
||||
<input type="hidden" name="nom" value={nom} />
|
||||
<textarea name="contenu" rows="10" spellcheck="false">{contenu}</textarea>
|
||||
<button class="bouton" type="submit">{fr.exports.enregistrer_template}</button>
|
||||
{#if form && 'templateEnregistre' in form && form.templateEnregistre === nom}
|
||||
<span class="tag tag-succes">{fr.formulaires.enregistrer} ✓</span>
|
||||
{/if}
|
||||
</form>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="titre-generes">
|
||||
<div class="titre-section">
|
||||
<h2 id="titre-generes">{fr.exports.section_generes}</h2>
|
||||
<form method="post" action="?/generer" use:enhance>
|
||||
<button class="bouton" type="submit">{fr.exports.generer}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{#if nomsExports.length === 0}
|
||||
<p>{fr.exports.aucun_export}</p>
|
||||
{:else}
|
||||
{#each nomsExports as nom (nom)}
|
||||
<details class="export">
|
||||
<summary>{nom}</summary>
|
||||
<pre>{data.exports[nom]}</pre>
|
||||
</details>
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-block: var(--space-3) var(--space-2);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.intro {
|
||||
color: var(--muted);
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
.aide {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.template {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.template textarea {
|
||||
width: 100%;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--fg);
|
||||
padding: 0.75em;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.titre-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.export {
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
align-content: center;
|
||||
color: var(--action);
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--surface);
|
||||
border: var(--border-card);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2);
|
||||
overflow-x: auto;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { lireProfils } from '$lib/server/stockage';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const profils = await lireProfils();
|
||||
return { profils };
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { fr } from '$lib/i18n/fr';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{fr.profils.titre} — {fr.app.nom}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="titre-page">
|
||||
<h1>{fr.profils.titre}</h1>
|
||||
<a class="bouton" href={resolve('/profils/nouveau')}>{fr.profils.nouveau}</a>
|
||||
</div>
|
||||
|
||||
{#if data.profils.length === 0}
|
||||
<p>{fr.profils.aucun}</p>
|
||||
{:else}
|
||||
<div class="grille">
|
||||
{#each data.profils as profil (profil.id)}
|
||||
<article class="carte">
|
||||
<h2>{profil.nom}</h2>
|
||||
{#if profil.requiert.length > 0}
|
||||
<p>
|
||||
<strong>{fr.profils.legende_requiert} :</strong>
|
||||
{profil.requiert.map((c) => fr.formulaires.cles[c]).join(', ')}
|
||||
</p>
|
||||
{/if}
|
||||
{#if profil.exclut.length > 0}
|
||||
<p>
|
||||
<strong>{fr.profils.legende_exclut} :</strong>
|
||||
{profil.exclut.map((c) => fr.formulaires.cles[c]).join(', ')}
|
||||
</p>
|
||||
{/if}
|
||||
{#if profil.contraintes_specifiques}
|
||||
<p class="contraintes">{profil.contraintes_specifiques}</p>
|
||||
{/if}
|
||||
<a href={resolve(`/profils/${profil.id}`)}>{fr.profils.modifier}</a>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.titre-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.carte h2 {
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.carte p {
|
||||
color: var(--muted);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.contraintes {
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { profilSchema } from '$lib/schemas';
|
||||
import { erreursZod, profilDepuisFormulaire } from '$lib/server/formulaires';
|
||||
import { ecrireProfils, lireProfils } from '$lib/server/stockage';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const profils = await lireProfils();
|
||||
const profil = profils.find((p) => p.id === params.id);
|
||||
if (!profil) error(404, 'Profil introuvable');
|
||||
return { profil };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
enregistrer: async ({ request, params }) => {
|
||||
const data = await request.formData();
|
||||
const brut = profilDepuisFormulaire(data);
|
||||
// L'identifiant d'un profil existant n'est pas modifiable (champ readonly).
|
||||
const resultat = profilSchema.safeParse({ ...(brut as object), id: params.id });
|
||||
if (!resultat.success) {
|
||||
return fail(400, { erreurs: erreursZod(resultat.error), brut });
|
||||
}
|
||||
|
||||
const profils = await lireProfils();
|
||||
const index = profils.findIndex((p) => p.id === params.id);
|
||||
if (index === -1) error(404, 'Profil introuvable');
|
||||
|
||||
profils[index] = resultat.data;
|
||||
await ecrireProfils(profils, `feat : mise à jour du profil ${params.id}`);
|
||||
redirect(303, '/profils');
|
||||
},
|
||||
|
||||
supprimer: async ({ params }) => {
|
||||
const profils = await lireProfils();
|
||||
const restant = profils.filter((p) => p.id !== params.id);
|
||||
if (restant.length === profils.length) error(404, 'Profil introuvable');
|
||||
|
||||
await ecrireProfils(restant, `feat : suppression du profil ${params.id}`);
|
||||
redirect(303, '/profils');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { fr } from '$lib/i18n/fr';
|
||||
import ProfilForm from '$lib/components/ProfilForm.svelte';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{fr.profils.titre_edition} : {data.profil.nom} — {fr.app.nom}</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>{fr.profils.titre_edition} : {data.profil.nom}</h1>
|
||||
|
||||
<ProfilForm
|
||||
profil={data.profil}
|
||||
edition
|
||||
actionSupprimer="?/supprimer"
|
||||
erreurs={form?.erreurs ?? []}
|
||||
brut={(form?.brut as Record<string, unknown> | undefined) ?? null}
|
||||
/>
|
||||
@@ -0,0 +1,27 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { profilSchema } from '$lib/schemas';
|
||||
import { erreursZod, profilDepuisFormulaire } from '$lib/server/formulaires';
|
||||
import { ecrireProfils, lireProfils } from '$lib/server/stockage';
|
||||
import type { Actions } from './$types';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const brut = profilDepuisFormulaire(data);
|
||||
const resultat = profilSchema.safeParse(brut);
|
||||
if (!resultat.success) {
|
||||
return fail(400, { erreurs: erreursZod(resultat.error), brut });
|
||||
}
|
||||
|
||||
const profils = await lireProfils();
|
||||
if (profils.some((p) => p.id === resultat.data.id)) {
|
||||
return fail(409, {
|
||||
erreurs: [`Un profil avec l'identifiant « ${resultat.data.id} » existe déjà.`],
|
||||
brut
|
||||
});
|
||||
}
|
||||
|
||||
await ecrireProfils([...profils, resultat.data], `feat : ajout du profil ${resultat.data.id}`);
|
||||
redirect(303, '/profils');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { fr } from '$lib/i18n/fr';
|
||||
import ProfilForm from '$lib/components/ProfilForm.svelte';
|
||||
import type { ActionData } from './$types';
|
||||
|
||||
let { form }: { form: ActionData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{fr.profils.titre_nouveau} — {fr.app.nom}</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>{fr.profils.titre_nouveau}</h1>
|
||||
|
||||
<ProfilForm
|
||||
erreurs={form?.erreurs ?? []}
|
||||
brut={(form?.brut as Record<string, unknown> | undefined) ?? null}
|
||||
/>
|
||||
@@ -0,0 +1,35 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { modaliteSchema } from '$lib/schemas';
|
||||
import { recommander } from '$lib/server/recommandation';
|
||||
import { lireProfils, lireRegistre } from '$lib/server/stockage';
|
||||
import { nombreOuNull, chaineOuNull } from '$lib/server/formulaires';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const profils = await lireProfils();
|
||||
return { profils };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const profilId = chaineOuNull(data.get('profil_id'));
|
||||
const modaliteBrute = chaineOuNull(data.get('modalite'));
|
||||
const vramMax = nombreOuNull(data.get('vram_max'));
|
||||
|
||||
const profils = await lireProfils();
|
||||
const profil = profils.find((p) => p.id === profilId);
|
||||
if (!profil) {
|
||||
return fail(400, { erreur: 'Profil invalide.', profils });
|
||||
}
|
||||
|
||||
const modalite = modaliteSchema.safeParse(modaliteBrute);
|
||||
const registre = await lireRegistre();
|
||||
const resultat = recommander(registre, profil, {
|
||||
modalite: modalite.success ? modalite.data : undefined,
|
||||
vram_max: vramMax ?? undefined
|
||||
});
|
||||
|
||||
return { resultat, profilId: profil.id, modalite: modalite.data ?? '', vramMax };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { fr } from '$lib/i18n/fr';
|
||||
import BadgeAVerifier from '$lib/components/BadgeAVerifier.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import { modaliteSchema } from '$lib/schemas';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
const resultat = $derived(form && 'resultat' in form ? form.resultat : null);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{fr.recommander.titre} — {fr.app.nom}</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>{fr.recommander.titre}</h1>
|
||||
|
||||
<form method="post" use:enhance class="filtres">
|
||||
<label class="champ">
|
||||
<span>{fr.recommander.champ_profil}</span>
|
||||
<select name="profil_id" required>
|
||||
{#each data.profils as profil (profil.id)}
|
||||
<option
|
||||
value={profil.id}
|
||||
selected={form && 'profilId' in form && form.profilId === profil.id}
|
||||
>
|
||||
{profil.nom}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="champ">
|
||||
<span>{fr.recommander.champ_modalite}</span>
|
||||
<select name="modalite">
|
||||
<option value="">{fr.recommander.toutes_modalites}</option>
|
||||
{#each modaliteSchema.options as modalite (modalite)}
|
||||
<option
|
||||
value={modalite}
|
||||
selected={form && 'modalite' in form && form.modalite === modalite}
|
||||
>
|
||||
{fr.formulaires.modalites[modalite]}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="champ">
|
||||
<span>{fr.recommander.champ_vram}</span>
|
||||
<input
|
||||
name="vram_max"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={form && 'vramMax' in form && form.vramMax !== null ? form.vramMax : ''}
|
||||
/>
|
||||
</label>
|
||||
<button class="bouton" type="submit">{fr.recommander.lancer}</button>
|
||||
</form>
|
||||
|
||||
{#if form && 'erreur' in form}
|
||||
<p class="erreur" role="alert">{form.erreur}</p>
|
||||
{/if}
|
||||
|
||||
{#if resultat}
|
||||
<section aria-labelledby="titre-shortlist">
|
||||
<h2 id="titre-shortlist">{fr.recommander.shortlist} — {resultat.profil.nom}</h2>
|
||||
{#if resultat.shortlist.length === 0}
|
||||
<p>{fr.recommander.aucun_eligible}</p>
|
||||
{:else}
|
||||
<div class="resultats">
|
||||
{#each resultat.shortlist as reco (reco.modele.id)}
|
||||
<article class="carte reco">
|
||||
<header>
|
||||
<h3>{reco.modele.nom}</h3>
|
||||
<span class="score">{fr.recommander.score} : {reco.score}</span>
|
||||
</header>
|
||||
<p class="editeur">{reco.modele.editeur} — {reco.modele.modalites.join(', ')}</p>
|
||||
|
||||
<details>
|
||||
<summary>{fr.recommander.justification}</summary>
|
||||
<ul>
|
||||
{#each reco.justification as ligne (ligne)}
|
||||
<li>{ligne}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
{#if reco.warnings.length > 0}
|
||||
<div class="warnings">
|
||||
<p>
|
||||
<Icon nom="avertissement" taille={16} />
|
||||
{fr.recommander.warnings}
|
||||
</p>
|
||||
<ul>
|
||||
{#each reco.warnings as warning (warning)}
|
||||
<li>{warning}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if reco.modele.licence.nom === null}
|
||||
<BadgeAVerifier texte="licence {fr.badge.a_verifier}" />
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="titre-exclus">
|
||||
<h2 id="titre-exclus">{fr.recommander.exclus}</h2>
|
||||
{#if resultat.exclus.length === 0}
|
||||
<p>{fr.recommander.aucun_exclu}</p>
|
||||
{:else}
|
||||
{#each resultat.exclus as exclu (exclu.modele.id)}
|
||||
<details class="exclu">
|
||||
<summary>{exclu.modele.nom}</summary>
|
||||
<ul>
|
||||
{#each exclu.raisons as raison (raison)}
|
||||
<li>{raison}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-block: var(--space-3) var(--space-2);
|
||||
}
|
||||
|
||||
.filtres {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
background: var(--card-bg);
|
||||
border: var(--border-card);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.champ {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3em;
|
||||
}
|
||||
|
||||
.champ > span {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
select,
|
||||
input {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--fg);
|
||||
padding: 0.5em 0.75em;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.resultats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.reco header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.reco h3 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.score {
|
||||
font-family: var(--font-display);
|
||||
color: var(--action);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.editeur {
|
||||
color: var(--muted);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
details {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
align-content: center;
|
||||
color: var(--action);
|
||||
}
|
||||
|
||||
.warnings {
|
||||
border-left: 4px solid var(--action);
|
||||
padding-left: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.warnings p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.exclu {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.erreur {
|
||||
color: var(--signal);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { lireRegistre } from '$lib/server/stockage';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const modeles = await lireRegistre();
|
||||
return { modeles };
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { fr } from '$lib/i18n/fr';
|
||||
import BadgeAVerifier from '$lib/components/BadgeAVerifier.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const tri = (v: boolean | null) =>
|
||||
v === true ? fr.formulaires.ouvert : v === false ? fr.formulaires.ferme : null;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{fr.registre.titre} — {fr.app.nom}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="titre-page">
|
||||
<h1>{fr.registre.titre}</h1>
|
||||
<a class="bouton" href={resolve('/registre/nouveau')}>{fr.registre.nouveau}</a>
|
||||
</div>
|
||||
|
||||
{#if data.modeles.length === 0}
|
||||
<p>{fr.registre.aucun}</p>
|
||||
{:else}
|
||||
<div class="table-conteneur">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{fr.registre.colonne_nom}</th>
|
||||
<th scope="col">{fr.registre.colonne_editeur}</th>
|
||||
<th scope="col">{fr.registre.colonne_modalites}</th>
|
||||
<th scope="col">{fr.registre.colonne_commercial}</th>
|
||||
<th scope="col">{fr.registre.colonne_statut}</th>
|
||||
<th scope="col">{fr.registre.colonne_verif}</th>
|
||||
<th scope="col"><span class="sr-only">{fr.registre.modifier}</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.modeles as modele (modele.id)}
|
||||
<tr>
|
||||
<th scope="row">
|
||||
{modele.nom}
|
||||
{#if modele.licence.nom === null}
|
||||
<BadgeAVerifier />
|
||||
{/if}
|
||||
</th>
|
||||
<td>{modele.editeur}</td>
|
||||
<td>{modele.modalites.join(', ')}</td>
|
||||
<td>
|
||||
{#if tri(modele.licence.commercial_ok) !== null}
|
||||
{tri(modele.licence.commercial_ok)}
|
||||
{:else}
|
||||
<BadgeAVerifier />
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="tag"
|
||||
class:tag-succes={modele.statut === 'production'}
|
||||
class:tag-signal={modele.statut === 'mort'}
|
||||
>
|
||||
{fr.formulaires.statuts[modele.statut]}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{#if modele.derniere_verif !== null}
|
||||
<time datetime={modele.derniere_verif}>{modele.derniere_verif}</time>
|
||||
{:else}
|
||||
<BadgeAVerifier />
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<a href={resolve(`/registre/${modele.id}`)}>{fr.registre.modifier}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.titre-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.table-conteneur {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: var(--space-1);
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
thead th {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 60px;
|
||||
}
|
||||
|
||||
th :global(.tag) {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { modeleSchema } from '$lib/schemas';
|
||||
import { erreursZod, modeleDepuisFormulaire } from '$lib/server/formulaires';
|
||||
import { ecrireRegistre, lireRegistre } from '$lib/server/stockage';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const registre = await lireRegistre();
|
||||
const modele = registre.find((m) => m.id === params.id);
|
||||
if (!modele) error(404, 'Modèle introuvable');
|
||||
return { modele };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
enregistrer: async ({ request, params }) => {
|
||||
const data = await request.formData();
|
||||
const brut = modeleDepuisFormulaire(data);
|
||||
// L'identifiant d'un modèle existant n'est pas modifiable (champ readonly).
|
||||
const resultat = modeleSchema.safeParse({ ...(brut as object), id: params.id });
|
||||
if (!resultat.success) {
|
||||
return fail(400, { erreurs: erreursZod(resultat.error), brut });
|
||||
}
|
||||
|
||||
const registre = await lireRegistre();
|
||||
const index = registre.findIndex((m) => m.id === params.id);
|
||||
if (index === -1) error(404, 'Modèle introuvable');
|
||||
|
||||
registre[index] = resultat.data;
|
||||
await ecrireRegistre(registre, `feat : mise à jour du modèle ${params.id}`);
|
||||
redirect(303, '/registre');
|
||||
},
|
||||
|
||||
supprimer: async ({ params }) => {
|
||||
const registre = await lireRegistre();
|
||||
const restant = registre.filter((m) => m.id !== params.id);
|
||||
if (restant.length === registre.length) error(404, 'Modèle introuvable');
|
||||
|
||||
await ecrireRegistre(restant, `feat : suppression du modèle ${params.id}`);
|
||||
redirect(303, '/registre');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { fr } from '$lib/i18n/fr';
|
||||
import ModeleForm from '$lib/components/ModeleForm.svelte';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{fr.registre.titre_edition} : {data.modele.nom} — {fr.app.nom}</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>{fr.registre.titre_edition} : {data.modele.nom}</h1>
|
||||
|
||||
<ModeleForm
|
||||
modele={data.modele}
|
||||
edition
|
||||
actionSupprimer="?/supprimer"
|
||||
erreurs={form?.erreurs ?? []}
|
||||
brut={(form?.brut as Record<string, unknown> | undefined) ?? null}
|
||||
/>
|
||||
@@ -0,0 +1,30 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { modeleSchema } from '$lib/schemas';
|
||||
import { erreursZod, modeleDepuisFormulaire } from '$lib/server/formulaires';
|
||||
import { ecrireRegistre, lireRegistre } from '$lib/server/stockage';
|
||||
import type { Actions } from './$types';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const brut = modeleDepuisFormulaire(data);
|
||||
const resultat = modeleSchema.safeParse(brut);
|
||||
if (!resultat.success) {
|
||||
return fail(400, { erreurs: erreursZod(resultat.error), brut });
|
||||
}
|
||||
|
||||
const registre = await lireRegistre();
|
||||
if (registre.some((m) => m.id === resultat.data.id)) {
|
||||
return fail(409, {
|
||||
erreurs: [`Un modèle avec l'identifiant « ${resultat.data.id} » existe déjà.`],
|
||||
brut
|
||||
});
|
||||
}
|
||||
|
||||
await ecrireRegistre(
|
||||
[...registre, resultat.data],
|
||||
`feat : ajout du modèle ${resultat.data.id} au registre`
|
||||
);
|
||||
redirect(303, '/registre');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { fr } from '$lib/i18n/fr';
|
||||
import ModeleForm from '$lib/components/ModeleForm.svelte';
|
||||
import type { ActionData } from './$types';
|
||||
|
||||
let { form }: { form: ActionData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{fr.registre.titre_nouveau} — {fr.app.nom}</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>{fr.registre.titre_nouveau}</h1>
|
||||
|
||||
<ModeleForm
|
||||
erreurs={form?.erreurs ?? []}
|
||||
brut={(form?.brut as Record<string, unknown> | undefined) ?? null}
|
||||
/>
|
||||
Reference in New Issue
Block a user