'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(null) const scrollRef = useRef(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 = const mediaPane = hasMedia && ( isLandscape ? carousel :
{carousel}
) const textPane = hasText && (
) const panes = (contentTop ? [textPane, mediaPane] : [mediaPane, textPane]).filter(Boolean) const isAudioDocked = isLandscape && hasRoomForDockedAudio const isAudioCompact = isLandscape && !hasRoomForDockedAudio const audioCard = audioUrl && (
GUIDE AUDIO {!isAudioCompact && (
)}
{duration > 0 ? formatTime(duration) : '--:--'}
{/* La variante compacte masque la waveform : la barre reste son seul indicateur. */} {isAudioCompact && duration > 0 && (
)}
) return (
{audioUrl && (
) } function MediaCarousel({ contents, language, fill }: { contents: ContentDTO[]; language: string; fill: boolean }) { const [index, setIndex] = useState(0) const [zoomed, setZoomed] = useState(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 (
{current?.resource && ( )} {canZoom && ( )}
{caption && (

{caption}

)} {contents.length > 1 && (
{contents.map((_, i) => (
{index + 1}/{contents.length}
)} {zoomed && ( setZoomed(null)} /> )}
) } 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 (
e.stopPropagation()} style={{ background: 'var(--color-surface)', borderRadius: 20, overflow: 'hidden', width: 'min(680px, 100%)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }} >
{title && (
)}
{content.resource && ( )}
{description && (
)}
) } 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) { const rect = e.currentTarget.getBoundingClientRect() onSeek(Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width))) } return ( ) } 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')}` }