mirror of
https://github.com/System-End/site.git
synced 2026-04-19 15:18:18 +00:00
Add Ship page (#18)
* Create Ship page * Add wave SVG * Variety of frontend improvements
This commit is contained in:
parent
096e79e4a9
commit
c08a421a11
9 changed files with 489 additions and 4 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -2,4 +2,4 @@
|
|||
.next
|
||||
node_modules
|
||||
.DS_Store
|
||||
public
|
||||
public/home
|
||||
|
|
|
|||
26
components/fade-in.js
Normal file
26
components/fade-in.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react'
|
||||
import { Box } from 'theme-ui'
|
||||
import styled from '@emotion/styled'
|
||||
import { keyframes } from '@emotion/core'
|
||||
|
||||
const fadeIn = keyframes({ from: { opacity: 0 }, to: { opacity: 1 } })
|
||||
|
||||
const Wrapper = styled(Box)`
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
animation-name: ${fadeIn};
|
||||
animation-fill-mode: backwards;
|
||||
}
|
||||
`
|
||||
|
||||
const FadeIn = ({ duration = 300, delay = 0, ...props }) => (
|
||||
<Wrapper
|
||||
{...props}
|
||||
style={{
|
||||
...(props.style || {}),
|
||||
animationDuration: duration + 'ms',
|
||||
animationDelay: delay + 'ms'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
export default FadeIn
|
||||
17
components/ship/why.mdx
Normal file
17
components/ship/why.mdx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Card } from 'theme-ui'
|
||||
|
||||
<Card>
|
||||
|
||||
## Your first ship your first day.
|
||||
|
||||
Students in many traditional computer science classes are lucky to make a single project. At Hack Clubs, every member makes & ships their first website their very first meeting.
|
||||
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
|
||||
## Keeping your eyes on the prize.
|
||||
|
||||
Instead of learning programming concepts in isolation, learning by shipping means you focus on what you need to build real projects. It’s more fun & leads to better learning.
|
||||
|
||||
</Card>
|
||||
29
components/slide-up.js
Normal file
29
components/slide-up.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import React from 'react'
|
||||
import { Box } from 'theme-ui'
|
||||
import styled from '@emotion/styled'
|
||||
import { keyframes } from '@emotion/core'
|
||||
|
||||
const slideUp = keyframes({
|
||||
from: { transform: 'translateY(100%)', opacity: 0 },
|
||||
to: { transform: 'translateY(0)', opacity: 1 }
|
||||
})
|
||||
|
||||
const Wrapper = styled(Box)`
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
animation-name: ${slideUp};
|
||||
animation-fill-mode: backwards;
|
||||
}
|
||||
`
|
||||
|
||||
const SlideUp = ({ duration = 300, delay = 0, ...props }) => (
|
||||
<Wrapper
|
||||
{...props}
|
||||
style={{
|
||||
...(props.style || {}),
|
||||
animationDuration: duration + 'ms',
|
||||
animationDelay: delay + 'ms'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
export default SlideUp
|
||||
92
components/stat.js
Normal file
92
components/stat.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { Flex, Text } from 'theme-ui'
|
||||
import { isEmpty } from 'lodash'
|
||||
|
||||
const Stat = ({
|
||||
value,
|
||||
label,
|
||||
unit = '',
|
||||
color = 'text',
|
||||
of,
|
||||
center = false,
|
||||
reversed = false,
|
||||
half = false,
|
||||
lg = false,
|
||||
...props
|
||||
}) => (
|
||||
<Flex
|
||||
{...props}
|
||||
sx={{
|
||||
fontFamily: 'heading',
|
||||
flexDirection: reversed ? 'column-reverse' : 'column',
|
||||
gridColumn: lg
|
||||
? ['initial', 'span 1']
|
||||
: half
|
||||
? 'span 1 !important'
|
||||
: 'initial',
|
||||
lineHeight: 1,
|
||||
...props.sx
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
justifyContent: center ? 'center' : 'start',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
as="span"
|
||||
sx={{
|
||||
color,
|
||||
fontSize: lg ? [5, 6, 7] : [4, 5, 6],
|
||||
fontWeight: 'heading',
|
||||
letterSpacing: 'title',
|
||||
my: 0
|
||||
}}
|
||||
children={value || '—'}
|
||||
/>
|
||||
{!isEmpty(unit) && (
|
||||
<Text
|
||||
as="sup"
|
||||
sx={{
|
||||
fontSize: lg ? [2, 3] : [1, 2],
|
||||
color: color === 'text' ? 'secondary' : color,
|
||||
ml: [null, unit === '%' ? 1 : null],
|
||||
mr: [null, 1],
|
||||
pt: [null, 1]
|
||||
}}
|
||||
children={unit}
|
||||
/>
|
||||
)}
|
||||
{!isEmpty(of) && (
|
||||
<Text
|
||||
as="sup"
|
||||
sx={{
|
||||
fontSize: lg ? [2, 3] : [1, 2],
|
||||
color: 'muted',
|
||||
ml: [null, 1],
|
||||
pt: [null, 1],
|
||||
'::before': {
|
||||
content: '"/"'
|
||||
}
|
||||
}}
|
||||
children={of}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
{!isEmpty(label) && (
|
||||
<Text
|
||||
as="span"
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontSize: lg ? [1, 2, 3] : [0, 1],
|
||||
letterSpacing: 'headline',
|
||||
textTransform: 'uppercase'
|
||||
}}
|
||||
children={label}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
)
|
||||
|
||||
export default Stat
|
||||
|
|
@ -10,8 +10,8 @@ export const tinyDt = d =>
|
|||
export const timeSince = (
|
||||
previous,
|
||||
absoluteDuration = false,
|
||||
current = new Date().toISOString(),
|
||||
longForm = false
|
||||
longForm = false,
|
||||
current = new Date().toISOString()
|
||||
) => {
|
||||
const msPerMinute = 60 * 1000
|
||||
const msPerHour = msPerMinute * 60
|
||||
|
|
@ -22,7 +22,7 @@ export const timeSince = (
|
|||
|
||||
const future = new Date(previous) - new Date(current)
|
||||
const past = new Date(current) - new Date(previous)
|
||||
const elapsed = [future, past].sort()[0]
|
||||
const elapsed = [future, past].sort()[1]
|
||||
|
||||
let humanizedTime
|
||||
if (elapsed < msPerMinute) {
|
||||
|
|
|
|||
18
lib/theme.js
18
lib/theme.js
|
|
@ -104,5 +104,23 @@ theme.forms.labelCheckbox = {
|
|||
theme.layout.copy.maxWidth = [null, null, 'copyPlus']
|
||||
|
||||
theme.text.lead = {}
|
||||
theme.text.eyebrow = {
|
||||
color: 'muted',
|
||||
fontSize: [3, 4],
|
||||
fontWeight: 'heading',
|
||||
letterSpacing: 'headline',
|
||||
lineHeight: 'subheading',
|
||||
textTransform: 'uppercase',
|
||||
mt: 0,
|
||||
mb: 2
|
||||
}
|
||||
|
||||
theme.text.titleUltra = {
|
||||
...theme.text.title,
|
||||
fontSize: [5, 6, 7],
|
||||
lineHeight: 0.875
|
||||
}
|
||||
|
||||
theme.text.subtitle.mt = 3
|
||||
|
||||
export default theme
|
||||
|
|
|
|||
296
pages/ship.js
Normal file
296
pages/ship.js
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Card,
|
||||
Grid,
|
||||
Heading,
|
||||
Image,
|
||||
Text,
|
||||
Flex
|
||||
} from 'theme-ui'
|
||||
import NextLink from 'next/link'
|
||||
import Head from 'next/head'
|
||||
import Meta from '@hackclub/meta'
|
||||
import Nav from '../components/nav'
|
||||
import SlideUp from '../components/slide-up'
|
||||
import Why from '../components/ship/why.mdx'
|
||||
import Icon from '../components/icon'
|
||||
import Stat from '../components/stat'
|
||||
import Footer from '../components/footer'
|
||||
import { timeSince } from '../lib/dates'
|
||||
import { orderBy, filter, take, map, uniq, reverse } from 'lodash'
|
||||
import { keyframes } from '@emotion/core'
|
||||
|
||||
const ShipBadge = props => (
|
||||
<Badge
|
||||
as="mark"
|
||||
sx={{
|
||||
bg: 'orange',
|
||||
color: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
display: 'inline-block',
|
||||
borderRadius: 'default',
|
||||
px: [2, 3],
|
||||
py: 1,
|
||||
...props.sx
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
const Ship = ({ timestamp, message, url, img, username, avatar }) => (
|
||||
<Card as="section" p={[0, 0]} sx={{ width: '100%' }}>
|
||||
{img && (
|
||||
<Image
|
||||
src={img}
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxHeight: 16 * 16,
|
||||
bg: 'snow',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box p={3}>
|
||||
<Text
|
||||
as="p"
|
||||
title={message}
|
||||
sx={{
|
||||
display: message ? null : 'none',
|
||||
fontSize: 2,
|
||||
lineHeight: 'caption',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
overflowY: 'hidden',
|
||||
maxWidth: '100%',
|
||||
'@supports (-webkit-line-clamp: 4)': {
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: ['6', null, '8'],
|
||||
WebkitBoxOrient: 'vertical'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
<Box
|
||||
as="footer"
|
||||
sx={{
|
||||
mt: 2,
|
||||
display: 'grid',
|
||||
gridGap: [2, null, 3],
|
||||
gridTemplateColumns: [null, null, '1fr auto'],
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<Flex sx={{ alignItems: 'center' }}>
|
||||
{avatar && (
|
||||
<Avatar size={48} src={avatar} alt={`${username} avatar`} mr={2} />
|
||||
)}
|
||||
<Box sx={{ flex: '1 1 auto' }}>
|
||||
<Text
|
||||
as="strong"
|
||||
variant="caption"
|
||||
sx={{ color: 'secondary', display: 'block' }}
|
||||
>
|
||||
{username}
|
||||
</Text>
|
||||
<Text as="time" variant="caption" sx={{ lineHeight: 'title' }}>
|
||||
{timeSince(new Date(timestamp), false, true)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
{url && !url?.includes('hackclub.slack.com') && (
|
||||
<Button as="a" href={url} sx={{ bg: 'cyan', svg: { ml: -1 } }}>
|
||||
{url.includes('slack-files') ? (
|
||||
<>
|
||||
<Icon glyph="attachment" size={24} />
|
||||
View file
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon glyph="link" size={24} />
|
||||
<Text as="span" sx={{ textTransform: 'lowercase' }}>
|
||||
{
|
||||
url
|
||||
.replace(/https?:\/\//, '')
|
||||
.replace('www.', '')
|
||||
.split(/[/?#]/)[0]
|
||||
}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
)
|
||||
|
||||
const waves = keyframes({
|
||||
'0%': { backgroundPositionX: '0' },
|
||||
'100%': { backgroundPositionX: '-100%' }
|
||||
})
|
||||
|
||||
export default ({ ships, stats }) => (
|
||||
<>
|
||||
<Meta
|
||||
as={Head}
|
||||
name="Ship"
|
||||
description={`Hack Clubbers ship projects: a real-time list of the ${stats.projects} projects created by the Hack Club high school community in the last month.`}
|
||||
image="https://assets.hackclub.com/log/2020-05-22-ship.png"
|
||||
/>
|
||||
<Nav />
|
||||
<Box
|
||||
as="header"
|
||||
sx={{
|
||||
bg: 'blue',
|
||||
backgroundImage: t =>
|
||||
`linear-gradient(to bottom, ${t.colors.cyan}, ${t.colors.blue})`,
|
||||
color: 'white',
|
||||
textAlign: 'center',
|
||||
pt: [5, 6],
|
||||
pb: [3, 4]
|
||||
}}
|
||||
>
|
||||
<Container
|
||||
sx={{
|
||||
maxWidth: [null, null, 'copyPlus', 'copyUltra'],
|
||||
p: { fontSize: [2, 3, 4], maxWidth: 'copy', mx: 'auto' }
|
||||
}}
|
||||
>
|
||||
<Text variant="eyebrow" sx={{ color: 'white', opacity: 0.875 }}>
|
||||
All aboard!
|
||||
</Text>
|
||||
<Heading as="h1" variant="titleUltra" sx={{ mb: [3, 4] }}>
|
||||
Hack Clubbers focus on one thing: <ShipBadge>shipping.</ShipBadge>
|
||||
</Heading>
|
||||
<Text as="p" variant="subtitle">
|
||||
After building a project, like an app or website, “shipping” is
|
||||
publishing & sharing it online.
|
||||
</Text>
|
||||
</Container>
|
||||
<SlideUp duration={750}>
|
||||
<Grid
|
||||
as="section"
|
||||
columns={[null, null, 2]}
|
||||
gap={[3, 4]}
|
||||
variant="layout.container"
|
||||
sx={{
|
||||
mt: [3, 4, 5],
|
||||
textAlign: 'left',
|
||||
div: { p: [3, 4] },
|
||||
h2: { variant: 'text.headline', color: 'blue', mt: 0, mb: 2 },
|
||||
p: { fontSize: 2, my: 0 }
|
||||
}}
|
||||
>
|
||||
<Why />
|
||||
</Grid>
|
||||
</SlideUp>
|
||||
</Box>
|
||||
<Box
|
||||
as="section"
|
||||
id="projects"
|
||||
sx={{
|
||||
bg: 'blue',
|
||||
color: 'white',
|
||||
py: [4, 5],
|
||||
backgroundImage: 'url(/ship/wave.svg)',
|
||||
backgroundSize: '200% auto',
|
||||
'@media (prefers-reduced-motion: no-preference)': {
|
||||
animation: `${waves} 15s linear forwards infinite`
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Grid
|
||||
as="header"
|
||||
columns={[2, null, 4]}
|
||||
gap={[3, 4]}
|
||||
variant="layout.container"
|
||||
sx={{
|
||||
mt: [2, 4],
|
||||
textAlign: 'left',
|
||||
span: { color: 'white' }
|
||||
}}
|
||||
>
|
||||
<Heading as="h2" variant="title" sx={{ my: 0, gridColumn: 'span 2' }}>
|
||||
In the last month on Hack Club…
|
||||
</Heading>
|
||||
<Stat value={stats.projects} label="Projects shipped" lg />
|
||||
<Stat value={stats.makers} label="Makers" lg />
|
||||
</Grid>
|
||||
<Grid
|
||||
as="article"
|
||||
gap={[3, null, null, 4]}
|
||||
p={[3, null, null, 4]}
|
||||
variant="layout.wide"
|
||||
sx={{
|
||||
alignItems: 'start',
|
||||
gridTemplateColumns: [
|
||||
null,
|
||||
'repeat(2,minmax(0, 1fr))',
|
||||
'repeat(3,minmax(0, 1fr))'
|
||||
],
|
||||
'> div': { width: '100%' }
|
||||
}}
|
||||
>
|
||||
{ships.map(s => (
|
||||
<Ship key={s.timestamp} {...s} />
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
<Box
|
||||
as="section"
|
||||
sx={{
|
||||
color: 'black',
|
||||
bg: 'white',
|
||||
py: [4, 5]
|
||||
}}
|
||||
>
|
||||
<Container variant="copy" sx={{ textAlign: 'center' }}>
|
||||
<Icon glyph="message-new" size={72} sx={{ color: 'blue' }} />
|
||||
<Heading as="h2" variant="headline" mt={0}>
|
||||
Want to ship your own projects?
|
||||
</Heading>
|
||||
<Text variant="subtitle" sx={{ lineHeight: 'caption', mb: 3 }}>
|
||||
These projects are streamed live from the #ship channel on the
|
||||
Hack Club Slack, where 9k teenagers from around the world share
|
||||
what they’re working on & help each other.
|
||||
</Text>
|
||||
<NextLink href="/" passHref>
|
||||
<Button bg="red" as="a">
|
||||
Learn more
|
||||
</Button>
|
||||
</NextLink>
|
||||
</Container>
|
||||
</Box>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
|
||||
export const getStaticProps = async () => {
|
||||
const ships = await fetch('http://api2.hackclub.com/v0.1/Ships/Ships')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const monthAgo = new Date().getTime() - 30 * 24 * 60 * 60 * 1000
|
||||
return filter(data, s => new Date(s.fields.Timestamp) > monthAgo)
|
||||
})
|
||||
.then(data =>
|
||||
data.map(({ fields }) => ({
|
||||
timestamp: fields['Timestamp'] || new Date().toISOString(),
|
||||
avatar: fields['User Avatar'] || null,
|
||||
username: fields['User Name'] || '@unknown',
|
||||
message: fields['Message'] || '',
|
||||
url: fields['Project URL'] || null,
|
||||
img: fields['Image URL'] || null
|
||||
}))
|
||||
)
|
||||
.then(data => orderBy(data, { timestamp: 'desc' }))
|
||||
const stats = {
|
||||
projects: ships.length,
|
||||
makers: uniq(map(ships, 'username')).length
|
||||
}
|
||||
return { props: { ships, stats }, unstable_revalidate: 1 }
|
||||
}
|
||||
7
public/ship/wave.svg
Normal file
7
public/ship/wave.svg
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1500" height="1200">
|
||||
<g id="y"><g id="x">
|
||||
<path id="w" fill="none" stroke="#42a1ec" stroke-width="66" d="m-115,50q38-30 75,0t75,0 75,0 75,0 75,0 75,0 75,0 75,0 75,0 75,0 75,0 75,0 75,0 75,0 75,0 75,0 75,0 75,0 75,0 75,0 75,0 75,0"/>
|
||||
<use xlink:href="#w" transform="translate(0,146)"/></g>
|
||||
<use xlink:href="#x" transform="translate(0,292)"/></g>
|
||||
<use xlink:href="#y" transform="translate(0,584)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 493 B |
Loading…
Add table
Reference in a new issue