Fix layout + misc

This commit is contained in:
Thomas Fransolet 2026-05-07 16:50:19 +02:00
parent 57ee68e72b
commit 67d8ce94d6
21 changed files with 284 additions and 86 deletions

View File

@ -23,12 +23,14 @@ export default async function HomePage({
// The featured event is rendered inside its parent configuration's context // The featured event is rendered inside its parent configuration's context
const featuredConfigId = featuredEvent?.configurationId const featuredConfigId = featuredEvent?.configurationId
const languages = [...new Set(configurations.flatMap((c) => c.languages ?? []))]
return ( return (
<> <>
{featuredEvent && featuredConfigId && ( {featuredEvent && featuredConfigId && (
<FeaturedEvent event={featuredEvent} slug={slug} configurationId={featuredConfigId} /> <FeaturedEvent event={featuredEvent} slug={slug} configurationId={featuredConfigId} />
)} )}
<ConfigurationGrid configurations={configurations} slug={slug} /> <ConfigurationGrid configurations={configurations} slug={slug} languages={languages} />
<QRScannerButton slug={slug} /> <QRScannerButton slug={slug} />
</> </>
) )

View File

@ -1,33 +1,28 @@
'use client' 'use client'
import { useEffect } from 'react'
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image' import Image from 'next/image'
import { useVisitor } from '@/context/VisitorContext' import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n' import { t, tPlain } from '@/lib/i18n'
import type { ConfigurationDTO } from '@/lib/api/types' import type { ConfigurationDTO } from '@/lib/api/types'
import AppBar from '@/components/ui/AppBar'
interface Props { interface Props {
configurations: ConfigurationDTO[] configurations: ConfigurationDTO[]
slug: string slug: string
languages: string[]
} }
export default function ConfigurationGrid({ configurations, slug }: Props) { export default function ConfigurationGrid({ configurations, slug, languages }: Props) {
const { language } = useVisitor() const { language, setAvailableLanguages } = useVisitor()
const active = configurations.filter((c) => !c.isOffline) const active = configurations.filter((c) => !c.isOffline)
useEffect(() => { setAvailableLanguages(languages) }, [languages])
return ( return (
<main className="min-h-screen" style={{ background: 'var(--color-background)' }}> <main className="min-h-screen" style={{ background: 'var(--color-background)' }}>
<header <AppBar />
className="flex items-center justify-center px-4 py-4"
style={{
background: 'linear-gradient(135deg, var(--color-primary), var(--color-secondary))',
minHeight: 56,
}}
>
<span className="text-lg font-semibold" style={{ color: 'var(--color-on-primary)' }}>
MyInfoMate
</span>
</header>
<div className="p-4 columns-2 gap-3"> <div className="p-4 columns-2 gap-3">
{active.map((config) => ( {active.map((config) => (

View File

@ -26,7 +26,7 @@ export default function SectionList({
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [searchNumber, setSearchNumber] = useState('') const [searchNumber, setSearchNumber] = useState('')
useEffect(() => { setAvailableLanguages(languages) }, [languages]) useEffect(() => { setAvailableLanguages([]) }, [languages])
const filtered = sections.filter((s) => { const filtered = sections.filter((s) => {
if (searchNumber) return (s.order ?? 0) + 1 === parseInt(searchNumber) if (searchNumber) return (s.order ?? 0) + 1 === parseInt(searchNumber)

View File

@ -65,13 +65,15 @@ export default function AgendaSection({ section, configId, languages }: Props) {
const router = useRouter() const router = useRouter()
const events = section.agenda?.events ?? [] const events = section.agenda?.events ?? []
useEffect(() => { setAvailableLanguages(languages) }, [languages]) useEffect(() => { setAvailableLanguages([]) }, [languages])
const now = new Date() const now = new Date()
const [selectedMonth, setSelectedMonth] = useState(now.getMonth()) const [selectedMonth, setSelectedMonth] = useState(now.getMonth())
const [selectedYear, setSelectedYear] = useState(now.getFullYear()) const [selectedYear, setSelectedYear] = useState(now.getFullYear())
const [selected, setSelected] = useState<EventAgendaDTO | null>(null) const [selected, setSelected] = useState<EventAgendaDTO | null>(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) => { const filtered = events.filter((e) => {
if (!e.dateFrom) return false if (!e.dateFrom) return false
const d = new Date(e.dateFrom) const d = new Date(e.dateFrom)

View File

@ -1,8 +1,8 @@
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import Image from 'next/image'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import ResourceViewer from '@/components/ui/ResourceViewer'
import { useVisitor } from '@/context/VisitorContext' import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n' import { t, tPlain } from '@/lib/i18n'
import type { SectionDTO } from '@/lib/api/types' import type { SectionDTO } from '@/lib/api/types'
@ -27,7 +27,7 @@ export default function ArticleSection({ section, configId, languages }: Props)
const scrollRef = useRef<HTMLElement>(null) const scrollRef = useRef<HTMLElement>(null)
const articleReadTrackedRef = useRef(false) const articleReadTrackedRef = useRef(false)
useEffect(() => { setAvailableLanguages(languages) }, [languages]) useEffect(() => { setAvailableLanguages([]) }, [languages])
useEffect(() => { useEffect(() => {
const el = scrollRef.current const el = scrollRef.current
@ -140,13 +140,11 @@ function ImageCarousel({ contents, language }: { contents: NonNullable<SectionDT
return ( return (
<div className="relative w-full" style={{ height: 240 }}> <div className="relative w-full" style={{ height: 240 }}>
{contents[index]?.resource?.url && ( {contents[index]?.resource && (
<Image <ResourceViewer
src={contents[index].resource!.url!} resource={contents[index].resource!}
alt={t(contents[index].title, language)} alt={tPlain(contents[index].title, language)}
fill objectFit="cover"
className="object-cover"
sizes="100vw"
/> />
)} )}
{contents.length > 1 && ( {contents.length > 1 && (

View File

@ -43,7 +43,7 @@ export default function EventSection({ section, slug, configId, languages }: Pro
const { language, setAvailableLanguages } = useVisitor() const { language, setAvailableLanguages } = useVisitor()
const router = useRouter() const router = useRouter()
useEffect(() => { setAvailableLanguages(languages) }, [languages]) useEffect(() => { setAvailableLanguages([]) }, [languages])
const event = section.event const event = section.event
const programme = useMemo( const programme = useMemo(

View File

@ -32,7 +32,7 @@ export default function GameSection({ section, configId, languages }: Props) {
const { language, setAvailableLanguages, instanceId } = useVisitor() const { language, setAvailableLanguages, instanceId } = useVisitor()
const router = useRouter() const router = useRouter()
useEffect(() => { setAvailableLanguages(languages) }, [languages]) useEffect(() => { setAvailableLanguages([]) }, [languages])
const kind = detectKind(section.gameType) const kind = detectKind(section.gameType)
const rows = Math.max(2, section.rows ?? 3) const rows = Math.max(2, section.rows ?? 3)
@ -204,6 +204,16 @@ export default function GameSection({ section, configId, languages }: Props) {
open={showEnd} open={showEnd}
title="Bravo !" title="Bravo !"
html={endMsg || 'Tu as gagné !'} html={endMsg || 'Tu as gagné !'}
icon={
<div
className="w-16 h-16 rounded-full flex items-center justify-center"
style={{ background: 'var(--color-primary-light)' }}
>
<svg width="32" height="32" viewBox="0 0 24 24" fill="var(--color-primary)">
<path d="M19 5h-2V3H7v2H5c-1.1 0-2 .9-2 2v1c0 2.55 1.92 4.63 4.39 4.94A5.01 5.01 0 0 0 11 15.9V18H9v2h6v-2h-2v-2.1a5.01 5.01 0 0 0 3.61-2.96C19.08 12.63 21 10.55 21 8V7c0-1.1-.9-2-2-2zM5 8V7h2v3.82C5.84 10.4 5 9.3 5 8zm14 0c0 1.3-.84 2.4-2 2.82V7h2v1z" />
</svg>
</div>
}
onClose={restart} onClose={restart}
primaryAction={{ label: 'Recommencer', onClick: restart }} primaryAction={{ label: 'Recommencer', onClick: restart }}
secondaryAction={{ label: 'Retour', onClick: () => router.back() }} secondaryAction={{ label: 'Retour', onClick: () => router.back() }}

View File

@ -36,7 +36,7 @@ export default function MapSection({ section, configId, languages }: Props) {
const { language, setAvailableLanguages, instanceId } = useVisitor() const { language, setAvailableLanguages, instanceId } = useVisitor()
const router = useRouter() const router = useRouter()
useEffect(() => { setAvailableLanguages(languages) }, [languages]) useEffect(() => { setAvailableLanguages([]) }, [languages])
const map = section.map const map = section.map
const hasData = !!map && (map.points?.length ?? 0) > 0 const hasData = !!map && (map.points?.length ?? 0) > 0

View File

@ -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 ?? [])] const subsections = [...(section.menu?.sections ?? [])]
.filter((s) => s.isActive !== false) .filter((s) => s.isActive !== false)

View File

@ -18,7 +18,7 @@ export default function PdfSection({ section, slug, configId, languages }: Props
const { language, setAvailableLanguages } = useVisitor() const { language, setAvailableLanguages } = useVisitor()
const router = useRouter() const router = useRouter()
useEffect(() => { setAvailableLanguages(languages) }, [languages]) useEffect(() => { setAvailableLanguages([]) }, [languages])
const pdfs = [...(section.pdfs ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) const pdfs = [...(section.pdfs ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
const [selectedIndex, setSelectedIndex] = useState(0) const [selectedIndex, setSelectedIndex] = useState(0)

View File

@ -21,7 +21,7 @@ export default function QuizSection({ section, configId, languages }: Props) {
const { language, setAvailableLanguages, instanceId } = useVisitor() const { language, setAvailableLanguages, instanceId } = useVisitor()
const router = useRouter() const router = useRouter()
useEffect(() => { setAvailableLanguages(languages) }, [languages]) useEffect(() => { setAvailableLanguages([]) }, [languages])
const quiz = section.quiz const quiz = section.quiz
const questions = [...(quiz?.questions ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) const questions = [...(quiz?.questions ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))

View File

@ -1,12 +1,12 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import Image from 'next/image'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useVisitor } from '@/context/VisitorContext' import { useVisitor } from '@/context/VisitorContext'
import { t, tPlain } from '@/lib/i18n' import { t, tPlain } from '@/lib/i18n'
import type { SectionDTO } from '@/lib/api/types' import type { SectionDTO } from '@/lib/api/types'
import AppBar from '@/components/ui/AppBar' import AppBar from '@/components/ui/AppBar'
import ResourceViewer from '@/components/ui/ResourceViewer'
interface Props { interface Props {
section: SectionDTO section: SectionDTO
@ -20,7 +20,7 @@ export default function SliderSection({ section, languages }: Props) {
const router = useRouter() const router = useRouter()
const [index, setIndex] = useState(0) const [index, setIndex] = useState(0)
useEffect(() => { setAvailableLanguages(languages) }, [languages]) useEffect(() => { setAvailableLanguages([]) }, [languages])
const contents = [...(section.slider?.contents ?? [])] const contents = [...(section.slider?.contents ?? [])]
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
@ -45,13 +45,10 @@ export default function SliderSection({ section, languages }: Props) {
<main style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <main style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Image zone — ~75% of remaining height */} {/* Image zone — ~75% of remaining height */}
<div style={{ flex: 3, minHeight: 0, position: 'relative' }}> <div style={{ flex: 3, minHeight: 0, position: 'relative' }}>
{current?.resource?.url && ( {current?.resource && (
<Image <ResourceViewer
src={current.resource.url} resource={current.resource}
alt={tPlain(current.title, language)} alt={tPlain(current.title, language)}
fill
className="object-contain"
sizes="100vw"
/> />
)} )}

View File

@ -38,7 +38,7 @@ export default function VideoSection({ section, slug, configId, languages }: Pro
const { language, setAvailableLanguages } = useVisitor() const { language, setAvailableLanguages } = useVisitor()
const router = useRouter() const router = useRouter()
useEffect(() => { setAvailableLanguages(languages) }, [languages]) useEffect(() => { setAvailableLanguages([]) }, [languages])
const source = section.source ?? section.video?.source const source = section.source ?? section.video?.source
const videoType = useMemo(() => detectType(source), [source]) const videoType = useMemo(() => detectType(source), [source])

View File

@ -223,7 +223,7 @@ export default function WeatherSection({ section, languages }: Props) {
const router = useRouter() const router = useRouter()
const [selectedDay, setSelectedDay] = useState(0) const [selectedDay, setSelectedDay] = useState(0)
useEffect(() => { setAvailableLanguages(languages) }, [languages]) useEffect(() => { setAvailableLanguages([]) }, [languages])
const weatherData: WeatherData | null = useMemo(() => { const weatherData: WeatherData | null = useMemo(() => {
const raw = section.weather?.result const raw = section.weather?.result

View File

@ -17,7 +17,7 @@ export default function WebSection({ section, slug, configId, languages }: Props
const { language, setAvailableLanguages } = useVisitor() const { language, setAvailableLanguages } = useVisitor()
const router = useRouter() const router = useRouter()
useEffect(() => { setAvailableLanguages(languages) }, [languages]) useEffect(() => { setAvailableLanguages([]) }, [languages])
const source = section.web?.source ?? section.source const source = section.web?.source ?? section.source
const title = tPlain(section.title, language) const title = tPlain(section.title, language)

View File

@ -188,6 +188,16 @@ export default function EscapeProgression({ paths, language }: Props) {
open={showEnd} open={showEnd}
title="Bravo !" title="Bravo !"
html={`Tu as terminé le parcours « ${tPlain(activePath.title, language) || 'parcours'} ».`} html={`Tu as terminé le parcours « ${tPlain(activePath.title, language) || 'parcours'} ».`}
icon={
<div
className="w-16 h-16 rounded-full flex items-center justify-center"
style={{ background: 'var(--color-primary-light)' }}
>
<svg width="32" height="32" viewBox="0 0 24 24" fill="var(--color-primary)">
<path d="M19 5h-2V3H7v2H5c-1.1 0-2 .9-2 2v1c0 2.55 1.92 4.63 4.39 4.94A5.01 5.01 0 0 0 11 15.9V18H9v2h6v-2h-2v-2.1a5.01 5.01 0 0 0 3.61-2.96C19.08 12.63 21 10.55 21 8V7c0-1.1-.9-2-2-2zM5 8V7h2v3.82C5.84 10.4 5 9.3 5 8zm14 0c0 1.3-.84 2.4-2 2.82V7h2v1z" />
</svg>
</div>
}
onClose={() => { setShowEnd(false); setActivePathId(null) }} onClose={() => { setShowEnd(false); setActivePathId(null) }}
primaryAction={{ label: 'Recommencer', onClick: () => { setCompletedSteps(new Set()); setQuizPassedSteps(new Set()); setTimerExpiredSteps(new Set()); setZoneNotifiedSteps(new Set()); setShowEnd(false) } }} 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) } }} secondaryAction={{ label: 'Retour', onClick: () => { setShowEnd(false); setActivePathId(null) } }}

View File

@ -6,6 +6,7 @@ interface Props {
open: boolean open: boolean
title?: string title?: string
html?: string html?: string
icon?: ReactNode
onClose: () => void onClose: () => void
primaryAction?: { label: string; onClick: () => void } primaryAction?: { label: string; onClick: () => void }
secondaryAction?: { label: string; onClick: () => void } secondaryAction?: { label: string; onClick: () => void }
@ -13,7 +14,7 @@ interface Props {
} }
export default function MessageDialog({ export default function MessageDialog({
open, title, html, onClose, primaryAction, secondaryAction, children, open, title, html, icon, onClose, primaryAction, secondaryAction, children,
}: Props) { }: Props) {
if (!open) return null if (!open) return null
return ( return (
@ -27,24 +28,43 @@ export default function MessageDialog({
}} }}
> >
<style>{`@keyframes mim-pop-in { from { transform: scale(0.85); opacity: 0; } to { transform: scale(1); opacity: 1; } }`}</style> <style>{`@keyframes mim-pop-in { from { transform: scale(0.85); opacity: 0; } to { transform: scale(1); opacity: 1; } }`}</style>
{icon && (
<div className="flex justify-center pt-6 pb-1">
{icon}
</div>
)}
{title && (
<div
className="px-5 pt-5 pb-2 text-center text-xl font-bold"
style={{ color: 'var(--color-text)' }}
>
{title}
</div>
)}
{(html || children) && (
<div
className="px-6 pb-5 text-sm text-center [&_p]:m-0 [&_p+p]:mt-2"
style={{ color: 'var(--color-text-muted)' }}
>
{html ? <div dangerouslySetInnerHTML={{ __html: html }} /> : children}
</div>
)}
<div <div
className="px-5 pt-5 pb-3 text-center text-lg font-bold" className="flex flex-col gap-2 px-5 pb-5 pt-3"
style={{ color: 'var(--color-text)' }} style={{ borderTop: '1px solid var(--color-border)' }}
> >
{title}
</div>
<div
className="px-6 pb-4 text-sm text-center [&_p]:m-0 [&_p+p]:mt-2"
style={{ color: 'var(--color-text)' }}
>
{html ? <div dangerouslySetInnerHTML={{ __html: html }} /> : children}
</div>
<div className="flex flex-col gap-2 px-5 pb-5 pt-2">
{primaryAction && ( {primaryAction && (
<button <button
onClick={primaryAction.onClick} onClick={primaryAction.onClick}
className="w-full py-3 rounded-2xl font-semibold text-sm" className="w-full py-3 rounded-2xl font-semibold text-sm"
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }} style={{
background: 'var(--color-primary)',
color: 'var(--color-on-primary)',
}}
> >
{primaryAction.label} {primaryAction.label}
</button> </button>
@ -54,7 +74,7 @@ export default function MessageDialog({
onClick={secondaryAction.onClick} onClick={secondaryAction.onClick}
className="w-full py-3 rounded-2xl font-semibold text-sm" className="w-full py-3 rounded-2xl font-semibold text-sm"
style={{ style={{
background: 'var(--color-surface)', background: 'transparent',
color: 'var(--color-text)', color: 'var(--color-text)',
border: '1px solid var(--color-border)', border: '1px solid var(--color-border)',
}} }}
@ -66,7 +86,10 @@ export default function MessageDialog({
<button <button
onClick={onClose} onClick={onClose}
className="w-full py-3 rounded-2xl font-semibold text-sm" className="w-full py-3 rounded-2xl font-semibold text-sm"
style={{ background: 'var(--color-primary)', color: 'var(--color-on-primary)' }} style={{
background: 'var(--color-primary)',
color: 'var(--color-on-primary)',
}}
> >
OK OK
</button> </button>

View File

@ -145,7 +145,7 @@ export default function PuzzleGame({ imageUrl, rows, cols, showHint, onWin }: Pr
> >
{board && ( {board && (
<> <>
{/* Board frame (target outlines) */} {/* Board frame (always visible) */}
<div <div
className="absolute" className="absolute"
style={{ style={{
@ -153,14 +153,28 @@ export default function PuzzleGame({ imageUrl, rows, cols, showHint, onWin }: Pr
top: `calc(50% - ${board.h / 2}px)`, top: `calc(50% - ${board.h / 2}px)`,
width: board.w, width: board.w,
height: board.h, height: board.h,
backgroundImage: showHint ? `url(${imageUrl})` : undefined,
backgroundSize: 'cover',
opacity: showHint ? 0.25 : 1,
border: '1.5px dashed rgba(0,0,0,0.18)', border: '1.5px dashed rgba(0,0,0,0.18)',
borderRadius: 8, borderRadius: 8,
background: showHint ? undefined : 'rgba(255,255,255,0.4)', background: 'rgba(255,255,255,0.4)',
}} }}
/> />
{/* Hint overlay — full image reference when hint is active */}
{showHint && (
<div
className="absolute"
style={{
left: `calc(50% - ${board.w / 2}px)`,
top: `calc(50% - ${board.h / 2}px)`,
width: board.w,
height: board.h,
backgroundImage: `url(${imageUrl})`,
backgroundSize: 'cover',
opacity: 0.55,
borderRadius: 8,
zIndex: 0,
}}
/>
)}
{/* Pieces */} {/* Pieces */}
{pieces.map((p) => { {pieces.map((p) => {
const pw = board.w / cols const pw = board.w / cols

View File

@ -1,5 +1,6 @@
'use client' 'use client'
import { useState, useRef, useEffect } from 'react'
import { useVisitor } from '@/context/VisitorContext' import { useVisitor } from '@/context/VisitorContext'
const FLAG: Record<string, string> = { const FLAG: Record<string, string> = {
@ -7,6 +8,11 @@ const FLAG: Record<string, string> = {
IT: '🇮🇹', ES: '🇪🇸', PL: '🇵🇱', CN: '🇨🇳', AR: '🇸🇦', UK: '🇺🇦', IT: '🇮🇹', ES: '🇪🇸', PL: '🇵🇱', CN: '🇨🇳', AR: '🇸🇦', UK: '🇺🇦',
} }
const LABEL: Record<string, string> = {
FR: 'Français', NL: 'Nederlands', EN: 'English', DE: 'Deutsch',
IT: 'Italiano', ES: 'Español', PL: 'Polski', CN: '中文', AR: 'العربية', UK: 'Українська',
}
interface Props { interface Props {
title?: string title?: string
onBack?: () => void onBack?: () => void
@ -14,6 +20,19 @@ interface Props {
export default function AppBar({ title, onBack }: Props) { export default function AppBar({ title, onBack }: Props) {
const { language, setLanguage, availableLanguages } = useVisitor() const { language, setLanguage, availableLanguages } = useVisitor()
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(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 ( return (
<header <header
@ -22,14 +41,13 @@ export default function AppBar({ title, onBack }: Props) {
background: 'linear-gradient(135deg, var(--color-primary), var(--color-secondary))', background: 'linear-gradient(135deg, var(--color-primary), var(--color-secondary))',
minHeight: 56, minHeight: 56,
color: 'var(--color-on-primary)', color: 'var(--color-on-primary)',
textShadow: '0 1px 3px rgba(0,0,0,0.15)',
}} }}
> >
<div className="flex items-center gap-3 flex-1"> <div className="flex items-center gap-3 flex-1 min-w-0">
{onBack && ( {onBack && (
<button <button
onClick={onBack} onClick={onBack}
className="p-1 rounded-full hover:opacity-70 transition-opacity" className="shrink-0 p-1 rounded-full hover:opacity-70 transition-opacity"
aria-label="Retour" aria-label="Retour"
> >
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
@ -42,25 +60,58 @@ export default function AppBar({ title, onBack }: Props) {
</span> </span>
</div> </div>
{availableLanguages.length > 1 && ( {showSelector && (
<div className="flex items-center gap-1"> <div ref={ref} className="relative shrink-0 ml-3">
{availableLanguages.map((lang) => { <button
const active = lang === language onClick={() => setOpen((o) => !o)}
return ( className="flex items-center gap-1.5 rounded-xl px-3 py-1.5 transition-all"
<button style={{ background: 'rgba(255,255,255,0.18)', color: 'var(--color-on-primary)' }}
key={lang} >
onClick={() => setLanguage(lang)} <span className="text-lg leading-none">{FLAG[language] ?? language}</span>
className="text-xs font-semibold px-2 py-1 rounded-lg transition-all" <span className="text-xs font-semibold tracking-wide">{language}</span>
style={{ <svg
background: active ? 'rgba(255,255,255,0.25)' : 'transparent', width="14" height="14" viewBox="0 0 24 24" fill="currentColor"
color: 'var(--color-on-primary)', style={{ transition: 'transform 0.2s', transform: open ? 'rotate(180deg)' : 'rotate(0deg)', opacity: 0.8 }}
opacity: active ? 1 : 0.6, >
}} <path d="M7 10l5 5 5-5z"/>
> </svg>
{FLAG[lang] ?? lang} </button>
</button>
) {open && (
})} <div
className="absolute right-0 mt-1 rounded-2xl overflow-hidden"
style={{
background: 'white',
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
minWidth: 160,
zIndex: 100,
}}
>
{availableLanguages.map((lang) => {
const active = lang === language
return (
<button
key={lang}
onClick={() => { setLanguage(lang); setOpen(false) }}
className="w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors"
style={{
background: active ? 'var(--color-primary-light)' : 'transparent',
color: active ? 'var(--color-primary)' : '#333',
fontWeight: active ? 600 : 400,
}}
>
<span className="text-xl leading-none">{FLAG[lang] ?? '🌐'}</span>
<span className="text-sm">{LABEL[lang] ?? lang}</span>
{active && (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="ml-auto" style={{ color: 'var(--color-primary)' }}>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
)}
</button>
)
})}
</div>
)}
</div> </div>
)} )}
</header> </header>

View File

@ -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 (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
<audio controls src={url} style={{ width: '100%', maxWidth: 400 }} />
</div>
)
}
// VideoUrl (YouTube / Vimeo) → iframe; other video URLs → <video>
if (t !== undefined && VIDEO_URLS.has(t as never)) {
const embedUrl = youtubeEmbedUrl(url) ?? vimeoEmbedUrl(url)
if (embedUrl) {
return (
<iframe
src={embedUrl}
style={{ width: '100%', height: '100%', border: 'none' }}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
title={alt}
/>
)
}
}
// Video (direct file) or VideoUrl non-embed
if (t !== undefined && VIDEOS.has(t as never)) {
return (
<video
src={url}
controls
playsInline
style={{ width: '100%', height: '100%', objectFit }}
/>
)
}
// Image, ImageUrl, or unknown → render as image (safe default)
return (
<Image
src={url}
alt={alt}
fill
className={`object-${objectFit}`}
sizes="100vw"
/>
)
}

View File

@ -1,5 +1,12 @@
import { getLuminance, parseToRgba } from 'color2k' import { getLuminance, parseToRgba } from 'color2k'
// Flutter sends colors as "Color(0xAARRGGBB)" — convert to "#RRGGBB"
function normalizeColor(color: string): string {
const match = color.match(/Color\(0x[0-9a-fA-F]{2}([0-9a-fA-F]{6})\)/)
if (match) return `#${match[1]}`
return color
}
export interface ThemeColors { export interface ThemeColors {
'--color-primary': string '--color-primary': string
'--color-secondary': string '--color-secondary': string
@ -37,7 +44,7 @@ export function resolveColors(
instance: { primaryColor?: string; secondaryColor?: string }, instance: { primaryColor?: string; secondaryColor?: string },
config?: { primaryColor?: string; secondaryColor?: string } config?: { primaryColor?: string; secondaryColor?: string }
): ThemeColors { ): ThemeColors {
const primary = config?.primaryColor || instance.primaryColor || '#264863' const primary = normalizeColor(config?.primaryColor || instance.primaryColor || '#264863')
const secondary = config?.secondaryColor || instance.secondaryColor || '#C2C9D6' const secondary = normalizeColor(config?.secondaryColor || instance.secondaryColor || '#C2C9D6')
return buildTheme(primary, secondary) return buildTheme(primary, secondary)
} }