Added demo villa (test 3d scene) + misc

This commit is contained in:
Thomas Fransolet 2026-09-04 16:48:42 +02:00
parent cb7f045b0b
commit e2a1c97c00
17 changed files with 2466 additions and 355 deletions

View File

@ -17,6 +17,13 @@ const nextConfig: NextConfig = {
hostname: '**', hostname: '**',
}, },
], ],
// Next 16 refuse toute image dont l'hôte résout vers une IP privée
// (protection anti-SSRF) et renvoie « "url" parameter is not allowed »,
// le même message qu'un motif non autorisé. Nécessaire pour les visuels
// de démonstration servis sur localhost:8099.
// ⚠️ À ne pas emporter en production : cela rouvre la porte que cette
// protection ferme.
dangerouslyAllowLocalIP: true,
}, },
}; };

View File

@ -23,15 +23,16 @@ export default async function ConfigLayout({
const theme = resolveColors(instance, config) const theme = resolveColors(instance, config)
const loaderImageUrl = config.loaderImageUrl ?? instance.loaderImageUrl const loaderImageUrl = config.loaderImageUrl ?? instance.loaderImageUrl
const showAssistant = !!(instance.isAssistant && instance.publicApiKey)
return ( return (
<div style={theme as React.CSSProperties}> <div style={theme as React.CSSProperties} data-assistant={showAssistant ? 'true' : undefined}>
{loaderImageUrl && <SplashScreen imageUrl={loaderImageUrl} configId={configId} />} {loaderImageUrl && <SplashScreen imageUrl={loaderImageUrl} configId={configId} />}
{children} {children}
{instance.isAssistant && instance.publicApiKey && ( {showAssistant && (
<AssistantBubble <AssistantBubble
instanceId={instance.id} instanceId={instance.id}
apiKey={instance.publicApiKey} apiKey={instance.publicApiKey!}
configId={configId} configId={configId}
slug={slug} slug={slug}
sections={config.sections} sections={config.sections}

View File

@ -39,7 +39,7 @@ export default async function SectionPage({
const section: SectionDTO | undefined = const section: SectionDTO | undefined =
sections.find((s) => s.id === sectionId) ?? sections.find((s) => s.id === sectionId) ??
sections.flatMap((s) => s.menu?.sections ?? []).find((s) => s.id === sectionId) sections.flatMap((s) => s.menu?.sections ?? []).find((s) => s.id === sectionId)
if (!section || section.isActive === false) notFound() if (!section || section.isActive === false) notFound()
// Parcours sections expose guided paths via their own endpoint // Parcours sections expose guided paths via their own endpoint
if (section.type === 'Parcours' && section.parcours) { if (section.type === 'Parcours' && section.parcours) {
@ -77,7 +77,16 @@ if (!section || section.isActive === false) notFound()
case 'Quiz': content = <QuizSection {...props} />; break case 'Quiz': content = <QuizSection {...props} />; break
case 'Game': content = <GameSection {...props} />; break case 'Game': content = <GameSection {...props} />; break
case 'Event': content = <EventSection {...props} />; break case 'Event': content = <EventSection {...props} />; break
case 'Parcours': content = <ParcoursSection {...props} />; break case 'Parcours':
// La bulle assistant vit dans le layout : on la masque le temps du parcours,
// lécran étant déjà pris par la navigation guidée.
content = (
<>
<style>{'.mim-assistant-hint,.mim-assistant-launcher,.mim-assistant-panel{display:none}'}</style>
<ParcoursSection {...props} />
</>
)
break
case 'Web': content = <WebSection {...props} />; break case 'Web': content = <WebSection {...props} />; break
default: default:
content = ( content = (

View File

@ -0,0 +1,482 @@
'use client'
/* Page de démonstration commerciale non branchée sur l'API.
Route : /demo/villa-echternach
Sert à montrer au client à quoi ressemblerait une section « scène 3D » avec
comparaison avant / aujourd'hui. Le chrome (AppBar, panneau flottant, jetons
de couleur) est celui de l'app ; seule la scène est codée en dur. */
import { useCallback, useEffect, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import AppBar from '@/components/ui/AppBar'
import FloatingPanel from '@/components/ui/FloatingPanel'
import {
buildVilla,
drawFaces,
drawSky,
project,
HOTSPOTS,
type Camera,
type Face,
} from './scene'
type Mode = 'ruins' | 'roman' | 'compare'
const THEME = {
'--color-primary': '#6b4a2e',
'--color-secondary': '#d8c7b0',
'--color-primary-light': 'rgba(107, 74, 46, 0.12)',
'--color-on-primary': '#ffffff',
'--color-surface': '#faf7f2',
'--color-border': '#e6ddd0',
'--color-text': '#2a211a',
} as CSSProperties
/* Cadrage par defaut : le site tient entre le panneau de gauche et la fiche de
droite sur un ecran 16/9, sans rien laisser sortir du cadre. */
const VIEW0: Camera = { yaw: -0.72, pitch: 0.66, dist: 315, tx: -6, ty: 6, tz: -8 }
const MODES: { id: Mode; label: string; sub: string }[] = [
{ id: 'ruins', label: "Aujourd'hui", sub: 'Vestiges dégagés' },
{ id: 'roman', label: 'Restitution', sub: 'Vers 300 ap. J.-C.' },
{ id: 'compare', label: 'Comparer', sub: 'Rideau glissant' },
]
export default function VillaDemoPage() {
const canvasRef = useRef<HTMLCanvasElement>(null)
const stageRef = useRef<HTMLDivElement>(null)
const markersRef = useRef<(HTMLButtonElement | null)[]>([])
const camRef = useRef<Camera>({ ...VIEW0 })
const scenesRef = useRef<{ ruins: Face[]; roman: Face[] } | null>(null)
const modeRef = useRef<Mode>('compare')
const splitRef = useRef(0.48)
const mixRef = useRef(1)
const fromRef = useRef<'ruins' | 'roman'>('ruins')
const toRef = useRef<'ruins' | 'roman'>('roman')
const [mode, setMode] = useState<Mode>('compare')
const [panelOpen, setPanelOpen] = useState(true)
const [selected, setSelected] = useState<string | null>('peristyle')
const [hidden, setHidden] = useState<Set<string>>(new Set())
const selectedSpot = HOTSPOTS.find(h => h.id === selected) ?? null
const changeMode = useCallback((next: Mode) => {
const current = modeRef.current
if (next === current) return
if (next !== 'compare' && current !== 'compare') {
fromRef.current = current as 'ruins' | 'roman'
toRef.current = next as 'ruins' | 'roman'
mixRef.current = 0
} else {
mixRef.current = 1
if (next !== 'compare') toRef.current = next as 'ruins' | 'roman'
}
modeRef.current = next
setMode(next)
}, [])
const toggleSpot = (id: string) => {
setHidden(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else { next.add(id); }
return next
})
setSelected(prev => (prev === id ? null : prev))
}
useEffect(() => {
scenesRef.current = { ruins: buildVilla('ruins'), roman: buildVilla('roman') }
}, [])
useEffect(() => {
const canvas = canvasRef.current
const stage = stageRef.current
if (!canvas || !stage) return
const ctx = canvas.getContext('2d')
if (!ctx) return
let w = 0, h = 0, raf = 0, last = 0
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const r = stage.getBoundingClientRect()
w = Math.max(320, Math.round(r.width))
h = Math.max(240, Math.round(r.height))
canvas.width = Math.round(w * dpr)
canvas.height = Math.round(h * dpr)
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
}
const placeMarkers = () => {
const cam = camRef.current
const compare = modeRef.current === 'compare'
HOTSPOTS.forEach((spot, i) => {
const el = markersRef.current[i]
if (!el) return
const p = compare ? null : project(spot.pos, cam, w, h)
if (!p || p.x < -60 || p.x > w + 60 || p.y < -40 || p.y > h + 40) {
el.style.visibility = 'hidden'
return
}
el.style.visibility = 'visible'
el.style.transform = `translate(${p.x}px, ${p.y}px) translate(-50%, -100%)`
})
}
const frame = (t: number) => {
const dt = last ? Math.min(64, t - last) : 16
last = t
const scenes = scenesRef.current
if (scenes) {
const cam = camRef.current
if (mixRef.current < 1) mixRef.current = Math.min(1, mixRef.current + dt / 520)
drawSky(ctx, w, h)
if (modeRef.current === 'compare') {
const sx = splitRef.current * w
ctx.save(); ctx.beginPath(); ctx.rect(0, 0, sx, h); ctx.clip()
drawFaces(ctx, scenes.ruins, cam, w, h)
ctx.restore()
ctx.save(); ctx.beginPath(); ctx.rect(sx, 0, w - sx, h); ctx.clip()
drawFaces(ctx, scenes.roman, cam, w, h)
ctx.restore()
ctx.strokeStyle = 'rgba(255,255,255,.92)'
ctx.lineWidth = 2
ctx.beginPath(); ctx.moveTo(sx, 0); ctx.lineTo(sx, h); ctx.stroke()
ctx.fillStyle = 'rgba(255,255,255,.95)'
ctx.beginPath(); ctx.arc(sx, h / 2, 19, 0, Math.PI * 2); ctx.fill()
ctx.fillStyle = '#6b4a2e'
ctx.font = 'bold 15px system-ui, sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText('↔', sx, h / 2 + 1)
} else {
const mix = mixRef.current
if (mix < 1) drawFaces(ctx, scenes[fromRef.current], cam, w, h, 1 - mix)
drawFaces(ctx, scenes[toRef.current], cam, w, h, mix)
}
}
placeMarkers()
raf = requestAnimationFrame(frame)
}
const ro = new ResizeObserver(resize)
ro.observe(stage)
resize()
raf = requestAnimationFrame(frame)
return () => { cancelAnimationFrame(raf); ro.disconnect() }
}, [])
/* Orbite / zoom / rideau écrits directement dans les refs pour ne pas
réveiller React à chaque image. */
useEffect(() => {
const stage = stageRef.current
if (!stage) return
let drag: { mode: 'orbit' | 'split'; x: number; y: number } | null = null
let pinch = 0
const down = (e: PointerEvent) => {
const target = e.target as HTMLElement
if (target.closest('[data-ui]')) return
const r = stage.getBoundingClientRect()
const fx = (e.clientX - r.left) / r.width
drag = modeRef.current === 'compare' && Math.abs(fx - splitRef.current) < 0.04
? { mode: 'split', x: e.clientX, y: e.clientY }
: { mode: 'orbit', x: e.clientX, y: e.clientY }
stage.setPointerCapture(e.pointerId)
}
const move = (e: PointerEvent) => {
if (!drag) return
const cam = camRef.current
if (drag.mode === 'split') {
const r = stage.getBoundingClientRect()
splitRef.current = Math.min(0.9, Math.max(0.1, (e.clientX - r.left) / r.width))
return
}
cam.yaw += (e.clientX - drag.x) * 0.006
cam.pitch = Math.min(1.28, Math.max(0.12, cam.pitch + (e.clientY - drag.y) * 0.004))
drag.x = e.clientX
drag.y = e.clientY
}
const up = () => { drag = null }
const wheel = (e: WheelEvent) => {
e.preventDefault()
const cam = camRef.current
cam.dist = Math.min(520, Math.max(90, cam.dist * (1 + Math.sign(e.deltaY) * 0.09)))
}
const touchStart = (e: TouchEvent) => {
if (e.touches.length === 2) {
drag = null
pinch = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY)
}
}
const touchMove = (e: TouchEvent) => {
if (e.touches.length !== 2 || !pinch) return
const d = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY)
const cam = camRef.current
cam.dist = Math.min(520, Math.max(90, (cam.dist * pinch) / d))
pinch = d
}
stage.addEventListener('pointerdown', down)
stage.addEventListener('pointermove', move)
stage.addEventListener('pointerup', up)
stage.addEventListener('pointercancel', up)
stage.addEventListener('wheel', wheel, { passive: false })
stage.addEventListener('touchstart', touchStart, { passive: true })
stage.addEventListener('touchmove', touchMove, { passive: true })
return () => {
stage.removeEventListener('pointerdown', down)
stage.removeEventListener('pointermove', move)
stage.removeEventListener('pointerup', up)
stage.removeEventListener('pointercancel', up)
stage.removeEventListener('wheel', wheel)
stage.removeEventListener('touchstart', touchStart)
stage.removeEventListener('touchmove', touchMove)
}
}, [])
const visible = HOTSPOTS.filter(h => !hidden.has(h.id))
return (
<div
ref={stageRef}
data-assistant="true"
style={{ ...THEME, touchAction: 'none' }}
className="relative flex-1 min-h-[100dvh] overflow-hidden select-none"
>
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
<div className="pointer-events-none absolute inset-x-0 top-0 h-28 bg-gradient-to-b from-black/55 to-transparent" />
<div data-ui className="relative z-50">
<AppBar overlay title="La villa, avant / aujourd'hui" onBack={() => history.back()} />
</div>
{/* Marqueurs ancrés dans la scène */}
{HOTSPOTS.map((spot, i) => {
const isHidden = hidden.has(spot.id)
const isActive = selected === spot.id
return (
<button
key={spot.id}
data-ui
ref={el => { markersRef.current[i] = el }}
onClick={() => setSelected(isActive ? null : spot.id)}
className="absolute left-0 top-0 z-30 flex items-center gap-2 rounded-full pl-1.5 pr-3 py-1.5 text-[12.5px] font-semibold shadow-lg transition-colors"
style={{
visibility: 'hidden',
display: isHidden ? 'none' : undefined,
background: isActive ? 'var(--color-primary)' : 'rgba(255,255,255,.94)',
color: isActive ? 'var(--color-on-primary)' : 'var(--color-text)',
backdropFilter: 'blur(6px)',
}}
>
<span
className="grid h-5 w-5 place-items-center rounded-full text-[13px] leading-none"
style={{
background: isActive ? 'rgba(255,255,255,.22)' : 'var(--color-primary)',
color: '#fff',
}}
>
+
</span>
{spot.title}
</button>
)
})}
{/* Panneau des lieux — même composant que la carte */}
<div data-ui>
<FloatingPanel
open={panelOpen}
onOpenChange={setPanelOpen}
side="left"
top={64}
width={330}
title="LIEUX ET CALQUES"
collapsedLabel="Lieux et calques"
badge={visible.length}
footer={<span className="text-[12px]" style={{ color: 'var(--color-text-muted)' }}>{visible.length} lieux · 2 états</span>}
>
<div className="flex flex-col gap-1 p-2">
{HOTSPOTS.map(spot => {
const off = hidden.has(spot.id)
return (
<div
key={spot.id}
className="flex items-start gap-2.5 rounded-xl p-2 transition-colors"
style={{ background: selected === spot.id ? 'var(--color-primary-light)' : 'transparent' }}
>
<input
type="checkbox"
checked={!off}
onChange={() => toggleSpot(spot.id)}
className="mt-1 h-4 w-4 shrink-0 accent-[var(--color-primary)]"
aria-label={`Afficher ${spot.title}`}
/>
<button
onClick={() => setSelected(spot.id)}
className="flex min-w-0 flex-1 items-start gap-2.5 text-left"
>
<span
className="mt-0.5 h-10 w-10 shrink-0 rounded-lg border"
style={{
borderColor: 'var(--color-border)',
background: 'linear-gradient(135deg, #8d8779 0%, #c37b5b 52%, #efe7d8 100%)',
opacity: off ? 0.35 : 1,
}}
/>
<span className="min-w-0">
<span
className="block truncate text-[13.5px] font-semibold"
style={{ color: selected === spot.id ? 'var(--color-primary)' : 'var(--color-text)', opacity: off ? 0.5 : 1 }}
>
{spot.title}
</span>
<span className="block truncate text-[12px]" style={{ color: 'var(--color-text-muted)' }}>
{spot.teaser}
</span>
</span>
</button>
</div>
)
})}
</div>
</FloatingPanel>
</div>
{/* Fiche du point sélectionné */}
{selectedSpot && !hidden.has(selectedSpot.id) && (
<div
data-ui
className="absolute right-3 top-[68px] z-40 w-[min(340px,44%)] overflow-hidden rounded-2xl border shadow-2xl"
style={{ borderColor: 'var(--color-border)', background: 'var(--color-surface)' }}
>
<div className="relative h-[92px]" style={{ background: 'linear-gradient(135deg, #7a5334 0%, #4a331f 100%)' }}>
<button
onClick={() => setSelected(null)}
aria-label="Fermer"
className="absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-full text-white"
style={{ background: 'rgba(0,0,0,.42)' }}
>
</button>
<span className="absolute bottom-2 left-3.5 text-[11px] font-semibold uppercase tracking-[.14em] text-white/70">
{selectedSpot.category}
</span>
</div>
<div className="flex flex-col gap-2 p-3.5">
<h2 className="text-[17px] font-bold leading-tight" style={{ color: 'var(--color-text)' }}>
{selectedSpot.title}
</h2>
<p className="text-[13px] leading-relaxed" style={{ color: 'var(--color-text-muted)' }}>
{selectedSpot.text}
</p>
<button
className="mt-1 self-start rounded-full px-3.5 py-1.5 text-[12.5px] font-semibold"
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }}
>
Voir la fiche complète
</button>
</div>
</div>
)}
{/* Étiquettes d'état en mode comparaison */}
{mode === 'compare' && (
<>
<span
data-ui
className="pointer-events-none absolute left-1/2 top-[74px] z-30 -translate-x-[calc(100%+14px)] rounded-full px-3 py-1 text-[11.5px] font-bold uppercase tracking-[.12em] text-white"
style={{ background: 'rgba(30,22,16,.62)' }}
>
Aujourd&apos;hui
</span>
<span
data-ui
className="pointer-events-none absolute left-1/2 top-[74px] z-30 translate-x-[14px] rounded-full px-3 py-1 text-[11.5px] font-bold uppercase tracking-[.12em]"
style={{ background: 'rgba(250,247,242,.9)', color: '#4a331f' }}
>
Vers 300 ap. J.-C.
</span>
</>
)}
{/* Barre d'états */}
<div
data-ui
className="absolute bottom-4 left-1/2 z-40 flex -translate-x-1/2 items-center gap-1 rounded-2xl border p-1 shadow-2xl"
style={{
borderColor: 'var(--color-border)',
background: 'color-mix(in srgb, var(--color-surface) 92%, transparent)',
backdropFilter: 'blur(14px)',
marginRight: 'var(--mim-assistant-inset-x)',
}}
>
{MODES.map(m => {
const on = mode === m.id
return (
<button
key={m.id}
onClick={() => changeMode(m.id)}
className="flex flex-col items-center rounded-xl px-4 py-2 transition-colors"
style={{
background: on ? 'var(--color-primary)' : 'transparent',
color: on ? 'var(--color-on-primary)' : 'var(--color-text)',
}}
>
<span className="text-[13px] font-bold leading-tight">{m.label}</span>
<span className="text-[10.5px] leading-tight" style={{ opacity: on ? 0.8 : 0.55 }}>{m.sub}</span>
</button>
)
})}
<span className="mx-1 h-8 w-px" style={{ background: 'var(--color-border)' }} />
<button
onClick={() => { camRef.current = { ...VIEW0 } }}
className="rounded-xl px-3 py-2 text-[12.5px] font-semibold"
style={{ color: 'var(--color-primary)' }}
>
Recadrer
</button>
</div>
<span
data-ui
className="pointer-events-none absolute bottom-6 left-4 z-30 text-[11.5px] font-medium"
style={{ color: 'rgba(42,33,26,.62)' }}
>
Glisser pour pivoter · molette pour zoomer{mode === 'compare' ? ' · tirer le rideau pour comparer' : ''}
</span>
{/* Lanceur de l'assistant — présent sur toutes les sections */}
<div data-ui className="pointer-events-none absolute bottom-5 right-5 z-40 flex items-center gap-2.5">
<span
className="rounded-full px-3.5 py-2 text-[13px] font-semibold shadow-lg"
style={{ background: 'var(--color-surface)', color: 'var(--color-text)' }}
>
Une question ?
</span>
<span
className="grid h-[54px] w-[54px] place-items-center rounded-full shadow-xl"
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }}
>
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M21 11.5a8.4 8.4 0 0 1-8.5 8.4 9.2 9.2 0 0 1-3.9-.8L3 21l1.6-4.2A8.2 8.2 0 0 1 3.6 11.5 8.4 8.4 0 0 1 12.1 3 8.4 8.4 0 0 1 21 11.5Z" />
</svg>
</span>
</div>
<span
data-ui
className="pointer-events-none absolute right-4 top-[calc(50%+6px)] z-20 rotate-90 text-[10px] font-bold uppercase tracking-[.2em]"
style={{ color: 'rgba(42,33,26,.28)' }}
>
Maquette de démonstration
</span>
</div>
)
}

View File

@ -0,0 +1,460 @@
/* Maquette 3D de démonstration villa romaine d'Echternach.
Géométrie procédurale + rendu Canvas 2D (projection perspective, tri en
profondeur, éclairage par face). Aucune dépendance : la page sert à montrer
au client à quoi ressemblerait une section « scène 3D », pas à charger un
vrai modèle glTF celui- viendra d'un studio 3D et se chargera via
`model-viewer`. Les volumes sont donc évocateurs, pas archéologiques. */
export type Vec3 = [number, number, number]
export type Face = { pts: Vec3[]; c: string }
export type Camera = { yaw: number; pitch: number; dist: number; tx: number; ty: number; tz: number }
export type VillaState = 'ruins' | 'roman'
const C = {
render: {
wall: '#efe7d8',
wallShade: '#e3d8c4',
roof: '#b4593c',
roofDark: '#9a4529',
column: '#f6f1e6',
stone: '#cfc4ad',
water: '#7ca7bb',
pavement: '#ded4c0',
},
ruins: {
stone: '#8d8779',
stoneTop: '#a39c8d',
gravel: '#c37b5b',
column: '#efe9dd',
entablature: '#e4ddcf',
rail: '#4a4f4a',
},
land: {
lawn: '#8ba46c',
lawnDark: '#7b945d',
hedge: '#4d6a3d',
tree: '#5d7d4a',
trunk: '#6a5741',
river: '#7fa0b4',
path: '#d8cfba',
},
}
/* ---------- primitives ---------- */
type Pt2 = [number, number]
function rect(cx: number, cz: number, w: number, d: number): Pt2[] {
const hw = w / 2, hd = d / 2
return [[cx - hw, cz - hd], [cx + hw, cz - hd], [cx + hw, cz + hd], [cx - hw, cz + hd]]
}
function ngon(cx: number, cz: number, r: number, n: number, rot = 0): Pt2[] {
const p: Pt2[] = []
for (let i = 0; i < n; i++) {
const a = rot + (i * 2 * Math.PI) / n
p.push([cx + r * Math.cos(a), cz + r * Math.sin(a)])
}
return p
}
function prism(outline: Pt2[], y0: number, y1: number, top: string, side: string): Face[] {
const f: Face[] = [{ pts: outline.map(p => [p[0], y1, p[1]] as Vec3), c: top }]
for (let i = 0; i < outline.length; i++) {
const a = outline[i], b = outline[(i + 1) % outline.length]
f.push({ pts: [[a[0], y0, a[1]], [b[0], y0, b[1]], [b[0], y1, b[1]], [a[0], y1, a[1]]], c: side })
}
return f
}
function slab(outline: Pt2[], y: number, c: string): Face[] {
return [{ pts: outline.map(p => [p[0], y, p[1]] as Vec3), c }]
}
/** Toit en croupe : deux versants trapézoïdaux + deux croupes triangulaires. */
function hip(cx: number, cz: number, w: number, d: number, h: number, y0: number, c: string): Face[] {
const hw = w / 2, hd = d / 2, y1 = y0 + h
const e = Math.min(hd, hw * 0.34)
const A: Vec3 = [cx - hw, y0, cz - hd], B: Vec3 = [cx + hw, y0, cz - hd]
const D: Vec3 = [cx + hw, y0, cz + hd], E: Vec3 = [cx - hw, y0, cz + hd]
const R1: Vec3 = [cx - hw + e, y1, cz], R2: Vec3 = [cx + hw - e, y1, cz]
return [
{ pts: [A, B, R2, R1], c },
{ pts: [E, D, R2, R1], c },
{ pts: [A, R1, E], c },
{ pts: [B, D, R2], c },
]
}
function column(cx: number, cz: number, r: number, h: number, c: string): Face[] {
return [
...prism(rect(cx, cz, r * 2.9, r * 2.9), 0, 0.35, c, c),
...prism(ngon(cx, cz, r, 6), 0.35, h, c, c),
...prism(rect(cx, cz, r * 2.9, r * 2.9), h, h + 0.45, c, c),
]
}
function colonnadeLine(x0: number, x1: number, z: number, step: number, r: number, h: number, c: string): Face[] {
const f: Face[] = []
const n = Math.max(1, Math.round(Math.abs(x1 - x0) / step))
for (let i = 0; i <= n; i++) f.push(...column(x0 + ((x1 - x0) * i) / n, z, r, h, c))
return f
}
function colonnadeSide(x: number, z0: number, z1: number, step: number, r: number, h: number, c: string): Face[] {
const f: Face[] = []
const n = Math.max(1, Math.round(Math.abs(z1 - z0) / step))
for (let i = 1; i < n; i++) f.push(...column(x, z0 + ((z1 - z0) * i) / n, r, h, c))
return f
}
function hedge(cx: number, cz: number, w: number, d: number, h = 1.9): Face[] {
return prism(rect(cx, cz, w, d), 0, h, C.land.hedge, C.land.hedge)
}
function tree(cx: number, cz: number, h = 9): Face[] {
return [
...prism(ngon(cx, cz, 0.5, 5), 0, h * 0.35, C.land.trunk, C.land.trunk),
...prism(ngon(cx, cz, 2.6, 6), h * 0.3, h * 0.72, C.land.tree, C.land.tree),
...prism(ngon(cx, cz, 1.6, 6), h * 0.72, h, C.land.tree, C.land.tree),
]
}
/* ---------- le site ---------- */
/* Plan retenu : cour à péristyle au centre, quatre ailes de pièces autour,
bloc thermal à l'ouest, jardins en terrasse au sud, portique ouvert au nord
vers la vallée de la Sûre. */
const COURT = { x: 0, z: 0, w: 36, d: 24 }
const WING = 11
function terrain(): Face[] {
const f: Face[] = [
...slab([[-330, -260], [330, -260], [330, 240], [-330, 240]], -0.6, C.land.lawn),
...slab([[-330, -260], [330, -260], [330, -120], [-330, -120]], -0.3, C.land.river),
...slab(rect(0, -95, 420, 26), -0.2, C.land.lawnDark),
]
return f
}
function baseCourt(state: VillaState): Face[] {
const p = state === 'roman' ? C.render.pavement : C.ruins.gravel
return slab(rect(COURT.x, COURT.z, COURT.w, COURT.d), 0.06, p)
}
function wingsRoman(): Face[] {
const hw = COURT.w / 2 + WING, hd = COURT.d / 2 + WING
const f: Face[] = []
const wallH = 6.4, roofH = 3.6
// Ailes nord et sud
for (const z of [-(COURT.d / 2 + WING / 2), COURT.d / 2 + WING / 2]) {
f.push(...prism(rect(0, z, hw * 2, WING), 0, wallH, C.render.wall, C.render.wallShade))
f.push(...hip(0, z, hw * 2 + 1.4, WING + 1.4, roofH, wallH, C.render.roof))
}
// Ailes est et ouest
for (const x of [-(COURT.w / 2 + WING / 2), COURT.w / 2 + WING / 2]) {
f.push(...prism(rect(x, 0, WING, COURT.d), 0, wallH, C.render.wall, C.render.wallShade))
f.push(...hip(x, 0, WING + 1.4, COURT.d + 1.4, roofH, wallH, C.render.roof))
}
// Péristyle : colonnade sur les quatre côtés de la cour + auvent
const cx = COURT.w / 2 - 1.5, cz = COURT.d / 2 - 1.5
f.push(...colonnadeLine(-cx, cx, -cz, 5, 0.55, 4.4, C.render.column))
f.push(...colonnadeLine(-cx, cx, cz, 5, 0.55, 4.4, C.render.column))
f.push(...colonnadeSide(-cx, -cz, cz, 5, 0.55, 4.4, C.render.column))
f.push(...colonnadeSide(cx, -cz, cz, 5, 0.55, 4.4, C.render.column))
f.push(...prism(rect(0, -cz, cx * 2 + 3, 3.4), 4.85, 5.5, C.render.roofDark, C.render.wall))
f.push(...prism(rect(0, cz, cx * 2 + 3, 3.4), 4.85, 5.5, C.render.roofDark, C.render.wall))
f.push(...prism(rect(-cx, 0, 3.4, cz * 2 - 3), 4.85, 5.5, C.render.roofDark, C.render.wall))
f.push(...prism(rect(cx, 0, 3.4, cz * 2 - 3), 4.85, 5.5, C.render.roofDark, C.render.wall))
// Tour d'angle / pavillon d'entrée à l'est
f.push(...prism(rect(hw + 5, 0, 14, 16), 0, 9.5, C.render.wall, C.render.wallShade))
f.push(...hip(hw + 5, 0, 15.4, 17.4, 4.6, 9.5, C.render.roof))
return f
}
function bathsRoman(): Face[] {
const x = -(COURT.w / 2 + WING) - 14
return [
...prism(rect(x, -4, 26, 30), 0, 7.2, C.render.wall, C.render.wallShade),
...hip(x, -4, 27.4, 31.4, 4.2, 7.2, C.render.roof),
...prism(ngon(x - 15, -4, 7, 8, Math.PI / 8), 0, 5.6, C.render.wall, C.render.wallShade),
...prism(ngon(x - 15, -4, 7.8, 8, Math.PI / 8), 5.6, 8.4, C.render.roofDark, C.render.roof),
...prism(rect(x, 17, 9, 10), 0, 5, C.render.wall, C.render.wallShade),
...hip(x, 17, 10, 11, 2.8, 5, C.render.roof),
]
}
function porticoRoman(): Face[] {
const z = -(COURT.d / 2 + WING) - 7
return [
...slab(rect(0, z, 96, 8), 0.5, C.render.pavement),
...prism(rect(0, z, 96, 8), 0, 0.5, C.render.stone, C.render.stone),
...colonnadeLine(-46, 46, z - 2.6, 5.4, 0.6, 5.2, C.render.column),
...prism(rect(0, z - 2.6, 98, 2.6), 5.65, 6.5, C.render.roofDark, C.render.wall),
]
}
function gardensRoman(): Face[] {
const f: Face[] = [...slab(rect(0, 52, 132, 56), 0.12, C.land.lawnDark)]
for (const x of [-26, 26]) {
f.push(...prism(rect(x, 46, 26, 8), 0.12, 1.1, C.render.stone, C.render.stone))
f.push(...slab(rect(x, 46, 23, 5.6), 0.9, C.render.water))
}
f.push(...prism(rect(0, 46, 6, 8), 0.12, 1.1, C.render.stone, C.render.stone))
for (let i = 0; i < 5; i++) {
f.push(...hedge(-52 + i * 26, 66, 20, 2.2, 1.6))
f.push(...hedge(-52 + i * 26, 76, 20, 2.2, 1.6))
}
for (const x of [-62, 62]) for (const z of [36, 58, 80]) f.push(...tree(x, z, 10))
return f
}
function wingsRuins(): Face[] {
/* Ce qui se voit sur place : arases de murs à hauteur d'homme au plus,
sols de gravier rouge dans les pièces, cloisons devinées au tracé. */
const hw = COURT.w / 2 + WING, hd = COURT.d / 2 + WING
const f: Face[] = []
const H = 1.15
const band = (cx: number, cz: number, w: number, d: number): Face[] => {
const t = 1.1
return [
...slab(rect(cx, cz, w, d), 0.08, C.ruins.gravel),
...prism(rect(cx, cz - d / 2, w, t), 0, H, C.ruins.stoneTop, C.ruins.stone),
...prism(rect(cx, cz + d / 2, w, t), 0, H, C.ruins.stoneTop, C.ruins.stone),
...prism(rect(cx - w / 2, cz, t, d), 0, H, C.ruins.stoneTop, C.ruins.stone),
...prism(rect(cx + w / 2, cz, t, d), 0, H, C.ruins.stoneTop, C.ruins.stone),
]
}
for (const z of [-(COURT.d / 2 + WING / 2), COURT.d / 2 + WING / 2]) {
f.push(...band(0, z, hw * 2, WING))
for (let i = -3; i <= 3; i++) f.push(...prism(rect(i * 13, z, 1.1, WING), 0, H, C.ruins.stoneTop, C.ruins.stone))
}
for (const x of [-(COURT.w / 2 + WING / 2), COURT.w / 2 + WING / 2]) {
f.push(...band(x, 0, WING, COURT.d))
for (const z of [-8, 0, 8]) f.push(...prism(rect(x, z, WING, 1.1), 0, H, C.ruins.stoneTop, C.ruins.stone))
}
f.push(...prism(rect(hw + 5, 0, 14, 16), 0, H, C.ruins.stoneTop, C.ruins.stone))
f.push(...slab(rect(hw + 5, 0, 12, 14), 0.09, C.ruins.gravel))
// Stylobate du péristyle : les bases restent, les fûts ont disparu
const cx = COURT.w / 2 - 1.5, cz = COURT.d / 2 - 1.5
for (const z of [-cz, cz]) f.push(...prism(rect(0, z, cx * 2, 1.6), 0, 0.55, C.ruins.stoneTop, C.ruins.stone))
for (const x of [-cx, cx]) f.push(...prism(rect(x, 0, 1.6, cz * 2), 0, 0.55, C.ruins.stoneTop, C.ruins.stone))
return f
}
function bathsRuins(): Face[] {
const x = -(COURT.w / 2 + WING) - 14
const H = 1.25
const f: Face[] = [...slab(rect(x, -4, 26, 30), 0.08, C.ruins.gravel)]
f.push(...prism(rect(x, -19, 26, 1.2), 0, H, C.ruins.stoneTop, C.ruins.stone))
f.push(...prism(rect(x, 11, 26, 1.2), 0, H, C.ruins.stoneTop, C.ruins.stone))
f.push(...prism(rect(x - 13, -4, 1.2, 30), 0, H, C.ruins.stoneTop, C.ruins.stone))
f.push(...prism(rect(x + 13, -4, 1.2, 30), 0, H, C.ruins.stoneTop, C.ruins.stone))
for (const z of [-12, -4, 4]) f.push(...prism(rect(x, z, 26, 1.2), 0, H, C.ruins.stoneTop, C.ruins.stone))
f.push(...prism(ngon(x - 15, -4, 7, 8, Math.PI / 8), 0, 0.9, C.ruins.stoneTop, C.ruins.stone))
// Hypocauste : pilettes de briques sous le sol de la salle chaude
for (let i = 0; i < 4; i++)
for (let j = 0; j < 3; j++)
f.push(...prism(rect(x - 8 + i * 5.5, -9 + j * 4.5, 1.3, 1.3), 0.08, 0.8, '#a35b42', '#8f4c36'))
return f
}
function porticoRuins(): Face[] {
/* Le seul volume relevé sur le site : une file de colonnes remontées avec
son entablement, exactement le repère visuel des photos. */
const z = -(COURT.d / 2 + WING) - 7
return [
...slab(rect(0, z, 96, 8), 0.4, C.ruins.gravel),
...prism(rect(0, z, 96, 8), 0, 0.4, C.ruins.stoneTop, C.ruins.stone),
...colonnadeLine(-46, 46, z - 2.6, 5.4, 0.6, 5.2, C.ruins.column),
...prism(rect(0, z - 2.6, 98, 2.2), 5.65, 6.4, C.ruins.entablature, C.ruins.entablature),
]
}
function gardensRuins(): Face[] {
const f: Face[] = [...slab(rect(0, 52, 132, 56), 0.12, C.land.lawn)]
for (const x of [-26, 26]) {
f.push(...prism(rect(x, 46, 26, 8), 0.12, 0.75, C.ruins.stoneTop, C.ruins.stone))
f.push(...slab(rect(x, 46, 23, 5.6), 0.5, C.ruins.gravel))
}
// Haies et pelouse tondue : la mise en valeur contemporaine du site
for (const x of [-70, 70]) f.push(...hedge(x, 30, 4, 120, 2.4))
f.push(...hedge(0, 92, 144, 4, 2.4))
for (const x of [-58, -20, 20, 58]) f.push(...tree(x, 74, 11))
f.push(...slab(rect(0, 20, 150, 4), 0.14, C.land.path))
f.push(...slab(rect(-60, 40, 4, 44), 0.14, C.land.path))
return f
}
export function buildVilla(state: VillaState): Face[] {
if (state === 'roman') {
return [
...terrain(),
...gardensRoman(),
...baseCourt('roman'),
...porticoRoman(),
...bathsRoman(),
...wingsRoman(),
]
}
return [
...terrain(),
...gardensRuins(),
...baseCourt('ruins'),
...porticoRuins(),
...bathsRuins(),
...wingsRuins(),
]
}
/* ---------- points d'intérêt ---------- */
export type Hotspot = {
id: string
title: string
category: string
teaser: string
text: string
pos: Vec3
}
export const HOTSPOTS: Hotspot[] = [
{
id: 'peristyle',
title: 'La cour à péristyle',
category: 'Le bâtiment',
teaser: 'Le cœur de la villa et sa galerie de colonnes.',
text: "Toutes les pièces de réception ouvraient sur cette cour couverte d'une galerie. Aujourd'hui seules subsistent les fondations et le socle continu qui portait les colonnes : c'est ce vide central que la restitution remplit.",
pos: [0, 5, 0],
},
{
id: 'thermes',
title: 'Les thermes',
category: 'Le bâtiment',
teaser: 'Salles froide, tiède et chaude, chauffées par le sol.',
text: "Le bloc thermal occupe l'aile ouest, avec son abside et son hypocauste — ces piles de briques qui laissaient circuler l'air chaud sous le pavement. Les pilettes sont encore en place sur le site.",
pos: [-61, 4, -4],
},
{
id: 'jardins',
title: 'Les jardins en terrasse',
category: 'Extérieurs',
teaser: "Bassins d'agrément et parterres descendant vers le sud.",
text: "Deux longs bassins encadraient l'axe du jardin, en contrebas des pièces de réception. Le tracé se lit toujours au sol : la restitution leur rend leur eau et leurs parterres.",
pos: [-26, 3, 46],
},
{
id: 'portique',
title: 'Le portique sur la vallée',
category: 'Panorama',
teaser: 'La façade ouverte sur la Sûre et sa voie.',
text: "La villa tournait sa plus belle façade vers la vallée. C'est la seule colonnade remontée sur le site : elle sert de repère pour passer d'un état à l'autre.",
pos: [0, 7.5, -48],
},
]
/* ---------- rendu ---------- */
const LIGHT = ((): Vec3 => {
const v: Vec3 = [-0.38, 0.87, -0.31]
const m = Math.hypot(v[0], v[1], v[2])
return [v[0] / m, v[1] / m, v[2] / m]
})()
export function cameraPosition(cam: Camera): Vec3 {
const cp = Math.cos(cam.pitch), sp = Math.sin(cam.pitch)
const cy = Math.cos(cam.yaw), sy = Math.sin(cam.yaw)
return [cam.tx - cam.dist * sy * cp, cam.ty + cam.dist * sp, cam.tz - cam.dist * cy * cp]
}
export function project(p: Vec3, cam: Camera, w: number, h: number) {
const x = p[0] - cam.tx, y = p[1] - cam.ty, z = p[2] - cam.tz
const cy = Math.cos(cam.yaw), sy = Math.sin(cam.yaw)
const x1 = x * cy - z * sy, z1 = x * sy + z * cy
const cp = Math.cos(cam.pitch), sp = Math.sin(cam.pitch)
const y2 = y * cp + z1 * sp, z2 = z1 * cp - y * sp
const zc = z2 + cam.dist
if (zc < 6) return null
const f = 0.95 * Math.min(w, h * 1.6)
return { x: w / 2 + (x1 * f) / zc, y: h / 2 - (y2 * f) / zc, z: zc }
}
const shadeCache = new Map<string, string>()
function shade(hex: string, k: number) {
const q = Math.round(k * 32) / 32
const key = `${hex}|${q}`
const hit = shadeCache.get(key)
if (hit) return hit
const n = parseInt(hex.slice(1), 16)
const r = Math.min(255, Math.round(((n >> 16) & 255) * q))
const g = Math.min(255, Math.round(((n >> 8) & 255) * q))
const b = Math.min(255, Math.round((n & 255) * q))
const out = `rgb(${r},${g},${b})`
shadeCache.set(key, out)
return out
}
export function drawFaces(
ctx: CanvasRenderingContext2D,
faces: Face[],
cam: Camera,
w: number,
h: number,
alpha = 1,
) {
const cp = cameraPosition(cam)
const out: { pts: { x: number; y: number }[]; fill: string; z: number }[] = []
for (const f of faces) {
const pr: { x: number; y: number; z: number }[] = []
let ok = true
for (const p of f.pts) {
const q = project(p, cam, w, h)
if (!q) { ok = false; break }
pr.push(q)
}
if (!ok) continue
const [p0, p1, p2] = f.pts
const ax = p1[0] - p0[0], ay = p1[1] - p0[1], az = p1[2] - p0[2]
const bx = p2[0] - p0[0], by = p2[1] - p0[1], bz = p2[2] - p0[2]
let nx = ay * bz - az * by, ny = az * bx - ax * bz, nz = ax * by - ay * bx
const ln = Math.hypot(nx, ny, nz) || 1
nx /= ln; ny /= ln; nz /= ln
let cxw = 0, cyw = 0, czw = 0
for (const p of f.pts) { cxw += p[0]; cyw += p[1]; czw += p[2] }
cxw /= f.pts.length; cyw /= f.pts.length; czw /= f.pts.length
if ((cp[0] - cxw) * nx + (cp[1] - cyw) * ny + (cp[2] - czw) * nz < 0) { nx = -nx; ny = -ny; nz = -nz }
const dot = nx * LIGHT[0] + ny * LIGHT[1] + nz * LIGHT[2]
let zavg = 0
for (const q of pr) zavg += q.z
out.push({ pts: pr, fill: shade(f.c, 0.6 + 0.5 * Math.max(0, dot)), z: zavg / pr.length })
}
out.sort((a, b) => b.z - a.z)
ctx.globalAlpha = alpha
ctx.lineJoin = 'round'
for (const o of out) {
ctx.beginPath()
ctx.moveTo(o.pts[0].x, o.pts[0].y)
for (let i = 1; i < o.pts.length; i++) ctx.lineTo(o.pts[i].x, o.pts[i].y)
ctx.closePath()
ctx.fillStyle = o.fill
ctx.fill()
ctx.strokeStyle = 'rgba(40,34,26,.08)'
ctx.lineWidth = 1
ctx.stroke()
}
ctx.globalAlpha = 1
}
export function drawSky(ctx: CanvasRenderingContext2D, w: number, h: number) {
const g = ctx.createLinearGradient(0, 0, 0, h)
g.addColorStop(0, '#c9d6dd')
g.addColorStop(0.5, '#dfe3dc')
g.addColorStop(1, '#c2c9b4')
ctx.fillStyle = g
ctx.fillRect(0, 0, w, h)
}

View File

@ -8,6 +8,16 @@
--color-text-muted: #6b7280; --color-text-muted: #6b7280;
--color-border: #e5e7eb; --color-border: #e5e7eb;
/* Palette neutre partagee des sections : papier creme + encre bleu ardoise.
Independante des couleurs d'instance, portees par --color-primary. */
--color-paper: #f6f3ee;
--color-paper-soft: #efeae2;
--color-paper-accent: #e8e3da;
--color-paper-border: #e2dcd2;
--color-ink: #1e2a33;
--color-ink-soft: #46555f;
--color-ink-muted: #54636e;
/* Dynamic tokens — overridden per instance/config via inline style on <html> */ /* Dynamic tokens — overridden per instance/config via inline style on <html> */
--color-primary: #264863; --color-primary: #264863;
--color-secondary: #c2c9d6; --color-secondary: #c2c9d6;
@ -25,6 +35,13 @@
--color-text: var(--color-text); --color-text: var(--color-text);
--color-text-muted: var(--color-text-muted); --color-text-muted: var(--color-text-muted);
--color-border: var(--color-border); --color-border: var(--color-border);
--color-paper: var(--color-paper);
--color-paper-soft: var(--color-paper-soft);
--color-paper-accent: var(--color-paper-accent);
--color-paper-border: var(--color-paper-border);
--color-ink: var(--color-ink);
--color-ink-soft: var(--color-ink-soft);
--color-ink-muted: var(--color-ink-muted);
} }
body { body {
@ -32,3 +49,51 @@ body {
color: var(--color-text); color: var(--color-text);
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
} }
/* Encombrement des calques flottants
Le lanceur de l'assistant occupe le coin bas-droit sur toutes les sections
(monté dans [configId]/layout.tsx). Toute section qui pose un bouton ou une
barre flottante s'écarte via ces deux utilitaires plutôt qu'en recalculant
des coordonnées dans son coin sinon la collision revient à la section
suivante. Sans assistant, les insets valent 0 et rien ne bouge. */
:root {
--mim-assistant-inset-y: 0px;
--mim-assistant-inset-x: 0px;
}
[data-assistant='true'] {
/* 54px de lanceur + 12px de respiration ; en x on ajoute la marge droite. */
--mim-assistant-inset-y: 66px;
--mim-assistant-inset-x: 82px;
}
/* Élément ancré en bas à droite : il se range au-dessus du lanceur.
`margin-bottom` décale un élément en position absolue/fixe qui pose `bottom`. */
.mim-clear-assistant { margin-bottom: var(--mim-assistant-inset-y); }
/* Une barre pleine largeur ne peut pas se décaler sans laisser un trou : elle
compose plutôt l'inset dans son propre padding, d' les variables ci-dessus. */
/* Bouton flottant du scanner QR même gabarit que le lanceur de l'assistant.
Quand l'assistant est actif, il occupe le coin bas-droit et le scanner se
range juste au-dessus. */
.mim-fab-qr {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 1200;
width: 54px;
height: 54px;
border-radius: 999px;
border: none;
display: grid;
place-items: center;
cursor: pointer;
background: var(--color-surface);
color: var(--color-primary);
box-shadow: 0 10px 26px -8px rgba(0, 0, 0, .45), 0 1px 3px rgba(0, 0, 0, .18);
transition: transform .22s cubic-bezier(.2, .7, .3, 1);
}
.mim-fab-qr:hover { transform: translateY(-2px) scale(1.04); }
.mim-fab-qr svg { width: 26px; height: 26px; }
[data-assistant='true'] .mim-fab-qr { bottom: calc(20px + var(--mim-assistant-inset-y)); }

View File

@ -92,12 +92,7 @@ export default function QRScannerButton({ slug, configurationId }: Props) {
<> <>
<button <button
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
className="fixed bottom-5 right-5 w-14 h-14 rounded-full flex items-center justify-center z-40" className="mim-fab-qr"
style={{
background: 'rgba(255,255,255,0.95)',
color: 'var(--color-primary)',
boxShadow: '0 4px 20px rgba(0,0,0,0.35)',
}}
aria-label="Scanner un QR code" aria-label="Scanner un QR code"
> >
<svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor"> <svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor">

View File

@ -1,11 +1,12 @@
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useBack } from '@/hooks/useBack' import { useBack } from '@/hooks/useBack'
import ResourceViewer from '@/components/ui/ResourceViewer' import { useIsLandscape, useMediaQuery } from '@/hooks/useOrientation'
import ResourceViewer, { isImageResource } from '@/components/ui/ResourceViewer'
import { useVisitor } from '@/context/VisitorContext' import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n' import { t, tPlain } from '@/lib/i18n'
import type { SectionDTO } from '@/lib/api/types' import type { ContentDTO, SectionDTO } from '@/lib/api/types'
import AppBar from '@/components/ui/AppBar' import AppBar from '@/components/ui/AppBar'
import { trackEvent } from '@/lib/stats' import { trackEvent } from '@/lib/stats'
@ -20,11 +21,14 @@ export default function ArticleSection({ section, configId, languages }: Props)
const { language, setAvailableLanguages, instanceId } = useVisitor() const { language, setAvailableLanguages, instanceId } = useVisitor()
const back = useBack() const back = useBack()
const article = section.article const article = section.article
const isLandscape = useIsLandscape()
// Sous cette hauteur, un lecteur ancré mangerait la colonne : il repasse en flottant.
const hasRoomForDockedAudio = useMediaQuery('(min-height: 480px)')
const [isPlaying, setIsPlaying] = useState(false) const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0) const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0) const [duration, setDuration] = useState(0)
const audioRef = useRef<HTMLAudioElement>(null) const audioRef = useRef<HTMLAudioElement>(null)
const scrollRef = useRef<HTMLElement>(null) const scrollRef = useRef<HTMLElement | null>(null)
const articleReadTrackedRef = useRef(false) const articleReadTrackedRef = useRef(false)
useEffect(() => { setAvailableLanguages([]) }, [languages]) useEffect(() => { setAvailableLanguages([]) }, [languages])
@ -32,22 +36,29 @@ export default function ArticleSection({ section, configId, languages }: Props)
useEffect(() => { useEffect(() => {
const el = scrollRef.current const el = scrollRef.current
if (!el || !instanceId) return if (!el || !instanceId) return
const onScroll = () => {
const track = () => {
if (articleReadTrackedRef.current) return if (articleReadTrackedRef.current) return
articleReadTrackedRef.current = true
trackEvent({
instanceId, configurationId: configId, sectionId: section.id,
eventType: 'ArticleRead', language,
})
}
// Un article qui tient dans l'écran ne produit aucun scroll : il est lu d'emblée.
if (el.scrollHeight - el.clientHeight <= 0) {
track()
return
}
const onScroll = () => {
const total = el.scrollHeight - el.clientHeight const total = el.scrollHeight - el.clientHeight
if (total <= 0) return if (total > 0 && el.scrollTop / total >= 0.8) track()
const ratio = el.scrollTop / total
if (ratio >= 0.8) {
articleReadTrackedRef.current = true
trackEvent({
instanceId, configurationId: configId, sectionId: section.id,
eventType: 'ArticleRead', language,
})
}
} }
el.addEventListener('scroll', onScroll, { passive: true }) el.addEventListener('scroll', onScroll, { passive: true })
return () => el.removeEventListener('scroll', onScroll) return () => el.removeEventListener('scroll', onScroll)
}, [instanceId, configId, section.id, language]) }, [instanceId, configId, section.id, language, isLandscape])
const audioUrl = article?.audioIds?.find((a) => a.language === language)?.value const audioUrl = article?.audioIds?.find((a) => a.language === language)?.value
?? article?.audioIds?.[0]?.value ?? article?.audioIds?.[0]?.value
@ -59,132 +70,314 @@ export default function ArticleSection({ section, configId, languages }: Props)
} }
}, [article?.isReadAudioAuto]) }, [article?.isReadAudioAuto])
function seekAudio(ratio: number) {
if (!audioRef.current || !duration) return
const time = ratio * duration
audioRef.current.currentTime = time
setCurrentTime(time)
}
function toggleAudio() { function toggleAudio() {
if (!audioRef.current) return if (!audioRef.current) return
if (isPlaying) { audioRef.current.pause(); setIsPlaying(false) } if (isPlaying) { audioRef.current.pause(); setIsPlaying(false) }
else { audioRef.current.play(); setIsPlaying(true) } else { audioRef.current.play(); setIsPlaying(true) }
} }
const setScrollTarget = useCallback((el: HTMLElement | null) => { scrollRef.current = el }, [])
const contents = [...(article?.contents ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) const contents = [...(article?.contents ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
const htmlContent = t(article?.content, language) const htmlContent = t(article?.content, language)
const contentTop = article?.isContentTop ?? false
const hasMedia = contents.length > 0
const hasText = htmlContent.length > 0
const carousel = <MediaCarousel contents={contents} language={language} fill={isLandscape} />
const mediaPane = hasMedia && (
isLandscape ? carousel : <div style={{ padding: '12px 12px 0' }}>{carousel}</div>
)
const textPane = hasText && (
<div
ref={isLandscape ? setScrollTarget : undefined}
style={isLandscape
? { height: '100%', overflowY: 'auto', padding: '4px 6px' }
: { padding: '20px 16px' }}
>
<div
className="prose prose-sm max-w-none"
style={{ color: 'var(--color-text)', maxWidth: isLandscape || !hasMedia ? '70ch' : undefined, margin: isLandscape || !hasMedia ? '0 auto' : undefined }}
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
</div>
)
const panes = (contentTop ? [textPane, mediaPane] : [mediaPane, textPane]).filter(Boolean)
const isAudioDocked = isLandscape && hasRoomForDockedAudio
const isAudioCompact = isLandscape && !hasRoomForDockedAudio
const audioCard = audioUrl && (
<div style={{ borderRadius: 16, overflow: 'hidden', background: 'var(--color-surface)', border: '1px solid var(--color-paper-accent)', boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: isAudioCompact ? '8px 10px' : '12px 14px' }}>
<button
onClick={toggleAudio}
aria-label={isPlaying ? 'Mettre en pause' : 'Écouter le guide audio'}
style={{ width: isAudioCompact ? 34 : 44, height: isAudioCompact ? 34 : 44, borderRadius: '50%', border: 'none', cursor: 'pointer', flexShrink: 0, background: 'var(--color-ink)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
{isPlaying ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="#fff"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="#fff"><path d="M8 5v14l11-7z"/></svg>
)}
</button>
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ color: 'var(--color-ink-muted)', fontSize: 11, fontWeight: 700, letterSpacing: '0.5px' }}>GUIDE AUDIO</span>
{!isAudioCompact && (
<div style={{ marginTop: 5 }}>
<Waveform currentTime={currentTime} duration={duration} onSeek={seekAudio} />
</div>
)}
</div>
<span style={{ color: 'var(--color-ink-muted)', fontSize: 12.5, fontWeight: 600, flexShrink: 0 }}>
{duration > 0 ? formatTime(duration) : '--:--'}
</span>
</div>
{/* La variante compacte masque la waveform : la barre reste son seul indicateur. */}
{isAudioCompact && duration > 0 && (
<div style={{ height: 3, background: 'var(--color-paper-soft)' }}>
<div style={{ height: '100%', width: `${(currentTime / duration) * 100}%`, background: 'var(--color-primary)', transition: 'width 0.3s linear' }} />
</div>
)}
</div>
)
return ( return (
<div className="min-h-screen flex flex-col" style={{ background: '#F6F3EE' }}> <div
style={isLandscape
? { position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', background: 'var(--color-paper)' }
: { minHeight: '100vh', display: 'flex', flexDirection: 'column', background: 'var(--color-paper)' }}
>
<AppBar title={tPlain(section.title, language)} onBack={back} /> <AppBar title={tPlain(section.title, language)} onBack={back} />
<main ref={scrollRef} className="flex-1 overflow-y-auto pb-24">
{/* Carousel images */}
{contents.length > 0 && (
<ImageCarousel contents={contents} language={language} />
)}
{/* HTML content */}
{htmlContent && (
<div
className="px-4 py-5 prose prose-sm max-w-none"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
)}
</main>
{/* Floating audio player */}
{audioUrl && ( {audioUrl && (
<> <audio
<audio ref={audioRef}
ref={audioRef} src={audioUrl}
src={audioUrl} onTimeUpdate={() => setCurrentTime(audioRef.current?.currentTime ?? 0)}
onTimeUpdate={() => setCurrentTime(audioRef.current?.currentTime ?? 0)} onLoadedMetadata={() => setDuration(audioRef.current?.duration ?? 0)}
onLoadedMetadata={() => setDuration(audioRef.current?.duration ?? 0)} onEnded={() => setIsPlaying(false)}
onEnded={() => setIsPlaying(false)}
/>
<div style={{ position: 'fixed', left: 0, right: 0, bottom: 0, padding: '14px 16px 16px', background: 'linear-gradient(to top, #F6F3EE 78%, transparent)' }}>
<div style={{ borderRadius: 16, overflow: 'hidden', background: '#fff', border: '1px solid #E8E3DA', boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px' }}>
<button
onClick={toggleAudio}
style={{ width: 44, height: 44, borderRadius: '50%', border: 'none', cursor: 'pointer', flexShrink: 0, background: '#1E2A33', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
{isPlaying ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="#fff"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="#fff"><path d="M8 5v14l11-7z"/></svg>
)}
</button>
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ color: '#6B7B86', fontSize: 11, fontWeight: 700, letterSpacing: '0.5px' }}>GUIDE AUDIO</span>
<div style={{ marginTop: 5 }}>
<WaveformDecor currentTime={currentTime} duration={duration} />
</div>
</div>
<span style={{ color: '#6B7B86', fontSize: 12.5, fontWeight: 600, flexShrink: 0 }}>
{duration > 0 ? formatTime(duration) : '--:--'}
</span>
</div>
{duration > 0 && (
<div style={{ height: 3, background: '#F0EBE3' }}>
<div style={{ height: '100%', width: `${(currentTime / duration) * 100}%`, background: 'var(--color-primary)', transition: 'width 0.3s linear' }} />
</div>
)}
</div>
</div>
</>
)}
</div>
)
}
function ImageCarousel({ contents, language }: { contents: NonNullable<SectionDTO['article']>['contents'] & object; language: string }) {
const [index, setIndex] = useState(0)
if (!contents || contents.length === 0) return null
return (
<div className="relative w-full" style={{ height: 240 }}>
{contents[index]?.resource && (
<ResourceViewer
resource={contents[index].resource!}
alt={tPlain(contents[index].title, language)}
objectFit="cover"
/> />
)} )}
{contents.length > 1 && (
<> {isLandscape ? (
<button <div style={{ flex: 1, minHeight: 0, display: 'flex', padding: '8px 10px 10px', gap: 10 }}>
onClick={() => setIndex((i) => Math.max(0, i - 1))} {panes.map((pane, i) => (
disabled={index === 0} <div key={i} style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', background: 'rgba(0,0,0,0.35)', border: 'none', cursor: 'pointer', borderRadius: '50%', width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: index === 0 ? 0.3 : 1 }} <div style={{ flex: 1, minHeight: 0 }}>{pane}</div>
> {i === 0 && isAudioDocked && audioCard}
<svg width="16" height="16" viewBox="0 0 24 24" fill="white"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg> </div>
</button> ))}
<button </div>
onClick={() => setIndex((i) => Math.min(contents.length - 1, i + 1))} ) : (
disabled={index === contents.length - 1} <main
style={{ position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)', background: 'rgba(0,0,0,0.35)', border: 'none', cursor: 'pointer', borderRadius: '50%', width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: index === contents.length - 1 ? 0.3 : 1 }} ref={setScrollTarget}
> style={{ flex: 1, overflowY: 'auto', paddingBottom: audioUrl ? 96 : 16 }}
<svg width="16" height="16" viewBox="0 0 24 24" fill="white"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg> >
</button> {panes[0]}
<div className="absolute bottom-2 left-0 right-0 flex justify-center gap-1"> {panes[1]}
{contents.map((_, i) => ( </main>
<div key={i} className="w-1.5 h-1.5 rounded-full" style={{ background: i === index ? 'white' : 'rgba(255,255,255,0.4)' }} /> )}
))}
</div> {audioCard && !isAudioDocked && (
</> <div
style={isAudioCompact
? { position: 'fixed', left: 12, bottom: 12, width: 'min(46%, 320px)' }
: { position: 'fixed', left: 0, right: 0, bottom: 0, padding: '14px 16px 16px', background: 'linear-gradient(to top, var(--color-paper) 78%, transparent)' }}
>
{audioCard}
</div>
)} )}
</div> </div>
) )
} }
function WaveformDecor({ currentTime, duration }: { currentTime: number; duration: number }) { function MediaCarousel({ contents, language, fill }: { contents: ContentDTO[]; language: string; fill: boolean }) {
const bars = [4, 8, 13, 10, 16, 11, 7, 14, 9, 5, 12, 8, 10, 15, 7, 11, 13, 6, 14, 9, 5, 12, 10, 7, 13] const [index, setIndex] = useState(0)
const progress = duration > 0 ? currentTime / duration : 0 const [zoomed, setZoomed] = useState<ContentDTO | null>(null)
const total = bars.length * 5
const current = contents[index]
const caption = tPlain(current?.title, language)
const canZoom = isImageResource(current?.resource?.type)
&& (caption.length > 0 || t(current?.description, language).length > 0)
return ( return (
<svg width={total} height={18} viewBox={`0 0 ${total} 18`}> <div
{bars.map((h, i) => ( style={{
<rect key={i} x={i * 5} y={(18 - h) / 2} width={3} height={h} rx={1.5} display: 'flex', flexDirection: 'column', minHeight: 0,
fill={i / bars.length <= progress ? 'var(--color-primary)' : 'rgba(100,110,120,0.2)'} /> height: fill ? '100%' : undefined,
borderRadius: 20, overflow: 'hidden',
background: 'var(--color-surface)',
border: '1px solid var(--color-paper-accent)',
}}
>
<div style={{ position: 'relative', flex: fill ? '1 1 auto' : '0 0 240px', minHeight: 0, background: 'var(--color-paper-soft)' }}>
{current?.resource && (
<ResourceViewer
resource={current.resource}
alt={caption}
objectFit="contain"
/>
)}
{canZoom && (
<button
onClick={() => setZoomed(current)}
aria-label="Agrandir l'image"
style={{ position: 'absolute', inset: 0, background: 'transparent', border: 'none', cursor: 'zoom-in' }}
/>
)}
{contents.length > 1 && (
<>
<button
onClick={() => setIndex((i) => Math.max(0, i - 1))}
disabled={index === 0}
aria-label="Média précédent"
style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', background: 'rgba(0,0,0,0.35)', border: 'none', cursor: 'pointer', borderRadius: '50%', width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: index === 0 ? 0.3 : 1 }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="white"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button>
<button
onClick={() => setIndex((i) => Math.min(contents.length - 1, i + 1))}
disabled={index === contents.length - 1}
aria-label="Média suivant"
style={{ position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)', background: 'rgba(0,0,0,0.35)', border: 'none', cursor: 'pointer', borderRadius: '50%', width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: index === contents.length - 1 ? 0.3 : 1 }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="white"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</button>
</>
)}
</div>
{caption && (
<p style={{ margin: 0, padding: '8px 12px 0', fontSize: 13, fontWeight: 600, color: 'var(--color-ink)', textAlign: 'center' }}>
{caption}
</p>
)}
{contents.length > 1 && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '8px 0 10px' }}>
<div style={{ display: 'flex', gap: 5 }}>
{contents.map((_, i) => (
<button
key={i}
onClick={() => setIndex(i)}
aria-label={`Média ${i + 1}`}
style={{ width: i === index ? 18 : 7, height: 7, borderRadius: 9999, border: 'none', cursor: 'pointer', transition: 'width 0.2s', background: i === index ? 'var(--color-primary)' : 'var(--color-paper-accent)' }}
/>
))}
</div>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-ink-muted)', fontVariantNumeric: 'tabular-nums' }}>
{index + 1}/{contents.length}
</span>
</div>
)}
{zoomed && (
<MediaDialog content={zoomed} language={language} onClose={() => setZoomed(null)} />
)}
</div>
)
}
function MediaDialog({ content, language, onClose }: { content: ContentDTO; language: string; onClose: () => void }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
const title = t(content.title, language)
const description = t(content.description, language)
return (
<div
role="dialog"
aria-modal="true"
onClick={onClose}
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(20,26,31,0.72)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
>
<div
onClick={(e) => e.stopPropagation()}
style={{ background: 'var(--color-surface)', borderRadius: 20, overflow: 'hidden', width: 'min(680px, 100%)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, padding: '16px 16px 0' }}>
{title && (
<div
className="prose prose-sm max-w-none"
style={{ flex: 1, color: 'var(--color-ink)', fontWeight: 600 }}
dangerouslySetInnerHTML={{ __html: title }}
/>
)}
<button
onClick={onClose}
aria-label="Fermer"
style={{ marginLeft: 'auto', width: 32, height: 32, flexShrink: 0, borderRadius: '50%', border: 'none', cursor: 'pointer', background: 'var(--color-paper-soft)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="var(--color-ink)"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
</div>
<div style={{ position: 'relative', flex: '1 1 auto', minHeight: 220, margin: 16, borderRadius: 15, overflow: 'hidden', background: 'var(--color-paper-soft)' }}>
{content.resource && (
<ResourceViewer resource={content.resource} alt={tPlain(content.title, language)} objectFit="contain" />
)}
</div>
{description && (
<div
className="prose prose-sm max-w-none"
style={{ padding: '0 16px 16px', overflowY: 'auto', color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ __html: description }}
/>
)}
</div>
</div>
)
}
const WAVEFORM_BARS = [
4, 8, 13, 10, 16, 11, 7, 14, 9, 5, 12, 8, 10, 15, 7, 11, 13, 6, 14, 9, 5, 12, 10, 7, 13,
9, 15, 6, 11, 14, 8, 12, 5, 10, 16, 7, 13, 9, 11, 6, 14, 10, 8, 15, 7, 12, 9, 5, 13, 10,
]
function Waveform({ currentTime, duration, onSeek }: { currentTime: number; duration: number; onSeek: (ratio: number) => void }) {
const progress = duration > 0 ? currentTime / duration : 0
function onClick(e: React.MouseEvent<HTMLButtonElement>) {
const rect = e.currentTarget.getBoundingClientRect()
onSeek(Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)))
}
return (
<button
onClick={onClick}
aria-label="Se déplacer dans le guide audio"
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2, height: 18, width: '100%', padding: 0, border: 'none', background: 'none', cursor: duration > 0 ? 'pointer' : 'default' }}
>
{WAVEFORM_BARS.map((h, i) => (
<div
key={i}
style={{
flex: 1,
maxWidth: 3,
height: h,
borderRadius: 2,
background: i / WAVEFORM_BARS.length <= progress ? 'var(--color-primary)' : 'rgba(100,110,120,0.2)',
}}
/>
))} ))}
</svg> </button>
) )
} }

