390 lines
16 KiB
TypeScript
390 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { useBack } from '@/hooks/useBack'
|
|
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 { ContentDTO, SectionDTO } from '@/lib/api/types'
|
|
import AppBar from '@/components/ui/AppBar'
|
|
import { trackEvent } from '@/lib/stats'
|
|
|
|
interface Props {
|
|
section: SectionDTO
|
|
slug: string
|
|
configId: string
|
|
languages: string[]
|
|
}
|
|
|
|
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>(null)
|
|
const articleReadTrackedRef = useRef(false)
|
|
|
|
useEffect(() => { setAvailableLanguages([]) }, [languages])
|
|
|
|
useEffect(() => {
|
|
const el = scrollRef.current
|
|
if (!el || !instanceId) return
|
|
|
|
const track = () => {
|
|
if (articleReadTrackedRef.current) return
|
|
articleReadTrackedRef.current = true
|
|
trackEvent({
|
|
instanceId, configurationId: configId, sectionId: section.id,
|
|
eventType: 'ArticleRead', language,
|
|
})
|
|
}
|
|
|
|
// Un article qui tient dans l'écran ne produit aucun scroll : il est lu d'emblée.
|
|
if (el.scrollHeight - el.clientHeight <= 0) {
|
|
track()
|
|
return
|
|
}
|
|
|
|
const onScroll = () => {
|
|
const total = el.scrollHeight - el.clientHeight
|
|
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, isLandscape])
|
|
|
|
const audioUrl = article?.audioIds?.find((a) => a.language === language)?.value
|
|
?? article?.audioIds?.[0]?.value
|
|
|
|
useEffect(() => {
|
|
if (article?.isReadAudioAuto && audioRef.current) {
|
|
audioRef.current.play().catch(() => {})
|
|
setIsPlaying(true)
|
|
}
|
|
}, [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
|
|
|
|
const carousel = <MediaCarousel contents={contents} language={language} fill={isLandscape} />
|
|
const mediaPane = hasMedia && (
|
|
isLandscape ? carousel : <div style={{ padding: '12px 12px 0' }}>{carousel}</div>
|
|
)
|
|
|
|
const textPane = hasText && (
|
|
<div
|
|
ref={isLandscape ? setScrollTarget : undefined}
|
|
style={isLandscape
|
|
? { height: '100%', overflowY: 'auto', padding: '4px 6px' }
|
|
: { padding: '20px 16px' }}
|
|
>
|
|
<div
|
|
className="prose prose-sm max-w-none"
|
|
style={{ color: 'var(--color-text)', maxWidth: isLandscape || !hasMedia ? '70ch' : undefined, margin: isLandscape || !hasMedia ? '0 auto' : undefined }}
|
|
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
|
/>
|
|
</div>
|
|
)
|
|
|
|
const panes = (contentTop ? [textPane, mediaPane] : [mediaPane, textPane]).filter(Boolean)
|
|
|
|
const isAudioDocked = isLandscape && hasRoomForDockedAudio
|
|
const isAudioCompact = isLandscape && !hasRoomForDockedAudio
|
|
|
|
const audioCard = audioUrl && (
|
|
<div style={{ borderRadius: 16, overflow: 'hidden', background: 'var(--color-surface)', border: '1px solid var(--color-paper-accent)', boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: isAudioCompact ? '8px 10px' : '12px 14px' }}>
|
|
<button
|
|
onClick={toggleAudio}
|
|
aria-label={isPlaying ? 'Mettre en pause' : 'Écouter le guide audio'}
|
|
style={{ width: isAudioCompact ? 34 : 44, height: isAudioCompact ? 34 : 44, borderRadius: '50%', border: 'none', cursor: 'pointer', flexShrink: 0, background: 'var(--color-ink)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
|
>
|
|
{isPlaying ? (
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="#fff"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
|
) : (
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="#fff"><path d="M8 5v14l11-7z"/></svg>
|
|
)}
|
|
</button>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<span style={{ color: 'var(--color-ink-muted)', fontSize: 11, fontWeight: 700, letterSpacing: '0.5px' }}>GUIDE AUDIO</span>
|
|
{!isAudioCompact && (
|
|
<div style={{ marginTop: 5 }}>
|
|
<Waveform currentTime={currentTime} duration={duration} onSeek={seekAudio} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<span style={{ color: 'var(--color-ink-muted)', fontSize: 12.5, fontWeight: 600, flexShrink: 0 }}>
|
|
{duration > 0 ? formatTime(duration) : '--:--'}
|
|
</span>
|
|
</div>
|
|
{/* La variante compacte masque la waveform : la barre reste son seul indicateur. */}
|
|
{isAudioCompact && duration > 0 && (
|
|
<div style={{ height: 3, background: 'var(--color-paper-soft)' }}>
|
|
<div style={{ height: '100%', width: `${(currentTime / duration) * 100}%`, background: 'var(--color-primary)', transition: 'width 0.3s linear' }} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<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 MediaCarousel({ contents, language, fill }: { contents: ContentDTO[]; language: string; fill: boolean }) {
|
|
const [index, setIndex] = useState(0)
|
|
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
|
|
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={current.resource}
|
|
alt={caption}
|
|
objectFit="contain"
|
|
/>
|
|
)}
|
|
|
|
{canZoom && (
|
|
<button
|
|
onClick={() => setZoomed(current)}
|
|
aria-label="Agrandir l'image"
|
|
style={{ position: 'absolute', inset: 0, background: 'transparent', border: 'none', cursor: 'zoom-in' }}
|
|
/>
|
|
)}
|
|
|
|
{contents.length > 1 && (
|
|
<>
|
|
<button
|
|
onClick={() => setIndex((i) => Math.max(0, i - 1))}
|
|
disabled={index === 0}
|
|
aria-label="Média précédent"
|
|
style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', background: 'rgba(0,0,0,0.35)', border: 'none', cursor: 'pointer', borderRadius: '50%', width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: index === 0 ? 0.3 : 1 }}
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="white"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
|
|
</button>
|
|
<button
|
|
onClick={() => setIndex((i) => Math.min(contents.length - 1, i + 1))}
|
|
disabled={index === contents.length - 1}
|
|
aria-label="Média suivant"
|
|
style={{ position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)', background: 'rgba(0,0,0,0.35)', border: 'none', cursor: 'pointer', borderRadius: '50%', width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: index === contents.length - 1 ? 0.3 : 1 }}
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="white"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{caption && (
|
|
<p style={{ margin: 0, padding: '8px 12px 0', fontSize: 13, fontWeight: 600, color: 'var(--color-ink)', textAlign: 'center' }}>
|
|
{caption}
|
|
</p>
|
|
)}
|
|
|
|
{contents.length > 1 && (
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '8px 0 10px' }}>
|
|
<div style={{ display: 'flex', gap: 5 }}>
|
|
{contents.map((_, i) => (
|
|
<button
|
|
key={i}
|
|
onClick={() => setIndex(i)}
|
|
aria-label={`Média ${i + 1}`}
|
|
style={{ width: i === index ? 18 : 7, height: 7, borderRadius: 9999, border: 'none', cursor: 'pointer', transition: 'width 0.2s', background: i === index ? 'var(--color-primary)' : 'var(--color-paper-accent)' }}
|
|
/>
|
|
))}
|
|
</div>
|
|
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-ink-muted)', fontVariantNumeric: 'tabular-nums' }}>
|
|
{index + 1}/{contents.length}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{zoomed && (
|
|
<MediaDialog content={zoomed} language={language} onClose={() => setZoomed(null)} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function MediaDialog({ content, language, onClose }: { content: ContentDTO; language: string; onClose: () => void }) {
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
|
window.addEventListener('keydown', onKey)
|
|
return () => window.removeEventListener('keydown', onKey)
|
|
}, [onClose])
|
|
|
|
const title = t(content.title, language)
|
|
const description = t(content.description, language)
|
|
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
onClick={onClose}
|
|
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(20,26,31,0.72)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
|
>
|
|
<div
|
|
onClick={(e) => e.stopPropagation()}
|
|
style={{ background: 'var(--color-surface)', borderRadius: 20, overflow: 'hidden', width: 'min(680px, 100%)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, padding: '16px 16px 0' }}>
|
|
{title && (
|
|
<div
|
|
className="prose prose-sm max-w-none"
|
|
style={{ flex: 1, color: 'var(--color-ink)', fontWeight: 600 }}
|
|
dangerouslySetInnerHTML={{ __html: title }}
|
|
/>
|
|
)}
|
|
<button
|
|
onClick={onClose}
|
|
aria-label="Fermer"
|
|
style={{ marginLeft: 'auto', width: 32, height: 32, flexShrink: 0, borderRadius: '50%', border: 'none', cursor: 'pointer', background: 'var(--color-paper-soft)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="var(--color-ink)"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div style={{ position: 'relative', flex: '1 1 auto', minHeight: 220, margin: 16, borderRadius: 15, overflow: 'hidden', background: 'var(--color-paper-soft)' }}>
|
|
{content.resource && (
|
|
<ResourceViewer resource={content.resource} alt={tPlain(content.title, language)} objectFit="contain" />
|
|
)}
|
|
</div>
|
|
|
|
{description && (
|
|
<div
|
|
className="prose prose-sm max-w-none"
|
|
style={{ padding: '0 16px 16px', overflowY: 'auto', color: 'var(--color-text)' }}
|
|
dangerouslySetInnerHTML={{ __html: description }}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const WAVEFORM_BARS = [
|
|
4, 8, 13, 10, 16, 11, 7, 14, 9, 5, 12, 8, 10, 15, 7, 11, 13, 6, 14, 9, 5, 12, 10, 7, 13,
|
|
9, 15, 6, 11, 14, 8, 12, 5, 10, 16, 7, 13, 9, 11, 6, 14, 10, 8, 15, 7, 12, 9, 5, 13, 10,
|
|
]
|
|
|
|
function Waveform({ currentTime, duration, onSeek }: { currentTime: number; duration: number; onSeek: (ratio: number) => void }) {
|
|
const progress = duration > 0 ? currentTime / duration : 0
|
|
|
|
function onClick(e: React.MouseEvent<HTMLButtonElement>) {
|
|
const rect = e.currentTarget.getBoundingClientRect()
|
|
onSeek(Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)))
|
|
}
|
|
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
aria-label="Se déplacer dans le guide audio"
|
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2, height: 18, width: '100%', padding: 0, border: 'none', background: 'none', cursor: duration > 0 ? 'pointer' : 'default' }}
|
|
>
|
|
{WAVEFORM_BARS.map((h, i) => (
|
|
<div
|
|
key={i}
|
|
style={{
|
|
flex: 1,
|
|
maxWidth: 3,
|
|
height: h,
|
|
borderRadius: 2,
|
|
background: i / WAVEFORM_BARS.length <= progress ? 'var(--color-primary)' : 'rgba(100,110,120,0.2)',
|
|
}}
|
|
/>
|
|
))}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function formatTime(s: number): string {
|
|
if (!s || isNaN(s)) return '0:00'
|
|
const m = Math.floor(s / 60)
|
|
const sec = Math.floor(s % 60)
|
|
return `${m}:${sec.toString().padStart(2, '0')}`
|
|
}
|