Compare commits
4 Commits
7ff72345b3
...
d371b447c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d371b447c6 | ||
|
|
81c807cd77 | ||
|
|
d21d794420 | ||
|
|
82cff83bb6 |
@ -3,6 +3,7 @@ import { getInstanceBySlug, getConfiguration, getSections } from '@/lib/api/clie
|
|||||||
import SectionList from '@/components/SectionList'
|
import SectionList from '@/components/SectionList'
|
||||||
import QRScannerButton from '@/components/QRScannerButton'
|
import QRScannerButton from '@/components/QRScannerButton'
|
||||||
import ProximitySuggestion from '@/components/ProximitySuggestion'
|
import ProximitySuggestion from '@/components/ProximitySuggestion'
|
||||||
|
import CastingIntro from '@/components/CastingIntro'
|
||||||
|
|
||||||
export default async function ConfigPage({
|
export default async function ConfigPage({
|
||||||
params,
|
params,
|
||||||
@ -39,6 +40,7 @@ export default async function ConfigPage({
|
|||||||
configPrimaryColor={config.primaryColor}
|
configPrimaryColor={config.primaryColor}
|
||||||
languages={config.languages ?? ['FR']}
|
languages={config.languages ?? ['FR']}
|
||||||
/>
|
/>
|
||||||
|
{config.casting && config.casting.length >= 2 && <CastingIntro casting={config.casting} configId={configId} />}
|
||||||
{instance.isQRCodeEnabled !== false && <QRScannerButton slug={slug} configurationId={configId} />}
|
{instance.isQRCodeEnabled !== false && <QRScannerButton slug={slug} configurationId={configId} />}
|
||||||
<ProximitySuggestion sections={activeSections} slug={slug} configId={configId} />
|
<ProximitySuggestion sections={activeSections} slug={slug} configId={configId} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
73
src/components/CastingIntro.tsx
Normal file
73
src/components/CastingIntro.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { useVisitor } from '@/context/VisitorContext'
|
||||||
|
import { ui } from '@/lib/i18n'
|
||||||
|
import type { CastingVisitorMemberDTO } from '@/lib/api/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Écran d'entrée « les personnes que vous allez rencontrer » (plan Studio décision 25). Le serveur
|
||||||
|
* ne l'envoie qu'avec l'interrupteur armé et au moins deux portraits. Montré une fois par visite :
|
||||||
|
* le visiteur qui revient à la liste des sections ne le revoit pas.
|
||||||
|
*/
|
||||||
|
export default function CastingIntro({ casting, configId }: { casting: CastingVisitorMemberDTO[]; configId: string }) {
|
||||||
|
const { language } = useVisitor()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const storageKey = `casting-seen-${configId}`
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem(storageKey)) return
|
||||||
|
} catch {
|
||||||
|
// Stockage indisponible (navigation privée) : on montre l'écran, sans mémoriser.
|
||||||
|
}
|
||||||
|
setOpen(true)
|
||||||
|
}, [storageKey])
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(storageKey, '1')
|
||||||
|
} catch {
|
||||||
|
// Idem : l'écran reviendra à la prochaine ouverture, ce n'est pas grave.
|
||||||
|
}
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 50, background: 'rgba(0,0,0,0.55)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||||
|
>
|
||||||
|
<div style={{ width: '100%', maxWidth: 420, background: 'var(--color-paper, #fff)', borderRadius: 22, padding: '24px 20px', boxShadow: '0 12px 32px rgba(0,0,0,0.25)' }}>
|
||||||
|
<h2 style={{ fontSize: 18, fontWeight: 700, textAlign: 'center', margin: '0 0 20px', color: 'var(--color-ink, #1E2A33)' }}>
|
||||||
|
{ui('castingTitle', language)}
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(110px, 1fr))', gap: 16 }}>
|
||||||
|
{casting.map((member, index) => (
|
||||||
|
<div key={index} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 6 }}>
|
||||||
|
<Image
|
||||||
|
src={member.portraitUrl}
|
||||||
|
alt=""
|
||||||
|
width={88}
|
||||||
|
height={88}
|
||||||
|
style={{ width: 88, height: 88, borderRadius: '50%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-ink, #1E2A33)' }}>{member.name}</span>
|
||||||
|
{member.role && <span style={{ fontSize: 12, color: 'var(--color-ink-muted, #6B7B86)' }}>{member.role}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={close}
|
||||||
|
style={{ marginTop: 24, width: '100%', padding: '12px 16px', borderRadius: 14, border: 'none', cursor: 'pointer', background: 'var(--color-primary)', color: '#fff', fontSize: 15, fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
{ui('castingStart', language)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -8,6 +8,8 @@ import { useVisitor } from '@/context/VisitorContext'
|
|||||||
import { t, tPlain } from '@/lib/i18n'
|
import { t, tPlain } from '@/lib/i18n'
|
||||||
import type { ContentDTO, SectionDTO } from '@/lib/api/types'
|
import type { ContentDTO, SectionDTO } from '@/lib/api/types'
|
||||||
import AppBar from '@/components/ui/AppBar'
|
import AppBar from '@/components/ui/AppBar'
|
||||||
|
import NarratorAvatar from '@/components/ui/NarratorAvatar'
|
||||||
|
import { useAudioResource } from '@/hooks/useAudioResource'
|
||||||
import { trackEvent } from '@/lib/stats'
|
import { trackEvent } from '@/lib/stats'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -15,9 +17,10 @@ interface Props {
|
|||||||
slug: string
|
slug: string
|
||||||
configId: string
|
configId: string
|
||||||
languages: string[]
|
languages: string[]
|
||||||
|
apiKey: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ArticleSection({ section, configId, languages }: Props) {
|
export default function ArticleSection({ section, configId, languages, apiKey }: Props) {
|
||||||
const { language, setAvailableLanguages, instanceId } = useVisitor()
|
const { language, setAvailableLanguages, instanceId } = useVisitor()
|
||||||
const back = useBack()
|
const back = useBack()
|
||||||
const article = section.article
|
const article = section.article
|
||||||
@ -60,8 +63,11 @@ export default function ArticleSection({ section, configId, languages }: Props)
|
|||||||
return () => el.removeEventListener('scroll', onScroll)
|
return () => el.removeEventListener('scroll', onScroll)
|
||||||
}, [instanceId, configId, section.id, language, isLandscape])
|
}, [instanceId, configId, section.id, language, isLandscape])
|
||||||
|
|
||||||
const audioUrl = article?.audioIds?.find((a) => a.language === language)?.value
|
const audio = useAudioResource(
|
||||||
?? article?.audioIds?.[0]?.value
|
article?.audioIds?.find((a) => a.language === language)?.value ?? article?.audioIds?.[0]?.value,
|
||||||
|
apiKey,
|
||||||
|
)
|
||||||
|
const audioUrl = audio.url
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (article?.isReadAudioAuto && audioRef.current) {
|
if (article?.isReadAudioAuto && audioRef.current) {
|
||||||
@ -131,7 +137,9 @@ export default function ArticleSection({ section, configId, languages }: Props)
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<span style={{ color: 'var(--color-ink-muted)', fontSize: 11, fontWeight: 700, letterSpacing: '0.5px' }}>GUIDE AUDIO</span>
|
{audio.narratorName
|
||||||
|
? <NarratorAvatar name={audio.narratorName} portraitUrl={audio.narratorPortraitUrl} color="var(--color-ink)" />
|
||||||
|
: <span style={{ color: 'var(--color-ink-muted)', fontSize: 11, fontWeight: 700, letterSpacing: '0.5px' }}>GUIDE AUDIO</span>}
|
||||||
{!isAudioCompact && (
|
{!isAudioCompact && (
|
||||||
<div style={{ marginTop: 5 }}>
|
<div style={{ marginTop: 5 }}>
|
||||||
<Waveform currentTime={currentTime} duration={duration} onSeek={seekAudio} />
|
<Waveform currentTime={currentTime} duration={duration} onSeek={seekAudio} />
|
||||||
|
|||||||
@ -13,6 +13,8 @@ import { t, tPlain } from '@/lib/i18n'
|
|||||||
import type { SectionDTO, GeoPointDTO } from '@/lib/api/types'
|
import type { SectionDTO, GeoPointDTO } from '@/lib/api/types'
|
||||||
import { trackEvent } from '@/lib/stats'
|
import { trackEvent } from '@/lib/stats'
|
||||||
import { getGeoPointLatLng } from '@/lib/geo'
|
import { getGeoPointLatLng } from '@/lib/geo'
|
||||||
|
import NarratorAvatar from '@/components/ui/NarratorAvatar'
|
||||||
|
import { useAudioResource } from '@/hooks/useAudioResource'
|
||||||
import './map/map.css'
|
import './map/map.css'
|
||||||
|
|
||||||
const LeafletMap = dynamic(() => import('./map/LeafletMap'), {
|
const LeafletMap = dynamic(() => import('./map/LeafletMap'), {
|
||||||
@ -29,6 +31,7 @@ interface Props {
|
|||||||
slug: string
|
slug: string
|
||||||
configId: string
|
configId: string
|
||||||
languages: string[]
|
languages: string[]
|
||||||
|
apiKey: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mode = 'map' | 'list'
|
type Mode = 'map' | 'list'
|
||||||
@ -39,7 +42,7 @@ const DOCK_WIDTH = 340
|
|||||||
const DETAIL_WIDTH = 320
|
const DETAIL_WIDTH = 320
|
||||||
const PANEL_TOP = 60
|
const PANEL_TOP = 60
|
||||||
|
|
||||||
export default function MapSection({ section, configId, languages }: Props) {
|
export default function MapSection({ section, configId, languages, apiKey }: Props) {
|
||||||
const { language, setAvailableLanguages, instanceId } = useVisitor()
|
const { language, setAvailableLanguages, instanceId } = useVisitor()
|
||||||
const back = useBack()
|
const back = useBack()
|
||||||
|
|
||||||
@ -322,6 +325,7 @@ export default function MapSection({ section, configId, languages }: Props) {
|
|||||||
{/* Detail sheet (POI) */}
|
{/* Detail sheet (POI) */}
|
||||||
{selected && (
|
{selected && (
|
||||||
<PointDetail
|
<PointDetail
|
||||||
|
apiKey={apiKey}
|
||||||
point={selected}
|
point={selected}
|
||||||
language={language}
|
language={language}
|
||||||
variant={isLandscape ? 'card' : 'sheet'}
|
variant={isLandscape ? 'card' : 'sheet'}
|
||||||
@ -396,8 +400,9 @@ function PointList({
|
|||||||
|
|
||||||
// ── Detail sheet ────────────────────────────────────────────────────────────
|
// ── Detail sheet ────────────────────────────────────────────────────────────
|
||||||
function PointDetail({
|
function PointDetail({
|
||||||
point, language, variant, width, top, onClose,
|
apiKey, point, language, variant, width, top, onClose,
|
||||||
}: {
|
}: {
|
||||||
|
apiKey: string
|
||||||
point: GeoPointDTO
|
point: GeoPointDTO
|
||||||
language: string
|
language: string
|
||||||
/// `sheet` : feuille basse (portrait). `card` : colonne à droite (paysage),
|
/// `sheet` : feuille basse (portrait). `card` : colonne à droite (paysage),
|
||||||
@ -413,6 +418,10 @@ function PointDetail({
|
|||||||
const prices = t(point.prices, language)
|
const prices = t(point.prices, language)
|
||||||
const schedules = t(point.schedules, language)
|
const schedules = t(point.schedules, language)
|
||||||
const isCard = variant === 'card'
|
const isCard = variant === 'card'
|
||||||
|
const audio = useAudioResource(
|
||||||
|
point.audioIds?.find((a) => a.language === language)?.value ?? point.audioIds?.[0]?.value,
|
||||||
|
apiKey,
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -476,6 +485,15 @@ function PointDetail({
|
|||||||
className="overflow-y-auto px-5 py-4 flex flex-col gap-4"
|
className="overflow-y-auto px-5 py-4 flex flex-col gap-4"
|
||||||
style={isCard ? { flex: 1, minHeight: 0 } : { maxHeight: 'calc(80vh - 180px)' }}
|
style={isCard ? { flex: 1, minHeight: 0 } : { maxHeight: 'calc(80vh - 180px)' }}
|
||||||
>
|
>
|
||||||
|
{audio.url && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{audio.narratorName && (
|
||||||
|
<NarratorAvatar name={audio.narratorName} portraitUrl={audio.narratorPortraitUrl} color="var(--color-text)" />
|
||||||
|
)}
|
||||||
|
<audio controls src={audio.url} style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{t(point.description, language) && (
|
{t(point.description, language) && (
|
||||||
<div
|
<div
|
||||||
className="text-sm leading-relaxed [&_p]:m-0 [&_p+p]:mt-2"
|
className="text-sm leading-relaxed [&_p]:m-0 [&_p+p]:mt-2"
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
import { useBack } from '@/hooks/useBack'
|
import { useBack } from '@/hooks/useBack'
|
||||||
import ChevronLeft from '@/components/ui/ChevronLeftIcon'
|
import ChevronLeft from '@/components/ui/ChevronLeftIcon'
|
||||||
import ResourceViewer, { isImageResource } from '@/components/ui/ResourceViewer'
|
import ResourceViewer, { isImageResource } from '@/components/ui/ResourceViewer'
|
||||||
|
import NarratorAvatar from '@/components/ui/NarratorAvatar'
|
||||||
|
import { useAudioResource } from '@/hooks/useAudioResource'
|
||||||
import { useVisitor } from '@/context/VisitorContext'
|
import { useVisitor } from '@/context/VisitorContext'
|
||||||
import { useIsLandscape, useMediaQuery } from '@/hooks/useOrientation'
|
import { useIsLandscape, useMediaQuery } from '@/hooks/useOrientation'
|
||||||
import FloatingPanel from '@/components/ui/FloatingPanel'
|
import FloatingPanel from '@/components/ui/FloatingPanel'
|
||||||
@ -37,7 +39,7 @@ interface Props {
|
|||||||
|
|
||||||
type View = 'list' | 'start' | 'progress' | 'end'
|
type View = 'list' | 'start' | 'progress' | 'end'
|
||||||
|
|
||||||
export default function ParcoursSection({ section, languages }: Props) {
|
export default function ParcoursSection({ section, languages, apiKey }: Props) {
|
||||||
const { language, setAvailableLanguages } = useVisitor()
|
const { language, setAvailableLanguages } = useVisitor()
|
||||||
const back = useBack()
|
const back = useBack()
|
||||||
|
|
||||||
@ -130,6 +132,7 @@ export default function ParcoursSection({ section, languages }: Props) {
|
|||||||
if (useMapView) {
|
if (useMapView) {
|
||||||
return (
|
return (
|
||||||
<ParcoursMapProgression
|
<ParcoursMapProgression
|
||||||
|
apiKey={apiKey}
|
||||||
path={activePath}
|
path={activePath}
|
||||||
steps={steps}
|
steps={steps}
|
||||||
stepIndex={stepIndex}
|
stepIndex={stepIndex}
|
||||||
@ -149,6 +152,7 @@ export default function ParcoursSection({ section, languages }: Props) {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ProgressView
|
<ProgressView
|
||||||
|
apiKey={apiKey}
|
||||||
path={activePath}
|
path={activePath}
|
||||||
steps={steps}
|
steps={steps}
|
||||||
stepIndex={stepIndex}
|
stepIndex={stepIndex}
|
||||||
@ -291,7 +295,8 @@ export default function ParcoursSection({ section, languages }: Props) {
|
|||||||
|
|
||||||
// ── Map-based progression ─────────────────────────────────────────────────────
|
// ── Map-based progression ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ParcoursMapProgression({ path, steps, stepIndex, completedSteps, primaryColor, isGame, language, sectionLat, sectionLng, onNext, onPrev, onBack, canJumpTo, onJump }: {
|
function ParcoursMapProgression({ apiKey, path, steps, stepIndex, completedSteps, primaryColor, isGame, language, sectionLat, sectionLng, onNext, onPrev, onBack, canJumpTo, onJump }: {
|
||||||
|
apiKey: string
|
||||||
path: GuidedPathDTO
|
path: GuidedPathDTO
|
||||||
steps: GuidedStepDTO[]
|
steps: GuidedStepDTO[]
|
||||||
stepIndex: number
|
stepIndex: number
|
||||||
@ -425,9 +430,11 @@ function ParcoursMapProgression({ path, steps, stepIndex, completedSteps, primar
|
|||||||
const zoneOk = !geoTriggered || inZone || !requireSuccess || geoUnavailable
|
const zoneOk = !geoTriggered || inZone || !requireSuccess || geoUnavailable
|
||||||
const canAdvance = quizOk && zoneOk
|
const canAdvance = quizOk && zoneOk
|
||||||
|
|
||||||
const audioUrlRaw = currentStep?.audioIds?.find((a) => a.language === language)?.value
|
const audio = useAudioResource(
|
||||||
?? currentStep?.audioIds?.[0]?.value
|
currentStep?.audioIds?.find((a) => a.language === language)?.value ?? currentStep?.audioIds?.[0]?.value,
|
||||||
const audioUrl = audioUrlRaw?.startsWith('http') ? audioUrlRaw : undefined
|
apiKey,
|
||||||
|
)
|
||||||
|
const audioUrl = audio.url
|
||||||
const stepContents = [...(currentStep?.contents ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
const stepContents = [...(currentStep?.contents ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||||
const hasCarousel = stepContents.length > 0
|
const hasCarousel = stepContents.length > 0
|
||||||
|
|
||||||
@ -875,6 +882,11 @@ function ParcoursMapProgression({ path, steps, stepIndex, completedSteps, primar
|
|||||||
border: `1px solid ${isGame ? 'rgba(212,175,55,0.2)' : '#E8E3DA'}`,
|
border: `1px solid ${isGame ? 'rgba(212,175,55,0.2)' : '#E8E3DA'}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{audio.narratorName && (
|
||||||
|
<div style={{ padding: '12px 14px 0' }}>
|
||||||
|
<NarratorAvatar name={audio.narratorName} portraitUrl={audio.narratorPortraitUrl} color={isGame ? '#fff' : '#1E2A33'} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px' }}>
|
||||||
<button
|
<button
|
||||||
onClick={toggleAudio}
|
onClick={toggleAudio}
|
||||||
@ -1379,7 +1391,8 @@ function StartSheet({ path, isGame, primaryColor, language, gameIntro, onStart,
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProgressView({ path, steps, stepIndex, completedSteps, primaryColor, isGame, language, onNext, onPrev, onBack, maxReached, canJumpTo, onJump }: {
|
function ProgressView({ apiKey, path, steps, stepIndex, completedSteps, primaryColor, isGame, language, onNext, onPrev, onBack, maxReached, canJumpTo, onJump }: {
|
||||||
|
apiKey: string
|
||||||
path: GuidedPathDTO
|
path: GuidedPathDTO
|
||||||
steps: GuidedStepDTO[]
|
steps: GuidedStepDTO[]
|
||||||
stepIndex: number
|
stepIndex: number
|
||||||
@ -1444,9 +1457,11 @@ function ProgressView({ path, steps, stepIndex, completedSteps, primaryColor, is
|
|||||||
|
|
||||||
const stepTitle = tPlain(step?.title, language)
|
const stepTitle = tPlain(step?.title, language)
|
||||||
const stepDesc = t(step?.description, language)
|
const stepDesc = t(step?.description, language)
|
||||||
const audioUrlRaw = step?.audioIds?.find((a) => a.language === language)?.value
|
const audio = useAudioResource(
|
||||||
?? step?.audioIds?.[0]?.value
|
step?.audioIds?.find((a) => a.language === language)?.value ?? step?.audioIds?.[0]?.value,
|
||||||
const audioUrl = audioUrlRaw?.startsWith('http') ? audioUrlRaw : undefined
|
apiKey,
|
||||||
|
)
|
||||||
|
const audioUrl = audio.url
|
||||||
const stepContents = [...(step?.contents ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
const stepContents = [...(step?.contents ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||||
const hasCarousel = stepContents.length > 0
|
const hasCarousel = stepContents.length > 0
|
||||||
|
|
||||||
@ -1660,6 +1675,11 @@ function ProgressView({ path, steps, stepIndex, completedSteps, primaryColor, is
|
|||||||
border: `1px solid ${isGame ? 'rgba(212,175,55,0.2)' : '#E8E3DA'}`,
|
border: `1px solid ${isGame ? 'rgba(212,175,55,0.2)' : '#E8E3DA'}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{audio.narratorName && (
|
||||||
|
<div style={{ padding: '12px 14px 0' }}>
|
||||||
|
<NarratorAvatar name={audio.narratorName} portraitUrl={audio.narratorPortraitUrl} color={isGame ? '#fff' : '#1E2A33'} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px' }}>
|
||||||
<button
|
<button
|
||||||
onClick={toggleAudio}
|
onClick={toggleAudio}
|
||||||
|
|||||||
26
src/components/ui/NarratorAvatar.tsx
Normal file
26
src/components/ui/NarratorAvatar.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import Image from 'next/image'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le personnage qui parle, à côté du lecteur audio (plan Studio lot 8d) : portrait canon et
|
||||||
|
* nom. Sans portrait, un disque neutre garde la place.
|
||||||
|
*/
|
||||||
|
export default function NarratorAvatar({ name, portraitUrl, color }: { name: string; portraitUrl?: string; color?: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||||
|
{portraitUrl ? (
|
||||||
|
<Image
|
||||||
|
src={portraitUrl}
|
||||||
|
alt=""
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
style={{ width: 32, height: 32, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#E8E3DA', flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import type { ResourceDTO } from '@/lib/api/types'
|
import { RESOURCE_ORIGIN_GENERATED, type ResourceDTO } from '@/lib/api/types'
|
||||||
|
import { ui } from '@/lib/i18n'
|
||||||
|
import { useVisitor } from '@/context/VisitorContext'
|
||||||
|
|
||||||
// Même stratégie que PdfSection : react-pdf a besoin de `window`.
|
// Même stratégie que PdfSection : react-pdf a besoin de `window`.
|
||||||
const PdfViewer = dynamic(() => import('@/components/sections/pdf/PdfViewer'), { ssr: false })
|
const PdfViewer = dynamic(() => import('@/components/sections/pdf/PdfViewer'), { ssr: false })
|
||||||
@ -44,6 +46,8 @@ interface Props {
|
|||||||
* Parent must have: position relative, explicit width & height (or fill via flex).
|
* Parent must have: position relative, explicit width & height (or fill via flex).
|
||||||
*/
|
*/
|
||||||
export default function ResourceViewer({ resource, alt = '', objectFit = 'contain' }: Props) {
|
export default function ResourceViewer({ resource, alt = '', objectFit = 'contain' }: Props) {
|
||||||
|
// La mention IA suit la langue de visite, comme le reste des libellés d'interface.
|
||||||
|
const { language } = useVisitor()
|
||||||
const { url, type } = resource
|
const { url, type } = resource
|
||||||
if (!url) return null
|
if (!url) return null
|
||||||
|
|
||||||
@ -98,12 +102,32 @@ export default function ResourceViewer({ resource, alt = '', objectFit = 'contai
|
|||||||
|
|
||||||
// Image, ImageUrl, or unknown → render as image (safe default)
|
// Image, ImageUrl, or unknown → render as image (safe default)
|
||||||
return (
|
return (
|
||||||
<Image
|
<>
|
||||||
src={url}
|
<Image
|
||||||
alt={alt}
|
src={url}
|
||||||
fill
|
alt={alt}
|
||||||
className={`object-${objectFit}`}
|
fill
|
||||||
sizes="100vw"
|
className={`object-${objectFit}`}
|
||||||
/>
|
sizes="100vw"
|
||||||
|
/>
|
||||||
|
{resource.origin === RESOURCE_ORIGIN_GENERATED && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 6,
|
||||||
|
bottom: 6,
|
||||||
|
padding: '3px 6px',
|
||||||
|
borderRadius: 4,
|
||||||
|
background: 'rgba(0,0,0,.72)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 9,
|
||||||
|
letterSpacing: '.02em',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ui('aiGeneratedImage', language)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/hooks/useAudioResource.ts
Normal file
42
src/hooks/useAudioResource.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { getResource } from '@/lib/api/client'
|
||||||
|
|
||||||
|
export interface AudioResource {
|
||||||
|
url?: string
|
||||||
|
narratorName?: string
|
||||||
|
narratorPortraitUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `audioIds` porte un identifiant de ressource — ce que range le manager, et la narration
|
||||||
|
* générée — ou, sur d'anciens contenus, une URL. Sans cette résolution, un audio rangé par
|
||||||
|
* identifiant ne jouait jamais. Le narrateur vient avec la ressource (plan Studio lot 8d).
|
||||||
|
*/
|
||||||
|
export function useAudioResource(value: string | undefined, apiKey: string): AudioResource {
|
||||||
|
const [resource, setResource] = useState<AudioResource>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!value) {
|
||||||
|
setResource({})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (value.startsWith('http')) {
|
||||||
|
setResource({ url: value })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
getResource(value, apiKey)
|
||||||
|
.then((r) => {
|
||||||
|
if (!cancelled) setResource({ url: r.url, narratorName: r.narratorName, narratorPortraitUrl: r.narratorPortraitUrl })
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setResource({})
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [value, apiKey])
|
||||||
|
|
||||||
|
return resource
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import type { ResourceDTO } from './types'
|
||||||
import type { ApplicationInstanceDTO, AppConfigurationLinkDTO, ConfigurationDTO, SectionDTO, GuidedPathDTO, ParcoursDTO, AiChatMessageDTO, AiChatResponseDTO } from './types'
|
import type { ApplicationInstanceDTO, AppConfigurationLinkDTO, ConfigurationDTO, SectionDTO, GuidedPathDTO, ParcoursDTO, AiChatMessageDTO, AiChatResponseDTO } from './types'
|
||||||
|
|
||||||
const BASE_URL = process.env.NEXT_PUBLIC_API_URL
|
const BASE_URL = process.env.NEXT_PUBLIC_API_URL
|
||||||
@ -188,6 +189,11 @@ export async function getConfiguration(configId: string, apiKey: string): Promis
|
|||||||
return apiFetch(`/api/configuration/${configId}`, apiKey)
|
return apiFetch(`/api/configuration/${configId}`, apiKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** URL et narrateur d'une ressource rangee par identifiant, typiquement un audio. */
|
||||||
|
export async function getResource(resourceId: string, apiKey: string): Promise<ResourceDTO> {
|
||||||
|
return apiFetch(`/api/Resource/${resourceId}/detail`, apiKey, 'default')
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSections(configId: string, apiKey: string): Promise<SectionDTO[]> {
|
export async function getSections(configId: string, apiKey: string): Promise<SectionDTO[]> {
|
||||||
const raw: any[] = await apiFetch(`/api/Section/configuration/${configId}/detail`, apiKey, 'no-store')
|
const raw: any[] = await apiFetch(`/api/Section/configuration/${configId}/detail`, apiKey, 'no-store')
|
||||||
return raw.map(normalizeSectionDTO)
|
return raw.map(normalizeSectionDTO)
|
||||||
|
|||||||
@ -7,8 +7,16 @@ export interface ResourceDTO {
|
|||||||
id?: string
|
id?: string
|
||||||
url?: string
|
url?: string
|
||||||
type?: string
|
type?: string
|
||||||
|
/** ResourceOrigin cote backend : 0 televersee, 1 generee par le Studio, 2 derivee. */
|
||||||
|
origin?: number
|
||||||
|
/** Audio narre : le personnage qui parle et son portrait canon (lot 8d). */
|
||||||
|
narratorName?: string
|
||||||
|
narratorPortraitUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Une image generee porte la mention IA ; une image televersee, jamais. */
|
||||||
|
export const RESOURCE_ORIGIN_GENERATED = 1
|
||||||
|
|
||||||
export interface ContentDTO {
|
export interface ContentDTO {
|
||||||
order: number
|
order: number
|
||||||
title?: TranslationDTO[]
|
title?: TranslationDTO[]
|
||||||
@ -88,6 +96,14 @@ export interface ConfigurationDTO {
|
|||||||
isOffline?: boolean
|
isOffline?: boolean
|
||||||
sectionIds?: string[]
|
sectionIds?: string[]
|
||||||
sections?: SectionDTO[]
|
sections?: SectionDTO[]
|
||||||
|
/** Casting montre au visiteur (decision 25). Absent tant que l'ecran d'entree ne doit pas s'afficher. */
|
||||||
|
casting?: CastingVisitorMemberDTO[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CastingVisitorMemberDTO {
|
||||||
|
name: string
|
||||||
|
role?: string
|
||||||
|
portraitUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppConfigurationLinkDTO {
|
export interface AppConfigurationLinkDTO {
|
||||||
@ -249,6 +265,8 @@ export interface GeoPointDTO {
|
|||||||
site?: TranslationDTO[]
|
site?: TranslationDTO[]
|
||||||
geometry?: GeometryDTO
|
geometry?: GeometryDTO
|
||||||
polyColor?: string
|
polyColor?: string
|
||||||
|
/** Un audio par langue : identifiants de ressource (lot 8e). */
|
||||||
|
audioIds?: TranslationDTO[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CategorieDTO {
|
export interface CategorieDTO {
|
||||||
|
|||||||
@ -11,6 +11,45 @@ export function t(translations: AnyTranslation[] | undefined, lang: string): str
|
|||||||
}
|
}
|
||||||
|
|
||||||
const UI_STRINGS: Record<string, Record<string, string>> = {
|
const UI_STRINGS: Record<string, Record<string, string>> = {
|
||||||
|
aiGeneratedImage: {
|
||||||
|
FR: 'Image générée par IA',
|
||||||
|
NL: 'Beeld gegenereerd door AI',
|
||||||
|
EN: 'AI-generated image',
|
||||||
|
DE: 'KI-generiertes Bild',
|
||||||
|
ES: 'Imagen generada por IA',
|
||||||
|
IT: 'Immagine generata dall’IA',
|
||||||
|
PL: 'Obraz wygenerowany przez SI',
|
||||||
|
CN: '人工智能生成的图像',
|
||||||
|
AR: 'صورة من إنتاج الذكاء الاصطناعي',
|
||||||
|
UK: 'Зображення, створене ШІ',
|
||||||
|
LB: 'Bild generéiert vun der KI',
|
||||||
|
},
|
||||||
|
castingTitle: {
|
||||||
|
FR: 'Les personnes que vous allez rencontrer',
|
||||||
|
NL: 'De personen die u zult ontmoeten',
|
||||||
|
EN: 'The people you will meet',
|
||||||
|
DE: 'Die Menschen, denen Sie begegnen werden',
|
||||||
|
ES: 'Las personas que va a conocer',
|
||||||
|
IT: 'Le persone che incontrerete',
|
||||||
|
PL: 'Osoby, które spotkasz',
|
||||||
|
CN: '您将遇到的人物',
|
||||||
|
AR: 'الشخصيات التي ستلتقيها',
|
||||||
|
UK: 'Люди, яких ви зустрінете',
|
||||||
|
LB: "D'Leit, déi Dir kenne léiert",
|
||||||
|
},
|
||||||
|
castingStart: {
|
||||||
|
FR: 'Commencer la visite',
|
||||||
|
NL: 'Het bezoek beginnen',
|
||||||
|
EN: 'Start the visit',
|
||||||
|
DE: 'Besuch beginnen',
|
||||||
|
ES: 'Empezar la visita',
|
||||||
|
IT: 'Inizia la visita',
|
||||||
|
PL: 'Rozpocznij zwiedzanie',
|
||||||
|
CN: '开始参观',
|
||||||
|
AR: 'ابدأ الزيارة',
|
||||||
|
UK: 'Почати відвідування',
|
||||||
|
LB: 'Besuch ufänken',
|
||||||
|
},
|
||||||
noEventsThisMonth: {
|
noEventsThisMonth: {
|
||||||
FR: 'Aucun événement ce mois-ci',
|
FR: 'Aucun événement ce mois-ci',
|
||||||
NL: 'Geen evenementen deze maand',
|
NL: 'Geen evenementen deze maand',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user