Skip to content

Commit 98e4d24

Browse files
authored
Merge pull request #3 from danicostales/main
Add tag-based schedule filtering, refactor ScheduleCard, and update social links
2 parents ba79990 + 0118e69 commit 98e4d24

File tree

2 files changed

+166
-57
lines changed

2 files changed

+166
-57
lines changed

app/routes/index.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const SOCIAL_MASTODON = 'https://mastodon.social/@gpul_'
3838
const SOCIAL_BSKY = 'https://bsky.app/profile/gpul.org'
3939
const SOCIAL_LINKEDIN = 'https://www.linkedin.com/company/gpul'
4040
const SOCIAL_X = 'https://x.com/gpul_'
41+
const SOCIAL_INSTAGRAM = 'https://www.instagram.com/gpul_/'
4142

4243
function Header({ now }: { now: Date | null }) {
4344
return (
@@ -83,7 +84,7 @@ function StayConnected({ wifi }: { wifi: WifiInfo | null }) {
8384
rel="noreferrer"
8485
className="text-base md:text-lg font-semibold text-indigo-400 hover:text-indigo-300"
8586
>
86-
discord.gg/catyMZrF
87+
{DISCORD_URL.replace(/^https?:\/\//, '')}
8788
</a>
8889
</div>
8990
<div className="flex flex-col gap-1.5">
@@ -146,6 +147,17 @@ function StayConnected({ wifi }: { wifi: WifiInfo | null }) {
146147
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
147148
</svg>
148149
</a>
150+
<a
151+
href={SOCIAL_INSTAGRAM}
152+
target="_blank"
153+
rel="noopener noreferrer"
154+
className="text-slate-400 hover:text-slate-300 transition-colors"
155+
aria-label="Instagram"
156+
>
157+
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden>
158+
<path d="M7.75 2h8.5A5.75 5.75 0 0 1 22 7.75v8.5A5.75 5.75 0 0 1 16.25 22h-8.5A5.75 5.75 0 0 1 2 16.25v-8.5A5.75 5.75 0 0 1 7.75 2zm0 1.75A4 4 0 0 0 3.75 7.75v8.5a4 4 0 0 0 4 4h8.5a4 4 0 0 0 4-4v-8.5a4 4 0 0 0-4-4h-8.5zm8.9 1.35a1.2 1.2 0 1 1 0 2.4 1.2 1.2 0 0 1 0-2.4zM12 7a5 5 0 1 1 0 10 5 5 0 0 1 0-10zm0 1.75a3.25 3.25 0 1 0 0 6.5 3.25 3.25 0 0 0 0-6.5z" />
159+
</svg>
160+
</a>
149161
</div>
150162
</div>
151163
</div>

app/routes/schedule.tsx

Lines changed: 153 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Link } from 'react-router'
2-
import { useEffect, useMemo, useState } from 'react'
2+
import { useEffect, useMemo, useRef, useState } from 'react'
33
import ReactMarkdown from 'react-markdown'
4-
import { Clock, MapPin, Tag } from 'lucide-react'
4+
import { ChevronDown, Clock, Filter, MapPin, Tag } from 'lucide-react'
55
import { readItems, readSingleton } from '@directus/sdk'
66
import directus from '../lib/directus'
77
import {
@@ -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
162168
export 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

Comments
 (0)