feat(app) : socle SvelteKit phase 0 — SSO, thème OKI, adapter-node
- Scaffold SvelteKit 2 + Svelte 5 (runes), TypeScript strict, adapter-node - Auth SSO via header Ynh-User (hooks.server) ; accueil public minimal sans header, 401 sur toute autre route ; DEV_USER en dev local uniquement - Design system OKI : tokens, thème sombre par défaut, clair opt-in anti-FOUC, flag-bar, fonts Archivo/Inter self-hébergées, sprite icons.svg (zéro emoji) - CSP via kit.csp (mode nonce) + en-têtes de sécurité ; paths.base configurable au build (BASE_PATH) pour le sous-chemin YunoHost, paths.relative = false - i18n fr (structure prête gcf/en), pages : accueil public/privé, erreurs designées - Tests vitest : résolution SSO + routes publiques (10 cas) - Vérifié : check/lint/test/build verts, test fumée HTTP sur build (SSO, CSP, sous-chemin /veille), zéro emoji et zéro domaine tiers dans le build
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { resolve } from '$app/paths';
|
||||
import { fr } from '$lib/i18n/fr';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
|
||||
const statut = $derived(page.status);
|
||||
|
||||
const contenu = $derived(
|
||||
statut === 401 || statut === 404 ? fr.erreurs.parStatut[statut] : fr.erreurs.parStatut[500]
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{statut} — {contenu.titre} — {fr.app.nom}</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="erreur">
|
||||
<Icon nom="ka" taille={64} />
|
||||
<h1>{statut} — {contenu.titre}</h1>
|
||||
{#if statut === 404 && 'texte_ka' in contenu}
|
||||
<p lang="gcf" class="ka">{contenu.texte_ka}</p>
|
||||
{/if}
|
||||
<p>{contenu.texte}</p>
|
||||
<a class="bouton" href={resolve('/')}>{fr.erreurs.retour_accueil}</a>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.erreur {
|
||||
max-width: 60ch;
|
||||
margin-inline: auto;
|
||||
padding-block: var(--space-5);
|
||||
text-align: center;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.erreur :global(svg) {
|
||||
margin-inline: auto;
|
||||
color: var(--action);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.ka {
|
||||
color: var(--action);
|
||||
font-style: italic;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--muted);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
user: locals.user
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
<script lang="ts">
|
||||
import '$lib/styles/oki-tokens.css';
|
||||
import '$lib/styles/base.css';
|
||||
import { resolve } from '$app/paths';
|
||||
import { fr } from '$lib/i18n/fr';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { LayoutProps } from './$types';
|
||||
|
||||
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.
|
||||
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: '/alertes', libelle: fr.nav.alertes, icone: 'alerte', bientot: true }
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<a class="skip-link" href="#contenu">{fr.nav.aller_au_contenu}</a>
|
||||
|
||||
<header class="entete">
|
||||
<nav class="conteneur nav-principale" aria-label={fr.nav.navigation_principale}>
|
||||
<a class="marque" href={resolve('/')}>
|
||||
<Icon nom="ka" taille={28} />
|
||||
<span>{fr.app.nom}</span>
|
||||
</a>
|
||||
{#if data.user}
|
||||
<ul class="liens">
|
||||
{#each entrees as entree (entree.href)}
|
||||
<li>
|
||||
{#if entree.bientot}
|
||||
<span
|
||||
class="lien lien-desactive"
|
||||
aria-disabled="true"
|
||||
title={fr.accueil.phase_a_venir}
|
||||
>
|
||||
<Icon nom={entree.icone} taille={18} />
|
||||
{entree.libelle}
|
||||
</span>
|
||||
{:else}
|
||||
<a class="lien" href={resolve(entree.href)}>
|
||||
<Icon nom={entree.icone} taille={18} />
|
||||
{entree.libelle}
|
||||
</a>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<span class="utilisateur">{data.user}</span>
|
||||
{/if}
|
||||
</nav>
|
||||
<div class="flag-bar" aria-hidden="true"></div>
|
||||
</header>
|
||||
|
||||
<main id="contenu" class="conteneur" tabindex="-1">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<footer class="pied conteneur">
|
||||
<p>{fr.pied.ligne}</p>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.entete {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.nav-principale {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding-block: var(--space-1);
|
||||
}
|
||||
|
||||
.marque {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
color: var(--action);
|
||||
font-family: var(--font-display);
|
||||
font-weight: 800;
|
||||
font-size: 1.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.01em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.liens {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.lien {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
min-height: 44px;
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition:
|
||||
color var(--dur-tanbou) var(--ease-ka),
|
||||
border-color var(--dur-tanbou) var(--ease-ka);
|
||||
}
|
||||
|
||||
.lien:hover {
|
||||
color: var(--action);
|
||||
border-color: var(--action);
|
||||
}
|
||||
|
||||
.lien-desactive {
|
||||
color: var(--muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.utilisateur {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Flag-bar OKI : 6 px, 4 segments francs, une occurrence par écran. */
|
||||
.flag-bar {
|
||||
height: 6px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#0d0d0d 0 25%,
|
||||
#fdb813 25% 50%,
|
||||
#00d66c 50% 75%,
|
||||
#ff1654 75% 100%
|
||||
);
|
||||
}
|
||||
|
||||
main {
|
||||
padding-block: var(--space-4);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pied {
|
||||
padding-block: var(--space-3);
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
:global(body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { fr } from '$lib/i18n/fr';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const cartes = [
|
||||
{
|
||||
titre: fr.accueil.carte_registre_titre,
|
||||
texte: fr.accueil.carte_registre_texte,
|
||||
icone: 'registre'
|
||||
},
|
||||
{
|
||||
titre: fr.accueil.carte_profils_titre,
|
||||
texte: fr.accueil.carte_profils_texte,
|
||||
icone: 'profils'
|
||||
},
|
||||
{
|
||||
titre: fr.accueil.carte_recommander_titre,
|
||||
texte: fr.accueil.carte_recommander_texte,
|
||||
icone: 'cible'
|
||||
},
|
||||
{
|
||||
titre: fr.accueil.carte_alertes_titre,
|
||||
texte: fr.accueil.carte_alertes_texte,
|
||||
icone: 'alerte'
|
||||
}
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{fr.app.nom} — {fr.accueil.titre_public}</title>
|
||||
<meta name="description" content={fr.app.description} />
|
||||
</svelte:head>
|
||||
|
||||
{#if data.user}
|
||||
<h1>{fr.accueil.tableau_de_bord}</h1>
|
||||
<p class="bienvenue">{fr.accueil.bienvenue} <strong>{data.user}</strong>.</p>
|
||||
|
||||
<div class="grille">
|
||||
{#each cartes as carte (carte.icone)}
|
||||
<article class="carte">
|
||||
<h2>
|
||||
<Icon nom={carte.icone} taille={22} />
|
||||
{carte.titre}
|
||||
</h2>
|
||||
<p>{carte.texte}</p>
|
||||
<span class="tag">{fr.accueil.phase_a_venir}</span>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="note-phase">{fr.pied.registre_vide}</p>
|
||||
{:else}
|
||||
<section class="public">
|
||||
<h1>{fr.accueil.titre_public}</h1>
|
||||
<p>{fr.app.description}</p>
|
||||
<p class="acces-reserve">
|
||||
<Icon nom="cle" taille={20} />
|
||||
{fr.accueil.acces_reserve}
|
||||
</p>
|
||||
<a class="bouton" href={resolve('/')}>{fr.erreurs.retour_accueil}</a>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.bienvenue {
|
||||
color: var(--muted);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.carte h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.carte p {
|
||||
color: var(--muted);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.note-phase {
|
||||
margin-top: var(--space-4);
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.public {
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
.public p {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.acces-reserve {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
color: var(--action);
|
||||
margin-bottom: var(--space-3) !important;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user