- 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>
76 lines
2.9 KiB
TypeScript
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>
|
|
)
|
|
}
|