Added demo villa (test 3d scene) + misc
This commit is contained in:
parent
cb7f045b0b
commit
e2a1c97c00
@ -17,6 +17,13 @@ const nextConfig: NextConfig = {
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -23,15 +23,16 @@ export default async function ConfigLayout({
|
||||
|
||||
const theme = resolveColors(instance, config)
|
||||
const loaderImageUrl = config.loaderImageUrl ?? instance.loaderImageUrl
|
||||
const showAssistant = !!(instance.isAssistant && instance.publicApiKey)
|
||||
|
||||
return (
|
||||
<div style={theme as React.CSSProperties}>
|
||||
<div style={theme as React.CSSProperties} data-assistant={showAssistant ? 'true' : undefined}>
|
||||
{loaderImageUrl && <SplashScreen imageUrl={loaderImageUrl} configId={configId} />}
|
||||
{children}
|
||||
{instance.isAssistant && instance.publicApiKey && (
|
||||
{showAssistant && (
|
||||
<AssistantBubble
|
||||
instanceId={instance.id}
|
||||
apiKey={instance.publicApiKey}
|
||||
apiKey={instance.publicApiKey!}
|
||||
configId={configId}
|
||||
slug={slug}
|
||||
sections={config.sections}
|
||||
|
||||
@ -77,7 +77,16 @@ if (!section || section.isActive === false) notFound()
|
||||
case 'Quiz': content = <QuizSection {...props} />; break
|
||||
case 'Game': content = <GameSection {...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
|
||||
default:
|
||||
content = (
|
||||
|
||||
482
src/app/demo/villa-echternach/page.tsx
Normal file
482
src/app/demo/villa-echternach/page.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
460
src/app/demo/villa-echternach/scene.ts
Normal file
460
src/app/demo/villa-echternach/scene.ts
Normal 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-là 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)
|
||||
}
|
||||
@ -8,6 +8,16 @@
|
||||
--color-text-muted: #6b7280;
|
||||
--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> */
|
||||
--color-primary: #264863;
|
||||
--color-secondary: #c2c9d6;
|
||||
@ -25,6 +35,13 @@
|
||||
--color-text: var(--color-text);
|
||||
--color-text-muted: var(--color-text-muted);
|
||||
--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 {
|
||||
@ -32,3 +49,51 @@ body {
|
||||
color: var(--color-text);
|
||||
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'où 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)); }
|
||||
|
||||
@ -92,12 +92,7 @@ export default function QRScannerButton({ slug, configurationId }: Props) {
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed bottom-5 right-5 w-14 h-14 rounded-full flex items-center justify-center z-40"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
color: 'var(--color-primary)',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.35)',
|
||||
}}
|
||||
className="mim-fab-qr"
|
||||
aria-label="Scanner un QR code"
|
||||
>
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor">
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
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 { 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 { trackEvent } from '@/lib/stats'
|
||||
|
||||
@ -20,11 +21,14 @@ export default function ArticleSection({ section, configId, languages }: Props)
|
||||
const { language, setAvailableLanguages, instanceId } = useVisitor()
|
||||
const back = useBack()
|
||||
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 [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const scrollRef = useRef<HTMLElement>(null)
|
||||
const scrollRef = useRef<HTMLElement | null>(null)
|
||||
const articleReadTrackedRef = useRef(false)
|
||||
|
||||
useEffect(() => { setAvailableLanguages([]) }, [languages])
|
||||
@ -32,22 +36,29 @@ export default function ArticleSection({ section, configId, languages }: Props)
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el || !instanceId) return
|
||||
const onScroll = () => {
|
||||
|
||||
const track = () => {
|
||||
if (articleReadTrackedRef.current) return
|
||||
const total = el.scrollHeight - el.clientHeight
|
||||
if (total <= 0) return
|
||||
const ratio = el.scrollTop / total
|
||||
if (ratio >= 0.8) {
|
||||
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
|
||||
if (total > 0 && el.scrollTop / total >= 0.8) track()
|
||||
}
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
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
|
||||
?? article?.audioIds?.[0]?.value
|
||||
@ -59,51 +70,59 @@ export default function ArticleSection({ section, configId, languages }: Props)
|
||||
}
|
||||
}, [article?.isReadAudioAuto])
|
||||
|
||||
function seekAudio(ratio: number) {
|
||||
if (!audioRef.current || !duration) return
|
||||
const time = ratio * duration
|
||||
audioRef.current.currentTime = time
|
||||
setCurrentTime(time)
|
||||
}
|
||||
|
||||
function toggleAudio() {
|
||||
if (!audioRef.current) return
|
||||
if (isPlaying) { audioRef.current.pause(); setIsPlaying(false) }
|
||||
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 htmlContent = t(article?.content, language)
|
||||
const contentTop = article?.isContentTop ?? false
|
||||
const hasMedia = contents.length > 0
|
||||
const hasText = htmlContent.length > 0
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col" style={{ background: '#F6F3EE' }}>
|
||||
<AppBar title={tPlain(section.title, language)} onBack={back} />
|
||||
const carousel = <MediaCarousel contents={contents} language={language} fill={isLandscape} />
|
||||
const mediaPane = hasMedia && (
|
||||
isLandscape ? carousel : <div style={{ padding: '12px 12px 0' }}>{carousel}</div>
|
||||
)
|
||||
|
||||
<main ref={scrollRef} className="flex-1 overflow-y-auto pb-24">
|
||||
{/* Carousel images */}
|
||||
{contents.length > 0 && (
|
||||
<ImageCarousel contents={contents} language={language} />
|
||||
)}
|
||||
|
||||
{/* HTML content */}
|
||||
{htmlContent && (
|
||||
const textPane = hasText && (
|
||||
<div
|
||||
className="px-4 py-5 prose prose-sm max-w-none"
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
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 }}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
{/* Floating audio player */}
|
||||
{audioUrl && (
|
||||
<>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioUrl}
|
||||
onTimeUpdate={() => setCurrentTime(audioRef.current?.currentTime ?? 0)}
|
||||
onLoadedMetadata={() => setDuration(audioRef.current?.duration ?? 0)}
|
||||
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' }}>
|
||||
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}
|
||||
style={{ width: 44, height: 44, borderRadius: '50%', border: 'none', cursor: 'pointer', flexShrink: 0, background: '#1E2A33', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
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>
|
||||
@ -112,46 +131,118 @@ export default function ArticleSection({ section, configId, languages }: Props)
|
||||
)}
|
||||
</button>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ color: '#6B7B86', fontSize: 11, fontWeight: 700, letterSpacing: '0.5px' }}>GUIDE AUDIO</span>
|
||||
<span style={{ color: 'var(--color-ink-muted)', fontSize: 11, fontWeight: 700, letterSpacing: '0.5px' }}>GUIDE AUDIO</span>
|
||||
{!isAudioCompact && (
|
||||
<div style={{ marginTop: 5 }}>
|
||||
<WaveformDecor currentTime={currentTime} duration={duration} />
|
||||
<Waveform currentTime={currentTime} duration={duration} onSeek={seekAudio} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ color: '#6B7B86', fontSize: 12.5, fontWeight: 600, flexShrink: 0 }}>
|
||||
<span style={{ color: 'var(--color-ink-muted)', fontSize: 12.5, fontWeight: 600, flexShrink: 0 }}>
|
||||
{duration > 0 ? formatTime(duration) : '--:--'}
|
||||
</span>
|
||||
</div>
|
||||
{duration > 0 && (
|
||||
<div style={{ height: 3, background: '#F0EBE3' }}>
|
||||
{/* 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 (
|
||||
<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} />
|
||||
|
||||
{audioUrl && (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioUrl}
|
||||
onTimeUpdate={() => setCurrentTime(audioRef.current?.currentTime ?? 0)}
|
||||
onLoadedMetadata={() => setDuration(audioRef.current?.duration ?? 0)}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLandscape ? (
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', padding: '8px 10px 10px', gap: 10 }}>
|
||||
{panes.map((pane, i) => (
|
||||
<div key={i} style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>{pane}</div>
|
||||
{i === 0 && isAudioDocked && audioCard}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<main
|
||||
ref={setScrollTarget}
|
||||
style={{ flex: 1, overflowY: 'auto', paddingBottom: audioUrl ? 96 : 16 }}
|
||||
>
|
||||
{panes[0]}
|
||||
{panes[1]}
|
||||
</main>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
function ImageCarousel({ contents, language }: { contents: NonNullable<SectionDTO['article']>['contents'] & object; language: string }) {
|
||||
function MediaCarousel({ contents, language, fill }: { contents: ContentDTO[]; language: string; fill: boolean }) {
|
||||
const [index, setIndex] = useState(0)
|
||||
if (!contents || contents.length === 0) return null
|
||||
const [zoomed, setZoomed] = useState<ContentDTO | null>(null)
|
||||
|
||||
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 (
|
||||
<div className="relative w-full" style={{ height: 240 }}>
|
||||
{contents[index]?.resource && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex', flexDirection: 'column', minHeight: 0,
|
||||
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={contents[index].resource!}
|
||||
alt={tPlain(contents[index].title, language)}
|
||||
objectFit="cover"
|
||||
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>
|
||||
@ -159,32 +250,134 @@ function ImageCarousel({ contents, language }: { contents: NonNullable<SectionDT
|
||||
<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 className="absolute bottom-2 left-0 right-0 flex justify-center gap-1">
|
||||
</>
|
||||
)}
|
||||
</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) => (
|
||||
<div key={i} className="w-1.5 h-1.5 rounded-full" style={{ background: i === index ? 'white' : 'rgba(255,255,255,0.4)' }} />
|
||||
<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 WaveformDecor({ currentTime, duration }: { currentTime: number; duration: number }) {
|
||||
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 progress = duration > 0 ? currentTime / duration : 0
|
||||
const total = bars.length * 5
|
||||
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 (
|
||||
<svg width={total} height={18} viewBox={`0 0 ${total} 18`}>
|
||||
{bars.map((h, i) => (
|
||||
<rect key={i} x={i * 5} y={(18 - h) / 2} width={3} height={h} rx={1.5}
|
||||
fill={i / bars.length <= progress ? 'var(--color-primary)' : 'rgba(100,110,120,0.2)'} />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -5,6 +5,10 @@ import { useBack } from '@/hooks/useBack'
|
||||
import ChevronLeftIcon from '@/components/ui/ChevronLeftIcon'
|
||||
import dynamic from 'next/dynamic'
|
||||
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 type { SectionDTO, GeoPointDTO } from '@/lib/api/types'
|
||||
import { trackEvent } from '@/lib/stats'
|
||||
@ -29,6 +33,12 @@ interface Props {
|
||||
|
||||
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) {
|
||||
const { language, setAvailableLanguages, instanceId } = useVisitor()
|
||||
const back = useBack()
|
||||
@ -40,24 +50,34 @@ export default function MapSection({ section, configId, languages }: Props) {
|
||||
const points = useMemo(() => map?.points ?? [], [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 [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [activeCats, setActiveCats] = useState<Set<number>>(new Set())
|
||||
const [filterOpen, setFilterOpen] = useState(false)
|
||||
const [activeCats, setActiveCats] = useState<Set<string>>(new Set())
|
||||
const [hiddenIds, setHiddenIds] = useState<Set<string>>(new Set())
|
||||
const [panelOpen, setPanelOpen] = useState(false)
|
||||
|
||||
const norm = (s: string) =>
|
||||
s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
||||
// En paysage le dock est ouvert d'emblée et remplace la vue liste ;
|
||||
// en portrait il reste une feuille basse ouverte à la demande.
|
||||
useEffect(() => {
|
||||
setPanelOpen(isLandscape && hasRoomForDock)
|
||||
if (isLandscape) setMode('map')
|
||||
}, [isLandscape, hasRoomForDock])
|
||||
|
||||
const filteredPoints = useMemo(() => {
|
||||
const q = norm(search.trim())
|
||||
const q = normalizeSearch(search.trim())
|
||||
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
|
||||
const title = norm(tPlain(p.title, language))
|
||||
return title.includes(q)
|
||||
return normalizeSearch(tPlain(p.title, language)).includes(q)
|
||||
})
|
||||
}, [points, activeCats, search, language])
|
||||
}, [points, activeCats, hiddenIds, search, language])
|
||||
|
||||
const center: [number, number] = useMemo(() => {
|
||||
const lat = parseFloat(map?.centerLatitude ?? '')
|
||||
@ -79,7 +99,32 @@ export default function MapSection({ section, configId, languages }: Props) {
|
||||
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) => {
|
||||
const next = new Set(prev)
|
||||
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) {
|
||||
return (
|
||||
<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}
|
||||
zoom={zoom}
|
||||
selectedId={selectedId}
|
||||
onSelect={(id) => {
|
||||
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) }),
|
||||
})
|
||||
}
|
||||
}}
|
||||
onSelect={selectPoint}
|
||||
primaryColor={primaryColor}
|
||||
insets={{
|
||||
top: PANEL_TOP,
|
||||
left: isLandscape && panelOpen ? DOCK_WIDTH + 24 : 0,
|
||||
right: isLandscape && selected ? DETAIL_WIDTH + 24 : 0,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<PointList
|
||||
@ -141,7 +210,7 @@ export default function MapSection({ section, configId, languages }: Props) {
|
||||
|
||||
{/* Top dark AppBar */}
|
||||
<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={{
|
||||
background: 'linear-gradient(to bottom, rgba(0,0,0,0.55), rgba(0,0,0,0))',
|
||||
paddingTop: 'max(env(safe-area-inset-top), 10px)',
|
||||
@ -149,7 +218,7 @@ export default function MapSection({ section, configId, languages }: Props) {
|
||||
>
|
||||
<button
|
||||
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)' }}
|
||||
aria-label="Retour"
|
||||
>
|
||||
@ -162,103 +231,70 @@ export default function MapSection({ section, configId, languages }: Props) {
|
||||
{tPlain(section.title, language)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setFilterOpen((v) => !v)}
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center relative"
|
||||
style={{ background: filterOpen ? 'var(--color-primary)' : 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)' }}
|
||||
aria-label="Filtres"
|
||||
onClick={() => setPanelOpen((v) => !v)}
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center relative flex-shrink-0"
|
||||
style={{ background: panelOpen ? 'var(--color-primary)' : 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)' }}
|
||||
aria-label={panelOpen ? 'Masquer les filtres' : 'Filtrer les lieux'}
|
||||
aria-expanded={panelOpen}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z" />
|
||||
</svg>
|
||||
{activeCats.size > 0 && (
|
||||
{activeFilterCount > 0 && (
|
||||
<span
|
||||
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)' }}
|
||||
>
|
||||
{activeCats.size}
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter panel */}
|
||||
{filterOpen && (
|
||||
<div
|
||||
className="absolute top-16 left-3 right-3 rounded-2xl p-3 z-[1000] flex flex-col gap-3"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.97)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.18)',
|
||||
maxHeight: 'calc(100vh - 96px)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-full" style={{ background: '#f1f3f5' }}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="#666">
|
||||
<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" />
|
||||
</svg>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Rechercher un lieu…"
|
||||
className="flex-1 bg-transparent text-sm outline-none"
|
||||
style={{ color: '#1a1a1a' }}
|
||||
{/* Filtres + liste des lieux — dock à gauche en paysage, feuille basse en portrait */}
|
||||
<FloatingPanel
|
||||
open={panelOpen}
|
||||
onOpenChange={setPanelOpen}
|
||||
side="left"
|
||||
top={PANEL_TOP}
|
||||
width={DOCK_WIDTH}
|
||||
title="Lieux et filtres"
|
||||
collapsedLabel="Filtres"
|
||||
badge={activeFilterCount}
|
||||
footer={
|
||||
<PointFilterFooter
|
||||
count={filteredPoints.length}
|
||||
total={points.length}
|
||||
onReset={activeFilterCount > 0 || search ? resetFilters : undefined}
|
||||
/>
|
||||
{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 && (
|
||||
<div className="flex flex-wrap gap-2 overflow-y-auto" style={{ maxHeight: 260 }}>
|
||||
{categories.map((cat) => {
|
||||
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}`,
|
||||
}
|
||||
>
|
||||
<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)
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ background: active ? 'white' : color }}
|
||||
activeGroupIds={activeCats}
|
||||
onToggleGroup={toggleCat}
|
||||
hiddenIds={hiddenIds}
|
||||
onToggleItem={toggleHidden}
|
||||
emptyLabel="Aucun lieu ne correspond."
|
||||
/>
|
||||
{tPlain(cat.label, language) || `Cat. ${cat.id}`}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</FloatingPanel>
|
||||
|
||||
<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 && (
|
||||
{/* Bascule Carte / Liste — portrait seulement : en paysage le dock est la liste.
|
||||
`mim-clear-assistant` la range au-dessus du lanceur de l'assistant. */}
|
||||
{!isLandscape && map?.isListViewEnabled && (
|
||||
<button
|
||||
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={{
|
||||
background: 'var(--color-primary)',
|
||||
color: 'var(--color-on-primary)',
|
||||
@ -285,7 +321,14 @@ export default function MapSection({ section, configId, languages }: Props) {
|
||||
|
||||
{/* Detail sheet (POI) */}
|
||||
{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>
|
||||
)
|
||||
@ -353,10 +396,15 @@ function PointList({
|
||||
|
||||
// ── Detail sheet ────────────────────────────────────────────────────────────
|
||||
function PointDetail({
|
||||
point, language, onClose,
|
||||
point, language, variant, width, top, onClose,
|
||||
}: {
|
||||
point: GeoPointDTO
|
||||
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
|
||||
}) {
|
||||
const phone = tPlain(point.phone, language)
|
||||
@ -364,27 +412,43 @@ function PointDetail({
|
||||
const site = tPlain(point.site, language)
|
||||
const prices = t(point.prices, language)
|
||||
const schedules = t(point.schedules, language)
|
||||
const isCard = variant === 'card'
|
||||
|
||||
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
|
||||
className="absolute left-0 right-0 bottom-0 z-[1600] rounded-t-3xl overflow-hidden"
|
||||
style={{
|
||||
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={isCard
|
||||
? {
|
||||
top, right: 12, width: `min(${width}px, 38%)`, minWidth: 240,
|
||||
maxHeight: `calc(100% - ${top + 16}px)`,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
boxShadow: '0 2px 6px rgba(20,30,40,0.08), 0 22px 50px -26px rgba(20,30,40,0.55)',
|
||||
animation: 'mim-fade-in 0.2s 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 */}
|
||||
<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 && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={point.imageUrl} alt="" className="absolute inset-0 w-full h-full object-cover" />
|
||||
@ -401,14 +465,17 @@ function PointDetail({
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
className="absolute bottom-3 left-4 right-12 text-white text-xl font-bold [&_p]:m-0"
|
||||
style={{ textShadow: '0 2px 6px rgba(0,0,0,0.6)' }}
|
||||
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)', fontSize: isCard ? 16 : 20 }}
|
||||
dangerouslySetInnerHTML={{ __html: t(point.title, language) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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) && (
|
||||
<div
|
||||
className="text-sm leading-relaxed [&_p]:m-0 [&_p+p]:mt-2"
|
||||
|
||||
@ -5,6 +5,10 @@ import { useBack } from '@/hooks/useBack'
|
||||
import ChevronLeft from '@/components/ui/ChevronLeftIcon'
|
||||
import ResourceViewer, { isImageResource } from '@/components/ui/ResourceViewer'
|
||||
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 dynamic from 'next/dynamic'
|
||||
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 [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
|
||||
const [quizAnswered, setQuizAnswered] = useState<Record<number, number>>({})
|
||||
const [puzzleSolved, setPuzzleSolved] = useState<Record<number, boolean>>({})
|
||||
@ -472,6 +485,20 @@ function ParcoursMapProgression({ path, steps, stepIndex, completedSteps, primar
|
||||
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 (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
{/* Map fills screen */}
|
||||
@ -496,6 +523,11 @@ function ParcoursMapProgression({ path, steps, stepIndex, completedSteps, primar
|
||||
currentStepId={currentStep?.id ?? null}
|
||||
completedStepIds={completedSteps}
|
||||
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>
|
||||
|
||||
@ -540,26 +572,66 @@ function ParcoursMapProgression({ path, steps, stepIndex, completedSteps, primar
|
||||
</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
|
||||
style={{
|
||||
style={isLandscape
|
||||
? {
|
||||
position: 'absolute', top: 60, right: 12, zIndex: 1000,
|
||||
width: 'min(360px, 40%)', minWidth: 260,
|
||||
// Le bas de la colonne s'arrête au-dessus du lanceur de l'assistant.
|
||||
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)',
|
||||
paddingBottom: 'max(18px, env(safe-area-inset-bottom))',
|
||||
// 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 */}
|
||||
{!isLandscape && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '10px 0 4px' }}>
|
||||
<div style={{ width: 40, height: 5, borderRadius: 3, background: '#E2DCD2' }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step card — tap to open detail */}
|
||||
<button
|
||||
onClick={() => setShowDetail(true)}
|
||||
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',
|
||||
}}
|
||||
>
|
||||
@ -886,7 +958,7 @@ function ParcoursMapProgression({ path, steps, stepIndex, completedSteps, primar
|
||||
<div
|
||||
style={{
|
||||
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
|
||||
? 'linear-gradient(to top, #0B1018 80%, transparent)'
|
||||
: 'linear-gradient(to top, #F6F3EE 80%, transparent)',
|
||||
@ -1187,7 +1259,7 @@ function StartSheet({ path, isGame, primaryColor, language, gameIntro, onStart,
|
||||
: '#F6F3EE',
|
||||
borderRadius: '26px 26px 0 0',
|
||||
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',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
@ -1644,7 +1716,7 @@ function ProgressView({ path, steps, stepIndex, completedSteps, primaryColor, is
|
||||
<div
|
||||
style={{
|
||||
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
|
||||
? 'linear-gradient(to top, #0B1018 80%, transparent)'
|
||||
: 'linear-gradient(to top, #F6F3EE 80%, transparent)',
|
||||
@ -1791,7 +1863,7 @@ function ChallengeModal({ stepIndex, questions, puzzleQuestions, simpleQuestions
|
||||
borderRadius: '24px 24px 0 0',
|
||||
maxHeight: '85dvh',
|
||||
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()}
|
||||
>
|
||||
|
||||
@ -19,6 +19,8 @@ interface Props {
|
||||
|
||||
type Phase = 'quiz' | 'result' | 'review'
|
||||
|
||||
const CONTENT_MAX_WIDTH = 560
|
||||
|
||||
function isAudio(r: ResourceDTO) {
|
||||
const t = r.type as string | number | undefined
|
||||
return t === 'Audio' || t === 4
|
||||
@ -29,11 +31,6 @@ function isVideo(r: ResourceDTO) {
|
||||
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) {
|
||||
const { language, setAvailableLanguages, instanceId } = useVisitor()
|
||||
const back = useBack()
|
||||
@ -51,7 +48,7 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
|
||||
if (questions.length === 0) {
|
||||
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} />
|
||||
<div className="flex-1 flex items-center justify-center text-sm p-8 text-center" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Aucune question disponible.
|
||||
@ -106,18 +103,20 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
}
|
||||
const levelText = t(getLevel(), language)
|
||||
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} />
|
||||
<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 className="w-full flex flex-col items-center gap-6" style={{ maxWidth: CONTENT_MAX_WIDTH }}>
|
||||
<div
|
||||
className="flex flex-col items-center justify-center rounded-full"
|
||||
className="flex flex-col items-center justify-center rounded-full shrink-0"
|
||||
style={{
|
||||
width: 140, height: 140,
|
||||
width: 'clamp(120px, 26vw, 168px)',
|
||||
height: 'clamp(120px, 26vw, 168px)',
|
||||
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)' }}>
|
||||
<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 }}>
|
||||
@ -130,8 +129,8 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
className="w-full px-5 py-4 text-sm text-center [&_p]:m-0"
|
||||
style={{
|
||||
background: '#fff',
|
||||
color: '#1E2A33',
|
||||
border: '1px solid #EFEAE2',
|
||||
color: 'var(--color-ink)',
|
||||
border: '1px solid var(--color-paper-soft)',
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
@ -150,13 +149,14 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
<button
|
||||
onClick={() => { setCurrentIndex(0); setPhase('review') }}
|
||||
className="w-full font-bold"
|
||||
style={{ padding: 16, borderRadius: 18, border: '1.5px solid #E2DCD2', background: '#fff', color: '#46555F', fontSize: 16, cursor: 'pointer' }}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -172,15 +172,20 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
const questionText = questionEntry?.value ?? ''
|
||||
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 (
|
||||
<>
|
||||
<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={isReview ? tPlain(section.title, language) : undefined}
|
||||
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 */}
|
||||
{question.imageBackgroundResourceUrl && (
|
||||
<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 */}
|
||||
{questionResource?.url && (
|
||||
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%' }} />
|
||||
</div>
|
||||
) : isVideo(questionResource) ? (
|
||||
<button
|
||||
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
|
||||
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" />
|
||||
<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 */}
|
||||
{questionText && (
|
||||
<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={{
|
||||
background: 'white',
|
||||
color: '#1a1a1a',
|
||||
color: 'var(--color-text)',
|
||||
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 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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) => {
|
||||
const isSelected = chosen === i
|
||||
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 responseText = responseEntry?.value ?? ''
|
||||
const responseResource = responseEntry?.resource
|
||||
@ -255,16 +274,19 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
padding: '13px 14px', borderRadius: 16,
|
||||
background: bg,
|
||||
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',
|
||||
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={{
|
||||
width: 30, height: 30, borderRadius: 9, flexShrink: 0,
|
||||
background: (isReview && response.isCorrect) || (isReview && isSelected) || (!isReview && isSelected) ? 'rgba(255,255,255,0.22)' : '#E8E3DA',
|
||||
color: (isReview && response.isCorrect) || (isReview && isSelected) || (!isReview && isSelected) ? '#fff' : '#54636E',
|
||||
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) ? 'inherit' : 'var(--color-ink-muted)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontWeight: 800, fontSize: 13,
|
||||
}}>
|
||||
@ -272,25 +294,22 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
</span>
|
||||
{responseResource?.url && (
|
||||
isAudio(responseResource) ? (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ marginBottom: responseText ? 8 : 0 }}
|
||||
>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ flexShrink: 0 }}>
|
||||
<audio controls src={responseResource.url} style={{ width: '100%' }} />
|
||||
</div>
|
||||
) : isVideo(responseResource) ? (
|
||||
<div
|
||||
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
|
||||
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>
|
||||
)
|
||||
)}
|
||||
@ -303,19 +322,19 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
</div>
|
||||
|
||||
{/* 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 && (
|
||||
<button
|
||||
onClick={() => currentIndex > 0 && setCurrentIndex(currentIndex - 1)}
|
||||
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>
|
||||
</button>
|
||||
)}
|
||||
<div style={{ flex: 1, display: 'flex', gap: 4 }}>
|
||||
{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>
|
||||
<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
|
||||
onClick={() => canGoNext && !isLast && setCurrentIndex(currentIndex + 1)}
|
||||
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>
|
||||
</button>
|
||||
@ -336,8 +355,8 @@ export default function QuizSection({ section, configId, languages }: Props) {
|
||||
{isReview && (
|
||||
<button
|
||||
onClick={restart}
|
||||
className="w-full font-bold"
|
||||
style={{ padding: 16, borderRadius: 18, border: '1.5px solid #E2DCD2', background: '#fff', color: '#46555F', fontSize: 15, cursor: 'pointer' }}
|
||||
className="w-full font-bold shrink-0"
|
||||
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
|
||||
</button>
|
||||
@ -379,10 +398,10 @@ function youtubeThumbnail(url: string): string | 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
|
||||
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 && (
|
||||
// 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 }} />
|
||||
|
||||
@ -32,6 +32,9 @@ interface Props {
|
||||
userPosition?: { lat: number; lng: number } | null
|
||||
/// Index à partir duquel les étapes sont verrouillées. `undefined` = navigation libre.
|
||||
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) {
|
||||
@ -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()
|
||||
useEffect(() => {
|
||||
if (fitBounds && fitBounds.length > 0) {
|
||||
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
|
||||
}
|
||||
if (target) {
|
||||
const current = map.getZoom()
|
||||
map.flyTo(target, Math.max(current, 15), { duration: 1.2 })
|
||||
const zoom = Math.max(map.getZoom(), 15)
|
||||
// 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
|
||||
}
|
||||
|
||||
@ -123,6 +144,7 @@ export default function LeafletMap({
|
||||
completedStepIds,
|
||||
userPosition,
|
||||
lockedFromIndex,
|
||||
insets,
|
||||
}: Props) {
|
||||
const categoryColor = (id?: number): string => {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
133
src/components/ui/FloatingPanel.tsx
Normal file
133
src/components/ui/FloatingPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
237
src/components/ui/PointFilter.tsx
Normal file
237
src/components/ui/PointFilter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
144
src/components/ui/floating-panel.css
Normal file
144
src/components/ui/floating-panel.css
Normal 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; }
|
||||
}
|
||||
176
src/components/ui/point-filter.css
Normal file
176
src/components/ui/point-filter.css
Normal 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);
|
||||
}
|
||||
22
src/hooks/useOrientation.ts
Normal file
22
src/hooks/useOrientation.ts
Normal 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)')
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user