Add Ship page (#18)

* Create Ship page

* Add wave SVG

* Variety of frontend improvements
This commit is contained in:
Lachlan Campbell 2020-05-24 12:12:26 -04:00 committed by GitHub
parent 096e79e4a9
commit c08a421a11
9 changed files with 489 additions and 4 deletions

2
.gitignore vendored
View file

@ -2,4 +2,4 @@
.next .next
node_modules node_modules
.DS_Store .DS_Store
public public/home

26
components/fade-in.js Normal file
View 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
View 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. Its more fun & leads to better learning.
</Card>

29
components/slide-up.js Normal file
View 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
View 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

View file

@ -10,8 +10,8 @@ export const tinyDt = d =>
export const timeSince = ( export const timeSince = (
previous, previous,
absoluteDuration = false, absoluteDuration = false,
current = new Date().toISOString(), longForm = false,
longForm = false current = new Date().toISOString()
) => { ) => {
const msPerMinute = 60 * 1000 const msPerMinute = 60 * 1000
const msPerHour = msPerMinute * 60 const msPerHour = msPerMinute * 60
@ -22,7 +22,7 @@ export const timeSince = (
const future = new Date(previous) - new Date(current) const future = new Date(previous) - new Date(current)
const past = new Date(current) - new Date(previous) const past = new Date(current) - new Date(previous)
const elapsed = [future, past].sort()[0] const elapsed = [future, past].sort()[1]
let humanizedTime let humanizedTime
if (elapsed < msPerMinute) { if (elapsed < msPerMinute) {

View file

@ -104,5 +104,23 @@ theme.forms.labelCheckbox = {
theme.layout.copy.maxWidth = [null, null, 'copyPlus'] theme.layout.copy.maxWidth = [null, null, 'copyPlus']
theme.text.lead = {} 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 export default theme

296
pages/ship.js Normal file
View 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&nbsp;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&nbsp;Club Slack, where 9k teenagers from around the world share
what theyre 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
View 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