Skip to content

Commit dabecbe

Browse files
committed
feat: add regional channels browser page; fix nav mobile state, onScroll memoization, prefers-motion logic, slide-up animation props, guide index, image paths, geo private IP range, conduct URL trailing slash
1 parent 7b47750 commit dabecbe

7 files changed

Lines changed: 344 additions & 28 deletions

File tree

components/nav.js

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,9 @@ function Header({ unfixed, color, bgColor, dark, fixed, ...props }) {
150150
const [toggled, setToggled] = useState(false)
151151
const [mobile, setMobile] = useState(false)
152152

153-
const onScroll = () => {
154-
const newState = window.scrollY >= 16
155-
156-
setScrolled(newState)
157-
}
158-
153+
const onScroll=React.useCallback(()=>{
154+
setScrolled(window.scrollY>=16)
155+
},[])
159156
const handleToggleMenu = () => {
160157
setToggled((t) => !t)
161158
}
@@ -167,8 +164,8 @@ function Header({ unfixed, color, bgColor, dark, fixed, ...props }) {
167164
}
168165

169166
const mobileQuery = window.matchMedia('(max-width: 48em)')
170-
mobileQuery.addEventListener('change', () => {
171-
setMobile(true)
167+
mobileQuery.addEventListener('change', (e) => {
168+
setMobile(e.matches)
172169
setToggled(false)
173170
})
174171
}

components/slide-up.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ const SlideUp = ({ duration = 500, delay = 0, ...props }) => (
2020
{...props}
2121
style={{
2222
...(props.style || {}),
23-
animationDuration: 0 + 'ms',
24-
animationDelay: 0 + 'ms'
23+
animationDuration: duration + 'ms',
24+
animationDelay: delay + 'ms'
2525
}}
2626
/>
2727
)

components/slides/Slides.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const slideData = [
2424
downSlide: {
2525
id: 'conduct-content',
2626
type: 'fetch',
27-
url: 'https://hackclub.com/conduct'
27+
url: 'https://hackclub.com/conduct/'
2828
}
2929
},
3030
{
@@ -248,7 +248,7 @@ const ConductContent = ({ content, loading, error }) => (
248248
<Text sx={{ color: 'slate', fontSize: 2, mb: 2 }}>
249249
Could not load the Code of Conduct.
250250
</Text>
251-
<ThemeLink href="https://hackclub.com/conduct" target="_blank">
251+
<ThemeLink href="https://hackclub.com/conduct/" target="_blank">
252252
Read it on hackclub.com →
253253
</ThemeLink>
254254
</Box>
@@ -441,7 +441,7 @@ const Slides = ({ isOpen, onClose }) => {
441441

442442
window.addEventListener('popstate', handlePopState)
443443
return () => window.removeEventListener('popstate', handlePopState)
444-
}, [isOpen, currentX, currentY, onClose])
444+
}, [isOpen, handleAction, onClose])
445445

446446
useEffect(() => {
447447
if (isOpen) {

lib/use-prefers-motion.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ function usePrefersMotion() {
1212
React.useEffect(() => {
1313
const mediaQueryList = window.matchMedia(QUERY)
1414
const listener = (event) => {
15-
setPrefersMotion(!event.matches)
15+
setPrefersMotion(event.matches)
1616
}
1717
mediaQueryList.addEventListener('change', listener)
1818
return () => {

pages/api/geo.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export default async function handler(req, res) {
3939
}
4040

4141
// Reject requests where we still cannot determine a usable client IP.
42-
if (!ip || ip === '::1' || ip === '127.0.0.1' || ip.startsWith('192.168.') || ip.startsWith('10.')) {
42+
if (!ip || ip === '::1' || ip === '127.0.0.1' || ip.startsWith('192.168.') || ip.startsWith('10.') || /^172\.(1[6-9]|2\d|3[01])\./.test(ip)) {
4343
return res.status(400).json({ error: 'Could not determine client IP' })
4444
}
4545

pages/channels.js

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
/** @jsxImportSource theme-ui */
2+
import Meta from '@hackclub/meta'
3+
import Head from 'next/head'
4+
import { Box, Heading, Text, Link as ThemeLink, Input } from 'theme-ui'
5+
import { useState, useMemo } from 'react'
6+
import channels from '../channels.json'
7+
import Footer from '../components/footer'
8+
import ForceTheme from '../components/force-theme'
9+
import Nav from '../components/nav'
10+
11+
const formatName = (str) =>
12+
str.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
13+
14+
const getTypeLabel = (type) => {
15+
if (type === 'us-state') return 'US State'
16+
if (type === 'island') return 'Island'
17+
return 'Country'
18+
}
19+
20+
const ChannelCard = ({ channel }) => (
21+
<Box
22+
as="a"
23+
href={channel.url}
24+
target="_blank"
25+
rel="noopener noreferrer"
26+
sx={{
27+
display: 'flex',
28+
flexDirection: 'column',
29+
alignItems: 'center',
30+
justifyContent: 'center',
31+
gap: 2,
32+
p: 3,
33+
bg: 'white',
34+
borderRadius: '12px',
35+
border: '1px solid',
36+
borderColor: 'smoke',
37+
borderTop: '4px solid',
38+
borderTopColor: 'primary',
39+
textDecoration: 'none',
40+
transition: 'all 0.2s ease-in-out',
41+
cursor: 'pointer',
42+
'&:hover': {
43+
transform: 'translateY(-4px)',
44+
boxShadow: '0 8px 24px rgba(236, 55, 80, 0.15)',
45+
borderTopColor: 'orange'
46+
}
47+
}}
48+
>
49+
<Text
50+
sx={{
51+
fontWeight: 800,
52+
fontSize: '1rem',
53+
color: 'primary',
54+
textAlign: 'center',
55+
lineHeight: 1.3
56+
}}
57+
>
58+
#{channel.channel}
59+
</Text>
60+
<Text
61+
sx={{
62+
fontSize: '0.75rem',
63+
color: 'muted',
64+
fontWeight: 500,
65+
textTransform: 'uppercase',
66+
letterSpacing: '0.05em'
67+
}}
68+
>
69+
{getTypeLabel(channel.type)}
70+
</Text>
71+
</Box>
72+
)
73+
74+
const FilterButton = ({ active, onClick, children }) => (
75+
<Box
76+
as="button"
77+
onClick={onClick}
78+
sx={{
79+
px: 4,
80+
py: 2,
81+
borderRadius: '999px',
82+
border: '2px solid',
83+
borderColor: active ? 'primary' : 'smoke',
84+
bg: active ? 'primary' : 'white',
85+
color: active ? 'white' : 'slate',
86+
fontWeight: 700,
87+
fontSize: '0.9rem',
88+
cursor: 'pointer',
89+
fontFamily: 'inherit',
90+
transition: 'all 0.15s ease',
91+
'&:hover': {
92+
borderColor: 'primary',
93+
color: active ? 'white' : 'primary'
94+
}
95+
}}
96+
>
97+
{children}
98+
</Box>
99+
)
100+
101+
const ChannelsPage = () => {
102+
const [search, setSearch] = useState('')
103+
const [filter, setFilter] = useState('all')
104+
105+
const filtered = useMemo(() => {
106+
return channels.filter((c) => {
107+
const matchesSearch =
108+
c.channel.includes(search.toLowerCase()) ||
109+
c.match.includes(search.toLowerCase())
110+
const matchesFilter =
111+
filter === 'all' ||
112+
(filter === 'country' && c.type === 'country') ||
113+
(filter === 'us-state' && c.type === 'us-state') ||
114+
(filter === 'island' && c.type === 'island')
115+
return matchesSearch && matchesFilter
116+
})
117+
}, [search, filter])
118+
119+
const counts = useMemo(() => ({
120+
all: channels.length,
121+
country: channels.filter((c) => c.type === 'country').length,
122+
'us-state': channels.filter((c) => c.type === 'us-state').length,
123+
island: channels.filter((c) => c.type === 'island').length
124+
}), [])
125+
126+
return (
127+
<Box
128+
sx={{
129+
backgroundImage: 'url(/pattern.svg)',
130+
backgroundRepeat: 'repeat',
131+
backgroundAttachment: 'fixed',
132+
minHeight: '100vh',
133+
backgroundColor: 'snow'
134+
}}
135+
>
136+
<Meta
137+
as={Head}
138+
name="Regional Channels – Hack Club Slack"
139+
description="Browse all regional Hack Club Slack channels by country and US state. Find and join your local community!"
140+
/>
141+
<ForceTheme theme="light" />
142+
<Nav />
143+
144+
{/* Hero */}
145+
<Box
146+
sx={{
147+
backgroundImage: (t) => t.util.gx('orange', 'red'),
148+
pt: ['7rem', '8rem'],
149+
pb: ['3rem', '4rem'],
150+
px: ['1.5rem', '3rem'],
151+
textAlign: 'center'
152+
}}
153+
>
154+
<Heading
155+
as="h1"
156+
sx={{
157+
fontSize: ['2.5rem', '4rem'],
158+
color: 'white',
159+
fontWeight: 800,
160+
mb: 2,
161+
textShadow: '0 2px 12px rgba(0,0,0,0.2)'
162+
}}
163+
>
164+
Regional Channels
165+
</Heading>
166+
<Text
167+
sx={{
168+
color: 'white',
169+
fontSize: ['1rem', '1.25rem'],
170+
opacity: 0.9,
171+
mb: 3
172+
}}
173+
>
174+
Find and join your local Hack Club community on Slack
175+
</Text>
176+
<Text
177+
sx={{
178+
color: 'white',
179+
fontSize: '0.95rem',
180+
opacity: 0.75
181+
}}
182+
>
183+
{channels.length} channels across {counts.country} countries &amp; {counts['us-state']} US states
184+
</Text>
185+
</Box>
186+
187+
{/* Search + Filter */}
188+
<Box
189+
sx={{
190+
maxWidth: '1200px',
191+
mx: 'auto',
192+
px: ['1.5rem', '3rem'],
193+
py: ['1.5rem', '2rem']
194+
}}
195+
>
196+
<Box
197+
sx={{
198+
bg: 'white',
199+
borderRadius: '16px',
200+
p: ['1.25rem', '1.75rem'],
201+
boxShadow: 'card',
202+
border: '1px solid',
203+
borderColor: 'smoke',
204+
mb: ['1.5rem', '2rem']
205+
}}
206+
>
207+
<Input
208+
placeholder="Search channels..."
209+
value={search}
210+
onChange={(e) => setSearch(e.target.value)}
211+
sx={{
212+
mb: 3,
213+
fontSize: '1rem',
214+
borderRadius: '8px',
215+
border: '2px solid',
216+
borderColor: 'smoke',
217+
px: 3,
218+
py: 2,
219+
fontFamily: 'inherit',
220+
'&:focus': {
221+
borderColor: 'primary',
222+
outline: 'none'
223+
}
224+
}}
225+
/>
226+
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
227+
<FilterButton active={filter === 'all'} onClick={() => setFilter('all')}>
228+
All ({counts.all})
229+
</FilterButton>
230+
<FilterButton active={filter === 'country'} onClick={() => setFilter('country')}>
231+
Countries ({counts.country})
232+
</FilterButton>
233+
<FilterButton active={filter === 'us-state'} onClick={() => setFilter('us-state')}>
234+
US States ({counts['us-state']})
235+
</FilterButton>
236+
<FilterButton active={filter === 'island'} onClick={() => setFilter('island')}>
237+
Islands ({counts.island})
238+
</FilterButton>
239+
</Box>
240+
</Box>
241+
242+
{/* Results count */}
243+
<Text sx={{ color: 'muted', fontSize: '0.9rem', mb: 3, fontWeight: 500 }}>
244+
{filtered.length === 0
245+
? 'No channels found'
246+
: `Showing ${filtered.length} channel${filtered.length !== 1 ? 's' : ''}`}
247+
</Text>
248+
249+
{/* Grid */}
250+
{filtered.length > 0 ? (
251+
<Box
252+
sx={{
253+
display: 'grid',
254+
gridTemplateColumns: [
255+
'repeat(2, 1fr)',
256+
'repeat(3, 1fr)',
257+
'repeat(4, 1fr)',
258+
'repeat(5, 1fr)'
259+
],
260+
gap: ['0.75rem', '1rem']
261+
}}
262+
>
263+
{filtered.map((channel) => (
264+
<ChannelCard key={channel.id} channel={channel} />
265+
))}
266+
</Box>
267+
) : (
268+
<Box
269+
sx={{
270+
textAlign: 'center',
271+
py: '4rem',
272+
color: 'muted'
273+
}}
274+
>
275+
<Text sx={{ fontSize: '1.15rem', fontWeight: 600 }}>
276+
No channels match &quot;{search}&quot;
277+
</Text>
278+
<Text sx={{ fontSize: '0.95rem', mt: 1 }}>
279+
Try a different search term
280+
</Text>
281+
</Box>
282+
)}
283+
284+
{/* Back link */}
285+
<Box sx={{ textAlign: 'center', mt: ['3rem', '4rem'], mb: '2rem' }}>
286+
<ThemeLink
287+
href="/"
288+
sx={{
289+
color: 'primary',
290+
fontWeight: 700,
291+
fontSize: '1rem',
292+
textDecoration: 'none',
293+
'&:hover': { textDecoration: 'underline' }
294+
}}
295+
>
296+
Back to Hack Club Slack
297+
</ThemeLink>
298+
</Box>
299+
</Box>
300+
301+
<Footer />
302+
</Box>
303+
)
304+
}
305+
306+
export default ChannelsPage

0 commit comments

Comments
 (0)