<div id="root"></div>
<script src="https://unpkg.com/preact@8.4.2/dist/preact.min.js"></script>
<script src="https://unpkg.com/prop-types@15.5.8/prop-types.min.js"></script>
<script src="https://unpkg.com/preact-compat@3.17.0/dist/preact-compat.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.14/lodash.min.js"></script>
<script>
// 😔 react-router is compatible without compat only for alpha and beta release;
// -> from react-router@v4.0.0-beta.5 preact-compat is needed
// 😔 with latest version of preact-compat v3.19.0, createContext triggers an error;
// -> v3.17.0 and earlier are working fine.
var React = preactCompat, ReactDOM = preactCompat;
</script>
<script src="https://unpkg.com/react-router-dom@5.0.1/umd/react-router-dom.js"></script>
:root {
--background: #ffffff;
--cs-primary: #25b9f4;
--cs-surface: #ffffff;
--cs-surface-2: #eef4f8;
--cs-separator: #e8ecf0;
--cs-on-surface-primary: #000000;
--cs-on-surface-secondary: #657786;
--font-family-primary: 'DM Sans', Aria, sans-serif;
--joke-author-img-width: 60px;
--joke-width: 480px;
--layout-container-width: 920px;
}
* {
box-sizing: border-box;
}
html,
body {
background: var(--background);
font-family: var(--font-family-primary);
font-size: 14px;
line-height: 1.42;
height: 100%;
}
#root {
height: 100%;
}
.joke {
background-color: var(--cs-surface);
padding: 15px;
min-height: calc(33px + var(--joke-author-img-width));
transition: background-color .25s;
}
.joke:focus,
.joke:hover {
background-color: var(--cs-surface-2);
}
.joke_wrapper {
margin-left: calc(1.25 * var(--joke-author-img-width));
}
.joke_block {
position: relative;
}
.joke_block--header .joke_element {
display: inline-block;
margin-right: 7px;
}
.joke_element--author-img {
height: var(--joke-author-img-width);
left: calc(-1.25 * var(--joke-author-img-width));
position: absolute;
top: 3px;
width: var(--joke-author-img-width);
}
.joke_element--author-img img {
border-radius: 50%;
max-width: 100%;
}
.joke_element--author-name {
color: var(--cs-on-surface-primary);
font-weight: bold;
}
.joke_element--author-username {
color: var(--cs-on-surface-secondary);
}
.joke_element {
margin: 0;
}
.joke_block--text {
font-size: 18px;
margin-top: 7px;
}
.joke_block--footer {
margin-top: 12px;
}
.layout {
height: 100%;
}
.layout_wrapper {
display: grid;
grid-template-columns: 140px 480px 1fr;
height: 100%;
margin: auto;
width: var(--layout-container-width);
}
.layout_header {
background-color: var(--cs-surface);
height: 100%;
}
.layout_content {
border-left: 1px solid var(--cs-separator);
border-right: 1px solid var(--cs-separator);
width: var(--joke-width);
}
.layout_footer {
padding: 20px;
}
.feed_header {
border-bottom: 1px solid var(--cs-separator);
padding: 20px;
}
.feed {
margin-bottom: 60px;
}
.feed_title {
font-size: 20px;
font-weight: bolder;
margin: 0;
}
.feed_subtitle {
color: var(--cs-on-surface-secondary);
margin: 0;
margin-top: 7px;
}
.feed_item {
border-bottom: 1px solid var(--cs-separator);
}
.feed_error {
padding: 40px;
text-align: center;
}
.feed_error_icon {
fill: hsla(197, 20%, 92%, 1);
height: 90px;
width: 90px;
}
.feed_error_title {
color: var(--cs-on-surface-primary);
font-weight: bold;
margin: 0;
margin-top: 1.3em;
}
.feed_error_text {
color: var(--cs-on-surface-secondary);
margin: 0;
margin-top: .7em;
}
.navbar--header {
background-color: #ffffff;
/*box-shadow: 0 5px 30px hsla(197 , 75%, 55%, .08);*/
position: relative;
}
.navbar_block {
padding: 8px;
}
.navbar_header {
display: flex;
justify-content: flex-end;
}
.navbar_brand {
display: block;
padding: 20px 20px;
}
.navbar_brand svg {
display: block;
fill: var(--cs-primary);
height: 34px;
width: 34px;
}
.form--search-form input {
-webkit-appearance: none;
background-color: #e8ecf0;
border: 2px solid #e8ecf0;
border-radius: 25px;
font-size: 12px;
line-height: 1;
outline: none;
padding: 8px 12px;
width: 100%;
}
.form--search-form input::placeholder {
font-style: italic;
}
.form--search-form input:focus {
border: 2px solid var(--cs-primary);
}
.nav {
list-style: none;
margin: 0;
padding: 0;
}
.nav--joke_rebound .nav_item {
display: inline-block;
margin-right: 70px;
}
.nav--joke_rebound .nav_link svg {
display: block;
fill: var(--cs-on-surface-secondary);
height: 20px;
margin-right: 5px;
width: 20px;
}
.nav--joke_rebound .nav_link {
align-items: center;
color: var(--cs-on-surface-secondary);
display: flex;
text-decoration: none;
}
.nav--joke_rebound .nav_link:hover {
color: var(--cs-primary);
}
.nav--joke_rebound .nav_link:hover svg {
fill: var(--cs-primary);
}
.pagination--infinite-scroll .pagination_button_next {
background-color: #ffffff;
border-color: transparent;
color: var(--cs-primary);
cursor: pointer;
letter-spacing: 0.045em;
padding: 20px;
width: 100%;
}
.section {
background-color: var(--cs-surface-2);
border-radius: 10px;
margin-bottom: 20px;
}
.section--search {
background-color: #ffffff;
}
.section_block {
padding: 15px;
}
.section_block--header {
border-bottom: 1px solid var(--cs-separator);
}
.section_block--content p {
margin: 0;
margin-bottom: 1em;
}
.section_title {
margin: 0;
}
.nav_item + .nav_item {
margin-top: 10px;
}
.nav_link {
color: var(--cs-on-surface-primary);
text-decoration: none;
}
.nav_link_text--primary {
color: var(--cs-on-surface-primary);
display: block;
font-weight: bold;
font-size: 16px;
}
.nav_link_text--secondary {
color: var(--cs-on-surface-secondary);
}
const {h, render, Component} = preact; /* @jsx h */
const {
HashRouter,
Route,
Link
} = ReactRouterDOM;
const Icon = (props) => {
switch (props.name) {
case 'feed_no_results':
return (<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12.001 17.204l-1.798 1.796-1.203-1.204 1.798-1.796-1.798-1.796 1.203-1.204 1.798 1.796 1.796-1.796 1.203 1.204-1.797 1.796 1.797 1.796-1.202 1.204-1.797-1.796zm-.001-15.204c5.514 0 10 4.486 10 10s-4.486 10-10 10-10-4.486-10-10 4.486-10 10-10zm0-2c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm-3.5 8c-.828 0-1.5.671-1.5 1.5s.672 1.5 1.5 1.5 1.5-.671 1.5-1.5-.672-1.5-1.5-1.5zm7 0c-.828 0-1.5.671-1.5 1.5s.672 1.5 1.5 1.5 1.5-.671 1.5-1.5-.672-1.5-1.5-1.5z"/></svg>);
case 'joke_downvotes':
return (<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3.491 11.432v3.568h-2.254v-4.592c0-.779.366-1.512.989-1.979l4.821-3.621c.678-.509 1.078-.603 2.962-1.305.308-.114.513-.408.513-.737v-2.767h2.226v3.904c0 .688-.412 1.308-1.045 1.574l-2.481 1.045 2.537 3.433c1.046-.764 1.726-1.459 2.937-1.225l6.167 1.195-.529 2.713-4.865-.862c-1.489-.264-2.649 1.422-1.777 2.6 1.446 1.955 1.901 2.427 2.236 3.554l1.004 3.382-2.498 1.477-1.317-4.101c-.667-2.08-3.731-2.829-5.16-4.954l-2.839-4.226c-.723.563-1.627 1.037-1.627 1.924zm.096-10.941c-1.428 0-2.587 1.158-2.587 2.586 0 1.429 1.159 2.586 2.587 2.586 1.429 0 2.587-1.158 2.587-2.586.001-1.428-1.157-2.586-2.587-2.586zm17.184 23.508c3.614 0 2.383-4.295-.504-2.512-1.028.58-2.828 1.695-4.166 2.512h4.67z"/></svg>);
case 'joke_upvotes':
return (<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M22 12c0 5.514-4.486 10-10 10s-10-4.486-10-10 4.486-10 10-10 10 4.486 10 10zm2 0c0-6.627-5.373-12-12-12s-12 5.373-12 12 5.373 12 12 12 12-5.373 12-12zm-14 6v-12c-1.465.331-4 2.827-4 6.001 0 3.134 2.521 5.665 4 5.999zm3.998 0l-.506-.755s.947-.503.947-1.746c0-1.207-.947-1.745-.947-1.745l.506-.754c.748.281 2.002 1.205 2.002 2.499 0 1.295-1.254 2.218-2.002 2.501zm0-7l-.506-.755s.947-.503.947-1.746c0-1.207-.947-1.745-.947-1.745l.506-.754c.748.281 2.002 1.205 2.002 2.499 0 1.295-1.254 2.218-2.002 2.501z"/></svg>);
case 'logo_main':
return (<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M24 13.313c0-2.053-.754-3.026-1.417-3.489.391-1.524 1.03-5.146-.963-7.409-.938-1.065-2.464-1.54-4.12-1.274-1.301-.557-3.266-1.141-5.5-1.141s-4.199.584-5.5 1.141c-1.656-.266-3.182.208-4.12 1.274-1.993 2.263-1.353 5.885-.963 7.409-.663.463-1.417 1.435-1.417 3.489 0 .996.326 2.131.986 3.102-.485 1.421.523 3.049 2.283 2.854-.318 1.622 1.365 2.928 3.082 2.128-.201 1.163 1.421 2.58 3.443 1.569.671.572 1.188 1.034 2.204 1.034 1.155 0 1.846-.643 2.277-1.035 2.022 1.012 3.574-.406 3.374-1.569 1.718.8 3.4-.506 3.082-2.128 1.76.195 2.768-1.433 2.283-2.854.659-.97.986-2.106.986-3.101zm-12 6.57c-1.722 0-2.4-1.883-2.4-1.883h4.8s-.612 1.883-2.4 1.883zm3.578-2.992c-1.052-.515-2.455-1.126-3.578-.322-1.124-.804-2.526-.193-3.578.322-4.251 2.08-8.024-4.023-5.842-5.444.204-.132.488-.135.672-.053.661.292 1.406-.191 1.406-.914 0-2.214.692-4.434 2.154-5.988l.015-.01c2.604-2.596 7.741-2.596 10.345 0l.016.011c1.462 1.554 2.154 3.774 2.154 5.987 0 .726.748 1.205 1.406.914.141-.063.436-.1.671.053 2.15 1.392-1.514 7.561-5.841 5.444zm.172-7.391c-1.124 0-2.094.629-2.607 1.546-.373-.116-.744-.174-1.143-.174s-.77.058-1.143.174c-.513-.917-1.483-1.546-2.607-1.546-1.654 0-3 1.346-3 3s1.346 3 3 3c1.231 0 2.285-.748 2.747-1.811.246-.566.394-1.301 1.003-1.301s.758.735 1.003 1.301c.462 1.063 1.516 1.811 2.747 1.811 1.654 0 3-1.346 3-3s-1.346-3-3-3zm-7.5 4.5c-.828 0-1.5-.672-1.5-1.5s.672-1.5 1.5-1.5 1.5.672 1.5 1.5-.672 1.5-1.5 1.5zm7.5 0c-.828 0-1.5-.672-1.5-1.5s.672-1.5 1.5-1.5 1.5.672 1.5 1.5-.672 1.5-1.5 1.5z" /></svg>);
default:
return (<svg />);
}
};
class Layout extends Component
{
constructor(props)
{
super(props);
this.state = {
searchString: props.searchString,
trendingTerms: ['Dad', 'Walk', 'What',],
};
}
onSubmit(values)
{
if (!values.term) {
this.setState({
searchString: values.term,
});
this.props.history.push('/');
}
if (values.term.length >= 3) {
this.setState({
searchString: values.term,
});
this.props.history.push('/search/%term%/1'.replace('%term%', values.term));
}
}
render()
{
return (
<div className='layout'>
<div className='layout_wrapper'>
<div className='layout_header'>
<div className='navbar navbar--header'>
<div className='navbar_container'>
<div className='navbar_header'>
<Link className='navbar_brand' to='/'>
<Icon name='logo_main' />
</Link>
</div>
</div>
</div>
</div>
<div className='layout_content'>
{this.props.children}
</div>
<div className='layout_footer'>
<div className='section section--search'>
<SearchForm values={{term: this.state.searchString}} onSubmit={this.onSubmit.bind(this)} />
</div>
<div className='section'>
<div className='section_block section_block--header'>
<h3 className='section_title'>
Trending now!
</h3>
</div>
<div className='section_block section_block--content'>
<ul className='nav'>
{this.state.trendingTerms && this.state.trendingTerms.map((term, index) => (
<li className='nav_item'>
<Link className='nav_link' to={'/search/' + term + '/1'}>
<span class='nav_link_text nav_link_text--primary'>{'#' + term}</span>
<span class='nav_link_text nav_link_text--secondary'>Jokes</span>
</Link>
</li>
))}
</ul>
</div>
</div>
<div className='section'>
<div className='section_block section_block--header'>
<h3 className='section_title'>
What's this?
</h3>
</div>
<div className='section_block section_block--content'>
<p>What if Twitter consisted only of dad jokes? Well that's the idea behind this pen.</p>
<p>Also, it was the occasion to redesign Twitter based on the july 2019 #newtwitter teasing, all done from tiny screenshots.</p>
<p>The jokes are fetched from icanhazdad.com API, users are added locally, everything else is Preact or react-router v5: feed, pagination, search, routing, etc.</p>
<p>There's a LOT of movies/video games references here. Can you spot them all?</p>
</div>
</div>
<div className='section'>
<div className='section_block section_block--content'>
{this.props.debug.pathname}
</div>
</div>
</div>
</div>
</div>
);
}
};
class Home extends Component {
render()
{
return (
<Layout history={this.props.history} debug={{pathname: this.props.location.pathname}}>
<JokesFeed />
</Layout>
);
}
}
const Search = (props) => {
const {match} = props;
console.log(props)
return (
<Layout history={props.history} searchString={match.params.term} debug={{pathname: props.location.pathname}}>
<JokesFeed
searchString={match.params.term}
/>
</Layout>
);
};
const Joke = (props) => {
const {joke} = props;
const navItems = ['upvotes', 'downvotes'];
return (
<div className='joke'>
<div className='joke_wrapper'>
<div className='joke_block joke_block--header'>
<span className='joke_element joke_element--author-name'>
{joke.author.name}
</span>
<span className='joke_element joke_element--author-username'>
{'@' + joke.author.username}
</span>
<div className='joke_element joke_element--author-img'>
<img src={joke.author.profile_picture.url} />
</div>
</div>
<div className='joke_block joke_block--text'>
{joke.content}
</div>
<div className='joke_block joke_block--footer'>
<ul className='nav nav--joke_rebound'>
{navItems.map((iconName, index) => (
<li className='nav_item'>
<a className={'nav_link nav_link--' + iconName} href='#'>
<Icon name={'joke_' + iconName} />
{joke.meta[iconName]}
</a>
</li>
))}
</ul>
</div>
</div>
</div>
);
};
const FeedHeader = (props) => {
return (
<div className='feed_header'>
<h1 className='feed_title'>
{props.title}
</h1>
{props.subtitle ? (
<p class='feed_subtitle'>
{props.subtitle}
</p>
): null}
</div>
);
}
class JokesFeed extends Component
{
constructor(props)
{
super(props);
this.state = {
filters: {
term: props.searchString || '',
},
loading: true,
items: {
pagination: {},
data: [],
},
};
}
componentDidMount()
{
// Initial data fetching;
this.fetchJokes(this.state.filters);
}
componentDidUpdate(prevProps, prevState)
{
if (prevProps.searchString !== this.props.searchString && !this.state.loading) {
this.fetchJokes({
term: this.props.searchString,
}, true);
}
}
fetchJokes(queryParams, resetItems)
{
queryParams = queryParams || {};
const endpointUrl = new URL('https://icanhazdadjoke.com/search');
if (queryParams.page) {
endpointUrl.searchParams.append('page', queryParams.page);
}
if (queryParams.term) {
endpointUrl.searchParams.append('term', queryParams.term);
}
this.setState({
error: false,
});
fetch(endpointUrl, {
headers: {
'Accept': 'application/json'
},
method: 'GET',
}).then((response) => {
if (!response.ok) {
return Promise.reject(reponse.json());
}
return response.json();
}).then((data) => {
const originalJokes = data.results;
const reworkedJokes = originalJokes.map((joke) => {
const authorIdValue = joke.id.split('').reduce((accumulator, currentValue) => {
if ('string' === typeof accumulator) {
accumulator = accumulator.charCodeAt(0);
}
return accumulator + currentValue.charCodeAt(0);
});
return {
author: AUTHORS[authorIdValue % AUTHORS.length],
slug: joke.id,
content: joke.joke,
meta: {
downvotes: 339,
upvotes: 4,
replies: 2,
},
}
});
let currentData = this.state.items.data;
if (resetItems) {
currentData = [];
}
this.setState({
filters: queryParams,
items: {
pagination: {
page: data.current_page,
totalItems: data.total_jokes,
totalPages: data.total_pages,
},
data: currentData.concat(reworkedJokes),
},
loading: false,
});
}).catch((err) => {
this.setState({
error: true,
loading: false,
});
});
}
onPaginationNext()
{
this.fetchJokes({
page: this.state.items.pagination.page + 1,
term: this.state.filters.term,
});
}
render()
{
if (this.state.loading) {
return (<div>Loading...</div>);
}
if (!this.state.items || 0 === this.state.items.data.length) {
return (
<div className='feed'>
<FeedHeader
title={this.props.searchString ? 'Dad jokes for “%term%”'.replace('%term%', this.props.searchString): 'Home'}
/>
<div className='feed_error'>
<Icon className='feed_error_icon' name='feed_no_results'/>
<p className='feed_error_title'>
No jokes matching.
</p>
<p className='feed_error_text'>
Your search did not match any jokes in the database. Dad's just wanna have fun... but not this one yet!
</p>
</div>
</div>
);
}
return (
<div className='feed'>
<FeedHeader
subtitle={this.props.searchString ? "%totalCount% jokes matched, and that's not a joke!".replace('%totalCount%', this.state.items.pagination.totalItems): null}
title={this.props.searchString ? 'Dad jokes for “%term%”'.replace('%term%', this.props.searchString): 'Home'}
/>
{this.state.items.data.map((item, index) => {
return (
<div className='feed_item'>
<Joke joke={item} />
</div>
);
})}
{this.state.items.pagination.page < this.state.items.pagination.totalPages ? (
<div className='feed_footer'>
<div className='pagination pagination--infinite-scroll'>
<button className='pagination_button_next' onClick={this.onPaginationNext.bind(this)}>
Load more...
</button>
</div>
</div>
): null}
</div>
);
}
}
class SearchForm extends Component
{
constructor(props)
{
super(props);
this.state = {
values: props.values,
};
}
onChange(event)
{
this.setState({
values: {
term: event.target.value,
},
});
}
onSubmit(event)
{
// Prevent actual submition by the browser.
event.preventDefault();
// Dispatch action with values.
this.props.onSubmit(this.state.values, true);
}
render()
{
return (
<form className='form form--search-form' onSubmit={this.onSubmit.bind(this)}>
<input
placeholder='Search dad jokes...'
onInput={this.onChange.bind(this)}
value={this.state.values.term}
/>
</form>
);
}
}
ReactDOM.render(
<HashRouter>
<div>
<Route exact path='/' component={Home} />
<Route path='/search/:term/:page' component={Search} />
</div>
</HashRouter>,
document.getElementById('root')
);
/* Local author's */
const AUTHORS = [
{
name: 'Johnny Silverhand',
profile_picture: {
url: 'https://www.placekeanu.com/236/236',
},
username: 'GetUpAndBurn',
},
{
name: 'Castor (or Sean)',
profile_picture: {
url: 'https://www.placecage.com/223/223',
},
username: 'castor.troy',
},
{
name: 'Keanuuuu Reeeeves',
profile_picture: {
url: 'https://www.placekeanu.com/232/232',
},
username: 'youre_breathtaking',
},
{
name: 'K. Arch',
profile_picture: {
url: 'https://www.placekeanu.com/222/222',
},
username: 'doggo.biker',
},
{
name: 'Macready',
profile_picture: {
url: 'https://www.placecage.com/226/226',
},
username: 'realBigDaddy',
},
{
name: 'Cameron Poe',
profile_picture: {
url: 'https://www.placecage.com/232/232',
},
username: 'ConExRanger',
},
{
name: 'John',
profile_picture: {
url: 'https://www.placekeanu.com/215/215',
},
username: 'wicked_but_not_retired',
},
{
name: 'Steve Zissou',
profile_picture: {
url: 'https://www.fillmurray.com/225/225',
},
username: 'aquaticJoker',
},
{
name: 'Murray',
profile_picture: {
url: 'https://www.fillmurray.com/220/220',
},
username: 'murray_head',
},
];
View Compiled
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.