rcnn/web/src/challenges/MyChallengesSidePanel.tsx
munsel 42197bfbc9 add coaster naming, challenge detail panel, and fix GeoJSON id bug
- Coaster editor: name input in top bar, saved/loaded with coaster data
- CoasterListPanel: show coaster name prominently alongside creator username
- ChallengesListPanel: drill-in detail view with center map, plan coaster,
  and accept challenge buttons; coaster count shown in list and detail
- AllCoastersPanel: coaster count visible in challenge entries
- Backend: add coaster_count to ChallengeMapSerializer and ChallengeDetailSerializer
- Fix: ChallengeLayer and ChallengesListPanel were reading f.properties.id
  (always undefined) instead of f.id — GeoFeatureModelSerializer puts the pk
  at the GeoJSON Feature level, not in properties
- Types: remove id from ChallengeMapProperties to reflect actual data shape

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 01:43:10 +02:00

76 lines
2.9 KiB
TypeScript

import { useEffect, useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { fetchMyChallenges } from '../api/challenges'
import { useChallengeStore } from '../store/challengeStore'
import { useUIStore } from '../store/uiStore'
import type { ChallengeDetail } from '../types/api'
import styles from './ChallengesListPanel.module.css'
/** Full-page side panel for My Challenges — rendered outside CesiumViewer. */
export function MyChallengesSidePanel() {
const { showMyChallenges, setShowMyChallenges } = useUIStore()
const { setSelectedChallengeId } = useChallengeStore()
const navigate = useNavigate()
const location = useLocation()
const [challenges, setChallenges] = useState<ChallengeDetail[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!showMyChallenges) return
setLoading(true)
setError(null)
fetchMyChallenges()
.then(setChallenges)
.catch(() => setError('Failed to load your challenges.'))
.finally(() => setLoading(false))
}, [showMyChallenges])
if (!showMyChallenges) return null
function handleSelect(id: string) {
setSelectedChallengeId(id)
setShowMyChallenges(false)
if (location.pathname !== '/') navigate('/')
}
return (
<div className={styles.overlay} onClick={() => setShowMyChallenges(false)}>
<div className={styles.panel} onClick={(e) => e.stopPropagation()}>
<div className={styles.panelHeader}>
<h2 className={styles.title}>My Challenges</h2>
<button className={styles.closeBtn} onClick={() => setShowMyChallenges(false)}></button>
</div>
<div className={styles.body}>
{loading && <p className={styles.state}>Loading</p>}
{error && <p className={styles.errorState}>{error}</p>}
{!loading && !error && challenges.length === 0 && (
<p className={styles.state}>You haven't created any challenges yet.</p>
)}
{challenges.map((c) => (
<div key={c.id} className={styles.row} onClick={() => handleSelect(c.id)}>
<span className={styles.rowTitle}>{c.title}</span>
<div className={styles.rowMeta}>
<span className={`${styles.statusBadge} ${c.status === 'active' ? styles.active : styles.closed}`}>
{c.status}
</span>
<span className={styles.metaText}>
{c.submission_count} submission{c.submission_count !== 1 ? 's' : ''}
</span>
{c.expires_at && (
<span className={styles.metaText}>
{new Date(c.expires_at) < new Date() ? 'Expired' : `Expires ${new Date(c.expires_at).toLocaleDateString()}`}
</span>
)}
</div>
</div>
))}
</div>
</div>
</div>
)
}