View File

@ -5,6 +5,10 @@ import { useBack } from '@/hooks/useBack'
import ChevronLeftIcon from '@/components/ui/ChevronLeftIcon' import ChevronLeftIcon from '@/components/ui/ChevronLeftIcon'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { useVisitor } from '@/context/VisitorContext' import { useVisitor } from '@/context/VisitorContext'
import { useIsLandscape, useMediaQuery } from '@/hooks/useOrientation'
import FloatingPanel from '@/components/ui/FloatingPanel'
import PointFilter, { PointFilterFooter, normalizeSearch } from '@/components/ui/PointFilter'
import type { FilterGroup, FilterItem } from '@/components/ui/PointFilter'
import { t, tPlain } from '@/lib/i18n' import { t, tPlain } from '@/lib/i18n'
import type { SectionDTO, GeoPointDTO } from '@/lib/api/types' import type { SectionDTO, GeoPointDTO } from '@/lib/api/types'
import { trackEvent } from '@/lib/stats' import { trackEvent } from '@/lib/stats'
@ -29,6 +33,12 @@ interface Props {
type Mode = 'map' | 'list' type Mode = 'map' | 'list'
/// Largeurs des deux colonnes flottantes en paysage — servent aussi à décaler
/// le recentrage de la carte pour que le point visé reste visible.
const DOCK_WIDTH = 340
const DETAIL_WIDTH = 320
const PANEL_TOP = 60
export default function MapSection({ section, configId, languages }: Props) { export default function MapSection({ section, configId, languages }: Props) {
const { language, setAvailableLanguages, instanceId } = useVisitor() const { language, setAvailableLanguages, instanceId } = useVisitor()
const back = useBack() const back = useBack()
@ -40,24 +50,34 @@ export default function MapSection({ section, configId, languages }: Props) {
const points = useMemo(() => map?.points ?? [], [map]) const points = useMemo(() => map?.points ?? [], [map])
const categories = useMemo(() => map?.categories ?? [], [map]) const categories = useMemo(() => map?.categories ?? [], [map])
const isLandscape = useIsLandscape()
// Sous cette hauteur, un dock ouvert ne laisserait plus de carte à regarder :
// il démarre replié, comme le lecteur audio compact d'ArticleSection.
const hasRoomForDock = useMediaQuery('(min-height: 420px)')
const [mode, setMode] = useState<Mode>('map') const [mode, setMode] = useState<Mode>('map')
const [selectedId, setSelectedId] = useState<number | null>(null) const [selectedId, setSelectedId] = useState<number | null>(null)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [activeCats, setActiveCats] = useState<Set<number>>(new Set()) const [activeCats, setActiveCats] = useState<Set<string>>(new Set())
const [filterOpen, setFilterOpen] = useState(false) const [hiddenIds, setHiddenIds] = useState<Set<string>>(new Set())
const [panelOpen, setPanelOpen] = useState(false)
const norm = (s: string) => // En paysage le dock est ouvert d'emblée et remplace la vue liste ;
s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase() // en portrait il reste une feuille basse ouverte à la demande.
useEffect(() => {
setPanelOpen(isLandscape && hasRoomForDock)
if (isLandscape) setMode('map')
}, [isLandscape, hasRoomForDock])
const filteredPoints = useMemo(() => { const filteredPoints = useMemo(() => {
const q = norm(search.trim()) const q = normalizeSearch(search.trim())
return points.filter((p) => { return points.filter((p) => {
if (activeCats.size > 0 && (p.categorieId == null || !activeCats.has(p.categorieId))) return false if (hiddenIds.has(String(p.id))) return false
if (activeCats.size > 0 && (p.categorieId == null || !activeCats.has(String(p.categorieId)))) return false
if (!q) return true if (!q) return true
const title = norm(tPlain(p.title, language)) return normalizeSearch(tPlain(p.title, language)).includes(q)
return title.includes(q)
}) })
}, [points, activeCats, search, language]) }, [points, activeCats, hiddenIds, search, language])
const center: [number, number] = useMemo(() => { const center: [number, number] = useMemo(() => {
const lat = parseFloat(map?.centerLatitude ?? '') const lat = parseFloat(map?.centerLatitude ?? '')
@ -79,7 +99,32 @@ export default function MapSection({ section, configId, languages }: Props) {
if (c) setPrimaryColor(c) if (c) setPrimaryColor(c)
}, []) }, [])
function toggleCat(id: number) { const colorOfCategory = (id?: number): string | undefined =>
id == null ? undefined : categories.find((c) => c.id === id)?.color || undefined
const filterGroups: FilterGroup[] = useMemo(
() => categories.map((c) => ({
id: String(c.id),
label: tPlain(c.label, language) || `Catégorie ${c.id}`,
color: c.color || undefined,
})),
[categories, language]
)
const filterItems: FilterItem[] = useMemo(
() => points.map((p) => ({
id: String(p.id),
title: tPlain(p.title, language) || `Lieu ${p.id}`,
subtitle: tPlain(p.description, language) || undefined,
imageUrl: p.imageUrl,
groupId: p.categorieId != null ? String(p.categorieId) : null,
color: colorOfCategory(p.categorieId),
})),
// eslint-disable-next-line react-hooks/exhaustive-deps
[points, categories, language]
)
function toggleCat(id: string) {
setActiveCats((prev) => { setActiveCats((prev) => {
const next = new Set(prev) const next = new Set(prev)
if (next.has(id)) next.delete(id) if (next.has(id)) next.delete(id)
@ -88,6 +133,35 @@ export default function MapSection({ section, configId, languages }: Props) {
}) })
} }
function toggleHidden(id: string) {
setHiddenIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function selectPoint(id: number) {
setSelectedId(id)
const p = points.find((pp) => pp.id === id)
if (instanceId && p) {
trackEvent({
instanceId, configurationId: configId, sectionId: section.id,
eventType: 'MapPoiTap', language,
metadata: JSON.stringify({ geoPointId: p.id, title: tPlain(p.title, language) }),
})
}
}
function resetFilters() {
setActiveCats(new Set())
setHiddenIds(new Set())
setSearch('')
}
const activeFilterCount = activeCats.size + hiddenIds.size
if (!hasData) { if (!hasData) {
return ( return (
<div style={{ position: 'fixed', inset: 0, background: 'var(--color-background)' }} className="flex flex-col"> <div style={{ position: 'fixed', inset: 0, background: 'var(--color-background)' }} className="flex flex-col">
@ -117,18 +191,13 @@ export default function MapSection({ section, configId, languages }: Props) {
center={center} center={center}
zoom={zoom} zoom={zoom}
selectedId={selectedId} selectedId={selectedId}
onSelect={(id) => { onSelect={selectPoint}
setSelectedId(id)
const p = points.find((pp) => pp.id === id)
if (instanceId && p) {
trackEvent({
instanceId, configurationId: configId, sectionId: section.id,
eventType: 'MapPoiTap', language,
metadata: JSON.stringify({ geoPointId: p.id, title: tPlain(p.title, language) }),
})
}
}}
primaryColor={primaryColor} primaryColor={primaryColor}
insets={{
top: PANEL_TOP,
left: isLandscape && panelOpen ? DOCK_WIDTH + 24 : 0,
right: isLandscape && selected ? DETAIL_WIDTH + 24 : 0,
}}
/> />
) : ( ) : (
<PointList <PointList
@ -141,7 +210,7 @@ export default function MapSection({ section, configId, languages }: Props) {
{/* Top dark AppBar */} {/* Top dark AppBar */}
<div <div
className="absolute top-0 left-0 right-0 flex items-center gap-2 px-3 py-2 z-[1000]" className="absolute top-0 left-0 right-0 flex items-center gap-2 px-3 py-2 z-[1001]"
style={{ style={{
background: 'linear-gradient(to bottom, rgba(0,0,0,0.55), rgba(0,0,0,0))', background: 'linear-gradient(to bottom, rgba(0,0,0,0.55), rgba(0,0,0,0))',
paddingTop: 'max(env(safe-area-inset-top), 10px)', paddingTop: 'max(env(safe-area-inset-top), 10px)',
@ -149,7 +218,7 @@ export default function MapSection({ section, configId, languages }: Props) {
> >
<button <button
onClick={back} onClick={back}
className="w-10 h-10 rounded-2xl flex items-center justify-center" className="w-10 h-10 rounded-2xl flex items-center justify-center flex-shrink-0"
style={{ background: 'rgba(255,255,255,0.2)', backdropFilter: 'blur(8px)' }} style={{ background: 'rgba(255,255,255,0.2)', backdropFilter: 'blur(8px)' }}
aria-label="Retour" aria-label="Retour"
> >
@ -162,103 +231,70 @@ export default function MapSection({ section, configId, languages }: Props) {
{tPlain(section.title, language)} {tPlain(section.title, language)}
</span> </span>
<button <button
onClick={() => setFilterOpen((v) => !v)} onClick={() => setPanelOpen((v) => !v)}
className="w-10 h-10 rounded-full flex items-center justify-center relative" className="w-10 h-10 rounded-full flex items-center justify-center relative flex-shrink-0"
style={{ background: filterOpen ? 'var(--color-primary)' : 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)' }} style={{ background: panelOpen ? 'var(--color-primary)' : 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)' }}
aria-label="Filtres" aria-label={panelOpen ? 'Masquer les filtres' : 'Filtrer les lieux'}
aria-expanded={panelOpen}
> >
<svg width="22" height="22" viewBox="0 0 24 24" fill="white"> <svg width="22" height="22" viewBox="0 0 24 24" fill="white">
<path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z" /> <path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z" />
</svg> </svg>
{activeCats.size > 0 && ( {activeFilterCount > 0 && (
<span <span
className="absolute top-0 right-0 w-5 h-5 rounded-full text-[10px] font-bold flex items-center justify-center" className="absolute top-0 right-0 w-5 h-5 rounded-full text-[10px] font-bold flex items-center justify-center"
style={{ background: 'white', color: 'var(--color-primary)' }} style={{ background: 'white', color: 'var(--color-primary)' }}
> >
{activeCats.size} {activeFilterCount}
</span> </span>
)} )}
</button> </button>
</div> </div>
{/* Filter panel */} {/* Filtres + liste des lieux — dock à gauche en paysage, feuille basse en portrait */}
{filterOpen && ( <FloatingPanel
<div open={panelOpen}
className="absolute top-16 left-3 right-3 rounded-2xl p-3 z-[1000] flex flex-col gap-3" onOpenChange={setPanelOpen}
style={{ side="left"
background: 'rgba(255,255,255,0.97)', top={PANEL_TOP}
backdropFilter: 'blur(12px)', width={DOCK_WIDTH}
boxShadow: '0 8px 24px rgba(0,0,0,0.18)', title="Lieux et filtres"
maxHeight: 'calc(100vh - 96px)', collapsedLabel="Filtres"
badge={activeFilterCount}
footer={
<PointFilterFooter
count={filteredPoints.length}
total={points.length}
onReset={activeFilterCount > 0 || search ? resetFilters : undefined}
/>
}
>
<PointFilter
items={filterItems}
groups={filterGroups}
search={search}
onSearchChange={setSearch}
searchPlaceholder="Rechercher un lieu…"
selectedId={selectedId != null ? String(selectedId) : null}
onSelect={(id) => {
selectPoint(Number(id))
// En portrait la feuille couvre la carte : on la referme pour montrer le point.
if (!isLandscape) setPanelOpen(false)
}} }}
> activeGroupIds={activeCats}
<div className="flex items-center gap-2 px-3 py-2 rounded-full" style={{ background: '#f1f3f5' }}> onToggleGroup={toggleCat}
<svg width="18" height="18" viewBox="0 0 24 24" fill="#666"> hiddenIds={hiddenIds}
<path d="M15.5 14h-.79l-.28-.27A6.5 6.5 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" /> onToggleItem={toggleHidden}
</svg> emptyLabel="Aucun lieu ne correspond."
<input />
value={search} </FloatingPanel>
onChange={(e) => setSearch(e.target.value)}
placeholder="Rechercher un lieu…"
className="flex-1 bg-transparent text-sm outline-none"
style={{ color: '#1a1a1a' }}
/>
{search && (
<button onClick={() => setSearch('')} aria-label="Effacer">
<svg width="16" height="16" viewBox="0 0 24 24" fill="#999">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
</svg>
</button>
)}
</div>
{categories.length > 0 && ( {/* Bascule Carte / Liste portrait seulement : en paysage le dock est la liste.
<div className="flex flex-wrap gap-2 overflow-y-auto" style={{ maxHeight: 260 }}> `mim-clear-assistant` la range au-dessus du lanceur de l'assistant. */}
{categories.map((cat) => { {!isLandscape && map?.isListViewEnabled && (
const active = activeCats.has(cat.id)
const color = cat.color || primaryColor
return (
<button
key={cat.id}
onClick={() => toggleCat(cat.id)}
className="px-3 py-1.5 rounded-full text-xs font-medium flex items-center gap-1.5 transition-all"
style={{
background: active ? color : 'white',
color: active ? 'white' : '#333',
border: `1.5px solid ${color}`,
}}
>
<span
className="w-2 h-2 rounded-full"
style={{ background: active ? 'white' : color }}
/>
{tPlain(cat.label, language) || `Cat. ${cat.id}`}
</button>
)
})}
</div>
)}
<div className="flex items-center justify-between text-xs" style={{ color: '#666' }}>
<span>{filteredPoints.length} lieu{filteredPoints.length > 1 ? 'x' : ''}</span>
{(activeCats.size > 0 || search) && (
<button
onClick={() => { setActiveCats(new Set()); setSearch('') }}
className="font-medium"
style={{ color: 'var(--color-primary)' }}
>
Réinitialiser
</button>
)}
</div>
</div>
)}
{/* Bottom-right toggle */}
{map?.isListViewEnabled && (
<button <button
onClick={() => setMode((m) => (m === 'map' ? 'list' : 'map'))} onClick={() => setMode((m) => (m === 'map' ? 'list' : 'map'))}
className="absolute bottom-6 right-4 rounded-full flex items-center gap-2 px-4 py-3 font-semibold text-sm z-[1000]" className="absolute bottom-6 right-4 rounded-full flex items-center gap-2 px-4 py-3 font-semibold text-sm z-[1000] mim-clear-assistant"
style={{ style={{
background: 'var(--color-primary)', background: 'var(--color-primary)',
color: 'var(--color-on-primary)', color: 'var(--color-on-primary)',
@ -285,7 +321,14 @@ export default function MapSection({ section, configId, languages }: Props) {
{/* Detail sheet (POI) */} {/* Detail sheet (POI) */}
{selected && ( {selected && (
<PointDetail point={selected} language={language} onClose={() => setSelectedId(null)} /> <PointDetail
point={selected}
language={language}
variant={isLandscape ? 'card' : 'sheet'}
width={DETAIL_WIDTH}
top={PANEL_TOP}
onClose={() => setSelectedId(null)}
/>
)} )}
</div> </div>
) )
@ -353,10 +396,15 @@ function PointList({
// ── Detail sheet ──────────────────────────────────────────────────────────── // ── Detail sheet ────────────────────────────────────────────────────────────
function PointDetail({ function PointDetail({
point, language, onClose, point, language, variant, width, top, onClose,
}: { }: {
point: GeoPointDTO point: GeoPointDTO
language: string language: string
/// `sheet` : feuille basse (portrait). `card` : colonne à droite (paysage),
/// pour ne pas écraser une carte qui ne fait déjà que 350 px de haut.
variant: 'sheet' | 'card'
width: number
top: number
onClose: () => void onClose: () => void
}) { }) {
const phone = tPlain(point.phone, language) const phone = tPlain(point.phone, language)
@ -364,27 +412,43 @@ function PointDetail({
const site = tPlain(point.site, language) const site = tPlain(point.site, language)
const prices = t(point.prices, language) const prices = t(point.prices, language)
const schedules = t(point.schedules, language) const schedules = t(point.schedules, language)
const isCard = variant === 'card'
return ( return (
<> <>
{/* En colonne, la carte reste manipulable : pas de voile. */}
{!isCard && (
<div
className="absolute inset-0 z-[1500]"
style={{ background: 'rgba(0,0,0,0.3)' }}
onClick={onClose}
/>
)}
<div <div
className="absolute inset-0 z-[1500]" className={isCard ? 'absolute z-[1600] rounded-2xl overflow-hidden flex flex-col' : 'absolute left-0 right-0 bottom-0 z-[1600] rounded-t-3xl overflow-hidden'}
style={{ background: 'rgba(0,0,0,0.3)' }} style={isCard
onClick={onClose} ? {
/> top, right: 12, width: `min(${width}px, 38%)`, minWidth: 240,
<div maxHeight: `calc(100% - ${top + 16}px)`,
className="absolute left-0 right-0 bottom-0 z-[1600] rounded-t-3xl overflow-hidden" background: 'var(--color-surface)',
style={{ border: '1px solid var(--color-border)',
background: 'var(--color-surface)', boxShadow: '0 2px 6px rgba(20,30,40,0.08), 0 22px 50px -26px rgba(20,30,40,0.55)',
maxHeight: '80%', animation: 'mim-fade-in 0.2s ease',
boxShadow: '0 -8px 24px rgba(0,0,0,0.2)', }
animation: 'mim-slide-up 0.25s ease', : {
}} background: 'var(--color-surface)',
maxHeight: '80%',
boxShadow: '0 -8px 24px rgba(0,0,0,0.2)',
animation: 'mim-slide-up 0.25s ease',
}}
> >
<style>{`@keyframes mim-slide-up { from { transform: translateY(100%); } to { transform: translateY(0); } }`}</style> <style>{`
@keyframes mim-slide-up { from { transform: translateY(100%); } to { transform: translateY(0); } }
@keyframes mim-fade-in { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
`}</style>
{/* Header image */} {/* Header image */}
<div className="relative" style={{ height: 180, background: 'var(--color-primary)' }}> <div className="relative flex-shrink-0" style={{ height: isCard ? 120 : 180, background: 'var(--color-primary)' }}>
{point.imageUrl && ( {point.imageUrl && (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={point.imageUrl} alt="" className="absolute inset-0 w-full h-full object-cover" /> <img src={point.imageUrl} alt="" className="absolute inset-0 w-full h-full object-cover" />
@ -401,14 +465,17 @@ function PointDetail({
</svg> </svg>
</button> </button>
<div <div
className="absolute bottom-3 left-4 right-12 text-white text-xl font-bold [&_p]:m-0" className="absolute bottom-3 left-4 right-12 text-white font-bold [&_p]:m-0"
style={{ textShadow: '0 2px 6px rgba(0,0,0,0.6)' }} style={{ textShadow: '0 2px 6px rgba(0,0,0,0.6)', fontSize: isCard ? 16 : 20 }}
dangerouslySetInnerHTML={{ __html: t(point.title, language) }} dangerouslySetInnerHTML={{ __html: t(point.title, language) }}
/> />
</div> </div>
{/* Body */} {/* Body */}
<div className="overflow-y-auto px-5 py-4 flex flex-col gap-4" style={{ maxHeight: 'calc(80vh - 180px)' }}> <div
className="overflow-y-auto px-5 py-4 flex flex-col gap-4"
style={isCard ? { flex: 1, minHeight: 0 } : { maxHeight: 'calc(80vh - 180px)' }}
>
{t(point.description, language) && ( {t(point.description, language) && (
<div <div
className="text-sm leading-relaxed [&_p]:m-0 [&_p+p]:mt-2" className="text-sm leading-relaxed [&_p]:m-0 [&_p+p]:mt-2"

View File

@ -5,6 +5,10 @@ import { useBack } from '@/hooks/useBack'
import ChevronLeft from '@/components/ui/ChevronLeftIcon' import ChevronLeft from '@/components/ui/ChevronLeftIcon'
import ResourceViewer, { isImageResource } from '@/components/ui/ResourceViewer' import ResourceViewer, { isImageResource } from '@/components/ui/ResourceViewer'
import { useVisitor } from '@/context/VisitorContext' import { useVisitor } from '@/context/VisitorContext'
import { useIsLandscape, useMediaQuery } from '@/hooks/useOrientation'
import FloatingPanel from '@/components/ui/FloatingPanel'
import PointFilter from '@/components/ui/PointFilter'
import type { FilterItem } from '@/components/ui/PointFilter'
import { t, tPlain } from '@/lib/i18n' import { t, tPlain } from '@/lib/i18n'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import QuestionPuzzle from './game/QuestionPuzzle' import QuestionPuzzle from './game/QuestionPuzzle'
@ -307,6 +311,15 @@ function ParcoursMapProgression({ path, steps, stepIndex, completedSteps, primar
const [selectedStepId, setSelectedStepId] = useState<string | null>(currentStep?.id ?? null) const [selectedStepId, setSelectedStepId] = useState<string | null>(currentStep?.id ?? null)
const [showDetail, setShowDetail] = useState(false) const [showDetail, setShowDetail] = useState(false)
// Mêmes règles que la carte : en paysage la liste des étapes devient un dock à
// gauche et la carte de l'étape en cours passe à droite, plutôt qu'une feuille
// basse qui ne laisserait rien voir du parcours.
const isLandscape = useIsLandscape()
const hasRoomForDock = useMediaQuery('(min-height: 420px)')
const [stepsPanelOpen, setStepsPanelOpen] = useState(false)
const [stepSearch, setStepSearch] = useState('')
useEffect(() => { setStepsPanelOpen(isLandscape && hasRoomForDock) }, [isLandscape, hasRoomForDock])
// Quiz state // Quiz state
const [quizAnswered, setQuizAnswered] = useState<Record<number, number>>({}) const [quizAnswered, setQuizAnswered] = useState<Record<number, number>>({})
const [puzzleSolved, setPuzzleSolved] = useState<Record<number, boolean>>({}) const [puzzleSolved, setPuzzleSolved] = useState<Record<number, boolean>>({})
@ -472,6 +485,20 @@ function ParcoursMapProgression({ path, steps, stepIndex, completedSteps, primar
onNext() onNext()
} }
// Les étapes masquées par `hideNextStepsUntilComplete` ne sont pas listées non plus.
const stepItems: FilterItem[] = visibleSteps.map((s) => {
const i = steps.findIndex((x) => x.id === s.id)
const done = completedSteps.has(s.id)
return {
id: s.id,
title: tPlain(s.title, language) || `Étape ${i + 1}`,
subtitle: done ? 'Terminée' : i === stepIndex ? 'Étape en cours' : undefined,
badge: done ? '✓' : String(i + 1),
color: done ? '#16a34a' : i === stepIndex ? accentColor : undefined,
locked: !canJumpTo(i),
}
})
return ( return (
<div style={{ position: 'fixed', inset: 0 }}> <div style={{ position: 'fixed', inset: 0 }}>
{/* Map fills screen */} {/* Map fills screen */}
@ -496,6 +523,11 @@ function ParcoursMapProgression({ path, steps, stepIndex, completedSteps, primar
currentStepId={currentStep?.id ?? null} currentStepId={currentStep?.id ?? null}
completedStepIds={completedSteps} completedStepIds={completedSteps}
userPosition={geo.lat != null && geo.lng != null ? { lat: geo.lat, lng: geo.lng } : null} userPosition={geo.lat != null && geo.lng != null ? { lat: geo.lat, lng: geo.lng } : null}
insets={{
top: 60,
left: isLandscape && stepsPanelOpen ? 340 + 24 : 0,
right: isLandscape ? 360 + 24 : 0,
}}
/> />
</div> </div>
@ -540,26 +572,66 @@ function ParcoursMapProgression({ path, steps, stepIndex, completedSteps, primar
</div> </div>
</div> </div>
{/* Bottom sheet — current step */} {/* Liste des étapes — dock à gauche en paysage, feuille basse en portrait */}
<FloatingPanel
open={stepsPanelOpen}
onOpenChange={setStepsPanelOpen}
side="left"
top={60}
width={340}
title="Étapes du parcours"
collapsedLabel={`Étapes · ${stepIndex + 1}/${steps.length}`}
>
<PointFilter
items={stepItems}
search={stepSearch}
onSearchChange={setStepSearch}
searchPlaceholder="Rechercher une étape…"
selectedId={selectedStepId}
onSelect={(id) => {
setSelectedStepId(id)
const i = steps.findIndex((s) => s.id === id)
if (i >= 0 && canJumpTo(i)) onJump(i)
if (!isLandscape) setStepsPanelOpen(false)
}}
emptyLabel="Aucune étape ne correspond."
/>
</FloatingPanel>
{/* Étape en cours — carte à droite en paysage, feuille basse en portrait */}
<div <div
style={{ style={isLandscape
position: 'absolute', left: 0, right: 0, bottom: 0, zIndex: 1000, ? {
background: '#fff', position: 'absolute', top: 60, right: 12, zIndex: 1000,
borderRadius: '22px 22px 0 0', width: 'min(360px, 40%)', minWidth: 260,
boxShadow: '0 -12px 32px rgba(20,30,40,0.18)', // Le bas de la colonne s'arrête au-dessus du lanceur de l'assistant.
paddingBottom: 'max(18px, env(safe-area-inset-bottom))', maxHeight: 'calc(100% - 76px - var(--mim-assistant-inset-y))', overflowY: 'auto',
}} background: '#fff',
borderRadius: 18,
boxShadow: '0 2px 6px rgba(20,30,40,0.08), 0 22px 50px -26px rgba(20,30,40,0.55)',
paddingBottom: 16,
}
: {
position: 'absolute', left: 0, right: 0, bottom: 0, zIndex: 1000,
background: '#fff',
borderRadius: '22px 22px 0 0',
boxShadow: '0 -12px 32px rgba(20,30,40,0.18)',
// Les boutons d'action restent au-dessus du lanceur de l'assistant.
paddingBottom: 'calc(max(18px, env(safe-area-inset-bottom)) + var(--mim-assistant-inset-y))',
}}
> >
{/* Handle */} {/* Handle */}
<div style={{ display: 'flex', justifyContent: 'center', padding: '10px 0 4px' }}> {!isLandscape && (
<div style={{ width: 40, height: 5, borderRadius: 3, background: '#E2DCD2' }} /> <div style={{ display: 'flex', justifyContent: 'center', padding: '10px 0 4px' }}>
</div> <div style={{ width: 40, height: 5, borderRadius: 3, background: '#E2DCD2' }} />
</div>
)}
{/* Step card — tap to open detail */} {/* Step card — tap to open detail */}
<button <button
onClick={() => setShowDetail(true)} onClick={() => setShowDetail(true)}
style={{ style={{
display: 'flex', gap: 14, padding: '4px 18px 0', display: 'flex', gap: 14, padding: isLandscape ? '16px 18px 0' : '4px 18px 0',
background: 'none', border: 'none', cursor: 'pointer', width: '100%', textAlign: 'left', background: 'none', border: 'none', cursor: 'pointer', width: '100%', textAlign: 'left',
}} }}
> >
@ -886,7 +958,7 @@ function ParcoursMapProgression({ path, steps, stepIndex, completedSteps, primar
<div <div
style={{ style={{
position: 'fixed', left: 0, right: 0, bottom: 0, position: 'fixed', left: 0, right: 0, bottom: 0,
padding: 'max(14px, env(safe-area-inset-bottom)) 18px 18px', padding: 'max(14px, env(safe-area-inset-bottom)) 18px calc(18px + var(--mim-assistant-inset-y))',
background: isGame background: isGame
? 'linear-gradient(to top, #0B1018 80%, transparent)' ? 'linear-gradient(to top, #0B1018 80%, transparent)'
: 'linear-gradient(to top, #F6F3EE 80%, transparent)', : 'linear-gradient(to top, #F6F3EE 80%, transparent)',
@ -1187,7 +1259,7 @@ function StartSheet({ path, isGame, primaryColor, language, gameIntro, onStart,
: '#F6F3EE', : '#F6F3EE',
borderRadius: '26px 26px 0 0', borderRadius: '26px 26px 0 0',
boxShadow: '0 -20px 50px rgba(0,0,0,0.4)', boxShadow: '0 -20px 50px rgba(0,0,0,0.4)',
paddingBottom: 'max(28px, env(safe-area-inset-bottom))', paddingBottom: 'calc(max(28px, env(safe-area-inset-bottom)) + var(--mim-assistant-inset-y))',
maxHeight: '85vh', maxHeight: '85vh',
overflowY: 'auto', overflowY: 'auto',
}} }}
@ -1644,7 +1716,7 @@ function ProgressView({ path, steps, stepIndex, completedSteps, primaryColor, is
<div <div
style={{ style={{
position: 'fixed', left: 0, right: 0, bottom: 0, position: 'fixed', left: 0, right: 0, bottom: 0,
padding: 'max(14px, env(safe-area-inset-bottom)) 18px 18px', padding: 'max(14px, env(safe-area-inset-bottom)) 18px calc(18px + var(--mim-assistant-inset-y))',
background: isGame background: isGame
? 'linear-gradient(to top, #0B1018 80%, transparent)' ? 'linear-gradient(to top, #0B1018 80%, transparent)'
: 'linear-gradient(to top, #F6F3EE 80%, transparent)', : 'linear-gradient(to top, #F6F3EE 80%, transparent)',
@ -1791,7 +1863,7 @@ function ChallengeModal({ stepIndex, questions, puzzleQuestions, simpleQuestions
borderRadius: '24px 24px 0 0', borderRadius: '24px 24px 0 0',
maxHeight: '85dvh', maxHeight: '85dvh',
display: 'flex', flexDirection: 'column', display: 'flex', flexDirection: 'column',
paddingBottom: 'env(safe-area-inset-bottom)', paddingBottom: 'calc(env(safe-area-inset-bottom) + var(--mim-assistant-inset-y))',
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >

View File

@ -19,6 +19,8 @@ interface Props {
type Phase = 'quiz' | 'result' | 'review' type Phase = 'quiz' | 'result' | 'review'
const CONTENT_MAX_WIDTH = 560
function isAudio(r: ResourceDTO) { function isAudio(r: ResourceDTO) {
const t = r.type as string | number | undefined const t = r.type as string | number | undefined
return t === 'Audio' || t === 4 return t === 'Audio' || t === 4
@ -29,11 +31,6 @@ function isVideo(r: ResourceDTO) {
return t === 'Video' || t === 'VideoUrl' || t === 1 || t === 3 return t === 'Video' || t === 'VideoUrl' || t === 1 || t === 3
} }
function isImage(r: ResourceDTO) {
const t = r.type as string | number | undefined
return t === 'Image' || t === 'ImageUrl' || t === 0 || t === 2
}
export default function QuizSection({ section, configId, languages }: Props) { export default function QuizSection({ section, configId, languages }: Props) {
const { language, setAvailableLanguages, instanceId } = useVisitor() const { language, setAvailableLanguages, instanceId } = useVisitor()
const back = useBack() const back = useBack()
@ -51,7 +48,7 @@ export default function QuizSection({ section, configId, languages }: Props) {
if (questions.length === 0) { if (questions.length === 0) {
return ( return (
<div className="min-h-screen flex flex-col" style={{ background: '#F6F3EE' }}> <div className="flex flex-col" style={{ background: 'var(--color-paper)', height: '100dvh', minHeight: '100vh' }}>
<AppBar title={tPlain(section.title, language)} onBack={back} /> <AppBar title={tPlain(section.title, language)} onBack={back} />
<div className="flex-1 flex items-center justify-center text-sm p-8 text-center" style={{ color: 'var(--color-text-muted)' }}> <div className="flex-1 flex items-center justify-center text-sm p-8 text-center" style={{ color: 'var(--color-text-muted)' }}>
Aucune question disponible. Aucune question disponible.
@ -106,54 +103,57 @@ export default function QuizSection({ section, configId, languages }: Props) {
} }
const levelText = t(getLevel(), language) const levelText = t(getLevel(), language)
return ( return (
<div className="min-h-screen flex flex-col" style={{ background: '#F6F3EE' }}> <div className="flex flex-col" style={{ background: 'var(--color-paper)', height: '100dvh', minHeight: '100vh' }}>
<AppBar title={tPlain(section.title, language)} onBack={back} /> <AppBar title={tPlain(section.title, language)} onBack={back} />
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-6"> <div className="flex-1 min-h-0 overflow-y-auto flex flex-col items-center justify-center px-4 py-6">
<div <div className="w-full flex flex-col items-center gap-6" style={{ maxWidth: CONTENT_MAX_WIDTH }}>
className="flex flex-col items-center justify-center rounded-full"
style={{
width: 140, height: 140,
background: 'var(--color-primary)',
boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
}}
>
<span className="text-4xl font-bold" style={{ color: 'var(--color-on-primary)' }}>
{correctCount}/{totalQuestions}
</span>
<span className="text-sm mt-1" style={{ color: 'var(--color-on-primary)', opacity: 0.85 }}>
{Math.round(scorePercent)}%
</span>
</div>
{levelText && (
<div <div
className="w-full px-5 py-4 text-sm text-center [&_p]:m-0" className="flex flex-col items-center justify-center rounded-full shrink-0"
style={{ style={{
background: '#fff', width: 'clamp(120px, 26vw, 168px)',
color: '#1E2A33', height: 'clamp(120px, 26vw, 168px)',
border: '1px solid #EFEAE2', background: 'var(--color-primary)',
borderRadius: 16, boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)',
}} }}
dangerouslySetInnerHTML={{ __html: levelText }} >
/> <span className="font-bold" style={{ color: 'var(--color-on-primary)', fontSize: 'clamp(28px, 7vw, 40px)' }}>
)} {correctCount}/{totalQuestions}
</span>
<span className="text-sm mt-1" style={{ color: 'var(--color-on-primary)', opacity: 0.85 }}>
{Math.round(scorePercent)}%
</span>
</div>
<div className="flex flex-col gap-3 w-full"> {levelText && (
<button <div
onClick={restart} className="w-full px-5 py-4 text-sm text-center [&_p]:m-0"
className="w-full font-bold" style={{
style={{ padding: 16, borderRadius: 18, border: 'none', background: 'var(--color-primary)', color: 'var(--color-on-primary)', fontSize: 16, cursor: 'pointer' }} background: '#fff',
> color: 'var(--color-ink)',
Recommencer border: '1px solid var(--color-paper-soft)',
</button> borderRadius: 16,
<button boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)',
onClick={() => { setCurrentIndex(0); setPhase('review') }} }}
className="w-full font-bold" dangerouslySetInnerHTML={{ __html: levelText }}
style={{ padding: 16, borderRadius: 18, border: '1.5px solid #E2DCD2', background: '#fff', color: '#46555F', fontSize: 16, cursor: 'pointer' }} />
> )}
Voir les réponses
</button> <div className="flex flex-col gap-3 w-full">
<button
onClick={restart}
className="w-full font-bold"
style={{ padding: 16, borderRadius: 18, border: 'none', background: 'var(--color-primary)', color: 'var(--color-on-primary)', fontSize: 16, cursor: 'pointer' }}
>
Recommencer
</button>
<button
onClick={() => { setCurrentIndex(0); setPhase('review') }}
className="w-full font-bold"
style={{ padding: 16, borderRadius: 18, border: '1.5px solid var(--color-paper-border)', background: '#fff', color: 'var(--color-ink-soft)', fontSize: 16, cursor: 'pointer' }}
>
Voir les réponses
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -172,15 +172,20 @@ export default function QuizSection({ section, configId, languages }: Props) {
const questionText = questionEntry?.value ?? '' const questionText = questionEntry?.value ?? ''
const questionResource = questionEntry?.resource const questionResource = questionEntry?.resource
// Les réponses illustrées gardent leur hauteur naturelle ; sinon elles se
// répartissent l'espace vertical restant pour remplir l'écran.
const hasAnswerMedia = sortedResponses.some((r) => getLabelEntry(r.label, language)?.resource?.url)
const mediaHeight = 'clamp(120px, 20vh, 240px)'
return ( return (
<> <>
<div className="min-h-screen flex flex-col" style={{ background: '#F6F3EE' }}> <div className="flex flex-col" style={{ background: 'var(--color-paper)', height: '100dvh', minHeight: '100vh' }}>
<AppBar <AppBar
title={isReview ? tPlain(section.title, language) : undefined} title={isReview ? tPlain(section.title, language) : undefined}
onBack={isReview ? () => { setCurrentIndex(0); setPhase('result') } : back} onBack={isReview ? () => { setCurrentIndex(0); setPhase('result') } : back}
/> />
<div className="flex-1 flex flex-col relative overflow-hidden"> <div className="flex-1 min-h-0 flex flex-col relative overflow-hidden">
{/* Background image */} {/* Background image */}
{question.imageBackgroundResourceUrl && ( {question.imageBackgroundResourceUrl && (
<div <div
@ -194,24 +199,27 @@ export default function QuizSection({ section, configId, languages }: Props) {
/> />
)} )}
<div className="relative z-10 flex flex-col h-full p-4 gap-3"> <div
className="relative z-10 flex flex-col flex-1 min-h-0 w-full mx-auto p-4 gap-3"
style={{ maxWidth: CONTENT_MAX_WIDTH }}
>
{/* Question media */} {/* Question media */}
{questionResource?.url && ( {questionResource?.url && (
isAudio(questionResource) ? ( isAudio(questionResource) ? (
<div style={{ background: 'white', borderRadius: 12, padding: '12px 16px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}> <div className="shrink-0" style={{ background: 'white', borderRadius: 12, padding: '12px 16px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)' }}>
<audio controls src={questionResource.url} style={{ width: '100%' }} /> <audio controls src={questionResource.url} style={{ width: '100%' }} />
</div> </div>
) : isVideo(questionResource) ? ( ) : isVideo(questionResource) ? (
<button <button
onClick={() => setModalResource(questionResource)} onClick={() => setModalResource(questionResource)}
style={{ height: 160, borderRadius: 12, overflow: 'hidden', border: 'none', cursor: 'pointer', flexShrink: 0, padding: 0, display: 'block', width: '100%' }} style={{ height: mediaHeight, borderRadius: 12, overflow: 'hidden', border: 'none', cursor: 'pointer', flexShrink: 0, padding: 0, display: 'block', width: '100%' }}
> >
<VideoPreview resource={questionResource} height={160} playSize={52} /> <VideoPreview resource={questionResource} playSize={52} />
</button> </button>
) : ( ) : (
<button <button
onClick={() => setModalResource(questionResource)} onClick={() => setModalResource(questionResource)}
style={{ position: 'relative', height: 160, borderRadius: 12, overflow: 'hidden', border: 'none', cursor: 'pointer', flexShrink: 0 }} style={{ position: 'relative', height: mediaHeight, borderRadius: 12, overflow: 'hidden', border: 'none', cursor: 'pointer', flexShrink: 0 }}
> >
<Image src={questionResource.url} alt="" fill className="object-contain" sizes="100vw" /> <Image src={questionResource.url} alt="" fill className="object-contain" sizes="100vw" />
<div style={{ position: 'absolute', bottom: 8, right: 8, background: 'rgba(0,0,0,0.45)', borderRadius: 8, padding: '3px 7px', backdropFilter: 'blur(4px)' }}> <div style={{ position: 'absolute', bottom: 8, right: 8, background: 'rgba(0,0,0,0.45)', borderRadius: 8, padding: '3px 7px', backdropFilter: 'blur(4px)' }}>
@ -224,23 +232,34 @@ export default function QuizSection({ section, configId, languages }: Props) {
{/* Question text */} {/* Question text */}
{questionText && ( {questionText && (
<div <div
className="rounded-2xl px-4 py-4 text-sm font-medium text-center [&_p]:m-0" className="rounded-2xl px-5 font-medium text-center shrink-0 flex items-center justify-center [&_p]:m-0"
style={{ style={{
background: 'white', background: 'white',
color: '#1a1a1a', color: 'var(--color-text)',
boxShadow: '0 2px 8px rgba(0,0,0,0.12)', boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
minHeight: 72, fontSize: 'clamp(15px, 1.4vh + 10px, 19px)',
lineHeight: 1.4,
minHeight: 'clamp(76px, 12vh, 132px)',
paddingTop: 16,
paddingBottom: 16,
}} }}
dangerouslySetInnerHTML={{ __html: questionText }} dangerouslySetInnerHTML={{ __html: questionText }}
/> />
)} )}
{/* Answer buttons */} {/* Answer buttons */}
<div className="flex flex-col gap-2 flex-1"> <div
className="flex flex-col justify-center gap-2.5"
style={{ flex: '1 1 0', minHeight: 0, overflowY: hasAnswerMedia ? 'auto' : 'visible' }}
>
{sortedResponses.map((response, i) => { {sortedResponses.map((response, i) => {
const isSelected = chosen === i const isSelected = chosen === i
const bg = getAnswerBg(isReview, isSelected, response.isCorrect) const bg = getAnswerBg(isReview, isSelected, response.isCorrect)
const textColor = isSelected || (isReview && response.isCorrect) ? 'white' : '#1a1a1a' // Sélection = couleur principale de l'instance → texte lisible calculé (--color-on-primary).
// Vert/rouge de correction = couleurs sémantiques fixes → texte blanc.
const textColor = isReview
? (response.isCorrect || isSelected ? '#fff' : 'var(--color-text)')
: (isSelected ? 'var(--color-on-primary)' : 'var(--color-text)')
const responseEntry = getLabelEntry(response.label, language) const responseEntry = getLabelEntry(response.label, language)
const responseText = responseEntry?.value ?? '' const responseText = responseEntry?.value ?? ''
const responseResource = responseEntry?.resource const responseResource = responseEntry?.resource
@ -255,16 +274,19 @@ export default function QuizSection({ section, configId, languages }: Props) {
padding: '13px 14px', borderRadius: 16, padding: '13px 14px', borderRadius: 16,
background: bg, background: bg,
color: textColor, color: textColor,
border: `1.5px solid ${isReview && response.isCorrect ? '#2E9E6B' : isReview && isSelected ? '#D14343' : isSelected ? 'var(--color-primary)' : '#E2DCD2'}`, border: `1.5px solid ${isReview && response.isCorrect ? '#2E9E6B' : isReview && isSelected ? '#D14343' : isSelected ? 'var(--color-primary)' : 'var(--color-paper-border)'}`,
boxShadow: (!isReview && !isSelected) ? '0 6px 16px -12px rgba(0,0,0,0.3)' : 'none', boxShadow: (!isReview && !isSelected) ? '0 6px 16px -12px rgba(0,0,0,0.3)' : 'none',
cursor: isReview || chosen !== undefined ? 'default' : 'pointer', cursor: isReview || chosen !== undefined ? 'default' : 'pointer',
minHeight: 52, fontSize: 'clamp(14px, 1.2vh + 9px, 17px)',
flex: hasAnswerMedia ? '0 0 auto' : '1 1 0',
minHeight: 56,
maxHeight: hasAnswerMedia ? undefined : 110,
}} }}
> >
<span style={{ <span style={{
width: 30, height: 30, borderRadius: 9, flexShrink: 0, width: 30, height: 30, borderRadius: 9, flexShrink: 0,
background: (isReview && response.isCorrect) || (isReview && isSelected) || (!isReview && isSelected) ? 'rgba(255,255,255,0.22)' : '#E8E3DA', background: (isReview && response.isCorrect) || (isReview && isSelected) || (!isReview && isSelected) ? 'color-mix(in srgb, currentColor 22%, transparent)' : 'var(--color-paper-accent)',
color: (isReview && response.isCorrect) || (isReview && isSelected) || (!isReview && isSelected) ? '#fff' : '#54636E', color: (isReview && response.isCorrect) || (isReview && isSelected) || (!isReview && isSelected) ? 'inherit' : 'var(--color-ink-muted)',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 800, fontSize: 13, fontWeight: 800, fontSize: 13,
}}> }}>
@ -272,25 +294,22 @@ export default function QuizSection({ section, configId, languages }: Props) {
</span> </span>
{responseResource?.url && ( {responseResource?.url && (
isAudio(responseResource) ? ( isAudio(responseResource) ? (
<div <div onClick={(e) => e.stopPropagation()} style={{ flexShrink: 0 }}>
onClick={(e) => e.stopPropagation()}
style={{ marginBottom: responseText ? 8 : 0 }}
>
<audio controls src={responseResource.url} style={{ width: '100%' }} /> <audio controls src={responseResource.url} style={{ width: '100%' }} />
</div> </div>
) : isVideo(responseResource) ? ( ) : isVideo(responseResource) ? (
<div <div
onClick={(e) => { e.stopPropagation(); setModalResource(responseResource) }} onClick={(e) => { e.stopPropagation(); setModalResource(responseResource) }}
style={{ height: 90, borderRadius: 8, overflow: 'hidden', marginBottom: responseText ? 8 : 0, cursor: 'pointer' }} style={{ width: 120, height: 76, borderRadius: 8, overflow: 'hidden', flexShrink: 0, cursor: 'pointer' }}
> >
<VideoPreview resource={responseResource} height={90} playSize={38} /> <VideoPreview resource={responseResource} playSize={32} />
</div> </div>
) : ( ) : (
<div <div
onClick={(e) => { e.stopPropagation(); setModalResource(responseResource) }} onClick={(e) => { e.stopPropagation(); setModalResource(responseResource) }}
style={{ position: 'relative', height: 80, borderRadius: 8, overflow: 'hidden', marginBottom: responseText ? 8 : 0, cursor: 'pointer' }} style={{ position: 'relative', width: 90, height: 68, borderRadius: 8, overflow: 'hidden', flexShrink: 0, cursor: 'pointer' }}
> >
<Image src={responseResource.url} alt="" fill className="object-contain" sizes="100vw" /> <Image src={responseResource.url} alt="" fill className="object-contain" sizes="120px" />
</div> </div>
) )
)} )}
@ -303,19 +322,19 @@ export default function QuizSection({ section, configId, languages }: Props) {
</div> </div>
{/* Segmented progress + counter */} {/* Segmented progress + counter */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, paddingTop: 4 }}> <div className="shrink-0" style={{ display: 'flex', alignItems: 'center', gap: 10, paddingTop: 4 }}>
{isReview && ( {isReview && (
<button <button
onClick={() => currentIndex > 0 && setCurrentIndex(currentIndex - 1)} onClick={() => currentIndex > 0 && setCurrentIndex(currentIndex - 1)}
disabled={currentIndex === 0} disabled={currentIndex === 0}
style={{ width: 36, height: 36, borderRadius: 10, border: 'none', cursor: 'pointer', flexShrink: 0, background: currentIndex === 0 ? '#E8E3DA' : 'color-mix(in srgb, var(--color-primary) 12%, white)', display: 'flex', alignItems: 'center', justifyContent: 'center' }} style={{ width: 36, height: 36, borderRadius: 10, border: 'none', cursor: 'pointer', flexShrink: 0, background: currentIndex === 0 ? 'var(--color-paper-accent)' : 'color-mix(in srgb, var(--color-primary) 12%, white)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="2.4"><path d="M15 6l-6 6 6 6"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="2.4"><path d="M15 6l-6 6 6 6"/></svg>
</button> </button>
)} )}
<div style={{ flex: 1, display: 'flex', gap: 4 }}> <div style={{ flex: 1, display: 'flex', gap: 4 }}>
{questions.map((_, i) => ( {questions.map((_, i) => (
<div key={i} style={{ flex: 1, height: 6, borderRadius: 3, background: i <= currentIndex ? 'var(--color-primary)' : '#E2DCD2' }} /> <div key={i} style={{ flex: 1, height: 6, borderRadius: 3, background: i <= currentIndex ? 'var(--color-primary)' : 'var(--color-paper-border)' }} />
))} ))}
</div> </div>
<span style={{ padding: '6px 12px', borderRadius: 11, fontSize: 13, fontWeight: 700, background: 'color-mix(in srgb, var(--color-primary) 12%, white)', color: 'var(--color-primary)', whiteSpace: 'nowrap', flexShrink: 0 }}> <span style={{ padding: '6px 12px', borderRadius: 11, fontSize: 13, fontWeight: 700, background: 'color-mix(in srgb, var(--color-primary) 12%, white)', color: 'var(--color-primary)', whiteSpace: 'nowrap', flexShrink: 0 }}>
@ -325,7 +344,7 @@ export default function QuizSection({ section, configId, languages }: Props) {
<button <button
onClick={() => canGoNext && !isLast && setCurrentIndex(currentIndex + 1)} onClick={() => canGoNext && !isLast && setCurrentIndex(currentIndex + 1)}
disabled={!canGoNext || isLast} disabled={!canGoNext || isLast}
style={{ width: 36, height: 36, borderRadius: 10, border: 'none', cursor: 'pointer', flexShrink: 0, background: (!canGoNext || isLast) ? '#E8E3DA' : 'color-mix(in srgb, var(--color-primary) 12%, white)', display: 'flex', alignItems: 'center', justifyContent: 'center' }} style={{ width: 36, height: 36, borderRadius: 10, border: 'none', cursor: 'pointer', flexShrink: 0, background: (!canGoNext || isLast) ? 'var(--color-paper-accent)' : 'color-mix(in srgb, var(--color-primary) 12%, white)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="2.4"><path d="M9 6l6 6-6 6"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="2.4"><path d="M9 6l6 6-6 6"/></svg>
</button> </button>
@ -336,8 +355,8 @@ export default function QuizSection({ section, configId, languages }: Props) {
{isReview && ( {isReview && (
<button <button
onClick={restart} onClick={restart}
className="w-full font-bold" className="w-full font-bold shrink-0"
style={{ padding: 16, borderRadius: 18, border: '1.5px solid #E2DCD2', background: '#fff', color: '#46555F', fontSize: 15, cursor: 'pointer' }} style={{ padding: 14, borderRadius: 18, border: '1.5px solid var(--color-paper-border)', background: '#fff', color: 'var(--color-ink-soft)', fontSize: 15, cursor: 'pointer' }}
> >
Recommencer Recommencer
</button> </button>
@ -379,10 +398,10 @@ function youtubeThumbnail(url: string): string | null {
return match?.[1] ? `https://img.youtube.com/vi/${match[1]}/mqdefault.jpg` : null return match?.[1] ? `https://img.youtube.com/vi/${match[1]}/mqdefault.jpg` : null
} }
function VideoPreview({ resource, height, playSize }: { resource: ResourceDTO; height: number; playSize: number }) { function VideoPreview({ resource, playSize }: { resource: ResourceDTO; playSize: number }) {
const thumbnail = resource.url ? youtubeThumbnail(resource.url) : null const thumbnail = resource.url ? youtubeThumbnail(resource.url) : null
return ( return (
<div style={{ position: 'relative', width: '100%', height, background: thumbnail ? '#000' : 'linear-gradient(135deg, #1c1c2e 0%, #2d2d44 100%)' }}> <div style={{ position: 'relative', width: '100%', height: '100%', background: thumbnail ? '#000' : 'linear-gradient(135deg, #1c1c2e 0%, #2d2d44 100%)' }}>
{thumbnail && ( {thumbnail && (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={thumbnail} alt="" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', opacity: 0.85 }} /> <img src={thumbnail} alt="" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', opacity: 0.85 }} />

View File

@ -32,6 +32,9 @@ interface Props {
userPosition?: { lat: number; lng: number } | null userPosition?: { lat: number; lng: number } | null
/// Index à partir duquel les étapes sont verrouillées. `undefined` = navigation libre. /// Index à partir duquel les étapes sont verrouillées. `undefined` = navigation libre.
lockedFromIndex?: number lockedFromIndex?: number
/// Zones couvertes par les panneaux flottants de la section, en pixels. Le
/// recentrage vise l'espace resté libre plutôt que le milieu de l'écran.
insets?: { left?: number; right?: number; top?: number; bottom?: number }
} }
function pinIcon(color: string, selected: boolean, imageUrl?: string) { function pinIcon(color: string, selected: boolean, imageUrl?: string) {
@ -92,19 +95,37 @@ function userIcon() {
}) })
} }
function FitToTargets({ target, fitBounds }: { target: [number, number] | null; fitBounds: [number, number][] | null }) { function FitToTargets({ target, fitBounds, padLeft, padRight, padTop, padBottom }: {
target: [number, number] | null
fitBounds: [number, number][] | null
padLeft: number
padRight: number
padTop: number
padBottom: number
}) {
const map = useMap() const map = useMap()
useEffect(() => { useEffect(() => {
if (fitBounds && fitBounds.length > 0) { if (fitBounds && fitBounds.length > 0) {
const bounds = L.latLngBounds(fitBounds) const bounds = L.latLngBounds(fitBounds)
map.flyToBounds(bounds, { padding: [80, 80], duration: 1.0, maxZoom: 17 }) map.flyToBounds(bounds, {
paddingTopLeft: [padLeft + 60, padTop + 60],
paddingBottomRight: [padRight + 60, padBottom + 60],
duration: 1.0,
maxZoom: 17,
})
return return
} }
if (target) { if (target) {
const current = map.getZoom() const zoom = Math.max(map.getZoom(), 15)
map.flyTo(target, Math.max(current, 15), { duration: 1.2 }) // Les panneaux flottants (dock à gauche, fiche à droite) couvrent une partie
// de la carte : on décale le centre pour que le point vise l'espace resté libre.
const centered = map.unproject(
map.project(target, zoom).subtract([(padLeft - padRight) / 2, (padTop - padBottom) / 2]),
zoom
)
map.flyTo(centered, zoom, { duration: 1.2 })
} }
}, [target, fitBounds, map]) }, [target, fitBounds, padLeft, padRight, padTop, padBottom, map])
return null return null
} }
@ -123,6 +144,7 @@ export default function LeafletMap({
completedStepIds, completedStepIds,
userPosition, userPosition,
lockedFromIndex, lockedFromIndex,
insets,
}: Props) { }: Props) {
const categoryColor = (id?: number): string => { const categoryColor = (id?: number): string => {
if (id == null) return primaryColor if (id == null) return primaryColor
@ -267,7 +289,14 @@ export default function LeafletMap({
/> />
)} )}
<FitToTargets target={flyTarget} fitBounds={fitBounds} /> <FitToTargets
target={flyTarget}
fitBounds={fitBounds}
padLeft={insets?.left ?? 0}
padRight={insets?.right ?? 0}
padTop={insets?.top ?? 0}
padBottom={insets?.bottom ?? 0}
/>
</MapContainer> </MapContainer>
) )
} }

View File

@ -0,0 +1,133 @@
'use client'
import type { ReactNode } from 'react'
import { useIsLandscape } from '@/hooks/useOrientation'
import './floating-panel.css'
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
/// Bord d'ancrage en paysage. Sans effet en portrait (toujours en feuille basse).
side?: 'left' | 'right'
title?: string
/// Libellé de la pastille affichée à la place du panneau replié, en paysage.
/// Absent = le panneau replié ne laisse aucune trace (le déclencheur est ailleurs).
collapsedLabel?: string
badge?: number
/// Largeur maximale de la colonne en paysage.
width?: number
/// Décalage haut en paysage, pour passer sous l'AppBar de la section.
top?: number
footer?: ReactNode
children: ReactNode
}
export default function FloatingPanel({
open,
onOpenChange,
side = 'left',
title,
collapsedLabel,
badge,
width = 340,
top = 60,
footer,
children,
}: Props) {
const isLandscape = useIsLandscape()
if (isLandscape) {
if (!open) {
if (!collapsedLabel) return null
return (
<button
type="button"
className="mim-fp-rail"
data-side={side}
style={{ top }}
onClick={() => onOpenChange(true)}
>
<FilterIcon />
{collapsedLabel}
{badge != null && badge > 0 && <span className="mim-fp-badge">{badge}</span>}
</button>
)
}
return (
<div
className="mim-fp-dock"
data-side={side}
style={{ top, width: `min(${width}px, 38%)`, minWidth: 250, maxHeight: `calc(100% - ${top + 16}px)` }}
>
<div className="mim-fp-head">
{title && <span className="mim-fp-title">{title}</span>}
<button
type="button"
className="mim-fp-toggle"
onClick={() => onOpenChange(false)}
aria-label="Replier le panneau"
>
<ChevronIcon direction={side === 'left' ? 'left' : 'right'} />
</button>
</div>
<div className="mim-fp-body">{children}</div>
{footer && <div className="mim-fp-foot">{footer}</div>}
</div>
)
}
if (!open) return null
return (
<>
<button
type="button"
className="mim-fp-scrim"
aria-label="Fermer le panneau"
onClick={() => onOpenChange(false)}
/>
<div className="mim-fp-sheet" role="dialog" aria-label={title}>
<span className="mim-fp-handle" />
{title && (
<div className="mim-fp-head">
<span className="mim-fp-title">{title}</span>
<button
type="button"
className="mim-fp-toggle"
onClick={() => onOpenChange(false)}
aria-label="Fermer le panneau"
>
<CloseIcon />
</button>
</div>
)}
<div className="mim-fp-body">{children}</div>
{footer && <div className="mim-fp-foot">{footer}</div>}
</div>
</>
)
}
function FilterIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z" />
</svg>
)
}
function ChevronIcon({ direction }: { direction: 'left' | 'right' }) {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d={direction === 'left' ? 'M15 18l-6-6 6-6' : 'M9 18l6-6-6-6'} />
</svg>
)
}
function CloseIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
</svg>
)
}

View File

@ -0,0 +1,237 @@
'use client'
import './point-filter.css'
export interface FilterItem {
id: string
title: string
subtitle?: string
imageUrl?: string
/// Rattachement à un groupe de `groups`. `null` = « Sans catégorie ».
groupId?: string | null
color?: string
/// Pastille affichée à la place de la vignette (numéro d'étape, ✓…).
badge?: string
/// Visible dans la liste mais non sélectionnable (étape verrouillée).
locked?: boolean
}
export interface FilterGroup {
id: string
label: string
color?: string
}
interface Props {
items: FilterItem[]
groups?: FilterGroup[]
search: string
onSearchChange: (value: string) => void
searchPlaceholder?: string
selectedId?: string | null
onSelect: (id: string) => void
/// Groupes actifs. Ensemble vide = tous affichés (même règle qu'avant).
/// Absent = pas de puces de catégories.
activeGroupIds?: Set<string>
onToggleGroup?: (id: string) => void
/// Points masqués individuellement. Absent = pas de cases à cocher.
hiddenIds?: Set<string>
onToggleItem?: (id: string) => void
emptyLabel?: string
}
/// Recherche insensible à la casse et aux accents — « eglise » trouve « Église ».
export function normalizeSearch(value: string): string {
return value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
}
export default function PointFilter({
items,
groups = [],
search,
onSearchChange,
searchPlaceholder = 'Rechercher…',
selectedId,
onSelect,
activeGroupIds,
onToggleGroup,
hiddenIds,
onToggleItem,
emptyLabel = 'Aucun résultat.',
}: Props) {
const query = normalizeSearch(search.trim())
const visible = query
? items.filter((it) => normalizeSearch(it.title).includes(query))
: items
// Les groupes déclarés d'abord, dans leur ordre, puis les points sans catégorie.
const buckets: { group: FilterGroup | null; items: FilterItem[] }[] = [
...groups.map((g) => ({ group: g, items: visible.filter((it) => it.groupId === g.id) })),
{ group: null, items: visible.filter((it) => it.groupId == null || !groups.some((g) => g.id === it.groupId)) },
].filter((b) => b.items.length > 0)
return (
<div className="mim-pf">
<label className="mim-pf-search">
<SearchIcon />
<input
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
/>
{search && (
<button type="button" onClick={() => onSearchChange('')} aria-label="Effacer la recherche">
<CloseIcon />
</button>
)}
</label>
{activeGroupIds && groups.length > 0 && (
<div className="mim-pf-chips">
{groups.map((g) => {
const active = activeGroupIds.has(g.id)
const color = g.color || 'var(--color-primary)'
return (
<button
key={g.id}
type="button"
className="mim-pf-chip"
onClick={() => onToggleGroup?.(g.id)}
aria-pressed={active}
style={{
background: active ? color : 'var(--color-background)',
color: active ? '#fff' : 'var(--color-text)',
boxShadow: `inset 0 0 0 1.4px ${color}`,
}}
>
<span className="mim-pf-dot" style={{ background: active ? 'rgba(255,255,255,0.9)' : color }} />
{g.label}
</button>
)
})}
</div>
)}
{buckets.length === 0 && <p className="mim-pf-empty">{emptyLabel}</p>}
{buckets.map(({ group, items: bucketItems }) => (
<div key={group?.id ?? '__none'}>
{/* Un seul groupe sans catégorie déclarée : l'en-tête n'apprend rien. */}
{(group || buckets.length > 1) && (
<div className="mim-pf-group">
{group?.color && <span className="mim-pf-dot" style={{ background: group.color }} />}
{group?.label ?? 'Autres'}
<span className="mim-pf-rule" />
{bucketItems.length}
</div>
)}
{bucketItems.map((it) => {
const hidden = hiddenIds?.has(it.id) ?? false
return (
<div key={it.id} className="mim-pf-row">
{onToggleItem && (
<button
type="button"
role="checkbox"
aria-checked={!hidden}
aria-label={hidden ? `Afficher ${it.title}` : `Masquer ${it.title}`}
className="mim-pf-check"
data-on={!hidden}
onClick={() => onToggleItem(it.id)}
>
{!hidden && <CheckIcon />}
</button>
)}
<button
type="button"
className="mim-pf-item"
data-selected={it.id === selectedId}
data-hidden={hidden}
data-locked={it.locked === true}
onClick={() => { if (!it.locked) onSelect(it.id) }}
aria-disabled={it.locked === true}
>
<span
className="mim-pf-thumb"
style={{ background: it.locked ? 'var(--color-text-muted)' : (it.color || 'var(--color-primary)') }}
>
{it.locked
? <LockIcon />
// eslint-disable-next-line @next/next/no-img-element
: it.imageUrl ? <img src={it.imageUrl} alt="" />
: it.badge ? it.badge
: <PinIcon />}
</span>
<span className="mim-pf-text">
<span className="mim-pf-name">{it.title}</span>
{it.subtitle && <span className="mim-pf-sub">{it.subtitle}</span>}
</span>
</button>
</div>
)
})}
</div>
))}
</div>
)
}
export function PointFilterFooter({
count, total, onReset,
}: {
count: number
total: number
onReset?: () => void
}) {
return (
<div className="mim-pf-foot">
<span>
<b>{count}</b> {count > 1 ? 'lieux' : 'lieu'}{count < total ? ` sur ${total}` : ''}
</span>
{onReset && count < total && (
<button type="button" onClick={onReset}>Tout afficher</button>
)}
</div>
)
}
function SearchIcon() {
return (
<svg width="17" height="17" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M15.5 14h-.79l-.28-.27A6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
</svg>
)
}
function CloseIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
</svg>
)
}
function CheckIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 12.5l5 5L20 6.5" />
</svg>
)
}
function PinIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 0 1 0-5 2.5 2.5 0 0 1 0 5z" />
</svg>
)
}
function LockIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M18 8h-1V6a5 5 0 0 0-10 0v2H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2zM9 6a3 3 0 0 1 6 0v2H9V6z" />
</svg>
)
}

View File

@ -0,0 +1,144 @@
/* Panneau flottant partagé carte, parcours, et toute section plein écran qui
doit poser un panneau par-dessus son contenu.
Paysage : colonne vitrée ancrée sur un bord, la section reste visible à côté
(même parti que `GeoPointFilter` de tablet-app). Portrait : feuille basse
classique avec voile, parce qu'une colonne de 33 % sur un téléphone debout ne
laisse rien à voir. Le composant choisit seul selon l'orientation. */
.mim-fp-dock {
position: absolute;
z-index: 1000;
display: flex;
flex-direction: column;
border-radius: 16px;
overflow: hidden;
background: color-mix(in srgb, var(--color-surface) 90%, transparent);
backdrop-filter: blur(14px) saturate(1.1);
border: 1px solid var(--color-border);
box-shadow: 0 2px 6px rgba(20, 30, 40, .08), 0 22px 50px -26px rgba(20, 30, 40, .55);
animation: mim-fp-in .22s cubic-bezier(.2, .7, .3, 1);
}
.mim-fp-dock[data-side='left'] { left: 12px; }
.mim-fp-dock[data-side='right'] { right: 12px; }
@keyframes mim-fp-in {
from { opacity: 0; transform: translateY(-6px) scale(.985); }
to { opacity: 1; transform: none; }
}
/* Pastille de repli — garde le rappel de l'état actif sans masquer la carte. */
.mim-fp-rail {
position: absolute;
z-index: 1000;
display: flex;
align-items: center;
gap: 7px;
padding: 8px 13px;
border: 1px solid var(--color-border);
border-radius: 999px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
color: var(--color-text);
background: color-mix(in srgb, var(--color-surface) 90%, transparent);
backdrop-filter: blur(14px);
box-shadow: 0 2px 6px rgba(20, 30, 40, .1), 0 14px 30px -20px rgba(20, 30, 40, .6);
}
.mim-fp-rail[data-side='left'] { left: 12px; }
.mim-fp-rail[data-side='right'] { right: 12px; }
.mim-fp-rail .mim-fp-badge {
font-size: 11px;
font-weight: 700;
padding: 1px 7px;
border-radius: 999px;
background: var(--color-primary);
color: var(--color-on-primary);
}
/* Feuille basse — portrait. */
.mim-fp-scrim {
position: absolute;
inset: 0;
z-index: 999;
background: rgba(0, 0, 0, .3);
border: none;
padding: 0;
cursor: pointer;
animation: mim-fp-fade .2s ease;
}
@keyframes mim-fp-fade { from { opacity: 0; } to { opacity: 1; } }
.mim-fp-sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
flex-direction: column;
max-height: 72%;
border-radius: 20px 20px 0 0;
background: var(--color-surface);
box-shadow: 0 -10px 30px -14px rgba(20, 30, 40, .45);
padding-bottom: env(safe-area-inset-bottom);
animation: mim-fp-up .24s cubic-bezier(.2, .7, .3, 1);
}
@keyframes mim-fp-up { from { transform: translateY(100%); } to { transform: none; } }
.mim-fp-handle {
width: 38px;
height: 4px;
border-radius: 3px;
background: var(--color-border);
margin: 9px auto 2px;
flex-shrink: 0;
}
.mim-fp-head {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px 4px;
flex-shrink: 0;
}
.mim-fp-title {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 700;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mim-fp-toggle {
width: 30px;
height: 30px;
flex-shrink: 0;
border: none;
border-radius: 9px;
cursor: pointer;
display: grid;
place-items: center;
color: var(--color-primary);
background: var(--color-primary-light);
}
.mim-fp-body {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.mim-fp-foot {
flex-shrink: 0;
border-top: 1px solid var(--color-border);
}
@media (prefers-reduced-motion: reduce) {
.mim-fp-dock, .mim-fp-sheet, .mim-fp-scrim { animation: none; }
}

View File

@ -0,0 +1,176 @@
/* Contenu du panneau de filtres recherche, puces de catégories, liste des
points groupée. Partagé par la carte et par la liste d'étapes d'un parcours :
les deux affichent « des lieux qu'on coche, qu'on cherche et qu'on choisit ». */
.mim-pf { display: flex; flex-direction: column; }
.mim-pf-search {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 12px 8px;
padding: 7px 12px;
border-radius: 999px;
background: var(--color-background);
border: 1px solid var(--color-border);
color: var(--color-text-muted);
}
.mim-pf-search input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
font: inherit;
font-size: 13.5px;
color: var(--color-text);
}
.mim-pf-search button {
border: none;
background: none;
cursor: pointer;
display: grid;
place-items: center;
color: var(--color-text-muted);
padding: 0;
}
.mim-pf-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 12px 10px;
}
.mim-pf-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
background: var(--color-background);
color: var(--color-text);
}
.mim-pf-chip .mim-pf-dot { width: 7px; height: 7px; border-radius: 999px; flex-shrink: 0; }
.mim-pf-group {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 14px 5px;
font-size: 11px;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--color-text-muted);
}
.mim-pf-group .mim-pf-rule { flex: 1; height: 1px; background: var(--color-border); }
.mim-pf-row {
display: flex;
align-items: center;
gap: 2px;
padding: 0 6px;
}
.mim-pf-item {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
margin: 1px 0;
padding: 7px 8px;
border: none;
border-radius: 10px;
background: none;
text-align: left;
cursor: pointer;
color: var(--color-text);
}
.mim-pf-item:hover { background: var(--color-background); }
.mim-pf-item[data-selected='true'] { background: var(--color-primary-light); }
.mim-pf-item[data-hidden='true'] .mim-pf-name,
.mim-pf-item[data-hidden='true'] .mim-pf-thumb { opacity: .45; }
.mim-pf-item[data-locked='true'] { cursor: default; }
.mim-pf-item[data-locked='true'] .mim-pf-name { color: var(--color-text-muted); }
.mim-pf-check {
width: 20px;
height: 20px;
margin-left: 6px;
flex-shrink: 0;
border: none;
border-radius: 5px;
padding: 0;
cursor: pointer;
display: grid;
place-items: center;
background: transparent;
box-shadow: inset 0 0 0 1.6px var(--color-text-muted);
}
.mim-pf-check[data-on='true'] {
background: var(--color-primary);
box-shadow: inset 0 0 0 1.6px var(--color-primary);
color: var(--color-on-primary);
}
.mim-pf-thumb {
width: 34px;
height: 34px;
flex-shrink: 0;
border-radius: 9px;
overflow: hidden;
display: grid;
place-items: center;
color: #fff;
font-size: 13px;
font-weight: 700;
background: var(--color-primary);
}
.mim-pf-thumb img { width: 100%; height: 100%; object-fit: cover; }
.mim-pf-text { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.mim-pf-name {
font-size: 13.5px;
font-weight: 600;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mim-pf-item[data-selected='true'] .mim-pf-name { color: var(--color-primary); }
.mim-pf-sub {
font-size: 11.5px;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mim-pf-empty {
padding: 26px 16px;
text-align: center;
font-size: 13px;
color: var(--color-text-muted);
}
.mim-pf-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 9px 14px;
font-size: 12.5px;
color: var(--color-text-muted);
}
.mim-pf-foot button {
border: none;
background: none;
cursor: pointer;
font: inherit;
font-weight: 700;
color: var(--color-primary);
}

View File

@ -0,0 +1,22 @@
'use client'
import { useEffect, useState } from 'react'
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false)
useEffect(() => {
const mq = window.matchMedia(query)
setMatches(mq.matches)
const onChange = (e: MediaQueryListEvent) => setMatches(e.matches)
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}, [query])
return matches
}
/** Même règle que l'app Flutter : largeur > hauteur. */
export function useIsLandscape(): boolean {
return useMediaQuery('(orientation: landscape)')
}