@neobyzantine/series-player 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/SeriesPlayer.tsx","../src/useSeriesPlayer.ts","../src/SeriesStepList.tsx","../src/AnimatorControls.tsx","../src/AnimatedEventMap.tsx","../src/EventAnimator.ts","../src/SeriesEventPanel.tsx"],"sourcesContent":["import React, { useCallback, useRef, useState } from 'react';\nimport type { HistoricalEvent } from '@neobyzantine/types';\nimport type { AnimatorEvents } from './types';\nimport { useSeriesPlayer } from './useSeriesPlayer';\nimport { SeriesStepList } from './SeriesStepList';\nimport { AnimatorControls } from './AnimatorControls';\nimport { AnimatedEventMap } from './AnimatedEventMap';\nimport { SeriesEventPanel } from './SeriesEventPanel';\nimport type { SeriesEntry } from './types';\nimport type { EventAnimator } from './EventAnimator';\n\nexport interface SeriesPlayerProps {\n entries: SeriesEntry[];\n onEventLoad: (slug: string) => Promise<HistoricalEvent>;\n apiBase?: string;\n /** Color for map animation pulses. Defaults to active entry color or gold. */\n accentColor?: string;\n ttsEnabled?: boolean;\n autoPlayInterval?: number;\n resolvePortrait?: (path: string) => string;\n sanitizeHtml?: (html: string) => string;\n onEventChange?: (index: number, event: HistoricalEvent) => void;\n height?: string;\n className?: string;\n}\n\nexport function SeriesPlayer({\n entries,\n onEventLoad,\n accentColor,\n ttsEnabled = false,\n autoPlayInterval = 12000,\n resolvePortrait,\n sanitizeHtml,\n onEventChange,\n height = '600px',\n className,\n}: SeriesPlayerProps) {\n const [animatorState, setAnimatorState] = useState<{\n playing: boolean;\n current: number;\n currentProps: import('./types').AnimationFeatureProperties | null;\n }>({ playing: false, current: -1, currentProps: null });\n\n const animatorRef = useRef<EventAnimator | null>(null);\n\n const [state, actions] = useSeriesPlayer(entries, onEventLoad, {\n onEventChange,\n autoPlayInterval,\n });\n\n const currentEntry = entries[state.currentIndex];\n const resolvedColor = accentColor ?? currentEntry?.color ?? '#CFB53B';\n\n const geojson = state.event?.locations\n ? {\n type: 'FeatureCollection' as const,\n features: state.event.locations.map((loc) => ({\n type: 'Feature' as const,\n geometry: { type: 'Point' as const, coordinates: [loc.lng, loc.lat] as [number, number] },\n properties: {\n sort_order: loc.sort_order,\n label: loc.label,\n narration: loc.narration_text,\n zoom: undefined,\n animation_pause_ms: undefined,\n },\n })),\n }\n : null;\n\n const handleAnimatorReady = useCallback((animator: EventAnimator) => {\n animatorRef.current = animator;\n setAnimatorState({ playing: false, current: -1, currentProps: null });\n }, []);\n\n const handleStep = useCallback((data: AnimatorEvents['step']) => {\n setAnimatorState((prev) => ({ ...prev, current: data.index, currentProps: data.props }));\n }, []);\n\n const handleComplete = useCallback(() => {\n setAnimatorState((prev) => ({ ...prev, playing: false }));\n }, []);\n\n const handlePlay = useCallback(() => {\n animatorRef.current?.play();\n setAnimatorState((prev) => ({ ...prev, playing: true }));\n }, []);\n\n const handlePause = useCallback(() => {\n animatorRef.current?.pause();\n setAnimatorState((prev) => ({ ...prev, playing: false }));\n }, []);\n\n const handleReset = useCallback(() => {\n animatorRef.current?.reset();\n setAnimatorState({ playing: false, current: -1, currentProps: null });\n }, []);\n\n const handleStepForward = useCallback(() => {\n animatorRef.current?.stepForward();\n }, []);\n\n const handleStepBack = useCallback(() => {\n animatorRef.current?.stepBack();\n }, []);\n\n return (\n <div\n className={`nb-sp-root${className ? ` ${className}` : ''}`}\n style={{ '--nb-sp-accent': resolvedColor } as React.CSSProperties}\n >\n {/* Left: step list */}\n <aside className=\"nb-sp-sidebar\">\n <SeriesStepList\n entries={entries}\n currentIndex={state.currentIndex}\n jumpHistory={state.jumpHistory}\n onSelect={actions.goTo}\n />\n </aside>\n\n {/* Center: map + controls */}\n <main className=\"nb-sp-main\" style={{ height }}>\n <AnimatedEventMap\n geojson={geojson}\n color={resolvedColor}\n ttsEnabled={ttsEnabled}\n onAnimatorReady={handleAnimatorReady}\n onStep={handleStep}\n onComplete={handleComplete}\n height=\"100%\"\n className=\"nb-sp-map\"\n />\n <AnimatorControls\n playing={animatorState.playing}\n current={animatorState.current}\n total={geojson?.features.length ?? 0}\n currentStep={animatorState.currentProps}\n accentColor={resolvedColor}\n onPlay={handlePlay}\n onPause={handlePause}\n onReset={handleReset}\n onStepForward={handleStepForward}\n onStepBack={handleStepBack}\n />\n <div className=\"nb-sp-nav-row\">\n <button\n type=\"button\"\n className=\"nb-sp-nav-btn\"\n onClick={actions.prev}\n disabled={state.currentIndex === 0 && state.jumpHistory.length === 0}\n >\n ← Previous\n </button>\n <span className=\"nb-sp-nav-label\">\n {state.currentIndex + 1} / {entries.length}\n </span>\n <button\n type=\"button\"\n className=\"nb-sp-nav-btn\"\n onClick={actions.next}\n disabled={state.currentIndex >= entries.length - 1}\n >\n Next →\n </button>\n </div>\n </main>\n\n {/* Right: event detail */}\n <aside className=\"nb-sp-detail\">\n <SeriesEventPanel\n event={state.event}\n loading={state.loading}\n error={state.error}\n accentColor={resolvedColor}\n resolvePortrait={resolvePortrait}\n sanitizeHtml={sanitizeHtml}\n onRelatedClick={actions.jumpToRelated}\n />\n </aside>\n </div>\n );\n}\n","import { useState, useCallback, useRef, useEffect } from 'react';\nimport type { HistoricalEvent } from '@neobyzantine/types';\nimport type { SeriesEntry } from './types';\n\nexport interface SeriesPlayerState {\n currentIndex: number;\n event: HistoricalEvent | null;\n loading: boolean;\n error: string | null;\n jumpHistory: number[];\n autoPlaying: boolean;\n}\n\nexport interface SeriesPlayerActions {\n next(): void;\n prev(): void;\n goTo(index: number): void;\n jumpToRelated(slug: string): void;\n backFromJump(): void;\n toggleAutoPlay(): void;\n}\n\nexport function useSeriesPlayer(\n entries: SeriesEntry[],\n onEventLoad: (slug: string) => Promise<HistoricalEvent>,\n opts?: {\n onEventChange?: (index: number, event: HistoricalEvent) => void;\n autoPlayInterval?: number;\n },\n): [SeriesPlayerState, SeriesPlayerActions] {\n const [currentIndex, setCurrentIndex] = useState(0);\n const [event, setEvent] = useState<HistoricalEvent | null>(null);\n const [loading, setLoading] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const [jumpHistory, setJumpHistory] = useState<number[]>([]);\n const [autoPlaying, setAutoPlaying] = useState(false);\n\n const autoTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const loadingSlugRef = useRef<string | null>(null);\n const onEventChangeRef = useRef(opts?.onEventChange);\n onEventChangeRef.current = opts?.onEventChange;\n const autoPlayIntervalRef = useRef(opts?.autoPlayInterval ?? 12000);\n autoPlayIntervalRef.current = opts?.autoPlayInterval ?? 12000;\n\n const clearAutoTimer = useCallback(() => {\n if (autoTimerRef.current) {\n clearTimeout(autoTimerRef.current);\n autoTimerRef.current = null;\n }\n }, []);\n\n const loadSlug = useCallback(\n async (slug: string, index: number) => {\n loadingSlugRef.current = slug;\n clearAutoTimer();\n setLoading(true);\n setError(null);\n try {\n const ev = await onEventLoad(slug);\n if (loadingSlugRef.current !== slug) return; // superseded\n setEvent(ev);\n if (index >= 0) onEventChangeRef.current?.(index, ev);\n } catch (err) {\n if (loadingSlugRef.current === slug) {\n setError(err instanceof Error ? err.message : 'Failed to load event');\n }\n } finally {\n if (loadingSlugRef.current === slug) setLoading(false);\n }\n },\n [onEventLoad, clearAutoTimer],\n );\n\n // Load initial event on mount\n useEffect(() => {\n const entry = entries[0];\n if (entry) void loadSlug(entry.slug, 0);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // Load event when currentIndex changes (driven by nav actions)\n const loadAtIndex = useCallback(\n (index: number) => {\n const entry = entries[index];\n if (entry) void loadSlug(entry.slug, index);\n },\n [entries, loadSlug],\n );\n\n const next = useCallback(() => {\n setCurrentIndex((prev) => {\n if (prev >= entries.length - 1) {\n setAutoPlaying(false);\n clearAutoTimer();\n return prev;\n }\n const next = prev + 1;\n setJumpHistory([]);\n loadAtIndex(next);\n return next;\n });\n }, [entries.length, loadAtIndex, clearAutoTimer]);\n\n const prev = useCallback(() => {\n setJumpHistory((hist) => {\n if (hist.length > 0) {\n // back from jump\n const restored = hist[hist.length - 1];\n const newHist = hist.slice(0, -1);\n setCurrentIndex(restored);\n loadAtIndex(restored);\n return newHist;\n }\n setCurrentIndex((idx) => {\n if (idx <= 0) return idx;\n const newIdx = idx - 1;\n loadAtIndex(newIdx);\n return newIdx;\n });\n return hist;\n });\n }, [loadAtIndex]);\n\n const goTo = useCallback(\n (index: number) => {\n if (index < 0 || index >= entries.length) return;\n setJumpHistory([]);\n setAutoPlaying(false);\n clearAutoTimer();\n setCurrentIndex(index);\n loadAtIndex(index);\n },\n [entries.length, loadAtIndex, clearAutoTimer],\n );\n\n const jumpToRelated = useCallback(\n (slug: string) => {\n setJumpHistory((hist) => [...hist, currentIndex]);\n loadingSlugRef.current = slug;\n clearAutoTimer();\n setLoading(true);\n setError(null);\n onEventLoad(slug)\n .then((ev) => {\n if (loadingSlugRef.current !== slug) return;\n setEvent(ev);\n setLoading(false);\n })\n .catch((err) => {\n if (loadingSlugRef.current === slug) {\n setError(err instanceof Error ? err.message : 'Failed to load event');\n setLoading(false);\n }\n });\n },\n [currentIndex, onEventLoad, clearAutoTimer],\n );\n\n const backFromJump = useCallback(() => {\n setJumpHistory((hist) => {\n if (!hist.length) return hist;\n const restored = hist[hist.length - 1];\n const newHist = hist.slice(0, -1);\n setCurrentIndex(restored);\n loadAtIndex(restored);\n return newHist;\n });\n }, [loadAtIndex]);\n\n const toggleAutoPlay = useCallback(() => {\n setAutoPlaying((prev) => {\n if (prev) {\n clearAutoTimer();\n return false;\n }\n // Start auto-play timer\n autoTimerRef.current = setTimeout(() => {\n next();\n }, autoPlayIntervalRef.current);\n return true;\n });\n }, [clearAutoTimer, next]);\n\n return [\n { currentIndex, event, loading, error, jumpHistory, autoPlaying },\n { next, prev, goTo, jumpToRelated, backFromJump, toggleAutoPlay },\n ];\n}\n","import React from 'react';\nimport type { SeriesEntry } from './types';\n\nexport interface SeriesStepListProps {\n entries: SeriesEntry[];\n currentIndex: number;\n jumpHistory: number[];\n onSelect: (index: number) => void;\n}\n\nexport function SeriesStepList({ entries, currentIndex, jumpHistory, onSelect }: SeriesStepListProps) {\n const isJumped = jumpHistory.length > 0;\n\n return (\n <ol className=\"nb-sp-steps\">\n {entries.map((entry, i) => {\n const isActive = !isJumped && i === currentIndex;\n const isDone = !isJumped && i < currentIndex;\n return (\n <li\n key={entry.slug}\n className={`nb-sp-step${isActive ? ' nb-sp-step--active' : ''}${isDone ? ' nb-sp-step--done' : ''}`}\n >\n <button\n type=\"button\"\n className=\"nb-sp-step-btn\"\n onClick={() => onSelect(i)}\n aria-current={isActive ? 'step' : undefined}\n >\n <span className=\"nb-sp-step-num\">{i + 1}</span>\n <span className=\"nb-sp-step-body\">\n <span className=\"nb-sp-step-title\">{entry.title}</span>\n {entry.date_display && (\n <span className=\"nb-sp-step-date\">{entry.date_display}</span>\n )}\n </span>\n </button>\n </li>\n );\n })}\n </ol>\n );\n}\n","import React from 'react';\nimport type { AnimationFeatureProperties } from './types';\n\nexport interface AnimatorControlsProps {\n playing: boolean;\n current: number;\n total: number;\n currentStep: AnimationFeatureProperties | null;\n accentColor?: string;\n onPlay: () => void;\n onPause: () => void;\n onReset: () => void;\n onStepForward: () => void;\n onStepBack: () => void;\n}\n\nexport function AnimatorControls({\n playing,\n current,\n total,\n currentStep,\n accentColor = '#CFB53B',\n onPlay,\n onPause,\n onReset,\n onStepForward,\n onStepBack,\n}: AnimatorControlsProps) {\n if (total === 0) return null;\n\n return (\n <div className=\"nb-sp-anim-controls\">\n <div className=\"nb-sp-anim-btns\">\n <button\n type=\"button\"\n className=\"nb-sp-anim-btn\"\n onClick={onReset}\n title=\"Reset animation\"\n disabled={current < 0}\n >⏮</button>\n <button\n type=\"button\"\n className=\"nb-sp-anim-btn\"\n onClick={onStepBack}\n title=\"Step back\"\n disabled={current <= 0}\n >⏪</button>\n <button\n type=\"button\"\n className={`nb-sp-anim-btn nb-sp-anim-btn--play${playing ? ' nb-sp-anim-btn--active' : ''}`}\n onClick={playing ? onPause : onPlay}\n style={{ borderColor: `${accentColor}66`, color: accentColor }}\n >\n {playing ? '⏸' : '▶'}\n </button>\n <button\n type=\"button\"\n className=\"nb-sp-anim-btn\"\n onClick={onStepForward}\n title=\"Step forward\"\n disabled={current >= total - 1}\n >⏩</button>\n <span className=\"nb-sp-anim-counter\">\n {current < 0 ? `0 / ${total}` : `${current + 1} / ${total}`}\n </span>\n </div>\n\n {currentStep && (currentStep.label || currentStep.narration) && (\n <div className=\"nb-sp-anim-narration\">\n {currentStep.label && (\n <span className=\"nb-sp-anim-label\" style={{ color: accentColor }}>\n {currentStep.label}\n </span>\n )}\n {currentStep.narration && (\n <span className=\"nb-sp-anim-text\">{currentStep.narration}</span>\n )}\n </div>\n )}\n </div>\n );\n}\n","import React, { useEffect, useRef } from 'react';\nimport { MapContainer, TileLayer, useMap } from 'react-leaflet';\nimport type { Map as LMap } from 'leaflet';\nimport { EventAnimator } from './EventAnimator';\nimport type { AnimationFeatureCollection, AnimatorEvents } from './types';\n\nexport interface AnimatedEventMapProps {\n geojson: AnimationFeatureCollection | null;\n color?: string;\n stepMs?: number;\n ttsEnabled?: boolean;\n onAnimatorReady?: (animator: EventAnimator) => void;\n onStep?: (data: AnimatorEvents['step']) => void;\n onComplete?: () => void;\n center?: [number, number];\n zoom?: number;\n height?: string;\n className?: string;\n}\n\n// Zero-render inner component: lives inside MapContainer, bridges useMap() to EventAnimator\ninterface DriverProps {\n geojson: AnimationFeatureCollection;\n color: string;\n stepMs: number;\n ttsEnabled: boolean;\n onAnimatorReady?: (animator: EventAnimator) => void;\n onStep?: (data: AnimatorEvents['step']) => void;\n onComplete?: () => void;\n}\n\nfunction AnimatorDriver({ geojson, color, stepMs, ttsEnabled, onAnimatorReady, onStep, onComplete }: DriverProps) {\n const map = useMap() as LMap;\n const animatorRef = useRef<EventAnimator | null>(null);\n\n useEffect(() => {\n const animator = new EventAnimator(map, geojson, color, { stepMs, ttsEnabled });\n if (onStep) animator.on('step', onStep);\n if (onComplete) animator.on('complete', onComplete);\n animatorRef.current = animator;\n onAnimatorReady?.(animator);\n\n return () => {\n animator.reset();\n animatorRef.current = null;\n };\n // Rebuild animator when geojson or key visual settings change.\n // Stable refs used for callbacks — they don't need to be deps.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [geojson, color, stepMs, ttsEnabled]);\n\n return null;\n}\n\nexport function AnimatedEventMap({\n geojson,\n color = '#CFB53B',\n stepMs = 7500,\n ttsEnabled = false,\n onAnimatorReady,\n onStep,\n onComplete,\n center = [41.0, 29.0], // Constantinople\n zoom = 5,\n height = '400px',\n className,\n}: AnimatedEventMapProps) {\n return (\n <div className={`nb-sp-map-wrap${className ? ` ${className}` : ''}`} style={{ height }}>\n <MapContainer\n center={center}\n zoom={zoom}\n style={{ height: '100%', width: '100%' }}\n zoomControl\n scrollWheelZoom={false}\n >\n <TileLayer\n url=\"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png\"\n attribution='&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a>'\n maxZoom={18}\n />\n {geojson && geojson.features.length > 0 && (\n <AnimatorDriver\n geojson={geojson}\n color={color}\n stepMs={stepMs}\n ttsEnabled={ttsEnabled}\n onAnimatorReady={onAnimatorReady}\n onStep={onStep}\n onComplete={onComplete}\n />\n )}\n </MapContainer>\n </div>\n );\n}\n","import L from 'leaflet';\nimport type {\n AnimationFeature,\n AnimationFeatureCollection,\n AnimationFeatureProperties,\n AnimatorEvents,\n AnimatorEventName,\n} from './types';\n\ntype Listener<K extends AnimatorEventName> = (data: AnimatorEvents[K]) => void;\n\nexport interface EventAnimatorOptions {\n stepMs?: number;\n ttsEnabled?: boolean;\n}\n\n/**\n * Step-by-step playback of a GeoJSON feature collection on a Leaflet map.\n * Each Point feature represents one animation step: the map flies to it,\n * a pulsing marker is shown, and optional TTS narration is spoken.\n *\n * Pure TypeScript class — no React. Wire into React via AnimatorDriver (uses useMap()).\n */\nexport class EventAnimator {\n private readonly map: L.Map;\n private readonly color: string;\n private readonly stepMs: number;\n private ttsEnabled: boolean;\n\n current = -1;\n playing = false;\n\n private timer: ReturnType<typeof setTimeout> | null = null;\n private pulseMarker: L.Marker | null = null;\n private listeners = new Map<AnimatorEventName, Array<Listener<AnimatorEventName>>>();\n\n readonly steps: AnimationFeature[];\n\n constructor(\n map: L.Map,\n geojson: AnimationFeatureCollection,\n color: string,\n options?: EventAnimatorOptions,\n ) {\n this.map = map;\n this.color = color;\n this.stepMs = options?.stepMs ?? 7500;\n this.ttsEnabled = options?.ttsEnabled ?? false;\n\n this.steps = (geojson.features ?? [])\n .filter((f) => f.properties.sort_order != null && f.geometry?.type === 'Point')\n .sort((a, b) => a.properties.sort_order - b.properties.sort_order);\n }\n\n get total(): number { return this.steps.length; }\n\n on<K extends AnimatorEventName>(event: K, fn: Listener<K>): this {\n if (!this.listeners.has(event)) this.listeners.set(event, []);\n this.listeners.get(event)!.push(fn as Listener<AnimatorEventName>);\n return this;\n }\n\n private emit<K extends AnimatorEventName>(event: K, data?: AnimatorEvents[K]): void {\n this.listeners.get(event)?.forEach((fn) => (fn as Listener<K>)(data as AnimatorEvents[K]));\n }\n\n play(): void {\n if (this.playing || this.total === 0) return;\n this.playing = true;\n this.emit('play');\n if (this.current < 0) {\n this.goto(0);\n } else {\n this.schedule();\n }\n }\n\n pause(): void {\n if (!this.playing) return;\n this.playing = false;\n if (this.timer) { clearTimeout(this.timer); this.timer = null; }\n window.speechSynthesis?.cancel();\n this.emit('pause');\n }\n\n reset(): void {\n this.pause();\n this.current = -1;\n this.clearPulse();\n window.speechSynthesis?.cancel();\n this.emit('reset');\n }\n\n stepForward(): void {\n this.pause();\n if (this.current + 1 < this.total) this.goto(this.current + 1);\n }\n\n stepBack(): void {\n this.pause();\n if (this.current - 1 >= 0) this.goto(this.current - 1);\n }\n\n setTTS(enabled: boolean): void {\n this.ttsEnabled = enabled;\n if (!enabled) window.speechSynthesis?.cancel();\n }\n\n private goto(index: number): void {\n if (index < 0 || index >= this.total) return;\n this.current = index;\n\n const feat = this.steps[index];\n const props = feat.properties;\n const [lng, lat] = feat.geometry.coordinates;\n\n this.map.flyTo([lat, lng], props.zoom ?? 8, { duration: 1.4, easeLinearity: 0.4 });\n\n this.clearPulse();\n this.pulseMarker = L.marker([lat, lng], {\n icon: L.divIcon({\n className: '',\n html: `<div class=\"nb-anim-pulse\" style=\"--anim-color:${this.color}\"></div>`,\n iconSize: [48, 48],\n iconAnchor: [24, 24],\n }),\n zIndexOffset: 1000,\n interactive: false,\n }).addTo(this.map);\n\n if (this.ttsEnabled) this.speak(props.narration ?? props.label ?? '');\n\n this.emit('step', { index, total: this.total, props });\n\n if (this.playing) this.schedule();\n }\n\n private schedule(): void {\n const props = this.steps[this.current]?.properties;\n const delay = props?.animation_pause_ms ?? this.stepMs;\n this.timer = setTimeout(() => {\n if (this.current + 1 >= this.total) {\n this.playing = false;\n window.speechSynthesis?.cancel();\n this.emit('complete');\n } else {\n this.goto(this.current + 1);\n }\n }, delay);\n }\n\n private clearPulse(): void {\n if (this.pulseMarker) {\n this.map.removeLayer(this.pulseMarker);\n this.pulseMarker = null;\n }\n }\n\n private speak(text: string): void {\n const ss = window.speechSynthesis;\n if (!ss || !text) return;\n ss.cancel();\n setTimeout(() => {\n if (ss.paused) ss.resume();\n const u = new SpeechSynthesisUtterance(text);\n u.lang = 'en-GB';\n u.rate = 0.70;\n u.pitch = 0.95;\n u.volume = 1;\n ss.speak(u);\n }, 80);\n }\n}\n","import React from 'react';\nimport type { HistoricalEvent } from '@neobyzantine/types';\nimport { ActorGrid } from '@neobyzantine/actor';\n\nexport interface SeriesEventPanelProps {\n event: HistoricalEvent | null;\n loading?: boolean;\n error?: string | null;\n accentColor?: string;\n /** Convert storage path to URL — consumer knows the URL scheme. */\n resolvePortrait?: (path: string) => string;\n /** Sanitize HTML before dangerouslySetInnerHTML — consumer provides sanitizer. */\n sanitizeHtml?: (html: string) => string;\n onActorClick?: (actor: import('@neobyzantine/types').Actor) => void;\n onRelatedClick?: (slug: string) => void;\n className?: string;\n}\n\n/** Default strips all tags — safe fallback. Pass DOMPurify.sanitize for full HTML rendering. */\nfunction defaultSanitize(html: string) { return html.replace(/<[^>]*>/g, ''); }\n\nexport function SeriesEventPanel({\n event,\n loading,\n error,\n accentColor = '#CFB53B',\n resolvePortrait,\n sanitizeHtml = defaultSanitize,\n onActorClick,\n onRelatedClick,\n className,\n}: SeriesEventPanelProps) {\n if (loading) {\n return (\n <div className={`nb-sp-panel nb-sp-panel--loading${className ? ` ${className}` : ''}`}>\n <div className=\"nb-sp-panel-spinner\" />\n </div>\n );\n }\n\n if (error) {\n return (\n <div className={`nb-sp-panel nb-sp-panel--error${className ? ` ${className}` : ''}`}>\n <p className=\"nb-sp-panel-error\">{error}</p>\n </div>\n );\n }\n\n if (!event) return null;\n\n const hasActors = (event.actors?.length ?? 0) > 0;\n const hasGallery = (event.gallery_images?.length ?? 0) > 0;\n const hasRelations = (event.relations?.length ?? 0) > 0;\n\n return (\n <article className={`nb-sp-panel${className ? ` ${className}` : ''}`}>\n <header className=\"nb-sp-panel-header\">\n <h2 className=\"nb-sp-panel-title\" style={{ color: accentColor }}>{event.title}</h2>\n {event.date_display && (\n <p className=\"nb-sp-panel-date\">{event.date_display}</p>\n )}\n </header>\n\n {event.description && (\n <div\n className=\"nb-sp-panel-body\"\n dangerouslySetInnerHTML={{ __html: sanitizeHtml(event.description) }}\n />\n )}\n\n {hasActors && (\n <section className=\"nb-sp-panel-section\">\n <h3 className=\"nb-sp-panel-section-title\">Key Figures</h3>\n <ActorGrid\n actors={event.actors!}\n resolvePortrait={resolvePortrait}\n onActorClick={onActorClick}\n columns={2}\n compact\n />\n </section>\n )}\n\n {hasGallery && (\n <section className=\"nb-sp-panel-section\">\n <h3 className=\"nb-sp-panel-section-title\">Gallery</h3>\n <div className=\"nb-sp-panel-gallery\">\n {event.gallery_images!.map((src, i) => (\n <img\n key={i}\n src={src}\n alt=\"\"\n className=\"nb-sp-panel-gallery-img\"\n loading=\"lazy\"\n />\n ))}\n </div>\n </section>\n )}\n\n {hasRelations && (\n <section className=\"nb-sp-panel-section\">\n <h3 className=\"nb-sp-panel-section-title\">Related Events</h3>\n <ul className=\"nb-sp-panel-relations\">\n {event.relations!.map((rel) => (\n <li key={rel.slug} className={`nb-sp-panel-rel nb-sp-panel-rel--${rel.type}`}>\n <button\n type=\"button\"\n className=\"nb-sp-panel-rel-btn\"\n onClick={() => onRelatedClick?.(rel.slug)}\n >\n {rel.title}\n {rel.date && <span className=\"nb-sp-panel-rel-date\"> ({rel.date})</span>}\n </button>\n </li>\n ))}\n </ul>\n </section>\n )}\n </article>\n );\n}\n"],"mappings":"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","names":["useCallback","useRef","useState","prev","next","jsx","jsxs","useEffect","useRef","jsx","jsxs","useRef","useEffect","jsx","jsxs","jsx","jsxs","useState","useRef","useCallback"]}
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@neobyzantine/series-player",
3
+ "version": "0.2.0",
4
+ "description": "Animated multi-event series player with step-by-step map flythrough and optional TTS narration",
5
+ "license": "MIT",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.mjs",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.mjs",
13
+ "require": "./dist/index.js"
14
+ },
15
+ "./series-player.css": "./src/series-player.css"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "src/series-player.css"
20
+ ],
21
+ "dependencies": {
22
+ "@neobyzantine/tokens": "0.2.0",
23
+ "@neobyzantine/types": "0.2.0",
24
+ "@neobyzantine/actor": "0.2.0"
25
+ },
26
+ "devDependencies": {
27
+ "@testing-library/jest-dom": "^6.6.3",
28
+ "@testing-library/react": "^16.3.0",
29
+ "@testing-library/user-event": "^14.5.2",
30
+ "@types/leaflet": "^1.9.18",
31
+ "@types/react": "^18.3.23",
32
+ "jsdom": "^26.1.0",
33
+ "leaflet": "^1.9.4",
34
+ "react": "^18.3.1",
35
+ "react-dom": "^18.3.1",
36
+ "react-leaflet": "^4.2.1",
37
+ "tsup": "^8.5.1",
38
+ "typescript": "^5.8.0",
39
+ "vitest": "^3.2.3"
40
+ },
41
+ "peerDependencies": {
42
+ "leaflet": ">=1.9",
43
+ "react": ">=18",
44
+ "react-dom": ">=18",
45
+ "react-leaflet": ">=4"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "scripts": {
51
+ "build": "tsup",
52
+ "dev": "tsup --watch",
53
+ "test": "vitest run",
54
+ "lint": "tsc --noEmit"
55
+ }
56
+ }
@@ -0,0 +1,384 @@
1
+ /* ── Series Player ─────────────────────────────────────────────────────────── */
2
+ .nb-sp-root {
3
+ display: grid;
4
+ grid-template-columns: 240px 1fr 320px;
5
+ gap: 0;
6
+ background: #0d1220;
7
+ color: #e8dfc0;
8
+ font-family: 'Crimson Text', Georgia, serif;
9
+ border: 1px solid #1e2d45;
10
+ border-radius: 4px;
11
+ overflow: hidden;
12
+ }
13
+
14
+ /* ── Sidebar (step list) ───────────────────────────────────────────────────── */
15
+ .nb-sp-sidebar {
16
+ background: #080e1a;
17
+ border-right: 1px solid #1e2d45;
18
+ overflow-y: auto;
19
+ max-height: 100%;
20
+ }
21
+
22
+ .nb-sp-steps {
23
+ list-style: none;
24
+ margin: 0;
25
+ padding: 0;
26
+ }
27
+
28
+ .nb-sp-step {
29
+ border-bottom: 1px solid #1a2235;
30
+ }
31
+
32
+ .nb-sp-step-btn {
33
+ display: flex;
34
+ align-items: flex-start;
35
+ gap: 10px;
36
+ width: 100%;
37
+ padding: 10px 12px;
38
+ background: transparent;
39
+ border: none;
40
+ color: #8899aa;
41
+ text-align: left;
42
+ cursor: pointer;
43
+ transition: background 0.15s, color 0.15s;
44
+ font-family: inherit;
45
+ font-size: 0.85rem;
46
+ }
47
+
48
+ .nb-sp-step-btn:hover {
49
+ background: #111928;
50
+ color: #c8bfa0;
51
+ }
52
+
53
+ .nb-sp-step--active .nb-sp-step-btn {
54
+ background: #111928;
55
+ color: var(--nb-sp-accent, #cfb53b);
56
+ }
57
+
58
+ .nb-sp-step--done .nb-sp-step-btn {
59
+ color: #556677;
60
+ }
61
+
62
+ .nb-sp-step-num {
63
+ flex-shrink: 0;
64
+ width: 20px;
65
+ height: 20px;
66
+ border-radius: 50%;
67
+ background: #1a2235;
68
+ display: flex;
69
+ align-items: center;
70
+ justify-content: center;
71
+ font-size: 0.7rem;
72
+ font-weight: bold;
73
+ }
74
+
75
+ .nb-sp-step--active .nb-sp-step-num {
76
+ background: var(--nb-sp-accent, #cfb53b);
77
+ color: #080e1a;
78
+ }
79
+
80
+ .nb-sp-step--done .nb-sp-step-num {
81
+ background: #222d3a;
82
+ }
83
+
84
+ .nb-sp-step-body {
85
+ display: flex;
86
+ flex-direction: column;
87
+ gap: 2px;
88
+ min-width: 0;
89
+ }
90
+
91
+ .nb-sp-step-title {
92
+ white-space: nowrap;
93
+ overflow: hidden;
94
+ text-overflow: ellipsis;
95
+ font-size: 0.83rem;
96
+ }
97
+
98
+ .nb-sp-step-date {
99
+ font-size: 0.72rem;
100
+ color: #556677;
101
+ }
102
+
103
+ /* ── Main (map + controls) ─────────────────────────────────────────────────── */
104
+ .nb-sp-main {
105
+ display: flex;
106
+ flex-direction: column;
107
+ position: relative;
108
+ }
109
+
110
+ .nb-sp-map-wrap {
111
+ flex: 1;
112
+ min-height: 0;
113
+ }
114
+
115
+ .nb-sp-map-wrap .leaflet-container {
116
+ background: #0a1020;
117
+ }
118
+
119
+ /* Pulsing animation marker */
120
+ .nb-anim-pulse {
121
+ width: 48px;
122
+ height: 48px;
123
+ border-radius: 50%;
124
+ background: color-mix(in srgb, var(--anim-color, #cfb53b) 35%, transparent);
125
+ border: 2px solid var(--anim-color, #cfb53b);
126
+ box-shadow: 0 0 0 0 color-mix(in srgb, var(--anim-color, #cfb53b) 60%, transparent);
127
+ animation: nb-anim-pulse 1.6s ease-out infinite;
128
+ }
129
+
130
+ @keyframes nb-anim-pulse {
131
+ 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--anim-color, #cfb53b) 60%, transparent); }
132
+ 70% { box-shadow: 0 0 0 18px transparent; }
133
+ 100% { box-shadow: 0 0 0 0 transparent; }
134
+ }
135
+
136
+ /* ── Animator controls ─────────────────────────────────────────────────────── */
137
+ .nb-sp-anim-controls {
138
+ background: #080e1acc;
139
+ border-top: 1px solid #1e2d45;
140
+ padding: 8px 12px;
141
+ display: flex;
142
+ flex-direction: column;
143
+ gap: 6px;
144
+ }
145
+
146
+ .nb-sp-anim-btns {
147
+ display: flex;
148
+ align-items: center;
149
+ gap: 6px;
150
+ }
151
+
152
+ .nb-sp-anim-btn {
153
+ background: #111928;
154
+ border: 1px solid #1e2d45;
155
+ color: #8899aa;
156
+ border-radius: 3px;
157
+ padding: 4px 8px;
158
+ cursor: pointer;
159
+ font-size: 0.9rem;
160
+ transition: background 0.12s, color 0.12s;
161
+ }
162
+
163
+ .nb-sp-anim-btn:hover:not(:disabled) {
164
+ background: #1a2235;
165
+ color: #c8bfa0;
166
+ }
167
+
168
+ .nb-sp-anim-btn:disabled {
169
+ opacity: 0.35;
170
+ cursor: default;
171
+ }
172
+
173
+ .nb-sp-anim-btn--play {
174
+ border-color: transparent;
175
+ padding: 4px 12px;
176
+ }
177
+
178
+ .nb-sp-anim-btn--active {
179
+ background: #1a2235;
180
+ }
181
+
182
+ .nb-sp-anim-counter {
183
+ margin-left: auto;
184
+ font-size: 0.78rem;
185
+ color: #556677;
186
+ }
187
+
188
+ .nb-sp-anim-narration {
189
+ display: flex;
190
+ flex-direction: column;
191
+ gap: 2px;
192
+ }
193
+
194
+ .nb-sp-anim-label {
195
+ font-size: 0.8rem;
196
+ font-weight: 600;
197
+ }
198
+
199
+ .nb-sp-anim-text {
200
+ font-size: 0.82rem;
201
+ color: #8899aa;
202
+ font-style: italic;
203
+ }
204
+
205
+ /* ── Series nav row ────────────────────────────────────────────────────────── */
206
+ .nb-sp-nav-row {
207
+ display: flex;
208
+ align-items: center;
209
+ gap: 12px;
210
+ background: #080e1acc;
211
+ border-top: 1px solid #1e2d45;
212
+ padding: 6px 12px;
213
+ }
214
+
215
+ .nb-sp-nav-btn {
216
+ background: transparent;
217
+ border: 1px solid #1e2d45;
218
+ color: #8899aa;
219
+ border-radius: 3px;
220
+ padding: 4px 10px;
221
+ cursor: pointer;
222
+ font-family: inherit;
223
+ font-size: 0.82rem;
224
+ transition: background 0.12s, color 0.12s;
225
+ }
226
+
227
+ .nb-sp-nav-btn:hover:not(:disabled) {
228
+ background: #111928;
229
+ color: #c8bfa0;
230
+ }
231
+
232
+ .nb-sp-nav-btn:disabled {
233
+ opacity: 0.35;
234
+ cursor: default;
235
+ }
236
+
237
+ .nb-sp-nav-label {
238
+ flex: 1;
239
+ text-align: center;
240
+ font-size: 0.78rem;
241
+ color: #556677;
242
+ }
243
+
244
+ /* ── Event detail panel ────────────────────────────────────────────────────── */
245
+ .nb-sp-detail {
246
+ background: #080e1a;
247
+ border-left: 1px solid #1e2d45;
248
+ overflow-y: auto;
249
+ max-height: 100%;
250
+ }
251
+
252
+ .nb-sp-panel {
253
+ padding: 16px;
254
+ }
255
+
256
+ .nb-sp-panel--loading,
257
+ .nb-sp-panel--error {
258
+ display: flex;
259
+ align-items: center;
260
+ justify-content: center;
261
+ min-height: 200px;
262
+ padding: 16px;
263
+ }
264
+
265
+ .nb-sp-panel-spinner {
266
+ width: 28px;
267
+ height: 28px;
268
+ border: 3px solid #1e2d45;
269
+ border-top-color: var(--nb-sp-accent, #cfb53b);
270
+ border-radius: 50%;
271
+ animation: nb-sp-spin 0.7s linear infinite;
272
+ }
273
+
274
+ @keyframes nb-sp-spin {
275
+ to { transform: rotate(360deg); }
276
+ }
277
+
278
+ .nb-sp-panel-error {
279
+ color: #cc4444;
280
+ font-size: 0.85rem;
281
+ }
282
+
283
+ .nb-sp-panel-header {
284
+ margin-bottom: 12px;
285
+ }
286
+
287
+ .nb-sp-panel-title {
288
+ font-family: 'Old Standard TT', Georgia, serif;
289
+ font-size: 1.1rem;
290
+ margin: 0 0 4px;
291
+ line-height: 1.3;
292
+ }
293
+
294
+ .nb-sp-panel-date {
295
+ font-size: 0.78rem;
296
+ color: #556677;
297
+ margin: 0;
298
+ }
299
+
300
+ .nb-sp-panel-body {
301
+ font-size: 0.88rem;
302
+ line-height: 1.65;
303
+ color: #c8bfa0;
304
+ margin-bottom: 16px;
305
+ }
306
+
307
+ .nb-sp-panel-section {
308
+ margin-top: 16px;
309
+ border-top: 1px solid #1a2235;
310
+ padding-top: 12px;
311
+ }
312
+
313
+ .nb-sp-panel-section-title {
314
+ font-family: 'Old Standard TT', Georgia, serif;
315
+ font-size: 0.8rem;
316
+ color: #556677;
317
+ text-transform: uppercase;
318
+ letter-spacing: 0.08em;
319
+ margin: 0 0 10px;
320
+ }
321
+
322
+ .nb-sp-panel-gallery {
323
+ display: grid;
324
+ grid-template-columns: repeat(2, 1fr);
325
+ gap: 6px;
326
+ }
327
+
328
+ .nb-sp-panel-gallery-img {
329
+ width: 100%;
330
+ aspect-ratio: 4/3;
331
+ object-fit: cover;
332
+ border-radius: 2px;
333
+ }
334
+
335
+ .nb-sp-panel-relations {
336
+ list-style: none;
337
+ margin: 0;
338
+ padding: 0;
339
+ display: flex;
340
+ flex-direction: column;
341
+ gap: 4px;
342
+ }
343
+
344
+ .nb-sp-panel-rel-btn {
345
+ background: transparent;
346
+ border: 1px solid #1e2d45;
347
+ color: #6699ff;
348
+ border-radius: 3px;
349
+ padding: 4px 10px;
350
+ cursor: pointer;
351
+ font-family: inherit;
352
+ font-size: 0.82rem;
353
+ width: 100%;
354
+ text-align: left;
355
+ transition: background 0.12s;
356
+ }
357
+
358
+ .nb-sp-panel-rel-btn:hover {
359
+ background: #111928;
360
+ }
361
+
362
+ .nb-sp-panel-rel-date {
363
+ color: #556677;
364
+ }
365
+
366
+ /* ── Responsive: stack on narrow viewports ─────────────────────────────────── */
367
+ @media (max-width: 900px) {
368
+ .nb-sp-root {
369
+ grid-template-columns: 1fr;
370
+ grid-template-rows: auto auto auto;
371
+ }
372
+
373
+ .nb-sp-sidebar {
374
+ border-right: none;
375
+ border-bottom: 1px solid #1e2d45;
376
+ max-height: 180px;
377
+ }
378
+
379
+ .nb-sp-detail {
380
+ border-left: none;
381
+ border-top: 1px solid #1e2d45;
382
+ max-height: none;
383
+ }
384
+ }