Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/app/actualites/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ const CATEGORY_COLORS: Record<string, string> = {
Sciences: '#38bdf8',
}

function formatDate(date: string) {
function formatDate(date: string | null) {
if (!date) return 'Date inconnue'
return new Date(date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
Expand Down Expand Up @@ -174,7 +175,7 @@ export default function ActualitesPage() {
<span style={{ color, background: `${color}12`, border: `1px solid ${color}30`, borderRadius: 999, padding: '0.2rem 0.65rem', fontSize: '0.64rem', fontWeight: 800 }}>
{article.category}
</span>
<time dateTime={article.date} style={{ color: 'var(--text-muted)', fontSize: '0.65rem' }}>{formatDate(article.date)}</time>
<time dateTime={article.date ?? undefined} style={{ color: 'var(--text-muted)', fontSize: '0.65rem' }}>{formatDate(article.date)}</time>
</div>
<h2 style={{ margin: '1rem 0 0.6rem', color: 'var(--text)', font: "750 1rem/1.45 'Outfit', sans-serif" }}>{article.title}</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.77rem', lineHeight: 1.65, display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
Expand Down
48 changes: 48 additions & 0 deletions src/app/api/geocode/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
import { checkDistributedRateLimit } from '@/lib/security/rate-limit'

const FALLBACK_CITY = 'Votre position'

Expand All @@ -8,6 +9,21 @@ function parseCoordinate(value: string | null, min: number, max: number) {
return Number(parsed.toFixed(2))
}

function firstHeaderValue(request: NextRequest, names: string[]): string | null {
for (const name of names) {
const value = request.headers.get(name)?.split(',')[0]?.trim()
if (value) return value
}
return null
}

function clientIdentifier(request: NextRequest): string | null {
const headerNames = process.env.VERCEL === '1'
? ['x-vercel-forwarded-for', 'x-forwarded-for', 'x-real-ip']
: ['x-forwarded-for', 'x-real-ip']
return firstHeaderValue(request, headerNames)
}

export async function GET(request: NextRequest) {
const latitude = parseCoordinate(request.nextUrl.searchParams.get('lat'), -90, 90)
const longitude = parseCoordinate(request.nextUrl.searchParams.get('lon'), -180, 180)
Expand All @@ -16,6 +32,38 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Coordonnées invalides.' }, { status: 400 })
}

const identifier = clientIdentifier(request)
if (process.env.VERCEL === '1' && !identifier) {
return NextResponse.json(
{ error: 'Service de localisation temporairement protégé.' },
{ status: 503, headers: { 'Cache-Control': 'no-store' } },
)
}

const rateLimit = await checkDistributedRateLimit(`geocode:${identifier || 'local-anonymous'}`, {
namespace: 'geocode',
limit: 20,
windowSeconds: 60,
})
if (rateLimit.unavailable) {
return NextResponse.json(
{ error: 'Service de localisation temporairement protégé.' },
{ status: 503, headers: { 'Cache-Control': 'no-store' } },
)
}
if (!rateLimit.allowed) {
return NextResponse.json(
{ error: 'Trop de demandes de localisation. Réessaie dans un instant.' },
{
status: 429,
headers: {
'Cache-Control': 'no-store',
'Retry-After': String(rateLimit.retryAfter),
},
},
)
}

const url = new URL('https://nominatim.openstreetmap.org/reverse')
url.search = new URLSearchParams({
lat: String(latitude),
Expand Down
12 changes: 9 additions & 3 deletions src/app/confidentialite/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,15 @@ export default function PrivacyPage() {

<h2>Stockage local et intelligence artificielle</h2>
<p>
Le thème clair ou sombre est mémorisé uniquement dans votre navigateur. Les questions adressées à
SolarBot sont envoyées au service Gemini de Google pour produire une réponse, sans être conservées
dans une base de données SolarScope.
SolarScope ne crée pas de compte. Le navigateur peut conserver la langue choisie, le public ou niveau
sélectionné, la progression du passeport, l’étape ouverte dans une fiche pédagogique et un cache local
temporaire de météo spatiale. Ces informations restent sur l’appareil et ne sont pas envoyées à SolarScope.
</p>
<p>
Le passeport peut être effacé depuis la page « Passeport spatial ». Les autres préférences et le cache
peuvent être supprimés depuis les réglages de stockage du navigateur ; le cache météo expire également
automatiquement. Les questions adressées à SolarBot sont envoyées au service Gemini de Google lorsque
Gemini est activé, sans être conservées dans une base de données SolarScope.
</p>

<p><Link href="/">← Retour à l’accueil</Link></p>
Expand Down
5 changes: 5 additions & 0 deletions src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -382,10 +382,14 @@ export default function Navbar() {
{navGroups.map(group => {
const isGroupOpen = mobileGroup === group.id
const isActive = activeGroup === group.id
const mobileGroupPanelId = `mobile-navigation-group-${group.id}`
return (
<div key={group.id} style={{ marginBottom: '0.375rem' }}>
<button
type="button"
onClick={() => setMobileGroup(isGroupOpen ? null : group.id)}
aria-expanded={isGroupOpen}
aria-controls={mobileGroupPanelId}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0.7rem 0.875rem', borderRadius: '10px', cursor: 'pointer',
Expand All @@ -402,6 +406,7 @@ export default function Navbar() {
{isGroupOpen && (
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.2 }}
id={mobileGroupPanelId}
style={{ overflow: 'hidden', paddingLeft: '0.5rem', marginTop: '0.25rem' }}>
{group.pages.map(page => {
const active = pathname === page.href
Expand Down
46 changes: 37 additions & 9 deletions src/components/space/ISSGlobe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ export default function ISSGlobe({ issPos }: { issPos: ISSPos | null }) {
issMarker: THREE.Group
trail: THREE.Line
trailPositions: THREE.Vector3[]
animId: number
isDragging: boolean
previousMousePosition: { x: number; y: number }
} | null>(null)
Expand All @@ -57,7 +56,8 @@ export default function ISSGlobe({ issPos }: { issPos: ISSPos | null }) {
const starPositions = new Float32Array(starCount * 3)
for (let i = 0; i < starCount * 3; i++) starPositions[i] = (Math.random() - 0.5) * 400
starGeo.setAttribute('position', new THREE.BufferAttribute(starPositions, 3))
scene.add(new THREE.Points(starGeo, new THREE.PointsMaterial({ color: '#ffffff', size: 0.3, transparent: true, opacity: 0.7 })))
const starMaterial = new THREE.PointsMaterial({ color: '#ffffff', size: 0.3, transparent: true, opacity: 0.7 })
scene.add(new THREE.Points(starGeo, starMaterial))

// Lights
scene.add(new THREE.AmbientLight(0x222233, 0.8))
Expand All @@ -71,9 +71,10 @@ export default function ISSGlobe({ issPos }: { issPos: ISSPos | null }) {

// Earth
const loader = new THREE.TextureLoader()
const earthTexture = loader.load('/textures/earth.jpg')
const earthGeo = new THREE.SphereGeometry(1, 64, 64)
const earthMat = new THREE.MeshPhongMaterial({
map: loader.load('/textures/earth.jpg'),
map: earthTexture,
specular: new THREE.Color(0x226699),
shininess: 18,
})
Expand Down Expand Up @@ -116,9 +117,14 @@ export default function ISSGlobe({ issPos }: { issPos: ISSPos | null }) {
let isDragging = false
let previousMousePosition = { x: 0, y: 0 }
let autoRotate = true
let resumeTimeout: ReturnType<typeof setTimeout> | null = null

const onMouseDown = (e: MouseEvent) => { isDragging = true; autoRotate = false; previousMousePosition = { x: e.clientX, y: e.clientY } }
const onMouseUp = () => { isDragging = false; setTimeout(() => { autoRotate = true }, 3000) }
const onMouseUp = () => {
isDragging = false
if (resumeTimeout) clearTimeout(resumeTimeout)
resumeTimeout = setTimeout(() => { autoRotate = true }, 3000)
}
const onMouseMove = (e: MouseEvent) => {
if (!isDragging) return
const dx = e.clientX - previousMousePosition.x
Expand All @@ -133,26 +139,48 @@ export default function ISSGlobe({ issPos }: { issPos: ISSPos | null }) {
window.addEventListener('mousemove', onMouseMove)

let t = 0
const animId = requestAnimationFrame(function loop() {
let frameId: number | null = null
let disposed = false
const loop = () => {
if (disposed) return
t += 0.005
if (autoRotate) earthGroup.rotation.y += 0.0015
// Ring pulse
const scale = 1 + 0.4 * Math.abs(Math.sin(t * 3))
ring.scale.setScalar(scale)
ring.material.opacity = 0.7 - 0.4 * Math.abs(Math.sin(t * 3))
renderer.render(scene, camera)
requestAnimationFrame(loop)
})
frameId = requestAnimationFrame(loop)
}
frameId = requestAnimationFrame(loop)

sceneRef.current = { renderer, scene, camera, issMarker, trail, trailPositions, animId, isDragging, previousMousePosition }
sceneRef.current = { renderer, scene, camera, issMarker, trail, trailPositions, isDragging, previousMousePosition }

return () => {
cancelAnimationFrame(animId)
disposed = true
if (frameId !== null) cancelAnimationFrame(frameId)
if (resumeTimeout) clearTimeout(resumeTimeout)
renderer.domElement.removeEventListener('mousedown', onMouseDown)
window.removeEventListener('mouseup', onMouseUp)
window.removeEventListener('mousemove', onMouseMove)
earthTexture.dispose()
starGeo.dispose()
starMaterial.dispose()
earthGeo.dispose()
earthMat.dispose()
atmGeo.dispose()
atmMat.dispose()
gridGeo.dispose()
gridMat.dispose()
issDot.geometry.dispose()
issDot.material.dispose()
ringGeo.dispose()
ringMat.dispose()
trail.geometry.dispose()
trailMat.dispose()
renderer.dispose()
if (el.contains(renderer.domElement)) el.removeChild(renderer.domElement)
sceneRef.current = null
}
}, [])

Expand Down
28 changes: 15 additions & 13 deletions src/lib/data/space-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export interface NewsArticle {
title: string
source: string
url: string
date: string
date: string | null
summary: string
category: string
}
Expand Down Expand Up @@ -507,38 +507,40 @@ function classifyArticle(text: string): string {
return 'Sciences'
}

export async function getNasaNews(): Promise<{ articles: NewsArticle[]; updatedAt: string }> {
const response = await fetchWithTimeout(
'https://www.nasa.gov/feed/',
1_800,
'space-news',
'application/rss+xml, application/xml, text/xml',
)
const xml = await response.text()
export function parseNasaNewsFeed(xml: string): NewsArticle[] {
const items = xml.match(/<item>[\s\S]*?<\/item>/gi) || []

const articles = items
return items
.map((item): NewsArticle | null => {
const title = xmlTag(item, 'title')
const url = xmlTag(item, 'link')
const summary = xmlTag(item, 'description')
const published = xmlTag(item, 'pubDate')
if (!title || !url) return null
const parsedDate = published ? new Date(published) : new Date()

const parsedDate = published ? new Date(published) : null
return {
title,
source: 'NASA',
url,
date: Number.isNaN(parsedDate.getTime()) ? new Date().toISOString() : parsedDate.toISOString(),
date: parsedDate && !Number.isNaN(parsedDate.getTime()) ? parsedDate.toISOString() : null,
summary: summary || 'Consultez l’article complet sur le site officiel de la NASA.',
category: classifyArticle(`${title} ${summary}`),
}
})
.filter((article): article is NewsArticle => article !== null)
.slice(0, 18)
}

return { articles, updatedAt: new Date().toISOString() }
export async function getNasaNews(): Promise<{ articles: NewsArticle[]; updatedAt: string }> {
const response = await fetchWithTimeout(
'https://www.nasa.gov/feed/',
1_800,
'space-news',
'application/rss+xml, application/xml, text/xml',
)
const xml = await response.text()
return { articles: parseNasaNewsFeed(xml), updatedAt: new Date().toISOString() }
}

export function getNasaApiKey(): string {
Expand Down
10 changes: 9 additions & 1 deletion tests/e2e/device-compatibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,17 @@ test('the mobile menu remains usable', async ({ page }, testInfo) => {
const viewport = page.viewportSize()
test.skip(testInfo.project.name === 'desktop' || !viewport || viewport.width >= 768, 'This viewport uses the expanded navigation')
await page.goto('/')
await page.getByRole('button', { name: 'Ouvrir le menu' }).click()
const menuButton = page.getByRole('button', { name: 'Ouvrir le menu' })
await menuButton.click()
await expect(page.locator('#mobile-navigation')).toBeVisible()
await expect(page.locator('#mobile-navigation').getByRole('link', { name: /Accueil/ })).toBeVisible()

const solarSystemButton = page.getByRole('button', { name: /Système Solaire/ })
await expect(solarSystemButton).toHaveAttribute('aria-expanded', 'false')
await expect(solarSystemButton).toHaveAttribute('aria-controls', 'mobile-navigation-group-systeme')
await solarSystemButton.click()
await expect(solarSystemButton).toHaveAttribute('aria-expanded', 'true')
await expect(page.locator('#mobile-navigation-group-systeme')).toBeVisible()
})

test('the rover remains interactive in Safari-compatible WebKit', async ({ page }, testInfo) => {
Expand Down
Loading
Loading