11import { Link } from 'react-router'
2- import { useEffect , useMemo , useState } from 'react'
2+ import { useEffect , useMemo , useRef , useState } from 'react'
33import ReactMarkdown from 'react-markdown'
4- import { Clock , MapPin , Tag } from 'lucide-react'
4+ import { ChevronDown , Clock , Filter , MapPin , Tag } from 'lucide-react'
55import { readItems , readSingleton } from '@directus/sdk'
66import directus from '../lib/directus'
77import {
@@ -49,7 +49,34 @@ function ScheduleDescription({ markdown, className }: { markdown: string; classN
4949 )
5050}
5151
52- function ScheduleCard ( { item, isActive } : { item : TimedScheduleItem ; isActive : boolean } ) {
52+ function formatRelativeStartsIn ( startDate : Date , now : Date | null ) : string | null {
53+ if ( ! now ) return null
54+ const diffMs = startDate . getTime ( ) - now . getTime ( )
55+ const diffMinutes = Math . round ( diffMs / 60000 )
56+ if ( diffMinutes <= 0 ) return null
57+ if ( diffMinutes < 60 ) return `Starts in ${ diffMinutes } min`
58+ const hours = Math . floor ( diffMinutes / 60 )
59+ const minutes = diffMinutes % 60
60+ return minutes === 0 ? `Starts in ${ hours } h` : `Starts in ${ hours } h ${ minutes } m`
61+ }
62+
63+ function statusBadge ( status : TimedScheduleItem [ 'status' ] ) {
64+ if ( status === 'live' ) {
65+ return {
66+ label : 'Live' ,
67+ classes : 'text-amber-200 bg-amber-500/15 border-amber-400/50'
68+ }
69+ }
70+ if ( status === 'past' ) {
71+ return {
72+ label : 'Done' ,
73+ classes : 'text-slate-400 bg-slate-800/80 border-slate-700'
74+ }
75+ }
76+ return null
77+ }
78+
79+ function ScheduleCard ( { item, isActive, now } : { item : TimedScheduleItem ; isActive : boolean ; now : Date | null } ) {
5380 const startLabel = item . startDate . toLocaleTimeString ( undefined , {
5481 hour : '2-digit' ,
5582 minute : '2-digit' ,
@@ -72,69 +99,50 @@ function ScheduleCard({ item, isActive }: { item: TimedScheduleItem; isActive: b
7299 : `${ durationHours } h ${ durationRemainderMinutes } m`
73100 : `${ durationMinutes } m`
74101
102+ const badge = statusBadge ( item . status )
103+
75104 let textClasses = 'text-slate-400'
76105 let titleClasses = 'text-lg md:text-xl font-semibold'
77- let timeClasses = 'text-sm font-medium'
78- let statusLabel : string | null = null
79106 let borderClasses = 'border-neutral-800'
80- let liveAccentDot = false
81107
82108 if ( item . status === 'live' ) {
83109 textClasses = 'text-amber-100'
84110 titleClasses = 'text-xl md:text-2xl font-semibold text-slate-50'
85- timeClasses = 'text-base font-medium text-amber-200'
86- statusLabel = 'LIVE NOW'
87- borderClasses = 'border-amber-400/80 shadow-[0_0_24px_rgba(250,204,21,0.25)]'
88- liveAccentDot = true
111+ borderClasses = 'border-amber-400/70'
89112 } else if ( item . status === 'past' ) {
90113 textClasses = 'text-slate-500'
91114 titleClasses = 'text-lg md:text-xl font-semibold text-slate-500'
92- timeClasses = 'text-sm font-medium text-slate-500'
93115 } else {
94116 textClasses = 'text-slate-400'
95117 titleClasses = 'text-lg md:text-xl font-semibold text-slate-50'
96- timeClasses = 'text-sm font-medium text-slate-400'
97118 }
98119
99- let relativeTimeLabel : string | null = null
100- if ( item . status === 'upcoming' ) {
101- const now = new Date ( )
102- const diffMs = item . startDate . getTime ( ) - now . getTime ( )
103- const diffMinutes = Math . round ( diffMs / 60000 )
104- if ( diffMinutes > 0 ) {
105- if ( diffMinutes < 60 ) {
106- relativeTimeLabel = `Starts in ${ diffMinutes } min`
107- } else {
108- const hours = Math . floor ( diffMinutes / 60 )
109- const minutes = diffMinutes % 60
110- relativeTimeLabel = minutes === 0 ? `Starts in ${ hours } h` : `Starts in ${ hours } h ${ minutes } m`
111- }
112- }
113- }
120+ const relativeTimeLabel = item . status === 'upcoming' ? formatRelativeStartsIn ( item . startDate , now ) : null
114121
115122 return (
116123 < article
117- className = { `rounded-xl border bg-black /80 px-4 py-4 md:px-6 md:py-5 flex flex-col md:flex-row md:gap-6 gap-3 ${ borderClasses } ${ isActive ? 'shadow-[0_0_30px_rgba(0,0,0,0.4)] ' : '' } ` }
124+ className = { `rounded-xl border bg-neutral-900 /80 px-4 py-4 md:px-6 md:py-5 flex flex-col md:flex-row md:items-stretch gap-4 md: gap-5 ${ borderClasses } ${ isActive ? 'bg-neutral-900 ' : '' } ` }
118125 >
119- < div className = "md:w-40 flex flex-col gap-1 border-b border-neutral-800 md:border-b-0 md:border-r md:pr-4 md:pb-0 pb-2 text-xs text-slate-400 shrink-0" >
120- < div className = "flex flex-wrap items-baseline gap-2" >
121- < span className = "inline-flex items-center gap-1.5" >
122- < Clock className = "h-3.5 w-3.5 text-slate-500 shrink-0" />
123- < span className = "tabular-nums" > { startLabel } </ span >
124- < span className = "tabular-nums text-slate-500" > { durationLabel } </ span >
125- </ span >
126- < span className = "tabular-nums text-slate-500" > → { endLabel } </ span >
127- </ div >
128- < div className = "flex flex-col gap-0.5 text-[11px]" >
129- < span className = "inline-flex items-center gap-1.5" >
130- < MapPin className = "h-3 w-3 text-slate-600 shrink-0" />
131- < span className = "truncate max-w-[200px] md:max-w-36" > { item . location } </ span >
132- </ span >
133- { relativeTimeLabel ? < span > { relativeTimeLabel } </ span > : null }
134- </ div >
126+ < div className = "w-full md:w-44 md:shrink-0 md:pr-5 border-b border-neutral-800 pb-2 md:border-b-0 md:border-r md:pb-0 text-xs text-slate-400 flex flex-col gap-1.5" >
127+ < span className = "inline-flex items-center gap-1.5 tabular-nums text-slate-300" >
128+ < Clock className = "h-3.5 w-3.5 text-slate-500 shrink-0" />
129+ { startLabel } → { endLabel } < span className = "ml-1 text-slate-500" > ({ durationLabel } )</ span >
130+ </ span >
131+ < span className = "inline-flex items-center gap-1.5 min-w-0 text-slate-400" >
132+ < MapPin className = "h-3 w-3 text-slate-600 shrink-0" />
133+ < span className = "truncate" > { item . location } </ span >
134+ </ span >
135+ { relativeTimeLabel ? < span className = "text-slate-500" > { relativeTimeLabel } </ span > : null }
135136 </ div >
136137 < div className = "flex-1 min-w-0 flex flex-col gap-2" >
137- < div className = "flex flex-wrap items-baseline gap-2" >
138+ < div className = "flex flex-wrap items-center gap-2" >
139+ { badge ? (
140+ < span
141+ className = { `inline-flex items-center rounded-md border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] ${ badge . classes } ` }
142+ >
143+ { badge . label }
144+ </ span >
145+ ) : null }
138146 < h3 className = { titleClasses } > { item . title } </ h3 >
139147 { item . tags && item . tags . length ? (
140148 < div className = "flex flex-wrap items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-slate-500" >
@@ -145,12 +153,10 @@ function ScheduleCard({ item, isActive }: { item: TimedScheduleItem; isActive: b
145153 </ div >
146154 { item . description ? < ScheduleDescription markdown = { item . description } className = { textClasses } /> : null }
147155 < div className = "mt-1 flex items-center justify-end gap-3 text-xs text-slate-500" >
148- { statusLabel ? (
156+ { item . status === 'live' ? (
149157 < span className = "inline-flex items-center gap-2 text-[11px] font-bold text-amber-300 shrink-0" >
150- { liveAccentDot ? (
151- < span className = "h-1.5 w-1.5 rounded-full bg-amber-400 shadow-[0_0_10px_rgba(250,204,21,0.9)] animate-pulse" />
152- ) : null }
153- < span > { statusLabel } </ span >
158+ < span className = "h-1.5 w-1.5 rounded-full bg-amber-400" />
159+ < span > LIVE NOW</ span >
154160 </ span >
155161 ) : null }
156162 </ div >
@@ -162,8 +168,49 @@ function ScheduleCard({ item, isActive }: { item: TimedScheduleItem; isActive: b
162168export default function Schedule ( ) {
163169 const [ data , setData ] = useState < ScheduleData > ( { schedule : [ ] , hacking : null } )
164170 const [ isLoading , setIsLoading ] = useState ( true )
171+ const [ selectedTag , setSelectedTag ] = useState < string > ( 'all' )
172+ const [ isFilterOpen , setIsFilterOpen ] = useState ( false )
173+ const filterRef = useRef < HTMLDivElement | null > ( null )
165174 const now = useNow ( 1000 )
166175 const timedSchedule = useMemo ( ( ) => buildTimedSchedule ( data . schedule , now ) , [ data . schedule , now ] )
176+ const availableTags = useMemo ( ( ) => {
177+ const tagMap = new Map < string , string > ( )
178+ for ( const item of data . schedule ) {
179+ for ( const rawTag of item . tags ?? [ ] ) {
180+ const label = rawTag . trim ( )
181+ if ( ! label ) continue
182+ const key = label . toLowerCase ( )
183+ if ( ! tagMap . has ( key ) ) tagMap . set ( key , label )
184+ }
185+ }
186+ return Array . from ( tagMap . entries ( ) )
187+ . map ( ( [ key , label ] ) => ( { key, label } ) )
188+ . sort ( ( a , b ) => a . label . localeCompare ( b . label ) )
189+ } , [ data . schedule ] )
190+
191+ const filteredSchedule = useMemo ( ( ) => {
192+ if ( selectedTag === 'all' ) return timedSchedule
193+ return timedSchedule . filter ( ( item ) => item . tags ?. some ( ( tag ) => tag . trim ( ) . toLowerCase ( ) === selectedTag ) )
194+ } , [ selectedTag , timedSchedule ] )
195+
196+ useEffect ( ( ) => {
197+ if ( selectedTag === 'all' ) return
198+ const exists = availableTags . some ( ( tag ) => tag . key === selectedTag )
199+ if ( ! exists ) setSelectedTag ( 'all' )
200+ } , [ availableTags , selectedTag ] )
201+
202+ useEffect ( ( ) => {
203+ if ( ! isFilterOpen ) return
204+ const handlePointerDown = ( event : MouseEvent ) => {
205+ if ( ! filterRef . current ) return
206+ if ( event . target instanceof Node && ! filterRef . current . contains ( event . target ) ) {
207+ setIsFilterOpen ( false )
208+ }
209+ }
210+
211+ window . addEventListener ( 'mousedown' , handlePointerDown )
212+ return ( ) => window . removeEventListener ( 'mousedown' , handlePointerDown )
213+ } , [ isFilterOpen ] )
167214
168215 useEffect ( ( ) => {
169216 let cancelled = false
@@ -190,15 +237,65 @@ export default function Schedule() {
190237 return (
191238 < div className = "min-h-screen text-slate-50 font-sans flex flex-col" >
192239 < Header now = { now } />
193- < main className = "flex-1 px-4 md:px-12 py-6 md:py-8 max-w-4xl mx-auto w-full" >
194- < h2 className = "text-xs font-semibold uppercase tracking-[0.25em] text-slate-500 mb-4" > Schedule</ h2 >
240+ < main className = "flex-1 px-4 md:px-12 py-6 md:py-8 max-w-5xl mx-auto w-full" >
241+ < div className = "mb-4 flex items-center justify-between gap-3" >
242+ < h2 className = "text-xs font-semibold uppercase tracking-[0.25em] text-slate-500" > Schedule</ h2 >
243+ { availableTags . length > 0 ? (
244+ < div ref = { filterRef } className = "relative" >
245+ < button
246+ type = "button"
247+ onClick = { ( ) => setIsFilterOpen ( ( value ) => ! value ) }
248+ className = "inline-flex items-center gap-2 rounded-md border border-neutral-700 bg-neutral-900 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-300 hover:border-neutral-600"
249+ aria-expanded = { isFilterOpen }
250+ aria-haspopup = "menu"
251+ >
252+ < Filter className = "h-3.5 w-3.5" />
253+ < span >
254+ { selectedTag === 'all'
255+ ? 'All tags'
256+ : ( availableTags . find ( ( tag ) => tag . key === selectedTag ) ?. label ?? 'All tags' ) }
257+ </ span >
258+ < ChevronDown className = { `h-3.5 w-3.5 transition-transform ${ isFilterOpen ? 'rotate-180' : '' } ` } />
259+ </ button >
260+ { isFilterOpen ? (
261+ < div className = "absolute right-0 z-20 mt-2 min-w-44 overflow-hidden rounded-lg border border-neutral-700 bg-neutral-900 shadow-lg" >
262+ < button
263+ type = "button"
264+ onClick = { ( ) => {
265+ setSelectedTag ( 'all' )
266+ setIsFilterOpen ( false )
267+ } }
268+ className = { `block w-full px-3 py-2 text-left text-xs font-medium uppercase tracking-[0.12em] ${ selectedTag === 'all' ? 'bg-slate-800 text-slate-100' : 'text-slate-300 hover:bg-neutral-800' } ` }
269+ >
270+ All tags
271+ </ button >
272+ { availableTags . map ( ( tag ) => (
273+ < button
274+ key = { tag . key }
275+ type = "button"
276+ onClick = { ( ) => {
277+ setSelectedTag ( tag . key )
278+ setIsFilterOpen ( false )
279+ } }
280+ className = { `block w-full px-3 py-2 text-left text-xs font-medium uppercase tracking-[0.12em] ${ selectedTag === tag . key ? 'bg-indigo-500/20 text-indigo-100' : 'text-slate-300 hover:bg-neutral-800' } ` }
281+ >
282+ { tag . label }
283+ </ button >
284+ ) ) }
285+ </ div >
286+ ) : null }
287+ </ div >
288+ ) : null }
289+ </ div >
195290 < div className = "space-y-3" >
196- { timedSchedule . map ( ( item ) => (
197- < ScheduleCard key = { item . id } item = { item } isActive = { item . status === 'live' } />
291+ { filteredSchedule . map ( ( item ) => (
292+ < ScheduleCard key = { item . id } item = { item } isActive = { item . status === 'live' } now = { now } />
198293 ) ) }
199294 </ div >
200- { timedSchedule . length === 0 && ! isLoading ? (
201- < p className = "text-slate-500 py-8" > No schedule items yet.</ p >
295+ { filteredSchedule . length === 0 && ! isLoading ? (
296+ < p className = "text-slate-500 py-8" >
297+ { selectedTag === 'all' ? 'No schedule items yet.' : 'No schedule items for this tag.' }
298+ </ p >
202299 ) : null }
203300 </ main >
204301 { isLoading ? (
0 commit comments