diff --git a/src/app/[slug]/page.tsx b/src/app/[slug]/page.tsx index 21aa6c4..f1e16bf 100644 --- a/src/app/[slug]/page.tsx +++ b/src/app/[slug]/page.tsx @@ -23,12 +23,14 @@ export default async function HomePage({ // The featured event is rendered inside its parent configuration's context const featuredConfigId = featuredEvent?.configurationId + const languages = [...new Set(configurations.flatMap((c) => c.languages ?? []))] + return ( <> {featuredEvent && featuredConfigId && ( )} - + ) diff --git a/src/components/ConfigurationGrid.tsx b/src/components/ConfigurationGrid.tsx index d0a836d..860b816 100644 --- a/src/components/ConfigurationGrid.tsx +++ b/src/components/ConfigurationGrid.tsx @@ -1,33 +1,28 @@ 'use client' +import { useEffect } from 'react' import Link from 'next/link' import Image from 'next/image' import { useVisitor } from '@/context/VisitorContext' import { t, tPlain } from '@/lib/i18n' import type { ConfigurationDTO } from '@/lib/api/types' +import AppBar from '@/components/ui/AppBar' interface Props { configurations: ConfigurationDTO[] slug: string + languages: string[] } -export default function ConfigurationGrid({ configurations, slug }: Props) { - const { language } = useVisitor() +export default function ConfigurationGrid({ configurations, slug, languages }: Props) { + const { language, setAvailableLanguages } = useVisitor() const active = configurations.filter((c) => !c.isOffline) + useEffect(() => { setAvailableLanguages(languages) }, [languages]) + return (
-
- - MyInfoMate - -
+
{active.map((config) => ( diff --git a/src/components/SectionList.tsx b/src/components/SectionList.tsx index 3c7c06f..69cc14e 100644 --- a/src/components/SectionList.tsx +++ b/src/components/SectionList.tsx @@ -26,7 +26,7 @@ export default function SectionList({ const [search, setSearch] = useState('') const [searchNumber, setSearchNumber] = useState('') - useEffect(() => { setAvailableLanguages(languages) }, [languages]) + useEffect(() => { setAvailableLanguages([]) }, [languages]) const filtered = sections.filter((s) => { if (searchNumber) return (s.order ?? 0) + 1 === parseInt(searchNumber) diff --git a/src/components/sections/AgendaSection.tsx b/src/components/sections/AgendaSection.tsx index c364c73..c872d98 100644 --- a/src/components/sections/AgendaSection.tsx +++ b/src/components/sections/AgendaSection.tsx @@ -65,13 +65,15 @@ export default function AgendaSection({ section, configId, languages }: Props) { const router = useRouter() const events = section.agenda?.events ?? [] - useEffect(() => { setAvailableLanguages(languages) }, [languages]) + useEffect(() => { setAvailableLanguages([]) }, [languages]) const now = new Date() const [selectedMonth, setSelectedMonth] = useState(now.getMonth()) const [selectedYear, setSelectedYear] = useState(now.getFullYear()) const [selected, setSelected] = useState(null) + useEffect(() => { console.log('[Agenda] events:', events.map(e => ({ id: e.id, label: e.label, resourceId: e.resourceId, resource: e.resource }))) }, [events]) + const filtered = events.filter((e) => { if (!e.dateFrom) return false const d = new Date(e.dateFrom) diff --git a/src/components/sections/ArticleSection.tsx b/src/components/sections/ArticleSection.tsx index d037fd2..f53ee45 100644 --- a/src/components/sections/ArticleSection.tsx +++ b/src/components/sections/ArticleSection.tsx @@ -1,8 +1,8 @@ 'use client' import { useEffect, useRef, useState } from 'react' -import Image from 'next/image' import { useRouter } from 'next/navigation' +import ResourceViewer from '@/components/ui/ResourceViewer' import { useVisitor } from '@/context/VisitorContext' import { t, tPlain } from '@/lib/i18n' import type { SectionDTO } from '@/lib/api/types' @@ -27,7 +27,7 @@ export default function ArticleSection({ section, configId, languages }: Props) const scrollRef = useRef(null) const articleReadTrackedRef = useRef(false) - useEffect(() => { setAvailableLanguages(languages) }, [languages]) + useEffect(() => { setAvailableLanguages([]) }, [languages]) useEffect(() => { const el = scrollRef.current @@ -140,13 +140,11 @@ function ImageCarousel({ contents, language }: { contents: NonNullable - {contents[index]?.resource?.url && ( - {t(contents[index].title, )} {contents.length > 1 && ( diff --git a/src/components/sections/EventSection.tsx b/src/components/sections/EventSection.tsx index 0febf7c..70b4586 100644 --- a/src/components/sections/EventSection.tsx +++ b/src/components/sections/EventSection.tsx @@ -43,7 +43,7 @@ export default function EventSection({ section, slug, configId, languages }: Pro const { language, setAvailableLanguages } = useVisitor() const router = useRouter() - useEffect(() => { setAvailableLanguages(languages) }, [languages]) + useEffect(() => { setAvailableLanguages([]) }, [languages]) const event = section.event const programme = useMemo( diff --git a/src/components/sections/GameSection.tsx b/src/components/sections/GameSection.tsx index 64f8fdc..968ba1b 100644 --- a/src/components/sections/GameSection.tsx +++ b/src/components/sections/GameSection.tsx @@ -32,7 +32,7 @@ export default function GameSection({ section, configId, languages }: Props) { const { language, setAvailableLanguages, instanceId } = useVisitor() const router = useRouter() - useEffect(() => { setAvailableLanguages(languages) }, [languages]) + useEffect(() => { setAvailableLanguages([]) }, [languages]) const kind = detectKind(section.gameType) const rows = Math.max(2, section.rows ?? 3) @@ -204,6 +204,16 @@ export default function GameSection({ section, configId, languages }: Props) { open={showEnd} title="Bravo !" html={endMsg || 'Tu as gagné !'} + icon={ +
+ + + +
+ } onClose={restart} primaryAction={{ label: 'Recommencer', onClick: restart }} secondaryAction={{ label: 'Retour', onClick: () => router.back() }} diff --git a/src/components/sections/MapSection.tsx b/src/components/sections/MapSection.tsx index acfb859..8c461d3 100644 --- a/src/components/sections/MapSection.tsx +++ b/src/components/sections/MapSection.tsx @@ -36,7 +36,7 @@ export default function MapSection({ section, configId, languages }: Props) { const { language, setAvailableLanguages, instanceId } = useVisitor() const router = useRouter() - useEffect(() => { setAvailableLanguages(languages) }, [languages]) + useEffect(() => { setAvailableLanguages([]) }, [languages]) const map = section.map const hasData = !!map && (map.points?.length ?? 0) > 0 diff --git a/src/components/sections/MenuSection.tsx b/src/components/sections/MenuSection.tsx index 013fb6a..0a0f515 100644 --- a/src/components/sections/MenuSection.tsx +++ b/src/components/sections/MenuSection.tsx @@ -31,7 +31,7 @@ export default function MenuSection({ section, slug, configId, languages }: Prop }) } - useEffect(() => { setAvailableLanguages(languages) }, [languages]) + useEffect(() => { setAvailableLanguages([]) }, [languages]) const subsections = [...(section.menu?.sections ?? [])] .filter((s) => s.isActive !== false) diff --git a/src/components/sections/PdfSection.tsx b/src/components/sections/PdfSection.tsx index 2d392e6..8e36034 100644 --- a/src/components/sections/PdfSection.tsx +++ b/src/components/sections/PdfSection.tsx @@ -18,7 +18,7 @@ export default function PdfSection({ section, slug, configId, languages }: Props const { language, setAvailableLanguages } = useVisitor() const router = useRouter() - useEffect(() => { setAvailableLanguages(languages) }, [languages]) + useEffect(() => { setAvailableLanguages([]) }, [languages]) const pdfs = [...(section.pdfs ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) const [selectedIndex, setSelectedIndex] = useState(0) diff --git a/src/components/sections/QuizSection.tsx b/src/components/sections/QuizSection.tsx index 3d326ac..43d9e62 100644 --- a/src/components/sections/QuizSection.tsx +++ b/src/components/sections/QuizSection.tsx @@ -21,7 +21,7 @@ export default function QuizSection({ section, configId, languages }: Props) { const { language, setAvailableLanguages, instanceId } = useVisitor() const router = useRouter() - useEffect(() => { setAvailableLanguages(languages) }, [languages]) + useEffect(() => { setAvailableLanguages([]) }, [languages]) const quiz = section.quiz const questions = [...(quiz?.questions ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) diff --git a/src/components/sections/SliderSection.tsx b/src/components/sections/SliderSection.tsx index 6d1af52..279f249 100644 --- a/src/components/sections/SliderSection.tsx +++ b/src/components/sections/SliderSection.tsx @@ -1,12 +1,12 @@ 'use client' import { useEffect, useState } from 'react' -import Image from 'next/image' import { useRouter } from 'next/navigation' 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 ResourceViewer from '@/components/ui/ResourceViewer' interface Props { section: SectionDTO @@ -20,7 +20,7 @@ export default function SliderSection({ section, languages }: Props) { const router = useRouter() const [index, setIndex] = useState(0) - useEffect(() => { setAvailableLanguages(languages) }, [languages]) + useEffect(() => { setAvailableLanguages([]) }, [languages]) const contents = [...(section.slider?.contents ?? [])] .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) @@ -45,13 +45,10 @@ export default function SliderSection({ section, languages }: Props) {
{/* Image zone — ~75% of remaining height */}
- {current?.resource?.url && ( - {tPlain(current.title, )} diff --git a/src/components/sections/VideoSection.tsx b/src/components/sections/VideoSection.tsx index c9a4de6..0d5f2b1 100644 --- a/src/components/sections/VideoSection.tsx +++ b/src/components/sections/VideoSection.tsx @@ -38,7 +38,7 @@ export default function VideoSection({ section, slug, configId, languages }: Pro const { language, setAvailableLanguages } = useVisitor() const router = useRouter() - useEffect(() => { setAvailableLanguages(languages) }, [languages]) + useEffect(() => { setAvailableLanguages([]) }, [languages]) const source = section.source ?? section.video?.source const videoType = useMemo(() => detectType(source), [source]) diff --git a/src/components/sections/WeatherSection.tsx b/src/components/sections/WeatherSection.tsx index 2858f36..11eb418 100644 --- a/src/components/sections/WeatherSection.tsx +++ b/src/components/sections/WeatherSection.tsx @@ -223,7 +223,7 @@ export default function WeatherSection({ section, languages }: Props) { const router = useRouter() const [selectedDay, setSelectedDay] = useState(0) - useEffect(() => { setAvailableLanguages(languages) }, [languages]) + useEffect(() => { setAvailableLanguages([]) }, [languages]) const weatherData: WeatherData | null = useMemo(() => { const raw = section.weather?.result diff --git a/src/components/sections/WebSection.tsx b/src/components/sections/WebSection.tsx index 4f9ab8b..b60215f 100644 --- a/src/components/sections/WebSection.tsx +++ b/src/components/sections/WebSection.tsx @@ -17,7 +17,7 @@ export default function WebSection({ section, slug, configId, languages }: Props const { language, setAvailableLanguages } = useVisitor() const router = useRouter() - useEffect(() => { setAvailableLanguages(languages) }, [languages]) + useEffect(() => { setAvailableLanguages([]) }, [languages]) const source = section.web?.source ?? section.source const title = tPlain(section.title, language) diff --git a/src/components/sections/game/EscapeProgression.tsx b/src/components/sections/game/EscapeProgression.tsx index 52e11ba..65f6d21 100644 --- a/src/components/sections/game/EscapeProgression.tsx +++ b/src/components/sections/game/EscapeProgression.tsx @@ -188,6 +188,16 @@ export default function EscapeProgression({ paths, language }: Props) { open={showEnd} title="Bravo !" html={`Tu as terminé le parcours « ${tPlain(activePath.title, language) || 'parcours'} ».`} + icon={ +
+ + + +
+ } onClose={() => { setShowEnd(false); setActivePathId(null) }} primaryAction={{ label: 'Recommencer', onClick: () => { setCompletedSteps(new Set()); setQuizPassedSteps(new Set()); setTimerExpiredSteps(new Set()); setZoneNotifiedSteps(new Set()); setShowEnd(false) } }} secondaryAction={{ label: 'Retour', onClick: () => { setShowEnd(false); setActivePathId(null) } }} diff --git a/src/components/sections/game/MessageDialog.tsx b/src/components/sections/game/MessageDialog.tsx index 6806dd2..8c6a769 100644 --- a/src/components/sections/game/MessageDialog.tsx +++ b/src/components/sections/game/MessageDialog.tsx @@ -6,6 +6,7 @@ interface Props { open: boolean title?: string html?: string + icon?: ReactNode onClose: () => void primaryAction?: { label: string; onClick: () => void } secondaryAction?: { label: string; onClick: () => void } @@ -13,7 +14,7 @@ interface Props { } export default function MessageDialog({ - open, title, html, onClose, primaryAction, secondaryAction, children, + open, title, html, icon, onClose, primaryAction, secondaryAction, children, }: Props) { if (!open) return null return ( @@ -27,24 +28,43 @@ export default function MessageDialog({ }} > + + {icon && ( +
+ {icon} +
+ )} + + {title && ( +
+ {title} +
+ )} + + {(html || children) && ( +
+ {html ?
: children} +
+ )} +
- {title} -
-
- {html ?
: children} -
-
{primaryAction && ( @@ -54,7 +74,7 @@ export default function MessageDialog({ onClick={secondaryAction.onClick} className="w-full py-3 rounded-2xl font-semibold text-sm" style={{ - background: 'var(--color-surface)', + background: 'transparent', color: 'var(--color-text)', border: '1px solid var(--color-border)', }} @@ -66,7 +86,10 @@ export default function MessageDialog({ diff --git a/src/components/sections/game/PuzzleGame.tsx b/src/components/sections/game/PuzzleGame.tsx index f3ebb85..d027108 100644 --- a/src/components/sections/game/PuzzleGame.tsx +++ b/src/components/sections/game/PuzzleGame.tsx @@ -145,7 +145,7 @@ export default function PuzzleGame({ imageUrl, rows, cols, showHint, onWin }: Pr > {board && ( <> - {/* Board frame (target outlines) */} + {/* Board frame (always visible) */}
+ {/* Hint overlay — full image reference when hint is active */} + {showHint && ( +
+ )} {/* Pieces */} {pieces.map((p) => { const pw = board.w / cols diff --git a/src/components/ui/AppBar.tsx b/src/components/ui/AppBar.tsx index 045d98a..21ef1c2 100644 --- a/src/components/ui/AppBar.tsx +++ b/src/components/ui/AppBar.tsx @@ -1,5 +1,6 @@ 'use client' +import { useState, useRef, useEffect } from 'react' import { useVisitor } from '@/context/VisitorContext' const FLAG: Record = { @@ -7,6 +8,11 @@ const FLAG: Record = { IT: '🇮🇹', ES: '🇪🇸', PL: '🇵🇱', CN: '🇨🇳', AR: '🇸🇦', UK: '🇺🇦', } +const LABEL: Record = { + FR: 'Français', NL: 'Nederlands', EN: 'English', DE: 'Deutsch', + IT: 'Italiano', ES: 'Español', PL: 'Polski', CN: '中文', AR: 'العربية', UK: 'Українська', +} + interface Props { title?: string onBack?: () => void @@ -14,6 +20,19 @@ interface Props { export default function AppBar({ title, onBack }: Props) { const { language, setLanguage, availableLanguages } = useVisitor() + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + if (!open) return + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [open]) + + const showSelector = availableLanguages.length > 1 return (
-
+
{onBack && (
- {availableLanguages.length > 1 && ( -
- {availableLanguages.map((lang) => { - const active = lang === language - return ( - - ) - })} + {showSelector && ( +
+ + + {open && ( +
+ {availableLanguages.map((lang) => { + const active = lang === language + return ( + + ) + })} +
+ )}
)}
diff --git a/src/components/ui/ResourceViewer.tsx b/src/components/ui/ResourceViewer.tsx new file mode 100644 index 0000000..71b870a --- /dev/null +++ b/src/components/ui/ResourceViewer.tsx @@ -0,0 +1,89 @@ +'use client' + +import Image from 'next/image' +import type { ResourceDTO } from '@/lib/api/types' + +// Mirrors C# ResourceType enum — backend may serialize as int OR as string name +const IMAGES = new Set([0, 2, 'Image', 'ImageUrl']) +const VIDEOS = new Set([1, 3, 'Video', 'VideoUrl']) +const AUDIOS = new Set([4, 'Audio']) +const VIDEO_URLS = new Set([3, 'VideoUrl']) + +function youtubeEmbedUrl(url: string): string | null { + const match = url.match(/(?:v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/) + const id = match?.[1] + return id ? `https://www.youtube.com/embed/${id}?rel=0` : null +} + +function vimeoEmbedUrl(url: string): string | null { + const match = url.match(/vimeo\.com\/(?:.*?\/)?(\d+)/) + const id = match?.[1] + return id ? `https://player.vimeo.com/video/${id}` : null +} + +interface Props { + resource: ResourceDTO + alt?: string + objectFit?: 'contain' | 'cover' +} + +/** + * Renders any ResourceDTO inside a positioned parent (position:relative + defined size). + * Mirrors Flutter's showElementForResource / CachedCustomResource. + * + * Parent must have: position relative, explicit width & height (or fill via flex). + */ +export default function ResourceViewer({ resource, alt = '', objectFit = 'contain' }: Props) { + const { url, type } = resource + if (!url) return null + + const t = type as string | number | undefined + + // Audio + if (t !== undefined && AUDIOS.has(t as never)) { + return ( +
+
+ ) + } + + // VideoUrl (YouTube / Vimeo) → iframe; other video URLs →