Fix layout + misc
This commit is contained in:
parent
57ee68e72b
commit
67d8ce94d6
@ -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} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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) => (
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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() }}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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])
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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) } }}
|
||||||
|
|||||||
@ -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
|
<div
|
||||||
className="px-5 pt-5 pb-3 text-center text-lg font-bold"
|
className="px-5 pt-5 pb-2 text-center text-xl font-bold"
|
||||||
style={{ color: 'var(--color-text)' }}
|
style={{ color: 'var(--color-text)' }}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(html || children) && (
|
||||||
<div
|
<div
|
||||||
className="px-6 pb-4 text-sm text-center [&_p]:m-0 [&_p+p]:mt-2"
|
className="px-6 pb-5 text-sm text-center [&_p]:m-0 [&_p+p]:mt-2"
|
||||||
style={{ color: 'var(--color-text)' }}
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
>
|
>
|
||||||
{html ? <div dangerouslySetInnerHTML={{ __html: html }} /> : children}
|
{html ? <div dangerouslySetInnerHTML={{ __html: html }} /> : children}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 px-5 pb-5 pt-2">
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-2 px-5 pb-5 pt-3"
|
||||||
|
style={{ borderTop: '1px solid var(--color-border)' }}
|
||||||
|
>
|
||||||
{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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,27 +60,60 @@ 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">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="flex items-center gap-1.5 rounded-xl px-3 py-1.5 transition-all"
|
||||||
|
style={{ background: 'rgba(255,255,255,0.18)', color: 'var(--color-on-primary)' }}
|
||||||
|
>
|
||||||
|
<span className="text-lg leading-none">{FLAG[language] ?? language}</span>
|
||||||
|
<span className="text-xs font-semibold tracking-wide">{language}</span>
|
||||||
|
<svg
|
||||||
|
width="14" height="14" viewBox="0 0 24 24" fill="currentColor"
|
||||||
|
style={{ transition: 'transform 0.2s', transform: open ? 'rotate(180deg)' : 'rotate(0deg)', opacity: 0.8 }}
|
||||||
|
>
|
||||||
|
<path d="M7 10l5 5 5-5z"/>
|
||||||
|
</svg>
|
||||||
|
</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) => {
|
{availableLanguages.map((lang) => {
|
||||||
const active = lang === language
|
const active = lang === language
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={lang}
|
key={lang}
|
||||||
onClick={() => setLanguage(lang)}
|
onClick={() => { setLanguage(lang); setOpen(false) }}
|
||||||
className="text-xs font-semibold px-2 py-1 rounded-lg transition-all"
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors"
|
||||||
style={{
|
style={{
|
||||||
background: active ? 'rgba(255,255,255,0.25)' : 'transparent',
|
background: active ? 'var(--color-primary-light)' : 'transparent',
|
||||||
color: 'var(--color-on-primary)',
|
color: active ? 'var(--color-primary)' : '#333',
|
||||||
opacity: active ? 1 : 0.6,
|
fontWeight: active ? 600 : 400,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{FLAG[lang] ?? lang}
|
<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>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
89
src/components/ui/ResourceViewer.tsx
Normal file
89
src/components/ui/ResourceViewer.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user