mirror of
https://github.com/System-End/site.git
synced 2026-04-19 18:35:12 +00:00
Begin Scrapbook implementation (#53)
This commit is contained in:
parent
dc7038cde1
commit
90245c4fd7
7 changed files with 346 additions and 154 deletions
55
components/posts/emoji.js
Normal file
55
components/posts/emoji.js
Normal 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
219
components/posts/index.js
Normal 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(/&/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
|
||||
37
components/posts/mention.js
Normal file
37
components/posts/mention.js
Normal 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
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
176
pages/ship.js
176
pages/ship.js
|
|
@ -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 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 }
|
||||
}
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue