Begin Scrapbook implementation (#53)

This commit is contained in:
Lachlan Campbell 2020-11-13 17:33:42 -05:00 committed by GitHub
parent dc7038cde1
commit 90245c4fd7
7 changed files with 346 additions and 154 deletions

55
components/posts/emoji.js Normal file
View file

@ -0,0 +1,55 @@
import { memo, useState, useEffect } from 'react'
import Image from 'next/image'
const stripColons = str => {
const colonIndex = str.indexOf(':')
if (colonIndex > -1) {
// :emoji:
if (colonIndex === str.length - 1) {
str = str.substring(0, colonIndex)
return stripColons(str)
} else {
str = str.substr(colonIndex + 1)
return stripColons(str)
}
}
return str
}
export const EmojiImg = ({ name, ...props }) => (
<Image
alt={name + ' emoji'}
loading="lazy"
className="post-emoji"
width={128}
height={128}
{...props}
/>
)
const CustomEmoji = memo(({ name }) => {
const emoji = stripColons(name)
let [image, setImage] = useState()
useEffect(() => {
try {
fetch('https://scrapbook.hackclub.com/api/emoji')
.then(r => r.json())
.then(emojis => {
if (emojis[emoji]) {
setImage(emojis[emoji])
return
}
setImage(
'https://emoji.slack-edge.com/T0266FRGM/parrot/c9f4fddc5e03d762.gif'
)
})
} catch (e) {}
}, [])
return image ? (
<EmojiImg className="post-emoji" src={image} name={emoji} />
) : (
<span>:{emoji}:</span>
)
})
export default CustomEmoji

219
components/posts/index.js Normal file
View file

@ -0,0 +1,219 @@
import { Button, Box, Card, Text, Grid, Avatar, Flex } from 'theme-ui'
import { formatDate } from '../../lib/dates'
import { Fragment, memo } from 'react'
import { last, filter } from 'lodash'
import Masonry from 'react-masonry-css'
import Image from 'next/image'
import Mention from './mention'
import Emoji from './emoji'
const dataDetector = /(<.+?\|?\S+>)|(@\S+)|(`{3}[\S\s]+`{3})|(`[^`]+`)|(_[^_]+_)|(\*[^\*]+\*)|(:[^ .,;`\u2013~!@#$%^&*(){}=\\:"<>?|A-Z]+:)/
export const formatText = text =>
text.split(dataDetector).map((chunk, i) => {
if (chunk?.startsWith(':') && chunk?.endsWith(':')) {
return <Emoji name={chunk} key={i} />
}
if (chunk?.startsWith('@') || chunk?.startsWith('<@')) {
const punct = /([,!:.'"’”]|s|'s|\))+$/g
const username = chunk.replace(/[@<>]/g, '').replace(punct, '')
return (
<Fragment key={i}>
<Mention username={username} />
{chunk.match(punct)}
</Fragment>
)
}
if (chunk?.startsWith('<')) {
const parts = chunk.match(/<(([^\|]+)\|)?([^>]+?)>/)
const url = parts?.[2] || last(parts)
const children = last(parts)
?.replace(/https?:\/\//, '')
.replace(/\/$/, '')
return (
<a href={url} target="_blank" rel="noopener" key={i}>
{children}
</a>
)
}
if (chunk?.startsWith('```')) {
return <pre key={i}>{chunk.replace(/```/g, '')}</pre>
}
if (chunk?.startsWith('`')) {
return <code key={i}>{chunk.replace(/`/g, '')}</code>
}
if (chunk?.startsWith('*')) {
return <strong key={i}>{chunk.replace(/\*/g, '')}</strong>
}
if (chunk?.startsWith('_')) {
return <i key={i}>{chunk.replace(/_/g, '')}</i>
}
return <Fragment key={i}>{chunk?.replace(/&amp;/g, '&')}</Fragment>
})
const Post = ({
id = new Date().toISOString(),
profile = false,
user = {
username: 'abc',
avatar: '',
streakDisplay: false,
streakCount: 0
},
text,
attachments = [],
postedAt
}) => (
<Card className="post" sx={{ p: [3, 3], width: '100%', bg: 'elevated' }}>
<Flex
as="a"
href={`https://scrapbook.hackclub.com/${user.username}`}
sx={{
color: 'inherit',
textDecoration: 'none',
alignItems: 'center',
mb: 2
}}
>
<Avatar loading="lazy" src={user.avatar} alt={user.username} mr={2} />
<Box>
<Text variant="subheadline" my={0} fontSize={[1, 1]}>
@{user.username}
</Text>
<Text as="time" variant="caption" fontSize={0}>
{formatDate(postedAt)}
</Text>
</Box>
</Flex>
<Text
as="p"
sx={{
fontSize: 2,
a: {
color: 'primary',
wordBreak: 'break-all',
wordWrap: 'break-word'
},
'> div': { width: 18, height: 18 }
}}
>
{formatText(text)}
</Text>
{attachments.length > 0 && (
<>
<Grid
gap={2}
columns={2}
sx={{
alignItems: 'center',
textAlign: 'center',
mt: 2,
'> div': {
maxWidth: '100%',
maxHeight: 256,
bg: 'sunken',
gridColumn: attachments.length === 1 ? 'span 2' : null
},
img: { objectFit: 'contain' }
}}
>
{filter(attachments, a => a.type.startsWith('image')).map(img => (
<Image
key={img.url}
alt={img.filename}
src={img.thumbnails?.large?.url || img.url}
width={img.thumbnails?.large?.width}
height={img.thumbnails?.large?.height}
layout="responsive"
/>
))}
</Grid>
</>
)}
</Card>
)
const Posts = ({ data = [] }) => (
<Box as="section" sx={{ position: 'relative' }}>
<Masonry
breakpointCols={{
10000: 4,
1024: 3,
640: 2,
480: 1,
default: 1
}}
className="masonry-posts"
columnClassName="masonry-posts-column"
>
{data.map(post => (
<Post key={post.id} {...post} />
))}
</Masonry>
<Box
sx={{
paddingBottom: '30px',
textAlign: 'center'
}}
>
<Text as="p" variant="headline" sx={{ color: 'white', mb: 3 }}>
These are just a few posts
</Text>
<Button as="a" variant="cta" href="https://scrapbook.hackclub.com/">
Keep exploring
</Button>
</Box>
<style>{`
.masonry-posts {
display: flex;
width: 100%;
max-width: 100%;
}
.masonry-posts-column {
background-clip: padding-box;
}
.post {
margin-bottom: 16px;
}
@media (max-width: 32em) {
.post:nth-child(8) ~ .post {
display: none;
}
}
@media (min-width: 32em) {
.masonry-posts {
padding-right: 12px;
}
.masonry-posts-column {
padding-left: 12px;
}
.post {
border-radius: 12px;
margin-bottom: 12px;
}
}
@media (min-width: 64em) {
.masonry-posts {
padding-right: 24px;
}
.masonry-posts-column {
padding-left: 24px;
}
.post {
margin-bottom: 24px;
}
}
`}</style>
</Box>
)
export default Posts

View file

@ -0,0 +1,37 @@
import { Link, Avatar } from 'theme-ui'
import { memo, useState, useEffect } from 'react'
import { trim } from 'lodash'
const Mention = memo(({ username }) => {
const [img, setImg] = useState(null)
useEffect(() => {
try {
fetch(`https://scrapbook.hackclub.com/api/profiles/${trim(username)}`)
.then(r => r.json())
.then(profile => setImg(profile.avatar))
} catch (e) {}
}, [])
return (
<Link
href={`https://scrapbook.hackclub.com/${username}`}
sx={{
display: 'inline-flex',
alignItems: 'baseline',
textDecoration: 'none'
}}
>
{img && (
<Avatar
src={img}
alt={username}
width={24}
height={24}
sx={{ mr: 1, alignSelf: 'flex-end' }}
/>
)}
@{username}
</Link>
)
})
export default Mention

View file

@ -3,7 +3,12 @@ module.exports = withMDX({
trailingSlash: true,
pageExtensions: ['js', 'jsx', 'mdx'],
images: {
domains: ['hackclub.com', 'dl.airtable.com', 'cdn.glitch.com']
domains: [
'hackclub.com',
'dl.airtable.com',
'emoji.slack-edge.com',
'cdn.glitch.com'
]
},
webpack: (config, { isServer }) => {
if (isServer) require('./lib/sitemap')

View file

@ -22,6 +22,7 @@
"next": "^10.0.2-canary.2",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-masonry-css": "^1.0.14",
"react-reveal": "^1.2.2",
"react-scrolllock": "^5.0.1",
"react-use-websocket": "2.2.0",

View file

@ -18,7 +18,7 @@ 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 Posts from '../components/posts'
import Footer from '../components/footer'
import { timeSince } from '../lib/dates'
import { orderBy, filter, take, map, uniq, reverse } from 'lodash'
@ -41,107 +41,17 @@ const ShipBadge = 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 ({ stats = {} }) => (
const ShipPage = ({ posts = [] }) => (
<>
<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.`}
description={`Hack Clubbers ship projects: a real-time list of the 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 />
@ -191,14 +101,13 @@ export default ({ stats = {} }) => (
</Grid>
</SlideUp>
</Box>
{/*
<Box
as="section"
id="projects"
sx={{
bg: 'blue',
color: 'white',
py: [4, 5],
py: 4,
backgroundImage: 'url(/ship/wave.svg)',
backgroundSize: '200% auto',
'@media (prefers-reduced-motion: no-preference)': {
@ -206,44 +115,15 @@ export default ({ stats = {} }) => (
}
}}
>
<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={{ px: 3, mb: 4, textAlign: 'center' }}
>
<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>
Recently shipped
</Heading>
<Posts data={posts} />
</Box>
*/}
<Box
as="section"
sx={{
@ -272,29 +152,19 @@ export default ({ stats = {} }) => (
</>
)
/*
export default ShipPage
export const getStaticProps = async () => {
const ships = await fetch('https://airbridge.hackclub.com/v0.1/Ships/Ships')
const posts = await fetch('https://scrapbook.hackclub.com/api/r/ship')
.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(posts =>
filter(posts, p =>
['image/jpg', 'image/jpeg', 'image/png'].includes(
p.attachments?.[0]?.type
)
)
)
.then(data => orderBy(data, { timestamp: 'desc' }))
const stats = {
projects: ships.length,
makers: uniq(map(ships, 'username')).length
}
return { props: { stats }, revalidate: 1 }
.then(posts => orderBy(posts, 'postedAt', 'desc'))
.then(posts => take(posts, 24))
return { props: { posts }, revalidate: 2 }
}
*/

View file

@ -4292,6 +4292,11 @@ react-is@16.13.1, react-is@^16.8.1:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-masonry-css@^1.0.14:
version "1.0.14"
resolved "https://registry.yarnpkg.com/react-masonry-css/-/react-masonry-css-1.0.14.tgz#2ac1ca7bb2c7e96826f7da3accc9e95ae12b2f65"
integrity sha512-oAPVOCMApTT0HkxZJy84yU1EWaaQNZnJE0DjDMy/L+LxZoJEph4RRXsT9ppPKbFSo/tCzj+cCLwiBHjZmZ2eXA==
react-refresh@0.8.3:
version "0.8.3"
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f"