Add experimental example of next-news (#9249)

* Add next-news example

* Update components

* Use getStaticProps

* Move directory
This commit is contained in:
Tim Neutkens 2019-10-30 14:05:45 +01:00 committed by Joe Haddad
parent 6707909690
commit 4c6e294ca2
30 changed files with 884 additions and 0 deletions

View file

@ -0,0 +1,2 @@
node_modules
.next

View file

@ -0,0 +1,23 @@
export default () => (
<div>
<textarea />
<button>add comment</button>
<style jsx>{`
textarea {
width: 400px;
height: 100px;
display: block;
margin-bottom: 10px;
}
button {
padding: 3px 4px;
}
@media (max-width: 750px) {
textarea {
width: 100%;
}
}
`}</style>
</div>
)

View file

@ -0,0 +1,84 @@
import timeAgo from '../lib/time-ago'
import React from 'react'
export default class Comment extends React.Component {
constructor (props) {
super(props)
this.state = {}
this.toggle = this.toggle.bind(this)
}
render () {
const { user, text, date, comments } = this.props
return (
<div className='comment'>
<div className='meta'>
{user} {timeAgo(new Date(date))} ago{' '}
<span onClick={this.toggle} className='toggle'>
{this.state.toggled
? `[+${(this.props.commentsCount || 0) + 1}]`
: '[-]'}
</span>
</div>
{this.state.toggled
? null
: [
<div
key='text'
className='text'
dangerouslySetInnerHTML={{ __html: text }}
/>,
<div key='children' className='children'>
{comments.map(comment => (
<Comment key={comment.id} {...comment} />
))}
</div>
]}
<style jsx>{`
.comment {
padding-top: 15px;
}
.children {
padding-left: 20px;
}
.meta {
font-size: 12px;
margin-bottom: 5px;
}
.toggle {
cursor: pointer;
}
.text {
color: #000;
font-size: 13px;
line-height: 18px;
}
/* hn styles */
.text :global(p) {
margin-top: 10px;
}
.text :global(pre) {
margin-bottom: 10px;
}
.text :global(a) {
color: #000;
}
`}</style>
</div>
)
}
toggle () {
this.setState({ toggled: !this.state.toggled })
}
}

View file

@ -0,0 +1,91 @@
import Nav from './nav'
import Logo from './logo'
import Link from 'next/link'
export default () => (
<header>
<div className='left'>
<Link href='/'>
<a>
<span className='logo'>
<Logo />
</span>
<span className='title'>Hacker Next</span>
</a>
</Link>
<div className='nav'>
<Nav />
</div>
</div>
<div className='right'>
<Link href='/login'>
<a className='login'>login</a>
</Link>
</div>
<style jsx>{`
header {
background: #ffa52a;
display: flex;
font-size: 14px;
}
.logo {
margin: 4px 5px 2px 4px;
display: inline-block;
}
.left {
flex: 9;
}
.right {
flex: 1;
text-align: right;
}
.title {
font-weight: bold;
display: inline-block;
font-size: 14px;
text-decoration: none;
padding: 8px 10px 8px 4px;
color: #000;
vertical-align: top;
}
a.login {
padding: 10px;
display: inline-block;
font-size: 11px;
text-transform: uppercase;
text-decoration: none;
color: #000;
}
.login:hover {
color: #fff;
}
.nav {
display: inline-block;
vertical-align: top;
}
@media (max-width: 750px) {
.title {
font-size: 16px;
padding-bottom: 0;
}
a.login {
padding: 24px 10px 23px;
}
.nav {
display: block;
}
}
`}</style>
</header>
)

View file

@ -0,0 +1,45 @@
import Story from '../components/story'
import Comment from '../components/comment'
import CommentForm from '../components/comment-form'
export default ({ story, comments = null }) => (
<div className='item'>
<Story {...story} />
<div className='form'>
<CommentForm />
</div>
<div className='comments'>
{comments ? (
comments.map(comment => <Comment key={comment.id} {...comment} />)
) : (
<div className='loading'>Loading</div>
)}
</div>
<style jsx>{`
.item {
padding: 10px 29px;
}
.form {
padding: 15px 0;
}
.loading {
font-size: 13px;
}
.comments {
padding: 10px 0 20px;
}
@media (max-width: 750px) {
.item {
padding: 8px 0px;
}
}
`}</style>
</div>
)

View file

@ -0,0 +1,27 @@
export default () => (
<div>
<h4>Login</h4>
<p>
username: <input type='text' />
<br />
password: <input type='password' />
</p>
<button>login</button>
<p>
<a href='#'>Forgot your password?</a>
</p>
<h4>Create Account</h4>
<p>
username: <input type='text' />
<br />
password: <input type='password' />
</p>
<button>create account</button>
<style jsx>{`
p {
line-height: 22px;
}
`}</style>
</div>
)

View file

@ -0,0 +1,15 @@
export default () => (
<span>
N
<style jsx>{`
span {
border: 1px solid #fff;
display: inline-block;
color: #fff;
font-weight: bold;
font-size: 11px;
padding: 5px 8px;
}
`}</style>
</span>
)

View file

@ -0,0 +1,56 @@
import Head from 'next/head'
import NProgress from 'nprogress'
import Router from 'next/router'
Router.onRouteChangeStart = () => NProgress.start()
Router.onRouteChangeComplete = () => NProgress.done()
Router.onRouteChangeError = () => NProgress.done()
export default () => (
<div>
<Head>
<meta name='viewport' content='width=device-width, initial-scale=1' />
<meta charSet='utf-8' />
</Head>
<style jsx global>{`
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
'Helvetica Neue', sans-serif;
background: #eee;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* loading progress bar styles */
#nprogress {
pointer-events: none;
}
#nprogress .bar {
background: #ff9300;
position: fixed;
z-index: 1031;
top: 0;
left: 0;
width: 100%;
height: 2px;
}
#nprogress .peg {
display: block;
position: absolute;
right: 0px;
width: 100px;
height: 100%;
box-shadow: 0 0 10px #ff9300, 0 0 5px #ff9300;
opacity: 1;
transform: rotate(3deg) translate(0px, -4px);
}
`}</style>
</div>
)

View file

@ -0,0 +1,44 @@
import Link from 'next/link'
export default () => (
<ul>
<Item href='/newest'>new</Item>
<Item href='/show'>show</Item>
<Item href='/ask'>ask</Item>
<Item href='/jobs'>jobs</Item>
<Item href='/submit'>submit</Item>
<style jsx>{`
ul {
list-style-type: none;
}
`}</style>
</ul>
)
const Item = ({ href, children }) => (
<li>
<Link href={href}>
<a>{children}</a>
</Link>
<style jsx>{`
li {
display: inline-block;
}
a {
display: inline-block;
padding: 10px;
font-size: 11px;
text-transform: uppercase;
text-decoration: none;
color: #000;
}
a:hover {
color: #fff;
}
`}</style>
</li>
)

View file

@ -0,0 +1,32 @@
import Header from './header'
import Meta from './meta'
export default ({ children }) => (
<div className='main'>
<Meta />
<Header />
<div className='page'>{children}</div>
<style jsx>{`
.main {
width: 85%;
margin: auto;
padding: 10px 0 0 0;
}
.page {
color: #828282;
background: #fff;
padding: 3px 10px;
}
@media (max-width: 750px) {
.main {
padding: 0;
width: auto;
}
}
`}</style>
</div>
)

View file

@ -0,0 +1,60 @@
import Story from './updating-story'
import Link from 'next/link'
export default ({ stories, page = 1, offset = null }) => (
<div>
{stories.map((story, i) => (
<div key={story.id} className='item'>
{offset != null ? (
<span className='count'>{i + offset + 1}</span>
) : null}
<div className='story'>
<Story {...story} />
</div>
</div>
))}
<footer className='footer'>
<Link href='/news/[id]' as={`/news/${page + 1}`}>
<a>More</a>
</Link>
</footer>
<style jsx>{`
.item {
display: flex;
margin: 10px 0;
}
.count {
flex-basis: auto;
flex-grow: 1;
vertical-align: top;
font-size: 14px;
padding-right: 5px;
display: block;
width: 20px;
text-align: right;
}
.count::after {
content: '.';
}
.story {
flex: 100;
display: inline-block;
}
.footer {
padding: 10px 0 40px 30px;
}
.footer a {
color: #000;
font-size: 14px;
display: inline-block;
text-decoration: none;
}
`}</style>
</div>
)

View file

@ -0,0 +1,80 @@
import Link from 'next/link'
import timeAgo from '../lib/time-ago'
import parse from 'url-parse'
export default ({ id, title, date, url, user, score, commentsCount }) => {
const { host } = parse(url)
return (
<div>
<div className='title'>
{url ? (
<a href={url}>{title}</a>
) : (
<Link href='/item/[id]' as={`/item/${id}`}>
<a>{title}</a>
</Link>
)}
{url && (
<span className='source'>
<a href={`http://${host}`}>{host.replace(/^www\./, '')}</a>
</span>
)}
</div>
<div className='meta'>
{score} {plural(score, 'point')} by{' '}
<Link href='/user/[id]' as={`/user/${user}`}>
<a>{user}</a>
</Link>{' '}
<Link href='/item/[id]' as={`/item/${id}`}>
<a>
{timeAgo(new Date(date)) /* note: we re-hydrate due to ssr */} ago
</a>
</Link>{' '}
|{' '}
<Link href='/item/[id]' as={`/item/${id}`}>
<a>
{commentsCount} {plural(commentsCount, 'comment')}
</a>
</Link>
</div>
<style jsx>{`
.title {
font-size: 15px;
margin-bottom: 3px;
}
.title > a {
color: #000;
text-decoration: none;
}
.title > a:visited {
color: #828282;
}
.meta {
font-size: 12px;
}
.source {
font-size: 12px;
display: inline-block;
margin-left: 5px;
}
.source a,
.meta a {
color: #828282;
text-decoration: none;
}
.source a:hover,
.meta a:hover {
text-decoration: underline;
}
`}</style>
</div>
)
}
const plural = (n, s) => s + (n === 0 || n > 1 ? 's' : '')

View file

@ -0,0 +1,22 @@
import React from 'react'
import Story from './story'
import { observe } from '../lib/get-item'
export default class extends React.Component {
constructor (props) {
super(props)
this.state = props
}
componentDidMount () {
this.unsubscribe = observe(this.props.id, data => this.setState(data))
}
componentWillUnmount () {
this.unsubscribe()
}
render () {
return <Story {...this.state} />
}
}

View file

@ -0,0 +1,17 @@
import firebase from 'firebase'
try {
firebase.initializeApp({
databaseURL: 'https://hacker-news.firebaseio.com'
})
} catch (err) {
// we skip the "already exists" message which is
// not an actual error when we're hot-reloading
if (!/already exists/.test(err.message)) {
console.error('Firebase initialization error', err.stack)
}
}
const root = firebase.database().ref('v0')
export default root

View file

@ -0,0 +1,22 @@
import db from './db'
// hydrate comments based on an array of item ids
export default function fetch (ids) {
return Promise.all(
ids.map(async id => {
const item = await db
.child('item')
.child(id)
.once('value')
const val = item.val()
return {
id: val.id,
user: val.by,
text: val.text,
date: new Date(val.time * 1000),
comments: await fetch(val.kids || []),
commentsCount: val.descendants
}
})
)
}

View file

@ -0,0 +1,36 @@
import db from './db'
export default async id => {
const item = await db
.child('item')
.child(id)
.once('value')
const val = item.val()
if (val) {
return transform(val)
} else {
return null
}
}
export function observe (id, fn) {
const onval = data => fn(transform(data.val()))
const item = db.child('item').child(id)
item.on('value', onval)
return () => item.off('value', onval)
}
export function transform (val) {
return {
id: val.id,
url: val.url,
user: val.by,
// time is seconds since epoch, not ms
date: new Date(val.time * 1000),
// sometimes `kids` is `undefined`
comments: val.kids || [],
commentsCount: val.descendants,
score: val.score,
title: val.title
}
}

View file

@ -0,0 +1,20 @@
import db from './db'
import { transform } from './get-item'
export default async (type = 'topstories', { page = 1, max = 30 } = {}) => {
const start = max * (page - 1)
const end = start + max - 1
const ids = await db.child(type).once('value')
const stories = await Promise.all(
ids
.val()
.slice(start, end)
.map(id =>
db
.child('item')
.child(id)
.once('value')
)
)
return stories.map(obj => transform(obj.val()))
}

View file

@ -0,0 +1,12 @@
import ms from 'ms'
const map = {
s: 'seconds',
ms: 'milliseconds',
m: 'minutes',
h: 'hours',
d: 'days'
}
export default date =>
ms(new Date() - date).replace(/[a-z]+/, str => ' ' + map[str])

View file

@ -0,0 +1,3 @@
{
"builds": [{ "src": "package.json", "use": "@now/next@canary" }]
}

View file

@ -0,0 +1,18 @@
{
"name": "next-news",
"license": "MIT",
"dependencies": {
"firebase": "^7.2.1",
"ms": "^2.1.2",
"next": "^9.1.2-canary.7",
"nprogress": "^0.2.0",
"react": "^16.10.2",
"react-dom": "^16.10.2",
"url-parse": "^1.4.7"
},
"scripts": {
"dev": "next",
"build": "next build",
"start": "next start"
}
}

View file

@ -0,0 +1,22 @@
import React from 'react'
import Page from '../components/page'
import Stories from '../components/stories'
import getStories from '../lib/get-stories'
// eslint-disable-next-line camelcase
export async function unstable_getStaticProps () {
const page = 1
const stories = await getStories('askstories', { page })
return { props: { page, stories } }
}
function Ask ({ page, stories }) {
const offset = (page - 1) * 30
return (
<Page>
<Stories page={page} offset={offset} stories={stories} />
</Page>
)
}
export default Ask

View file

@ -0,0 +1,22 @@
import React from 'react'
import Page from '../components/page'
import Stories from '../components/stories'
import getStories from '../lib/get-stories'
// eslint-disable-next-line camelcase
export async function unstable_getStaticProps () {
const page = 1
const stories = await getStories('topstories', { page })
return { props: { page, stories } }
}
function Home ({ page, stories }) {
const offset = (page - 1) * 30
return (
<Page>
<Stories page={page} offset={offset} stories={stories} />
</Page>
)
}
export default Home

View file

@ -0,0 +1,27 @@
import React, { useState, useEffect } from 'react'
import Page from '../../components/page'
import Item from '../../components/item'
import getItem from '../../lib/get-item'
import getComments from '../../lib/get-comments'
// eslint-disable-next-line camelcase
export async function unstable_getStaticProps ({ params }) {
const story = await getItem(params.id)
return { props: { story } }
}
function ItemPage ({ story }) {
const [comments, setComments] = useState(null)
useEffect(() => {
getComments(story.comments)
.then(comments => setComments(comments))
.catch(err => console.error(err))
}, [])
return (
<Page>
<Item story={story} comments={comments} />
</Page>
)
}
export default ItemPage

View file

@ -0,0 +1,21 @@
import React from 'react'
import Page from '../components/page'
import Stories from '../components/stories'
import getStories from '../lib/get-stories'
// eslint-disable-next-line camelcase
export async function unstable_getStaticProps () {
const page = 1
const stories = await getStories('jobstories', { page })
return { props: { page, stories } }
}
function Jobs ({ page, stories }) {
return (
<Page>
<Stories page={page} stories={stories} />
</Page>
)
}
export default Jobs

View file

@ -0,0 +1,7 @@
import LoginForm from '../components/login-form'
function Login () {
return <LoginForm />
}
export default Login

View file

@ -0,0 +1,22 @@
import React from 'react'
import Page from '../components/page'
import Stories from '../components/stories'
import getStories from '../lib/get-stories'
// eslint-disable-next-line camelcase
export async function unstable_getStaticProps () {
const page = 1
const stories = await getStories('newstories', { page })
return { props: { page, stories } }
}
function Newest ({ page, stories }) {
const offset = (page - 1) * 30
return (
<Page>
<Stories page={page} offset={offset} stories={stories} />
</Page>
)
}
export default Newest

View file

@ -0,0 +1,22 @@
import React from 'react'
import Page from '../../components/page'
import Stories from '../../components/stories'
import getStories from '../../lib/get-stories'
// eslint-disable-next-line camelcase
export async function unstable_getStaticProps ({ params }) {
const page = Number(params.id)
const stories = await getStories('topstories', { page })
return { props: { page, stories } }
}
function News ({ page, stories }) {
const offset = (page - 1) * 30
return (
<Page>
<Stories page={page} offset={offset} stories={stories} />
</Page>
)
}
export default News

View file

@ -0,0 +1,22 @@
import React from 'react'
import Page from '../components/page'
import Stories from '../components/stories'
import getStories from '../lib/get-stories'
// eslint-disable-next-line camelcase
export async function unstable_getStaticProps () {
const page = 1
const stories = await getStories('showstories', { page })
return { props: { page, stories } }
}
function Show ({ page, stories }) {
const offset = (page - 1) * 30
return (
<Page>
<Stories page={page} offset={offset} stories={stories} />
</Page>
)
}
export default Show

View file

@ -0,0 +1,9 @@
import React from 'react'
import LoginForm from '../components/login-form'
export default () => (
<div>
<p>You have to be logged in to submit</p>
<LoginForm />
</div>
)

View file

@ -0,0 +1 @@
export default () => <>user page (WIP)</>