Import initial de la documentation : statut, roadmap, plans V1/V2, specs verticales (creche, sport), audits securite, plan de test, analyse concurrentielle et maquettes de design. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
236 lines
8.5 KiB
TypeScript
236 lines
8.5 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import { useBack } from '@/hooks/useBack'
|
|
import ResourceViewer from '@/components/ui/ResourceViewer'
|
|
import { useVisitor } from '@/context/VisitorContext'
|
|
import { t, tPlain } from '@/lib/i18n'
|
|
import type { 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 [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 articleReadTrackedRef = useRef(false)
|
|
|
|
useEffect(() => { setAvailableLanguages([]) }, [languages])
|
|
|
|
useEffect(() => {
|
|
const el = scrollRef.current
|
|
if (!el || !instanceId) return
|
|
const onScroll = () => {
|
|
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,
|
|
})
|
|
}
|
|
}
|
|
el.addEventListener('scroll', onScroll, { passive: true })
|
|
return () => el.removeEventListener('scroll', onScroll)
|
|
}, [instanceId, configId, section.id, language])
|
|
|
|
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 toggleAudio() {
|
|
if (!audioRef.current) return
|
|
if (isPlaying) { audioRef.current.pause(); setIsPlaying(false) }
|
|
else { audioRef.current.play(); setIsPlaying(true) }
|
|
}
|
|
|
|
const contents = [...(article?.contents ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
const htmlContent = t(article?.content, language)
|
|
|
|
return (
|
|
<div className="min-h-screen flex flex-col" style={{ background: 'var(--color-background)' }}>
|
|
<AppBar title={tPlain(section.title, language)} onBack={back} />
|
|
|
|
<main ref={scrollRef} className="flex-1 overflow-y-auto pb-28">
|
|
{/* Carousel images */}
|
|
{contents.length > 0 && (
|
|
<ImageCarousel contents={contents} language={language} />
|
|
)}
|
|
|
|
{/* HTML content */}
|
|
{htmlContent && (
|
|
<div
|
|
className="px-4 py-5 prose prose-sm max-w-none"
|
|
style={{ color: 'var(--color-text)' }}
|
|
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
|
/>
|
|
)}
|
|
</main>
|
|
|
|
{/* Floating audio player */}
|
|
{audioUrl && (
|
|
<>
|
|
<audio
|
|
ref={audioRef}
|
|
src={audioUrl}
|
|
onTimeUpdate={() => setCurrentTime(audioRef.current?.currentTime ?? 0)}
|
|
onLoadedMetadata={() => setDuration(audioRef.current?.duration ?? 0)}
|
|
onEnded={() => setIsPlaying(false)}
|
|
/>
|
|
<div
|
|
className="fixed bottom-0 left-0 right-0"
|
|
style={{
|
|
padding: 'max(14px, env(safe-area-inset-bottom)) 16px 16px',
|
|
background: 'linear-gradient(to top, #F6F3EE 78%, transparent)',
|
|
zIndex: 30,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
borderRadius: 16,
|
|
overflow: 'hidden',
|
|
background: '#fff',
|
|
border: '1px solid #E8E3DA',
|
|
boxShadow: '0 6px 16px -12px rgba(0,0,0,0.3)',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px' }}>
|
|
<button
|
|
onClick={toggleAudio}
|
|
style={{
|
|
width: 44, height: 44, borderRadius: '50%', border: 'none', cursor: 'pointer', flexShrink: 0,
|
|
background: '#1E2A33',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
}}
|
|
>
|
|
{isPlaying ? (
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="#fff">
|
|
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
|
</svg>
|
|
) : (
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="#fff">
|
|
<path d="M8 5v14l11-7z" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<span style={{ color: '#6B7B86', fontSize: 11, fontWeight: 700, letterSpacing: '0.5px' }}>
|
|
GUIDE AUDIO
|
|
</span>
|
|
<div style={{ marginTop: 5 }}>
|
|
<ArticleWaveform currentTime={currentTime} duration={duration} />
|
|
</div>
|
|
</div>
|
|
<span style={{ color: '#6B7B86', fontSize: 12.5, fontWeight: 600, flexShrink: 0 }}>
|
|
{duration > 0 ? formatTime(duration) : '--:--'}
|
|
</span>
|
|
</div>
|
|
{duration > 0 && (
|
|
<div style={{ height: 3, background: '#F0EBE3' }}>
|
|
<div
|
|
style={{
|
|
height: '100%',
|
|
width: `${(currentTime / duration) * 100}%`,
|
|
background: 'var(--color-primary)',
|
|
transition: 'width 0.3s linear',
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ImageCarousel({ contents, language }: { contents: NonNullable<SectionDTO['article']>['contents'] & object; language: string }) {
|
|
const [index, setIndex] = useState(0)
|
|
if (!contents || contents.length === 0) return null
|
|
|
|
return (
|
|
<div className="relative w-full" style={{ height: 240 }}>
|
|
{contents[index]?.resource && (
|
|
<ResourceViewer
|
|
resource={contents[index].resource!}
|
|
alt={tPlain(contents[index].title, language)}
|
|
objectFit="cover"
|
|
/>
|
|
)}
|
|
{contents.length > 1 && (
|
|
<>
|
|
<button
|
|
onClick={() => setIndex((i) => Math.max(0, i - 1))}
|
|
disabled={index === 0}
|
|
className="absolute left-2 top-1/2 -translate-y-1/2 bg-black/40 text-white rounded-full p-1 disabled:opacity-20"
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><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}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/40 text-white rounded-full p-1 disabled:opacity-20"
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><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">
|
|
{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)' }} />
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ArticleWaveform({ 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
|
|
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}
|
|
style={{ fill: i / bars.length <= progress ? 'var(--color-primary)' : 'rgba(100,110,120,0.2)' }}
|
|
/>
|
|
))}
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
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')}`
|
|
}
|