add privacy page as standalone SPA view outside slide navigation

- New Kirby template/blueprint/JSON for privacy page (confidentialite slug)
- Standalone page state in slides store + router handling for non-nav pages
- Privacy.svelte view with background image, text blocks, footer
- Centralize vertical lines in App.svelte as fixed elements with per-slide visibility
- Footer privacy link language-aware (FR/EN)
- Portfolio mockup fix: read from default language for consistent EN display
- menu.php: add privacy page to Kirby panel navigation

refs #44

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-03-30 18:43:35 +02:00
parent b12b839f1b
commit 44af8a9b4e
11 changed files with 259 additions and 50 deletions

View file

@ -0,0 +1,41 @@
title: Confidentialité
icon: lock
tabs:
content:
label: Contenu
icon: text
columns:
- width: 1/3
fields:
backgroundImage:
label: Image de fond
type: files
max: 1
layout: cards
accept: image/*
translate: false
- width: 2/3
fields:
body:
label: Corps
type: blocks
fieldsets:
text:
extends: blocks/text
fields:
text:
headings:
- 3
nodes:
- heading
marks:
- bold
- italic
- underline
- strike
- link
files: tabs/files
seo: seo/page

View file

@ -30,7 +30,8 @@ return [
'jouer' => menuItem('jouer', 'Jouer', 'play', 'pages/jouer'), 'jouer' => menuItem('jouer', 'Jouer', 'play', 'pages/jouer'),
'a-propos' => menuItem('a-propos', 'À propos', 'users', 'pages/a-propos'), 'a-propos' => menuItem('a-propos', 'À propos', 'users', 'pages/a-propos'),
'blog' => menuItem('blog', 'Blog', 'text', 'pages/blog'), 'blog' => menuItem('blog', 'Blog', 'text', 'pages/blog'),
'white-papers' => menuItem('livres-blancs', 'Livres blancs', 'book', 'pages/livres-blancs'), 'white-papers' => menuItem('livres-blancs', 'Livres blancs', 'book', 'pages/livres-blancs'),
'confidentialite' => menuItem('confidentialite', 'Confidentialité', 'lock', 'pages/confidentialite'),
'-', '-',
'users', 'users',
'system', 'system',

View file

@ -1,8 +1,11 @@
<?php <?php
$defaultLang = kirby()->defaultLanguage()->code();
$specificData = [ $specificData = [
'backgroundImage' => $page->backgroundImage()->toFile()?->url(), 'backgroundImage' => $page->backgroundImage()->toFile()?->url(),
'projects' => $page->children()->listed()->map(function($project) { 'projects' => $page->children()->listed()->map(function($project) use ($defaultLang) {
$mockupFile = $project->content($defaultLang)->mockup()->toFile();
return [ return [
'title' => $project->title()->value(), 'title' => $project->title()->value(),
'slug' => $project->slug(), 'slug' => $project->slug(),
@ -14,9 +17,9 @@ $specificData = [
'srcset' => $f->srcset('gallery'), 'srcset' => $f->srcset('gallery'),
'webp' => $f->srcset('gallery-webp'), 'webp' => $f->srcset('gallery-webp'),
])->values(), ])->values(),
'mockup' => $project->content(kirby()->defaultLanguage()->code())->mockup()->toFile()?->url(), 'mockup' => $mockupFile?->url(),
'mockupSrcset' => $project->content(kirby()->defaultLanguage()->code())->mockup()->toFile()?->srcset('mockup'), 'mockupSrcset' => $mockupFile?->srcset('mockup'),
'mockupWebp' => $project->content(kirby()->defaultLanguage()->code())->mockup()->toFile()?->srcset('mockup-webp'), 'mockupWebp' => $mockupFile?->srcset('mockup-webp'),
'galleryAnimationMode' => $project->galleryAnimationMode()->value() ?: 'vertical', 'galleryAnimationMode' => $project->galleryAnimationMode()->value() ?: 'vertical',
'secondsPerImage' => $project->secondsPerImage()->isNotEmpty() ? (int) $project->secondsPerImage()->value() : 8, 'secondsPerImage' => $project->secondsPerImage()->isNotEmpty() ? (int) $project->secondsPerImage()->value() : 8,
'galleryBackgroundColor' => $project->galleryBackgroundColor()->value(), 'galleryBackgroundColor' => $project->galleryBackgroundColor()->value(),

View file

@ -0,0 +1,21 @@
<?php
$bodyBlocks = [];
foreach ($page->body()->toBlocks() as $block) {
if ($block->type() === 'text') {
$bodyBlocks[] = [
'type' => 'text',
'html' => $block->content()->text()->value(),
];
}
}
$specificData = [
'backgroundImage' => $page->backgroundImage()->toFile()?->url(),
'body' => $bodyBlocks,
];
$pageData = array_merge($genericData, $specificData);
header('Content-Type: application/json');
echo json_encode($pageData);

View file

@ -0,0 +1,2 @@
<?php snippet('header') ?>
<?php snippet('footer') ?>

View file

@ -17,6 +17,7 @@
import Blog from '@views/Blog.svelte' import Blog from '@views/Blog.svelte'
import Article from '@views/Article.svelte' import Article from '@views/Article.svelte'
import WhitePapers from '@views/WhitePapers.svelte' import WhitePapers from '@views/WhitePapers.svelte'
import Privacy from '@views/Privacy.svelte'
import Default from '@views/Default.svelte' import Default from '@views/Default.svelte'
const templates = { const templates = {
@ -30,6 +31,7 @@
blog: Blog, blog: Blog,
article: Article, article: Article,
'white-papers': WhitePapers, 'white-papers': WhitePapers,
privacy: Privacy,
default: Default default: Default
} }
@ -37,13 +39,15 @@
const wrapperTransform = $derived(`translateX(-${slides.activeIndex * 100}vw)`) const wrapperTransform = $derived(`translateX(-${slides.activeIndex * 100}vw)`)
const linesBySlide = { const linesBySlide = {
home: [6, 11, 16], home: [6, 11, 16],
expertise: [6, 8, 11, 14, 16], expertise: [6, 8, 11, 14, 16],
about: [6, 8, 11, 14, 16], about: [6, 8, 11, 14, 16],
portfolio: [11, 16], portfolio: [11, 16],
privacy: [6, 8, 11, 14, 16],
} }
const ALL_COLS = [6, 8, 11, 14, 16] const ALL_COLS = [6, 8, 11, 14, 16]
const activeLines = $derived(new Set(linesBySlide[slides.active?.template] ?? [])) const activeTemplate = $derived(slides.standalone?.template ?? slides.active?.template)
const activeLines = $derived(new Set(linesBySlide[activeTemplate] ?? []))
let isReady = $state(false) let isReady = $state(false)
let isResizing = $state(false) let isResizing = $state(false)
@ -143,22 +147,29 @@
</div> </div>
<main class="main"> <main class="main">
<div {#if slides.standalone}
class="slides-wrapper" <svelte:component
class:is-animated={isReady && !isResizing} this={templates[slides.standalone.template] ?? Default}
style="width: {wrapperWidth}; transform: {wrapperTransform}" data={slides.standalone}
> />
{#each slides.all as slide, i} {:else}
<section class="slide" class:active={i === slides.activeIndex} data-slide={slide.id} inert={i !== slides.activeIndex}> <div
{#if slide.loaded} class="slides-wrapper"
<svelte:component class:is-animated={isReady && !isResizing}
this={templates[slide.template] ?? Default} style="width: {wrapperWidth}; transform: {wrapperTransform}"
data={slide.data} >
/> {#each slides.all as slide, i}
{/if} <section class="slide" class:active={i === slides.activeIndex} data-slide={slide.id} inert={i !== slides.activeIndex}>
</section> {#if slide.loaded}
{/each} <svelte:component
</div> this={templates[slide.template] ?? Default}
data={slide.data}
/>
{/if}
</section>
{/each}
</div>
{/if}
</main> </main>
<LanguageSwitcher /> <LanguageSwitcher />

View file

@ -1,5 +1,6 @@
<script> <script>
import { site } from '@state/site.svelte' import { site } from '@state/site.svelte'
import { locale } from '@state/locale.svelte'
import { t } from '@i18n' import { t } from '@i18n'
const logo = $derived(site.logo) const logo = $derived(site.logo)
@ -89,9 +90,9 @@
<div class="footer-divider" aria-hidden="true"></div> <div class="footer-divider" aria-hidden="true"></div>
{#if contact.legalNotice} {#if contact.legalNotice}
<a href={contact.legalNotice} target="_blank" rel="noopener noreferrer">{t('legal')}</a> <a href={contact.legalNotice} target="_blank" rel="noopener noreferrer">{t('legal')}</a>
{:else}
<a href="/privacy">{t('privacy')}</a>
{/if} {/if}
<div class="footer-divider" aria-hidden="true"></div>
<a href={locale.current === 'en' ? '/en/confidentialite' : '/confidentialite'}>{t('privacy')}</a>
</div> </div>
</footer> </footer>

View file

@ -64,7 +64,12 @@ async function loadSlide(path) {
siteInitialized = true; siteInitialized = true;
} }
slides.setData(slidePath, data); const finalIdx = slides.getIndexByPath(slidePath);
if (finalIdx !== -1) {
slides.setData(slidePath, data);
} else {
slides.setStandalone(data);
}
} catch (error) { } catch (error) {
console.error(`[router] Failed to load slide ${slidePath}:`, error); console.error(`[router] Failed to load slide ${slidePath}:`, error);
slides.setLoading(slidePath, false); slides.setLoading(slidePath, false);
@ -95,21 +100,26 @@ export function slideTo(path, { skipHistory = false } = {}) {
const idx = findSlideIndex(path); const idx = findSlideIndex(path);
const slidePath = idx !== -1 ? slides.all[idx].path : path; const slidePath = idx !== -1 ? slides.all[idx].path : path;
if (idx !== -1 && slides.all[idx].title) { if (idx !== -1) {
document.title = `World Game - ${slides.all[idx].title}`; slides.clearStandalone();
}
// Si on navigue vers la slide déjà active (ex: clic sur "Blog" depuis un article), if (slides.all[idx].title) {
// déclencher popstate pour que la vue puisse réagir au changement d'URL. document.title = `World Game - ${slides.all[idx].title}`;
if (idx === slides.activeIndex && !skipHistory) { }
window.dispatchEvent(new PopStateEvent('popstate'));
return;
}
slides.slideTo(slidePath); if (idx === slides.activeIndex && !skipHistory) {
window.dispatchEvent(new PopStateEvent('popstate'));
return;
}
if (idx !== -1 && !slides.all[idx].loaded) { slides.slideTo(slidePath);
loadSlide(slidePath);
if (!slides.all[idx].loaded) {
loadSlide(slidePath);
}
} else {
// Page standalone (hors navigation)
loadSlide(path);
} }
} }

View file

@ -1,6 +1,7 @@
let slidesData = $state([]) let slidesData = $state([])
let activeIndex = $state(0) let activeIndex = $state(0)
let pendingPath = $state(null) let pendingPath = $state(null)
let standaloneData = $state(null)
function getIndexByPath(path) { function getIndexByPath(path) {
return slidesData.findIndex(s => s.path === path) return slidesData.findIndex(s => s.path === path)
@ -11,6 +12,10 @@ export const slides = {
get activeIndex() { return activeIndex }, get activeIndex() { return activeIndex },
get active() { return slidesData[activeIndex] ?? null }, get active() { return slidesData[activeIndex] ?? null },
get pendingPath() { return pendingPath }, get pendingPath() { return pendingPath },
get standalone() { return standaloneData },
setStandalone(data) { standaloneData = data },
clearStandalone() { standaloneData = null },
init(siteNavigation) { init(siteNavigation) {
slidesData = siteNavigation.map(nav => ({ slidesData = siteNavigation.map(nav => ({

123
src/views/Privacy.svelte Normal file
View file

@ -0,0 +1,123 @@
<script>
import { navigation } from '@state/navigation.svelte'
import Footer from '@components/layout/Footer.svelte'
let { data } = $props()
const body = $derived(data?.body ?? [])
const backgroundImage = $derived(data?.backgroundImage ?? null)
let sectionEl = $state(null)
</script>
<div
class="privacy golden-grid page-scrollable"
style={backgroundImage ? `--background-image: url('${backgroundImage}')` : ''}
bind:this={sectionEl}
onscroll={() => navigation.setScrolled(sectionEl.scrollTop > 100)}
>
<div class="page-container">
{#if body.length > 0}
<section class="privacy-body">
{#each body as block}
{#if block.type === 'text'}
<div class="privacy-body-block">
{@html block.html}
</div>
{/if}
{/each}
</section>
{/if}
<Footer />
</div>
</div>
<style>
.privacy {
min-height: 100vh;
color: #fff;
overflow-y: auto;
overflow-x: hidden;
background-image: var(--background-image, none);
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}
.page-container {
grid-area: 6/6 / span 7 / span 10;
display: flex;
flex-direction: column;
align-items: center;
place-self: center;
width: 100%;
overflow: visible;
}
/* ── Body blocks ── */
.privacy-body {
width: 100%;
padding-top: 8rem;
}
.privacy-body-block {
border-left: 2px solid #04fea0;
padding-left: 20px;
margin-top: 3rem;
text-align: left;
font-family: "Danzza", sans-serif;
font-size: var(--font-size-paragraph);
font-weight: normal;
white-space: pre-line;
line-height: 1.6;
}
.privacy-body-block :global(h3) {
font-family: Danzza Medium, sans-serif;
font-size: var(--font-size-paragraph);
margin-bottom: 1rem;
}
.privacy-body-block :global(h3::before) {
content: '';
display: inline-block;
width: .6rem;
height: .6rem;
background-color: var(--color-primary);
margin-right: .6rem;
margin-bottom: .1rem;
}
.privacy-body-block :global(p) {
opacity: 0.9;
margin-bottom: 0.75rem;
}
.privacy-body-block :global(a) {
color: #04fea0;
}
/* ── Mobile (≤700px) ── */
@media (max-width: 700px) {
.page-container {
grid-area: auto;
width: 100vw;
}
.privacy-body {
padding: 6rem 1.25rem 2rem;
}
.privacy-body-block {
font-size: var(--font-size-paragraph-small, 0.875rem);
}
}
/* ── Tablet (701px912px) ── */
@media (min-width: 701px) and (max-width: 912px) {
.privacy-body-block {
font-size: var(--font-size-paragraph);
}
}
</style>

View file

@ -375,15 +375,6 @@
width: 100vw; width: 100vw;
max-width: none; max-width: none;
scale: 1.5; scale: 1.5;
/* position: absolute;
bottom: -10%;
left: 50%;
transform: translateX(-50%);
width: 75%;
max-width: 300px;
z-index: 0;
pointer-events: none; */
} }
/* Quand le formulaire est affiché : masque le contenu principal */ /* Quand le formulaire est affiché : masque le contenu principal */