Nickytonline/homepage main feed component (#6164) [deploy]
* wip * Removed reference to cached_tag_list as the feed enpoint only returns tag_list now. * Removed reference to cached_properties. * Updated <CommentsCount /> and <TagList /> to support additinal className * wip * wip * Undid init scrolling for Algolia articles * Converted homePageFeed.jsx to an ERB template to access Sprockets. * Now the homePageFeed.jsx files is an ERB so it can load Sprockets assets. * wip * Removed accidental commit of debugger statement in ERB. * Now different feed time frames are loaded based on the homepage feed tab clicked. * Prevent default Algolia paging when on the homepage. * Added a tiny comment about feed time frame. * Now feed loads properly for each time frame on home page. * prop name refactor. * Reneabled default Algolia scrolling. * On the homepage, we no longer overwrite article HTML with Algolia data for initial articles. * Assumption is the proper URL for main_image of featured article will be sent in the feed. * Fixed Google Analytics for featured article. * No longer need the new-feed ID for checking if we're on the homepage. * Trying to make codeclimate happy. * wip * Removed reference to cached_tag_list as the feed enpoint only returns tag_list now. * Removed reference to cached_properties. * Updated <CommentsCount /> and <TagList /> to support additinal className * wip * wip * Undid init scrolling for Algolia articles * Converted homePageFeed.jsx to an ERB template to access Sprockets. * Now the homePageFeed.jsx files is an ERB so it can load Sprockets assets. * wip * Removed accidental commit of debugger statement in ERB. * Now different feed time frames are loaded based on the homepage feed tab clicked. * Prevent default Algolia paging when on the homepage. * Added a tiny comment about feed time frame. * Now feed loads properly for each time frame on home page. * prop name refactor. * Reneabled default Algolia scrolling. * On the homepage, we no longer overwrite article HTML with Algolia data for initial articles. * Assumption is the proper URL for main_image of featured article will be sent in the feed. * Fixed Google Analytics for featured article. * No longer need the new-feed ID for checking if we're on the homepage. * Added postcst episodes to <Feed /> state. * Breaking up Article.jsx to make codeclimate happy. * wip * Created the <TodaysPodcastEpisodes /> and <PodcastEpisode /> components. * Fixed issues with some components that were updated for the <FeaturedArticle /> * Added tests for <FeaturedArticle /> * Barreled up <FeaturedArticle /> * Added Storybook stories for <FeaturedArticle /> * Featured articles don't display org, so removed that test. * Added proper today's podcasts components to feed. * Converted some leftover ERB + removed pro checkmark as it doesn't exist yet. * Now logic is same as master for feed timeframe. * Updated <FeaturedArticle /> snapshots. * On frontpage, bookmark button state is managed by Preact components now. * bookmarkedFeedItems is now a prop on <Feed />. * Fixed logic check for initializing reading list icons. * Now <Article /> and <FeaturedArticle /> receive a bookmarkClick prop. * Removed beginnings of scroll handler in <Feed /> as we're using what's there already for now. * Removed logic to not show main image on other time frame feeds as it was not correct. * Fixed issue with 0 rendering when no podcast episodes in feed. * Converted <SaveButton /> to a stateful component. * wip - <Feed /> render prop now passes bookmarked feed item ID set and a click event. * Moved FEED_ICONS to an export so it's easier to use JSX/JS in <Feed /> * Moved <Feed /> into article components. * Now reading list save buttons work properly in Preact components. * Fixed issue with Save buttons in Algolia paging/<Feed /> * Updated snapshots. * Took <Feed /> out of articles barrel file as jest was trying to import FEED_ICONs which is an ERB template. * Now the Preact feed is dynamically imported. * Now the Preact <Feed /> only loads for a logged on user. * Fixed <FeaturedArticle /> so that the save button behaves properly now. * Updated article stories for Storybook. * Putting this back to the order it was because I've removed my code changes for this block. * Fixed when renderFeed is imported for a logged on user. * Add podcasts to feed and render home from server * Add padding to feature laoding div * Update tests * Add more loading articles * Fix conflicts * Add readinglist javascript to initscrolling * Ensure first article is one with image * Organize feed items outside of feed instead of in it Co-authored-by: Ben Halpern <bendhalpern@gmail.com> Co-authored-by: Josh Puetz <josh@grorichpuetz.com>
This commit is contained in:
parent
00bced6179
commit
f6e99dec8b
37 changed files with 1499 additions and 333 deletions
|
|
@ -152,7 +152,8 @@ function insertArticles(articles) {
|
|||
if (el) {el.outerHTML = "";}
|
||||
articles.forEach(function insertAnArticle(article) {
|
||||
var existingEl = document.getElementById("article-link-"+article.id);
|
||||
if ( existingEl &&
|
||||
if ( !['/', '/top/week', '/top/month', '/top/year', '/top/infinity', '/latest'].includes(window.location.pathname) &&
|
||||
existingEl &&
|
||||
existingEl.parentElement &&
|
||||
existingEl.parentElement.classList.contains('single-article-small-pic') &&
|
||||
!document.getElementById("video-player-"+article.id)) {
|
||||
|
|
@ -209,7 +210,8 @@ function algoliaPaginate(tag, requiresApproval) {
|
|||
nextPage += 1;
|
||||
insertArticles(content.hits);
|
||||
const checkBlockedContentEvent = new CustomEvent('checkBlockedContent')
|
||||
window.dispatchEvent(checkBlockedContentEvent)
|
||||
window.dispatchEvent(checkBlockedContentEvent);
|
||||
initializeReadingListIcons();
|
||||
if (content.hits.length === 0) {
|
||||
document.getElementById("loading-articles").style.display = "none"
|
||||
done = true;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ function initializeBaseTracking() {
|
|||
ga('set', 'anonymizeIp', true);
|
||||
ga('send', 'pageview', location.pathname + location.search);
|
||||
clearInterval(waitingOnGA);
|
||||
logImpressions();
|
||||
}
|
||||
if (wait > 85) {
|
||||
clearInterval(waitingOnGA);
|
||||
|
|
@ -67,14 +66,6 @@ function eventListening(){
|
|||
}
|
||||
}
|
||||
|
||||
function logImpressions() {
|
||||
var el = document.getElementById("featured-story-marker")
|
||||
if (el){
|
||||
var data = el.dataset.featuredArticle;
|
||||
ga('send', 'event', 'view', 'featured-feed-impression', data, null);
|
||||
}
|
||||
}
|
||||
|
||||
function trackCustomImpressions() {
|
||||
setTimeout(function(){
|
||||
var ArticleElement = document.getElementById('article-body');
|
||||
|
|
@ -247,4 +238,4 @@ function trackAdClick(token, adBox) {
|
|||
})
|
||||
}
|
||||
adClicked = true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
function initializeFetchFollowedArticles() {
|
||||
insertPodcasts();
|
||||
if ( document.getElementById("featured-story-marker") && checkUserLoggedIn() ) {
|
||||
algoliaFollowedArticles();
|
||||
}
|
||||
|
|
@ -127,29 +126,6 @@ function algoliaFollowedArticles(){
|
|||
}
|
||||
}
|
||||
|
||||
function insertPodcasts() {
|
||||
var el = document.getElementById('followed-podcasts');
|
||||
var insertPlace = document.getElementById('article-index-podcast-div');
|
||||
if (el && insertPlace){
|
||||
var user = userData();
|
||||
if (user && user.followed_podcast_ids && user.followed_podcast_ids.length > 0) {
|
||||
var data = JSON.parse(el.dataset.episodes);
|
||||
var podcastHTML = '';
|
||||
var episodeCount = 0;
|
||||
data.forEach(function(ep) {
|
||||
if (user.followed_podcast_ids.indexOf(ep.podcast.id) > -1) {
|
||||
episodeCount += 1;
|
||||
podcastHTML = podcastHTML + '<a class="individual-podcast-link" href="/' + ep.podcast.slug + '/' + ep.slug + '">\
|
||||
<img src="' + ep.podcast.image_90 + '"/><div class="individual-podcast-link-details"><strong>' + ep.title + '</strong> ' + ep.podcast.title + '</div></a>'
|
||||
}
|
||||
});
|
||||
if (episodeCount > 0) {
|
||||
insertPlace.innerHTML = '<div class="single-article single-article-podcast-div"><h3><a href="/pod">Today\'s Podcasts</a></h3>' + podcastHTML + '</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findOne(haystack, arr) {
|
||||
return arr.some(function (v) {
|
||||
return haystack.indexOf(v) >= 0;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
'use strict';
|
||||
|
||||
/* eslint-disable no-use-before-define */
|
||||
/* eslint-disable no-undef */
|
||||
/* eslint-disable func-names */
|
||||
|
|
@ -14,7 +12,9 @@ function initializeReadingListIcons() {
|
|||
|
||||
// set SAVE or SAVED articles buttons
|
||||
function setReadingListButtonsState() {
|
||||
var readingListButtons = document.getElementsByClassName('bookmark-button');
|
||||
var readingListButtons = document.querySelectorAll(
|
||||
'.bookmark-button:not([data-initial-feed])',
|
||||
);
|
||||
Array.from(readingListButtons).forEach(highlightButton);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -462,42 +462,6 @@
|
|||
margin: 0;
|
||||
padding: 16px;
|
||||
}
|
||||
a.individual-podcast-link {
|
||||
display: flex;
|
||||
padding: 8px 16px;
|
||||
font-size: 0.8em;
|
||||
@include themeable(color, theme-secondary-color, $medium-gray);
|
||||
|
||||
img {
|
||||
height: 50px;
|
||||
width: 50px;
|
||||
border-radius: 50%;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.individual-podcast-link-details {
|
||||
padding-left: 16px;
|
||||
flex: 1 auto;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
@media screen and (min-width: 500px) {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
padding-top: 0.2em;
|
||||
font-weight: 500;
|
||||
@include themeable(color, theme-color, $black);
|
||||
}
|
||||
&:hover {
|
||||
@include themeable(
|
||||
background,
|
||||
theme-container-background-hover,
|
||||
$light-gray
|
||||
);
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin-left: 14px;
|
||||
}
|
||||
|
|
@ -901,6 +865,44 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Podcast episode that goes within the <TodaysPodcast /> component.
|
||||
.individual-podcast-link {
|
||||
display: flex;
|
||||
padding: 8px 16px;
|
||||
font-size: 0.8em;
|
||||
@include themeable(color, theme-secondary-color, $medium-gray);
|
||||
|
||||
img {
|
||||
height: 50px;
|
||||
width: 50px;
|
||||
border-radius: 50%;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.individual-podcast-link-details {
|
||||
padding-left: 16px;
|
||||
flex: 1 auto;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
@media screen and (min-width: 500px) {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
padding-top: 0.2em;
|
||||
font-weight: 500;
|
||||
@include themeable(color, theme-color, $black);
|
||||
}
|
||||
&:hover {
|
||||
@include themeable(
|
||||
background,
|
||||
theme-container-background-hover,
|
||||
$light-gray
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////
|
||||
// OTHER VIEWS /////////////////////////////////
|
||||
////////////////////////////////////////////////
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export const Article = ({
|
|||
reactionsIcon,
|
||||
commentsIcon,
|
||||
videoIcon,
|
||||
bookmarkClick,
|
||||
}) => {
|
||||
if (article && article.type_of === 'podcast_episodes') {
|
||||
return <PodcastArticle article={article} />;
|
||||
|
|
@ -99,7 +100,7 @@ export const Article = ({
|
|||
</a>
|
||||
</h4>
|
||||
|
||||
<TagList tags={article.tag_list || article.cached_tag_list_array} />
|
||||
<TagList tags={article.tag_list} />
|
||||
{article.class_name !== 'User' && (
|
||||
<CommentsCount
|
||||
count={article.comments_count}
|
||||
|
|
@ -116,7 +117,11 @@ export const Article = ({
|
|||
readingTime={article.reading_time}
|
||||
/>
|
||||
)}
|
||||
<SaveButton article={article} isBookmarked={isBookmarked} />
|
||||
<SaveButton
|
||||
article={article}
|
||||
isBookmarked={isBookmarked}
|
||||
onClick={bookmarkClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -133,4 +138,5 @@ Article.propTypes = {
|
|||
reactionsIcon: PropTypes.string.isRequired,
|
||||
commentsIcon: PropTypes.string.isRequired,
|
||||
videoIcon: PropTypes.string.isRequired,
|
||||
bookmarkClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
|
|
|||
113
app/javascript/articles/FeaturedArticle.jsx
Normal file
113
app/javascript/articles/FeaturedArticle.jsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { h } from 'preact';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
TagList,
|
||||
SaveButton,
|
||||
ReadingTime,
|
||||
ReactionsCount,
|
||||
CommentsCount,
|
||||
} from './components';
|
||||
import { articlePropTypes } from '../src/components/common-prop-types';
|
||||
|
||||
export const FeaturedArticle = ({
|
||||
article,
|
||||
isBookmarked,
|
||||
reactionsIcon,
|
||||
commentsIcon,
|
||||
bookmarkClick,
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
id="featured-story-marker"
|
||||
data-featured-article={`articles-${article.id}`}
|
||||
/>
|
||||
<img
|
||||
src={article.main_image}
|
||||
style={{ display: 'none' }}
|
||||
alt={article.title}
|
||||
/>
|
||||
<a
|
||||
href={article.path}
|
||||
id={`article-link-${article.id}`}
|
||||
className="index-article-link"
|
||||
aria-label="Main Story"
|
||||
data-featured-article={`articles-${article.id}`}
|
||||
/>
|
||||
<div
|
||||
className="single-article big-article"
|
||||
data-content-user-id={article.user_id}
|
||||
>
|
||||
<a
|
||||
href={article.path}
|
||||
id={`article-link-${article.id}`}
|
||||
className="index-article-link"
|
||||
aria-label="Main Story"
|
||||
data-featured-article={`articles-${article.id}`}
|
||||
>
|
||||
<div
|
||||
className="picture image-final"
|
||||
style={{
|
||||
backgroundColor: article.main_image_background_hex_color,
|
||||
backgroundImage: `url(${article.main_image})`,
|
||||
}}
|
||||
/>
|
||||
<div className="content-wrapper">
|
||||
<h3>{article.title}</h3>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href={`/${article.user.username}`}
|
||||
className="featured-profile-button"
|
||||
>
|
||||
<img
|
||||
className="featured-profile-pic"
|
||||
src={article.user.profile_image_90}
|
||||
alt={article.title}
|
||||
/>
|
||||
</a>
|
||||
<div className="featured-user-name">
|
||||
<a href="/<%= article.cached_user.username %>">
|
||||
{article.user.name} ・{' '}
|
||||
<time dateTime={article.published_timestamp}>
|
||||
{article.readable_publish_date}
|
||||
</time>
|
||||
<span
|
||||
className="time-ago-indicator-initial-placeholder"
|
||||
data-seconds="<%= article.published_at_int %>"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<TagList tags={article.tag_list} className="featured-tags" />
|
||||
<CommentsCount
|
||||
count={article.comments_count}
|
||||
articlePath={article.path}
|
||||
icon={commentsIcon}
|
||||
className="featured-engagement-count"
|
||||
/>
|
||||
<ReactionsCount article={article} icon={reactionsIcon} />
|
||||
<ReadingTime
|
||||
articlePath={article.path}
|
||||
readingTime={article.reading_time}
|
||||
/>
|
||||
<SaveButton
|
||||
article={article}
|
||||
isBookmarked={isBookmarked}
|
||||
onClick={bookmarkClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FeaturedArticle.defaultProps = {
|
||||
isBookmarked: false,
|
||||
};
|
||||
|
||||
FeaturedArticle.propTypes = {
|
||||
article: articlePropTypes.isRequired,
|
||||
isBookmarked: PropTypes.bool,
|
||||
reactionsIcon: PropTypes.string.isRequired,
|
||||
commentsIcon: PropTypes.string.isRequired,
|
||||
bookmarkClick: PropTypes.func.isRequired,
|
||||
};
|
||||
162
app/javascript/articles/Feed.jsx
Normal file
162
app/javascript/articles/Feed.jsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { h, Component } from 'preact';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FEED_ICONS } from '../packs/feedIcons.js.erb';
|
||||
|
||||
/* global userData sendHapticMessage showModal buttonFormData renderNewSidebarCount */
|
||||
|
||||
export class Feed extends Component {
|
||||
componentDidMount() {
|
||||
const { timeFrame } = this.props;
|
||||
const { reading_list_ids = [] } = userData(); // eslint-disable-line camelcase
|
||||
|
||||
this.setState({ bookmarkedFeedItems: new Set(reading_list_ids) });
|
||||
|
||||
Feed.getFeedItems(timeFrame).then(feedItems => {
|
||||
|
||||
// Ensure first article is one with a main_image
|
||||
const featuredStory = feedItems.find(story => story.main_image !== null);
|
||||
// Remove that first one from the array.
|
||||
const index = feedItems.indexOf(featuredStory);
|
||||
feedItems.splice(index, 1);
|
||||
const subStories = feedItems;
|
||||
const organizedFeedItems = [featuredStory, subStories].flat();
|
||||
console.log(organizedFeedItems);
|
||||
this.setState({feedItems: organizedFeedItems, podcastEpisodes: Feed.getPodcastEpisodes() });
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { timeFrame } = this.props;
|
||||
if (prevProps.timeFrame !== timeFrame) {
|
||||
// The feed timeframe has changed. Get new feed data.
|
||||
Feed.getFeedItems(timeFrame).then(feedItems => {
|
||||
this.setState(_prevState => ({ feedItems }));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves feed data.
|
||||
*
|
||||
* @param {number} [page=1] Page of feed data to retrieve
|
||||
*
|
||||
* @returns {Promise} A promise containing the JSON response for the feed data.
|
||||
*/
|
||||
static getFeedItems(timeFrame = '', page = 1) {
|
||||
return fetch(`/stories/feed/${timeFrame}?page=${page}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-Token': window.csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
}).then(response => response.json());
|
||||
}
|
||||
|
||||
static getPodcastEpisodes() {
|
||||
const el = document.getElementById('followed-podcasts');
|
||||
const user = userData(); // Global
|
||||
const episodes = []
|
||||
if (user && user.followed_podcast_ids && user.followed_podcast_ids.length > 0) {
|
||||
let data = JSON.parse(el.dataset.episodes);
|
||||
data.forEach(function(ep) {
|
||||
if (user.followed_podcast_ids.indexOf(ep.podcast.id) > -1) {
|
||||
episodes.push(ep)
|
||||
}
|
||||
});
|
||||
}
|
||||
return episodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a click event to bookmark/unbook,ard an article.
|
||||
*
|
||||
* @param {Event} event
|
||||
*/
|
||||
bookmarkClick = event => {
|
||||
// The assumption is that the user is logged on at this point.
|
||||
const { userStatus } = document.body;
|
||||
event.preventDefault();
|
||||
sendHapticMessage('medium');
|
||||
|
||||
if (userStatus === 'logged-out') {
|
||||
showModal('add-to-readinglist-from-index');
|
||||
return;
|
||||
}
|
||||
|
||||
const { currentTarget: button } = event;
|
||||
const data = buttonFormData(button);
|
||||
|
||||
getCsrfToken()
|
||||
.then(sendFetch('reaction-creation', data))
|
||||
// eslint-disable-next-line consistent-return
|
||||
.then(response => {
|
||||
if (response.status === 200) {
|
||||
return response.json().then(json => {
|
||||
const articleId = Number(button.dataset.reactableId);
|
||||
|
||||
this.setState(previousState => {
|
||||
const { bookmarkedFeedItems } = previousState;
|
||||
|
||||
const { result } = json;
|
||||
const updatedBookmarkedFeedItems = new Set([
|
||||
...bookmarkedFeedItems.values(),
|
||||
]);
|
||||
|
||||
if (result === 'create') {
|
||||
updatedBookmarkedFeedItems.add(articleId);
|
||||
}
|
||||
|
||||
if (result === 'destroy') {
|
||||
updatedBookmarkedFeedItems.delete(articleId);
|
||||
}
|
||||
|
||||
renderNewSidebarCount(button, json);
|
||||
|
||||
return {
|
||||
...previousState,
|
||||
bookmarkedFeedItems: updatedBookmarkedFeedItems,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { renderFeed } = this.props;
|
||||
const {
|
||||
feedItems = [],
|
||||
podcastEpisodes = [],
|
||||
bookmarkedFeedItems = new Set(),
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={element => {
|
||||
this.feedContainer = element;
|
||||
}}
|
||||
>
|
||||
{renderFeed({
|
||||
feedItems,
|
||||
feedIcons: FEED_ICONS,
|
||||
podcastEpisodes,
|
||||
bookmarkedFeedItems,
|
||||
bookmarkClick: this.bookmarkClick,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Feed.defaultProps = {
|
||||
timeFrame: '',
|
||||
};
|
||||
|
||||
Feed.propTypes = {
|
||||
timeFrame: PropTypes.string,
|
||||
renderFeed: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
Feed.displayName = 'Feed';
|
||||
|
|
@ -1,16 +1,19 @@
|
|||
import { h } from 'preact';
|
||||
|
||||
export const LoadingArticle = () => (
|
||||
<div className="single-article single-article-small-pic">
|
||||
<div className="small-pic">
|
||||
<div className="color single-article-loading" />
|
||||
export const LoadingArticle = ({version}) => {
|
||||
const bigPicArea = version === 'featured' ? <div class="picture"></div> : '';
|
||||
const smallPic = version === 'featured' ? '' : <div className="small-pic"><div className="color single-article-loading" /></div>;
|
||||
return (
|
||||
<div className="single-article single-article-small-pic big-article" style={{paddingBottom: version === 'featured' ? '46px' : '0'}}>
|
||||
{bigPicArea}
|
||||
{smallPic}
|
||||
<div className="content">
|
||||
<h3 className="single-article-loading"> </h3>
|
||||
</div>
|
||||
<h4 className="single-article-loading" style={{ width: '46%' }}>
|
||||
|
||||
</h4>
|
||||
<div className="tags single-article-loading"></div>
|
||||
</div>
|
||||
<div className="content">
|
||||
<h3 className="single-article-loading"> </h3>
|
||||
</div>
|
||||
<h4 className="single-article-loading" style={{ width: '46%' }}>
|
||||
|
||||
</h4>
|
||||
<div className="tags single-article-loading" style={{ width: '56%' }} />
|
||||
</div>
|
||||
);
|
||||
)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { h } from 'preact';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { withKnobs, object, text, boolean } from '@storybook/addon-knobs/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { Article } from '..';
|
||||
import {
|
||||
article,
|
||||
|
|
@ -25,11 +26,16 @@ const ICONS = {
|
|||
VIDEO_ICON: assetPath('video-camera.svg'),
|
||||
};
|
||||
|
||||
const commonProps = {
|
||||
bookmarkClick: action('Saved/unsaved article'),
|
||||
};
|
||||
|
||||
storiesOf('Components/Article/Standard', module)
|
||||
.addDecorator(withKnobs)
|
||||
.addDecorator(articleDecorator)
|
||||
.add('Default', () => (
|
||||
<Article
|
||||
{...commonProps}
|
||||
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
|
||||
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
|
||||
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
|
||||
|
|
@ -40,6 +46,7 @@ storiesOf('Components/Article/Standard', module)
|
|||
))
|
||||
.add('With Organization', () => (
|
||||
<Article
|
||||
{...commonProps}
|
||||
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
|
||||
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
|
||||
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
|
||||
|
|
@ -50,6 +57,7 @@ storiesOf('Components/Article/Standard', module)
|
|||
))
|
||||
.add('Wth Flare Tag', () => (
|
||||
<Article
|
||||
{...commonProps}
|
||||
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
|
||||
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
|
||||
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
|
||||
|
|
@ -60,6 +68,7 @@ storiesOf('Components/Article/Standard', module)
|
|||
))
|
||||
.add('Wth Snippet Result', () => (
|
||||
<Article
|
||||
{...commonProps}
|
||||
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
|
||||
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
|
||||
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
|
||||
|
|
@ -70,6 +79,7 @@ storiesOf('Components/Article/Standard', module)
|
|||
))
|
||||
.add('Wth Reading Time', () => (
|
||||
<Article
|
||||
{...commonProps}
|
||||
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
|
||||
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
|
||||
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
|
||||
|
|
@ -80,6 +90,7 @@ storiesOf('Components/Article/Standard', module)
|
|||
))
|
||||
.add('Wth Reactions', () => (
|
||||
<Article
|
||||
{...commonProps}
|
||||
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
|
||||
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
|
||||
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
|
||||
|
|
@ -90,6 +101,7 @@ storiesOf('Components/Article/Standard', module)
|
|||
))
|
||||
.add('With Comments', () => (
|
||||
<Article
|
||||
{...commonProps}
|
||||
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
|
||||
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
|
||||
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
|
||||
|
|
@ -100,6 +112,7 @@ storiesOf('Components/Article/Standard', module)
|
|||
))
|
||||
.add('Is on Reading List', () => (
|
||||
<Article
|
||||
{...commonProps}
|
||||
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
|
||||
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
|
||||
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
|
||||
|
|
@ -114,6 +127,7 @@ storiesOf('Components/Article/Video', module)
|
|||
.addDecorator(articleDecorator)
|
||||
.add('Default', () => (
|
||||
<Article
|
||||
{...commonProps}
|
||||
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
|
||||
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
|
||||
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
|
||||
|
|
@ -124,6 +138,7 @@ storiesOf('Components/Article/Video', module)
|
|||
))
|
||||
.add('Video Article and Flare Tag', () => (
|
||||
<Article
|
||||
{...commonProps}
|
||||
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
|
||||
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
|
||||
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
|
||||
|
|
@ -138,6 +153,7 @@ storiesOf('Components/Article/Podcast', module)
|
|||
.addDecorator(articleDecorator)
|
||||
.add('Default', () => (
|
||||
<Article
|
||||
{...commonProps}
|
||||
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
|
||||
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
|
||||
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
|
||||
|
|
@ -148,6 +164,7 @@ storiesOf('Components/Article/Podcast', module)
|
|||
))
|
||||
.add('Podcast Episode', () => (
|
||||
<Article
|
||||
{...commonProps}
|
||||
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
|
||||
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
|
||||
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
|
||||
|
|
@ -162,6 +179,7 @@ storiesOf('Components/Article/User', module)
|
|||
.addDecorator(articleDecorator)
|
||||
.add('Default', () => (
|
||||
<Article
|
||||
{...commonProps}
|
||||
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
|
||||
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
|
||||
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import { h } from 'preact';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { withKnobs, object, text, boolean } from '@storybook/addon-knobs/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import {
|
||||
featuredArticle,
|
||||
assetPath,
|
||||
} from '../__tests__/utilities/articleUtilities';
|
||||
import { FeaturedArticle } from '..';
|
||||
import { articleDecorator } from './articleDecorator';
|
||||
|
||||
import '../../../assets/stylesheets/articles.scss';
|
||||
|
||||
const ICONS = {
|
||||
REACTIONS_ICON: assetPath('reactions-stack.png'),
|
||||
COMMENTS_ICON: assetPath('comments-bubble.png'),
|
||||
VIDEO_ICON: assetPath('video-camera.svg'),
|
||||
};
|
||||
|
||||
const commonProps = {
|
||||
bookmarkClick: action('Saved/unsaved article'),
|
||||
};
|
||||
|
||||
storiesOf('Components/Article/Featured', module)
|
||||
.addDecorator(withKnobs)
|
||||
.addDecorator(articleDecorator)
|
||||
.add('Default', () => (
|
||||
<FeaturedArticle
|
||||
{...commonProps}
|
||||
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
|
||||
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
|
||||
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
|
||||
article={object('article', featuredArticle)}
|
||||
/>
|
||||
))
|
||||
.add('Is on Reading List', () => (
|
||||
<FeaturedArticle
|
||||
{...commonProps}
|
||||
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
|
||||
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
|
||||
isBookmarked={boolean('isBookmarked', true)}
|
||||
article={object('article', featuredArticle)}
|
||||
/>
|
||||
));
|
||||
37
app/javascript/articles/__tests__/FeaturedArticle.test.jsx
Normal file
37
app/javascript/articles/__tests__/FeaturedArticle.test.jsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { h } from 'preact';
|
||||
import render from 'preact-render-to-json';
|
||||
import { featuredArticle, assetPath } from './utilities/articleUtilities';
|
||||
import '../../../assets/javascripts/lib/xss';
|
||||
import '../../../assets/javascripts/utilities/timeAgo';
|
||||
import { FeaturedArticle } from '../FeaturedArticle';
|
||||
|
||||
const commonProps = {
|
||||
reactionsIcon: assetPath('reactions-stack.png'),
|
||||
commentsIcon: assetPath('comments-bubble.png'),
|
||||
};
|
||||
|
||||
describe('<FeaturedArticle /> component', () => {
|
||||
it('should render a standard article', () => {
|
||||
const tree = render(
|
||||
<FeaturedArticle
|
||||
{...commonProps}
|
||||
isBookmarked={false}
|
||||
article={featuredArticle}
|
||||
currentTag="javascript"
|
||||
/>,
|
||||
);
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render a bookmarked featured article', () => {
|
||||
const tree = render(
|
||||
<FeaturedArticle
|
||||
{...commonProps}
|
||||
isBookmarked
|
||||
article={featuredArticle}
|
||||
currentTag="javascript"
|
||||
/>,
|
||||
);
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
@ -232,19 +232,17 @@ exports[`<Article /> component should render a standard article 1`] = `
|
|||
</a>
|
||||
<button
|
||||
class="article-engagement-count engage-button bookmark-button "
|
||||
data-initial-feed={true}
|
||||
data-reactable-id={62407}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onMouseout={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="bm-initial"
|
||||
>
|
||||
SAVE
|
||||
</span>
|
||||
<span
|
||||
class="bm-success"
|
||||
>
|
||||
SAVED
|
||||
</span>
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -421,19 +419,17 @@ exports[`<Article /> component should render a video article 1`] = `
|
|||
</a>
|
||||
<button
|
||||
class="article-engagement-count engage-button bookmark-button "
|
||||
data-initial-feed={true}
|
||||
data-reactable-id={49020}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onMouseout={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="bm-initial"
|
||||
>
|
||||
SAVE
|
||||
</span>
|
||||
<span
|
||||
class="bm-success"
|
||||
>
|
||||
SAVED
|
||||
</span>
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -553,19 +549,17 @@ exports[`<Article /> component should render a video article with a flare tag 1`
|
|||
</a>
|
||||
<button
|
||||
class="article-engagement-count engage-button bookmark-button "
|
||||
data-initial-feed={true}
|
||||
data-reactable-id={49020}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onMouseout={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="bm-initial"
|
||||
>
|
||||
SAVE
|
||||
</span>
|
||||
<span
|
||||
class="bm-success"
|
||||
>
|
||||
SAVED
|
||||
</span>
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -706,19 +700,17 @@ exports[`<Article /> component should render as saved on reading list 1`] = `
|
|||
</a>
|
||||
<button
|
||||
class="article-engagement-count engage-button bookmark-button selected"
|
||||
data-initial-feed={true}
|
||||
data-reactable-id={62407}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onMouseout={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="bm-initial"
|
||||
>
|
||||
SAVE
|
||||
</span>
|
||||
<span
|
||||
class="bm-success"
|
||||
>
|
||||
SAVED
|
||||
</span>
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -822,19 +814,17 @@ exports[`<Article /> component should render with a flare tag 1`] = `
|
|||
</a>
|
||||
<button
|
||||
class="article-engagement-count engage-button bookmark-button "
|
||||
data-initial-feed={true}
|
||||
data-reactable-id={62407}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onMouseout={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="bm-initial"
|
||||
>
|
||||
SAVE
|
||||
</span>
|
||||
<span
|
||||
class="bm-success"
|
||||
>
|
||||
SAVED
|
||||
</span>
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -938,19 +928,17 @@ exports[`<Article /> component should render with a reading time 1`] = `
|
|||
</a>
|
||||
<button
|
||||
class="article-engagement-count engage-button bookmark-button "
|
||||
data-initial-feed={true}
|
||||
data-reactable-id={62407}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onMouseout={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="bm-initial"
|
||||
>
|
||||
SAVE
|
||||
</span>
|
||||
<span
|
||||
class="bm-success"
|
||||
>
|
||||
SAVED
|
||||
</span>
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -1061,19 +1049,17 @@ exports[`<Article /> component should render with a snippet result 1`] = `
|
|||
</a>
|
||||
<button
|
||||
class="article-engagement-count engage-button bookmark-button "
|
||||
data-initial-feed={true}
|
||||
data-reactable-id={62407}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onMouseout={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="bm-initial"
|
||||
>
|
||||
SAVE
|
||||
</span>
|
||||
<span
|
||||
class="bm-success"
|
||||
>
|
||||
SAVED
|
||||
</span>
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -1181,19 +1167,17 @@ exports[`<Article /> component should render with an organization 1`] = `
|
|||
</a>
|
||||
<button
|
||||
class="article-engagement-count engage-button bookmark-button "
|
||||
data-initial-feed={true}
|
||||
data-reactable-id={62407}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onMouseout={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="bm-initial"
|
||||
>
|
||||
SAVE
|
||||
</span>
|
||||
<span
|
||||
class="bm-success"
|
||||
>
|
||||
SAVED
|
||||
</span>
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -1334,19 +1318,17 @@ exports[`<Article /> component should render with comments 1`] = `
|
|||
</a>
|
||||
<button
|
||||
class="article-engagement-count engage-button bookmark-button "
|
||||
data-initial-feed={true}
|
||||
data-reactable-id={62407}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onMouseout={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="bm-initial"
|
||||
>
|
||||
SAVE
|
||||
</span>
|
||||
<span
|
||||
class="bm-success"
|
||||
>
|
||||
SAVED
|
||||
</span>
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -1469,19 +1451,17 @@ exports[`<Article /> component should render with reactions 1`] = `
|
|||
</a>
|
||||
<button
|
||||
class="article-engagement-count engage-button bookmark-button "
|
||||
data-initial-feed={true}
|
||||
data-reactable-id={62407}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onMouseout={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="bm-initial"
|
||||
>
|
||||
SAVE
|
||||
</span>
|
||||
<span
|
||||
class="bm-success"
|
||||
>
|
||||
SAVED
|
||||
</span>
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@
|
|||
|
||||
exports[`<LoadingArticle /> should render 1`] = `
|
||||
<div
|
||||
class="single-article single-article-small-pic"
|
||||
class="single-article single-article-small-pic big-article"
|
||||
style={
|
||||
Object {
|
||||
"paddingBottom": "0",
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
class="small-pic"
|
||||
|
|
@ -32,11 +37,6 @@ exports[`<LoadingArticle /> should render 1`] = `
|
|||
</h4>
|
||||
<div
|
||||
class="tags single-article-loading"
|
||||
style={
|
||||
Object {
|
||||
"width": "56%",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,341 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<FeaturedArticle /> component should render a bookmarked featured article 1`] = `
|
||||
<div>
|
||||
<div
|
||||
data-featured-article="articles-62407"
|
||||
id="featured-story-marker"
|
||||
/>
|
||||
<img
|
||||
alt="Unbranded Home Loan Account"
|
||||
src="/images/onboarding-background.png"
|
||||
style={
|
||||
Object {
|
||||
"display": "none",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<a
|
||||
aria-label="Main Story"
|
||||
class="index-article-link"
|
||||
data-featured-article="articles-62407"
|
||||
href="/some-post/path"
|
||||
id="article-link-62407"
|
||||
/>
|
||||
<div
|
||||
class="single-article big-article"
|
||||
data-content-user-id={23289}
|
||||
>
|
||||
<a
|
||||
aria-label="Main Story"
|
||||
class="index-article-link"
|
||||
data-featured-article="articles-62407"
|
||||
href="/some-post/path"
|
||||
id="article-link-62407"
|
||||
>
|
||||
<div
|
||||
class="picture image-final"
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": undefined,
|
||||
"backgroundImage": "url(/images/onboarding-background.png)",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
class="content-wrapper"
|
||||
>
|
||||
<h3>
|
||||
Unbranded Home Loan Account
|
||||
</h3>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
class="featured-profile-button"
|
||||
href="/Emil99"
|
||||
>
|
||||
<img
|
||||
alt="Unbranded Home Loan Account"
|
||||
class="featured-profile-pic"
|
||||
src="/images/10.png"
|
||||
/>
|
||||
</a>
|
||||
<div
|
||||
class="featured-user-name"
|
||||
>
|
||||
<a
|
||||
href="/<%= article.cached_user.username %>"
|
||||
>
|
||||
Stella Macejkovic ・
|
||||
<time
|
||||
dateTime="Tue, 18 Feb 2020 14:59:24 GMT"
|
||||
>
|
||||
February 18
|
||||
</time>
|
||||
<span
|
||||
class="time-ago-indicator-initial-placeholder"
|
||||
data-seconds="<%= article.published_at_int %>"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="tags featured-tags"
|
||||
>
|
||||
<a
|
||||
href="/t/javascript"
|
||||
>
|
||||
<span
|
||||
class="tag"
|
||||
>
|
||||
#javascript
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="/t/ruby"
|
||||
>
|
||||
<span
|
||||
class="tag"
|
||||
>
|
||||
#ruby
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="/t/go"
|
||||
>
|
||||
<span
|
||||
class="tag"
|
||||
>
|
||||
#go
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="article-engagement-count comments-count featured-engagement-count"
|
||||
>
|
||||
<a
|
||||
href="/some-post/path#comments"
|
||||
>
|
||||
<img
|
||||
alt="chat"
|
||||
loading="lazy"
|
||||
src="/images/comments-bubble.png"
|
||||
/>
|
||||
<span
|
||||
class="engagement-count-number"
|
||||
>
|
||||
213
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="article-engagement-count reactions-count"
|
||||
>
|
||||
<a
|
||||
href="/some-post/path"
|
||||
>
|
||||
<img
|
||||
alt="heart"
|
||||
loading="lazy"
|
||||
src="/images/reactions-stack.png"
|
||||
/>
|
||||
<span
|
||||
class="engagement-count-number"
|
||||
id="engagement-count-number-62407"
|
||||
>
|
||||
428
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
class="article-reading-time"
|
||||
href="/some-post/path"
|
||||
>
|
||||
undefined min read
|
||||
</a>
|
||||
<button
|
||||
class="article-engagement-count engage-button bookmark-button selected"
|
||||
data-initial-feed={true}
|
||||
data-reactable-id={62407}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onMouseout={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="bm-success"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<FeaturedArticle /> component should render a standard article 1`] = `
|
||||
<div>
|
||||
<div
|
||||
data-featured-article="articles-62407"
|
||||
id="featured-story-marker"
|
||||
/>
|
||||
<img
|
||||
alt="Unbranded Home Loan Account"
|
||||
src="/images/onboarding-background.png"
|
||||
style={
|
||||
Object {
|
||||
"display": "none",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<a
|
||||
aria-label="Main Story"
|
||||
class="index-article-link"
|
||||
data-featured-article="articles-62407"
|
||||
href="/some-post/path"
|
||||
id="article-link-62407"
|
||||
/>
|
||||
<div
|
||||
class="single-article big-article"
|
||||
data-content-user-id={23289}
|
||||
>
|
||||
<a
|
||||
aria-label="Main Story"
|
||||
class="index-article-link"
|
||||
data-featured-article="articles-62407"
|
||||
href="/some-post/path"
|
||||
id="article-link-62407"
|
||||
>
|
||||
<div
|
||||
class="picture image-final"
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": undefined,
|
||||
"backgroundImage": "url(/images/onboarding-background.png)",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div
|
||||
class="content-wrapper"
|
||||
>
|
||||
<h3>
|
||||
Unbranded Home Loan Account
|
||||
</h3>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
class="featured-profile-button"
|
||||
href="/Emil99"
|
||||
>
|
||||
<img
|
||||
alt="Unbranded Home Loan Account"
|
||||
class="featured-profile-pic"
|
||||
src="/images/10.png"
|
||||
/>
|
||||
</a>
|
||||
<div
|
||||
class="featured-user-name"
|
||||
>
|
||||
<a
|
||||
href="/<%= article.cached_user.username %>"
|
||||
>
|
||||
Stella Macejkovic ・
|
||||
<time
|
||||
dateTime="Tue, 18 Feb 2020 14:59:24 GMT"
|
||||
>
|
||||
February 18
|
||||
</time>
|
||||
<span
|
||||
class="time-ago-indicator-initial-placeholder"
|
||||
data-seconds="<%= article.published_at_int %>"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="tags featured-tags"
|
||||
>
|
||||
<a
|
||||
href="/t/javascript"
|
||||
>
|
||||
<span
|
||||
class="tag"
|
||||
>
|
||||
#javascript
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="/t/ruby"
|
||||
>
|
||||
<span
|
||||
class="tag"
|
||||
>
|
||||
#ruby
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="/t/go"
|
||||
>
|
||||
<span
|
||||
class="tag"
|
||||
>
|
||||
#go
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="article-engagement-count comments-count featured-engagement-count"
|
||||
>
|
||||
<a
|
||||
href="/some-post/path#comments"
|
||||
>
|
||||
<img
|
||||
alt="chat"
|
||||
loading="lazy"
|
||||
src="/images/comments-bubble.png"
|
||||
/>
|
||||
<span
|
||||
class="engagement-count-number"
|
||||
>
|
||||
213
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="article-engagement-count reactions-count"
|
||||
>
|
||||
<a
|
||||
href="/some-post/path"
|
||||
>
|
||||
<img
|
||||
alt="heart"
|
||||
loading="lazy"
|
||||
src="/images/reactions-stack.png"
|
||||
/>
|
||||
<span
|
||||
class="engagement-count-number"
|
||||
id="engagement-count-number-62407"
|
||||
>
|
||||
428
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
class="article-reading-time"
|
||||
href="/some-post/path"
|
||||
>
|
||||
undefined min read
|
||||
</a>
|
||||
<button
|
||||
class="article-engagement-count engage-button bookmark-button "
|
||||
data-initial-feed={true}
|
||||
data-reactable-id={62407}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onMouseout={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="bm-initial"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -242,6 +242,7 @@ export const podcastArticle = {
|
|||
slug: 'monitor-recontextualize',
|
||||
title: 'Rubber local',
|
||||
image_url: '/images/16.png',
|
||||
image_90: '/images/16.png',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -283,3 +284,15 @@ export const userArticle = {
|
|||
profile_image_90: '/images/3.png',
|
||||
},
|
||||
};
|
||||
|
||||
export const featuredArticle = {
|
||||
...article,
|
||||
main_image: '/images/onboarding-background.png',
|
||||
positive_reactions_count: 428,
|
||||
comments_count: 213,
|
||||
};
|
||||
|
||||
export const featuredArticleWithOrganization = {
|
||||
...articleWithOrganization,
|
||||
main_image: '/images/12.png',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import { h } from 'preact';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export const CommentsCount = ({ count, articlePath, icon }) => {
|
||||
export const CommentsCount = ({ count, articlePath, icon, className }) => {
|
||||
if (count > 0) {
|
||||
return (
|
||||
<div className="article-engagement-count comments-count">
|
||||
<div
|
||||
className={`article-engagement-count comments-count${
|
||||
className ? ` ${className}` : ''
|
||||
}`}
|
||||
>
|
||||
<a href={`${articlePath}#comments`}>
|
||||
<img src={icon} alt="chat" loading="lazy" />
|
||||
<span className="engagement-count-number">{count}</span>
|
||||
|
|
@ -18,12 +22,14 @@ export const CommentsCount = ({ count, articlePath, icon }) => {
|
|||
|
||||
CommentsCount.defaultProps = {
|
||||
count: 0,
|
||||
className: null,
|
||||
};
|
||||
|
||||
CommentsCount.propTypes = {
|
||||
count: PropTypes.number,
|
||||
articlePath: PropTypes.string.isRequired,
|
||||
icon: PropTypes.string.isRequired,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
CommentsCount.displayName = 'CommentsCount';
|
||||
|
|
|
|||
|
|
@ -1,42 +1,68 @@
|
|||
import { h } from 'preact';
|
||||
import { h, Component } from 'preact';
|
||||
import PropTypes from 'prop-types';
|
||||
import { articlePropTypes } from '../../src/components/common-prop-types';
|
||||
|
||||
export const SaveButton = ({ article, isBookmarked }) => {
|
||||
if (article.class_name === 'Article') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`article-engagement-count engage-button bookmark-button ${
|
||||
isBookmarked ? 'selected' : ''
|
||||
}`}
|
||||
data-reactable-id={article.id}
|
||||
>
|
||||
<span className="bm-initial">SAVE</span>
|
||||
<span className="bm-success">SAVED</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (article.class_name === 'User') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
style={{ width: '122px' }}
|
||||
className="article-engagement-count engage-button follow-action-button"
|
||||
data-info={`{"id":${article.id},"className":"User"}`}
|
||||
data-follow-action-button
|
||||
>
|
||||
|
||||
</button>
|
||||
);
|
||||
export class SaveButton extends Component {
|
||||
componentDidMount() {
|
||||
const { isBookmarked } = this.props;
|
||||
this.setState({ buttonText: isBookmarked ? 'SAVED' : 'SAVE' });
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
render() {
|
||||
const { buttonText } = this.state;
|
||||
const { article, isBookmarked, onClick } = this.props;
|
||||
const mouseOut = _e => {
|
||||
this.setState({ buttonText: isBookmarked ? 'SAVED' : 'SAVE' });
|
||||
};
|
||||
const mouseOver = _e => {
|
||||
if (isBookmarked) {
|
||||
this.setState({ buttonText: 'UNSAVE' });
|
||||
}
|
||||
};
|
||||
|
||||
if (article.class_name === 'Article') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`article-engagement-count engage-button bookmark-button ${
|
||||
isBookmarked ? 'selected' : ''
|
||||
}`}
|
||||
data-initial-feed
|
||||
data-reactable-id={article.id}
|
||||
onClick={onClick}
|
||||
onMouseOver={mouseOver}
|
||||
onFocus={mouseOver}
|
||||
onMouseout={mouseOut}
|
||||
onBlur={mouseOut}
|
||||
>
|
||||
<span className={isBookmarked ? 'bm-success' : 'bm-initial'}>
|
||||
{buttonText}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (article.class_name === 'User') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
style={{ width: '122px' }}
|
||||
className="article-engagement-count engage-button follow-action-button"
|
||||
data-info={`{"id":${article.id},"className":"User"}`}
|
||||
data-follow-action-button
|
||||
>
|
||||
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
SaveButton.propTypes = {
|
||||
article: articlePropTypes.isRequired,
|
||||
isBookmarked: PropTypes.bool.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
SaveButton.displayName = 'SaveButton';
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { h } from 'preact';
|
||||
import PropTypes from 'prop-types';
|
||||
import { tagPropTypes } from '../../src/components/common-prop-types';
|
||||
|
||||
export const TagList = ({ tags = [] }) => (
|
||||
<div className="tags">
|
||||
export const TagList = ({ tags = [], className }) => (
|
||||
<div className={`tags${className ? ` ${className}` : ''}`}>
|
||||
{tags.map(tag => (
|
||||
<a href={`/t/${tag}`}>
|
||||
<span className="tag">{`#${tag}`}</span>
|
||||
|
|
@ -11,8 +12,13 @@ export const TagList = ({ tags = [] }) => (
|
|||
</div>
|
||||
);
|
||||
|
||||
TagList.defaultProps = {
|
||||
className: null,
|
||||
};
|
||||
|
||||
TagList.propTypes = {
|
||||
tags: tagPropTypes.isRequired,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
TagList.displayName = 'TagList';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './Article';
|
||||
export * from './FeaturedArticle';
|
||||
export * from './LoadingArticle';
|
||||
export * from './PodcastArticle';
|
||||
|
|
|
|||
112
app/javascript/packs/Feed.jsx.erb
Normal file
112
app/javascript/packs/Feed.jsx.erb
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { h, Component } from 'preact';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
<% helpers = ActionController::Base.helpers %>
|
||||
|
||||
const FEED_ICONS = {
|
||||
COMMENTS_ICON: '<%= helpers.image_path("comments-bubble.png") %>',
|
||||
REACTIONS_ICON: '<%= helpers.image_path("reactions-stack.png") %>',
|
||||
VIDEO_ICON: '<%= helpers.image_path("video-camera.svg") %>',
|
||||
};
|
||||
|
||||
// TODO: Add scroll initial fetch and scroll etc.
|
||||
export class Feed extends Component {
|
||||
scrollHandler;
|
||||
|
||||
componentDidMount() {
|
||||
// this.scrollHandler = _event => {
|
||||
// // handle scroll logic for pages of feed data and get an extra page on load.
|
||||
// page += 1;
|
||||
// Feed.getFeedItems(this.props.timeFrame, page).then(feedItems => {
|
||||
// // We are not doing virtual scrolling at the moment, so feed items keep getting appended.
|
||||
// this.setState(previousState => ({
|
||||
// feedItems: previousState.feedItems.concat(feedItems),
|
||||
// }));
|
||||
// });
|
||||
// };
|
||||
|
||||
Feed.getFeedItems(this.props.timeFrame).then(feedItems => {
|
||||
this.setState({ feedItems });
|
||||
|
||||
this.feedContainer.addEventListener('scroll', this.scrollHandler);
|
||||
});
|
||||
this.setState({ podcastItems: Feed.getPodcastItems() });
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.timeFrame !== this.props.timeFrame) {
|
||||
// The feed timeframe has changed. Get new feed data.
|
||||
Feed.getFeedItems(this.props.timeFrame).then(feedItems => {
|
||||
this.setState({ feedItems });
|
||||
|
||||
this.feedContainer.addEventListener('scroll', this.scrollHandler);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.scrollHandler) {
|
||||
this.feedContainer.removeEventListener('scroll', this.scrollHandler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves feed data.
|
||||
*
|
||||
* @param {number} [page=1] Page of feed data to retrieve
|
||||
*
|
||||
* @returns {Promise} A promise containing the JSON response for the feed data.
|
||||
*/
|
||||
static getFeedItems(timeFrame = '', page = 1) {
|
||||
return fetch(`/stories/feed/${timeFrame}?page=${page}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-Token': window.csrfToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
}).then(response => response.json());
|
||||
}
|
||||
|
||||
static getPodcastItems() {
|
||||
const el = document.getElementById('followed-podcasts');
|
||||
const user = userData();
|
||||
const myEpisodes = []
|
||||
if (user && user.followed_podcast_ids && user.followed_podcast_ids.length > 0) {
|
||||
var data = JSON.parse(el.dataset.episodes);
|
||||
data.forEach(function(ep) {
|
||||
if (user.followed_podcast_ids.indexOf(ep.podcast.id) > -1) {
|
||||
myEpisodes.push(ep);
|
||||
}
|
||||
});
|
||||
}
|
||||
return myEpisodes;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { renderFeed } = this.props;
|
||||
const { feedItems = [], podcastItems = [] } = this.state;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={element => {
|
||||
this.feedContainer = element;
|
||||
}}
|
||||
>
|
||||
{renderFeed({ feedItems, podcastItems, feedIcons: FEED_ICONS })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Feed.defaultProps = {
|
||||
timeFrame: '',
|
||||
}
|
||||
|
||||
Feed.propTypes = {
|
||||
timeFrame: PropTypes.string,
|
||||
renderFeed: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
Feed.displayName = 'Feed';
|
||||
7
app/javascript/packs/feedIcons.js.erb
Normal file
7
app/javascript/packs/feedIcons.js.erb
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<% helpers = ActionController::Base.helpers %>
|
||||
|
||||
export const FEED_ICONS = {
|
||||
COMMENTS_ICON: '<%= helpers.image_path("comments-bubble.png") %>',
|
||||
REACTIONS_ICON: '<%= helpers.image_path("reactions-stack.png") %>',
|
||||
VIDEO_ICON: '<%= helpers.image_path("video-camera.svg") %>',
|
||||
};
|
||||
|
|
@ -1,7 +1,19 @@
|
|||
import { h, render } from 'preact';
|
||||
import { renderFeed } from './homePageFeed';
|
||||
|
||||
/* global userData */
|
||||
|
||||
// This logic is similar to that in initScrolling.js.erb
|
||||
// that prevents the classic Algolia scrolling for the front page.
|
||||
const frontPageFeedPathNames = new Map([
|
||||
['/', ''],
|
||||
['/top/week', 'week'],
|
||||
['/top/month', 'month'],
|
||||
['/top/year', 'year'],
|
||||
['/top/infinity', 'infinity'],
|
||||
['/latest', 'latest'],
|
||||
]);
|
||||
|
||||
function toggleListingsMinimization() {
|
||||
if (document.body.classList.contains('config_minimize_newest_listings')) {
|
||||
// Un-minimize
|
||||
|
|
@ -54,17 +66,40 @@ function renderTagsFollowed(tagsFollowedContainer, user = userData()) {
|
|||
});
|
||||
}
|
||||
|
||||
const feedTimeFrame = frontPageFeedPathNames.get(window.location.pathname);
|
||||
|
||||
let waitingForDataLoad = setTimeout(function dataLoadedCheck() {
|
||||
const { user = null, userStatus } = document.body.dataset;
|
||||
|
||||
if (userStatus === 'logged-out') {
|
||||
// Nothing to do, the user is not logged on.
|
||||
return;
|
||||
}
|
||||
|
||||
if (userStatus === 'logged-in' && user !== null) {
|
||||
// We have user data, render followed tags.
|
||||
clearTimeout(waitingForDataLoad);
|
||||
|
||||
import('./homePageFeed').then(({ renderFeed }) => {
|
||||
// We have user data, render followed tags.
|
||||
renderFeed(feedTimeFrame);
|
||||
|
||||
InstantClick.on('change', () => {
|
||||
const { userStatus: currentUserStatus } = document.body.dataset;
|
||||
|
||||
if (currentUserStatus === 'logged-out') {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(window.location);
|
||||
const changedFeedTimeFrame = frontPageFeedPathNames.get(url.pathname);
|
||||
|
||||
if (!frontPageFeedPathNames.has(url.pathname)) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderFeed(changedFeedTimeFrame);
|
||||
});
|
||||
});
|
||||
|
||||
renderTagsFollowed(document.getElementById('sidebar-nav-followed-tags'));
|
||||
return;
|
||||
}
|
||||
|
|
@ -73,7 +108,7 @@ let waitingForDataLoad = setTimeout(function dataLoadedCheck() {
|
|||
waitingForDataLoad = setTimeout(dataLoadedCheck, 40);
|
||||
}, 40);
|
||||
|
||||
InstantClick.on('receive', (_url, body, title) => {
|
||||
InstantClick.on('receive', (address, body, title) => {
|
||||
if (document.body.dataset.userStatus !== 'logged-in') {
|
||||
// Nothing to do, the user is not logged on.
|
||||
return false;
|
||||
|
|
|
|||
117
app/javascript/packs/homePageFeed.jsx
Normal file
117
app/javascript/packs/homePageFeed.jsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { h, render } from 'preact';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Article, FeaturedArticle, LoadingArticle } from '../articles';
|
||||
import { Feed } from '../articles/Feed';
|
||||
import { TodaysPodcasts, PodcastEpisode } from '../podcasts';
|
||||
import { articlePropTypes } from '../src/components/common-prop-types';
|
||||
|
||||
/**
|
||||
* Sends analytics about the featured article.
|
||||
*
|
||||
* @param {number} articleId
|
||||
*/
|
||||
function sendFeaturedArticleAnalytics(articleId) {
|
||||
(function logFeaturedArticleImpression() {
|
||||
if (!window.ga || !ga.create) {
|
||||
setTimeout(logFeaturedArticleImpression, 20);
|
||||
return;
|
||||
}
|
||||
|
||||
ga(
|
||||
'send',
|
||||
'event',
|
||||
'view',
|
||||
'featured-feed-impression',
|
||||
`articles-${articleId}`,
|
||||
null,
|
||||
);
|
||||
})();
|
||||
}
|
||||
|
||||
const FeedLoading = () => (
|
||||
<div>
|
||||
<LoadingArticle version='featured' />
|
||||
<LoadingArticle />
|
||||
<LoadingArticle />
|
||||
<LoadingArticle />
|
||||
<LoadingArticle />
|
||||
<LoadingArticle />
|
||||
<LoadingArticle />
|
||||
</div>
|
||||
);
|
||||
|
||||
const PodcastEpisodes = ({ episodes }) => (
|
||||
<TodaysPodcasts>
|
||||
{episodes.map(episode => (
|
||||
<PodcastEpisode episode={episode} />
|
||||
))}
|
||||
</TodaysPodcasts>
|
||||
);
|
||||
|
||||
PodcastEpisodes.defaultProps = {
|
||||
episodes: [],
|
||||
};
|
||||
|
||||
PodcastEpisodes.propTypes = {
|
||||
episodes: PropTypes.arrayOf(articlePropTypes),
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the main feed.
|
||||
*/
|
||||
export const renderFeed = timeFrame => {
|
||||
const feedContainer = document.getElementById('homepage-feed');
|
||||
|
||||
render(
|
||||
<Feed
|
||||
timeFrame={timeFrame}
|
||||
renderFeed={({
|
||||
feedItems,
|
||||
feedIcons,
|
||||
podcastEpisodes,
|
||||
bookmarkedFeedItems,
|
||||
bookmarkClick,
|
||||
}) => {
|
||||
if (feedItems.length === 0) {
|
||||
// Fancy loading ✨
|
||||
return <FeedLoading />;
|
||||
}
|
||||
|
||||
const commonProps = {
|
||||
reactionsIcon: feedIcons.REACTIONS_ICON,
|
||||
commentsIcon: feedIcons.COMMENTS_ICON,
|
||||
bookmarkClick,
|
||||
};
|
||||
|
||||
const [featuredStory, ...subStories] = feedItems;
|
||||
|
||||
sendFeaturedArticleAnalytics(featuredStory.id);
|
||||
|
||||
// 1. Show the featured story first
|
||||
// 2. Podcast episodes out today
|
||||
// 3. Rest of the stories for the feed
|
||||
return (
|
||||
<div>
|
||||
<FeaturedArticle
|
||||
{...commonProps}
|
||||
article={featuredStory}
|
||||
isBookmarked={bookmarkedFeedItems.has(featuredStory.id)}
|
||||
/>
|
||||
{podcastEpisodes.length > 0 && (
|
||||
<PodcastEpisodes episodes={podcastEpisodes} />
|
||||
)}
|
||||
{(subStories || []).map(story => (
|
||||
<Article
|
||||
{...commonProps}
|
||||
article={story}
|
||||
isBookmarked={bookmarkedFeedItems.has(story.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>,
|
||||
feedContainer,
|
||||
feedContainer.firstElementChild,
|
||||
);
|
||||
};
|
||||
24
app/javascript/podcasts/PodcastEpisode.jsx
Normal file
24
app/javascript/podcasts/PodcastEpisode.jsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { h } from 'preact';
|
||||
import { articlePropTypes } from '../src/components/common-prop-types';
|
||||
|
||||
export const PodcastEpisode = ({ episode }) => {
|
||||
return (
|
||||
<a
|
||||
className="individual-podcast-link"
|
||||
href={`/${episode.podcast.slug}/${episode.slug}`}
|
||||
>
|
||||
<img src={episode.podcast.image_90} alt={episode.title} />
|
||||
<div className="individual-podcast-link-details">
|
||||
<strong>{episode.title}</strong>
|
||||
{' '}
|
||||
{episode.podcast.title}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
PodcastEpisode.propTypes = {
|
||||
episode: articlePropTypes.isRequired,
|
||||
};
|
||||
|
||||
PodcastEpisode.displayName = 'PodcastEpisode';
|
||||
16
app/javascript/podcasts/PodcastFeed.jsx
Normal file
16
app/javascript/podcasts/PodcastFeed.jsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { h } from 'preact';
|
||||
|
||||
export const PodcastFeed = ({podcastItems}) => {
|
||||
const podcastItemDivs = podcastItems.map(ep => (
|
||||
<a class="individual-podcast-link" href={`/${ep.podcast.slug}/${ep.slug}`}>
|
||||
<img src={ep.podcast.image_90} /><div class="individual-podcast-link-details"><strong>{ep.title}</strong> {ep.podcast.title}</div>
|
||||
</a>
|
||||
));
|
||||
return (
|
||||
<div class="single-article single-article-podcast-div">
|
||||
<h3><a href="/pod">Today's Podcasts</a></h3>
|
||||
{podcastItemDivs}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
17
app/javascript/podcasts/TodaysPodcasts.jsx
Normal file
17
app/javascript/podcasts/TodaysPodcasts.jsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { h } from 'preact';
|
||||
import { defaultChildrenPropTypes } from '../src/components/common-prop-types';
|
||||
|
||||
export const TodaysPodcasts = ({ children }) => (
|
||||
<div className="single-article single-article-podcast-div">
|
||||
<h3>
|
||||
<a href="/pod">Today's Podcasts</a>
|
||||
</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
TodaysPodcasts.propTypes = {
|
||||
children: defaultChildrenPropTypes.isRequired,
|
||||
};
|
||||
|
||||
TodaysPodcasts.displayName = 'TodaysPodcasts';
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { h } from 'preact';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { PodcastEpisode } from '../PodcastEpisode';
|
||||
import { podcastArticle } from '../../articles/__tests__/utilities/articleUtilities';
|
||||
|
||||
import '../../../assets/stylesheets/articles.scss';
|
||||
|
||||
storiesOf(`Components/Podcasts/Episode`, module).add('Standard', () => (
|
||||
<PodcastEpisode episode={podcastArticle} />
|
||||
));
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { h } from 'preact';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
|
||||
import '../../../assets/stylesheets/articles.scss';
|
||||
import { TodaysPodcasts } from '../TodaysPodcasts';
|
||||
import { PodcastEpisode } from '../PodcastEpisode';
|
||||
import { podcastArticle } from '../../articles/__tests__/utilities/articleUtilities';
|
||||
import { articleDecorator } from '../../articles/__stories__/articleDecorator';
|
||||
|
||||
const episodes = [
|
||||
podcastArticle,
|
||||
podcastArticle,
|
||||
podcastArticle,
|
||||
podcastArticle,
|
||||
podcastArticle,
|
||||
];
|
||||
|
||||
storiesOf(`Components/Podcasts/Today's Episodes`, module)
|
||||
.addDecorator(articleDecorator)
|
||||
.add('Standard', () => (
|
||||
<TodaysPodcasts>
|
||||
{episodes.map(episode => (
|
||||
<PodcastEpisode episode={episode} />
|
||||
))}
|
||||
</TodaysPodcasts>
|
||||
));
|
||||
11
app/javascript/podcasts/__tests__/PodcastEpisode.test.jsx
Normal file
11
app/javascript/podcasts/__tests__/PodcastEpisode.test.jsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { h } from 'preact';
|
||||
import render from 'preact-render-to-json';
|
||||
import { PodcastEpisode } from '../PodcastEpisode';
|
||||
import { podcastArticle } from '../../articles/__tests__/utilities/articleUtilities';
|
||||
|
||||
describe('<PodcastEpisode /> component', () => {
|
||||
it('should render a podcast episode', () => {
|
||||
const tree = render(<PodcastEpisode episode={podcastArticle} />);
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
18
app/javascript/podcasts/__tests__/TodaysPodcasts.test.jsx
Normal file
18
app/javascript/podcasts/__tests__/TodaysPodcasts.test.jsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { h } from 'preact';
|
||||
import render from 'preact-render-to-json';
|
||||
import { PodcastEpisode } from '../PodcastEpisode';
|
||||
import { podcastArticle } from '../../articles/__tests__/utilities/articleUtilities';
|
||||
import { TodaysPodcasts } from '../TodaysPodcasts';
|
||||
|
||||
describe('<TodaysPodcasts /> component', () => {
|
||||
it(`should render a today's podcasts`, () => {
|
||||
const tree = render(
|
||||
<TodaysPodcasts>
|
||||
<PodcastEpisode episode={podcastArticle} />
|
||||
<PodcastEpisode episode={podcastArticle} />
|
||||
<PodcastEpisode episode={podcastArticle} />
|
||||
</TodaysPodcasts>,
|
||||
);
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<PodcastEpisode /> component should render a podcast episode 1`] = `
|
||||
<a
|
||||
class="individual-podcast-link"
|
||||
href="/monitor-recontextualize/undefined"
|
||||
>
|
||||
<img
|
||||
alt="monitor recontextualize"
|
||||
src="/images/16.png"
|
||||
/>
|
||||
<div
|
||||
class="individual-podcast-link-details"
|
||||
>
|
||||
<strong>
|
||||
monitor recontextualize
|
||||
</strong>
|
||||
Rubber local
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<TodaysPodcasts /> component should render a today's podcasts 1`] = `
|
||||
<div
|
||||
class="single-article single-article-podcast-div"
|
||||
>
|
||||
<h3>
|
||||
<a
|
||||
href="/pod"
|
||||
>
|
||||
Today's Podcasts
|
||||
</a>
|
||||
</h3>
|
||||
<a
|
||||
class="individual-podcast-link"
|
||||
href="/monitor-recontextualize/undefined"
|
||||
>
|
||||
<img
|
||||
alt="monitor recontextualize"
|
||||
src="/images/16.png"
|
||||
/>
|
||||
<div
|
||||
class="individual-podcast-link-details"
|
||||
>
|
||||
<strong>
|
||||
monitor recontextualize
|
||||
</strong>
|
||||
Rubber local
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
class="individual-podcast-link"
|
||||
href="/monitor-recontextualize/undefined"
|
||||
>
|
||||
<img
|
||||
alt="monitor recontextualize"
|
||||
src="/images/16.png"
|
||||
/>
|
||||
<div
|
||||
class="individual-podcast-link-details"
|
||||
>
|
||||
<strong>
|
||||
monitor recontextualize
|
||||
</strong>
|
||||
Rubber local
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
class="individual-podcast-link"
|
||||
href="/monitor-recontextualize/undefined"
|
||||
>
|
||||
<img
|
||||
alt="monitor recontextualize"
|
||||
src="/images/16.png"
|
||||
/>
|
||||
<div
|
||||
class="individual-podcast-link-details"
|
||||
>
|
||||
<strong>
|
||||
monitor recontextualize
|
||||
</strong>
|
||||
Rubber local
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
2
app/javascript/podcasts/index.jsx
Normal file
2
app/javascript/podcasts/index.jsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './PodcastEpisode';
|
||||
export * from './TodaysPodcasts';
|
||||
|
|
@ -34,6 +34,7 @@
|
|||
|
||||
<%= render "articles/sidebar" %>
|
||||
|
||||
<%# BEGIN Feed menu bar %>
|
||||
<div class="articles-list" id="articles-list">
|
||||
<div class="on-page-nav-controls" id="on-page-nav-controls">
|
||||
<div class="on-page-nav-label">
|
||||
|
|
@ -82,73 +83,15 @@
|
|||
<img src="<%= asset_path "lightning.svg" %>" alt="right-sidebar-nav">
|
||||
</button>
|
||||
</div>
|
||||
<%# END Feed menu bar %>
|
||||
|
||||
<% if @featured_story.id %>
|
||||
<div id="featured-story-marker" data-featured-article="articles-<%= @featured_story.id %>"></div>
|
||||
<img src="<%= cloud_cover_url(@featured_story.main_image) %>" style="display:none" alt="<%= @featured_story.title %>" />
|
||||
<a href="<%= @featured_story.path %>" id="article-link-<%= @featured_story.id %>" class="index-article-link" aria-label="Main Story" data-featured-article="articles-<%= @featured_story.id %>">
|
||||
<div class="single-article big-article" data-content-user-id="<%= @featured_story.user_id %>">
|
||||
<div class="picture image-final" style="background-color:<%= @featured_story.main_image_background_hex_color %>;background-image:url(<%= cloud_cover_url(@featured_story.main_image) %>)"></div>
|
||||
<div class="content-wrapper">
|
||||
<h3>
|
||||
<%= @featured_story.title %>
|
||||
</h3>
|
||||
</div>
|
||||
<a href="/<%= @featured_story.cached_user.username %>" class="featured-profile-button">
|
||||
<img class="featured-profile-pic" src="<%= ProfileImage.new(@featured_story.cached_user).get(width: 90) %>" width="40" height="40" alt="<%= @featured_story.title %>" />
|
||||
</a>
|
||||
<div class="featured-user-name">
|
||||
<a href="/<%= @featured_story.cached_user.username %>">
|
||||
<%= @featured_story.cached_user.name %><%= render "shared/pro_checkmark" if @featured_story.cached_user.pro %>・<time datetime="<%= @featured_story.published_timestamp %>"><%= @featured_story.readable_publish_date %></time>
|
||||
<span class="time-ago-indicator-initial-placeholder" data-seconds="<%= @featured_story.published_at_int %>"></span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="featured-tags tags">
|
||||
<% @featured_story.cached_tag_list_array.each do |tag| %>
|
||||
<a href="/t/<%= tag %>"><span class="tag">#<%= tag %></span></a>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if @featured_story.comments_count > 0 %>
|
||||
<div class="article-engagement-count comments-count featured-engagement-count">
|
||||
<a href="<%= @featured_story.path %>#comments"><img src="<%= asset_path("comments-bubble.png") %>" alt="chat" />
|
||||
<span class="engagement-count-number"><%= @featured_story.comments_count %></span>
|
||||
</a>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if @featured_story.positive_reactions_count > 0 %>
|
||||
<div class="article-engagement-count reactions-count featured-engagement-count" data-reaction-count data-reactable-id="<%= @featured_story.id %>">
|
||||
<a href="<%= @featured_story.path %>"><img src="<%= asset_path("reactions-stack.png") %>" alt="heart" /><span id="engagement-count-number-<%= @featured_story.id %>" class="engagement-count-number">
|
||||
<%= @featured_story.positive_reactions_count %>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<% end %>
|
||||
<a
|
||||
href="<%= @featured_story.path %>"
|
||||
class="article-reading-time">
|
||||
<%= @featured_story.reading_time < 1 ? 1 : @featured_story.reading_time %> min read
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="article-engagement-count engage-button bookmark-button featured-engagement-count"
|
||||
data-reactable-id="<%= @featured_story.id %>"
|
||||
aria-label="Save to reading list"
|
||||
title="Save to reading list">
|
||||
<span class="bm-initial">SAVE</span>
|
||||
<span class="bm-success">SAVED</span>
|
||||
</button>
|
||||
</div>
|
||||
</a>
|
||||
<% if user_signed_in? %>
|
||||
<div id="homepage-feed"></div>
|
||||
<% else %>
|
||||
<%= render "stories/main_stories_feed" %>
|
||||
<% end %>
|
||||
|
||||
<div id="article-index-podcast-div"></div>
|
||||
|
||||
<div class="substories" id="substories">
|
||||
<% if @stories.any? %>
|
||||
<%= render "stories/main_stories_feed" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div id="followed-podcasts" data-episodes="<%= @podcast_episodes.to_json(include: { podcast: { only: %i[slug title id], methods: %i[image_90] } }) %>"></div>
|
||||
<div class="loading-articles" id="loading-articles">
|
||||
loading...
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,36 +1,75 @@
|
|||
<% if @default_home_feed && user_signed_in? %>
|
||||
<% cache(cache_key_heroku_slug("fetched-home-articles-object"), expires_in: 3.minutes) do %>
|
||||
<div id="followed-podcasts" data-episodes="<%= @podcast_episodes.to_json(include: { podcast: { only: %i[slug title id], methods: %i[image_90] } }) %>">
|
||||
</div>
|
||||
<div id="home-articles-object"
|
||||
data-articles="<%= @stories.to_json(controller.class.const_get(:DEFAULT_HOME_FEED_ATTRIBUTES_FOR_SERIALIZATION)) %>">
|
||||
<% 3.times do %>
|
||||
<div class="single-article single-article-small-pic">
|
||||
<div class="small-pic">
|
||||
<div class="color single-article-loading"></div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h3 class="single-article-loading"> </h3>
|
||||
</div>
|
||||
<h4 class="single-article-loading" style="width:46%">
|
||||
|
||||
</h4>
|
||||
<div class="tags single-article-loading" style="width:56%">
|
||||
</div>
|
||||
<% if @featured_story.id %>
|
||||
<div id="featured-story-marker" data-featured-article="articles-<%= @featured_story.id %>"></div>
|
||||
<img src="<%= cloud_cover_url(@featured_story.main_image) %>" style="display:none" alt="<%= @featured_story.title %>" />
|
||||
<a href="<%= @featured_story.path %>" id="article-link-<%= @featured_story.id %>" class="index-article-link" aria-label="Main Story" data-featured-article="articles-<%= @featured_story.id %>">
|
||||
<div class="single-article big-article" data-content-user-id="<%= @featured_story.user_id %>">
|
||||
<div class="picture image-final" style="background-color:<%= @featured_story.main_image_background_hex_color %>;background-image:url(<%= cloud_cover_url(@featured_story.main_image) %>)"></div>
|
||||
<div class="content-wrapper">
|
||||
<h3>
|
||||
<%= @featured_story.title %>
|
||||
</h3>
|
||||
</div>
|
||||
<a href="/<%= @featured_story.cached_user.username %>" class="featured-profile-button">
|
||||
<img class="featured-profile-pic" src="<%= ProfileImage.new(@featured_story.cached_user).get(width: 90) %>" width="40" height="40" alt="<%= @featured_story.title %>" />
|
||||
</a>
|
||||
<div class="featured-user-name">
|
||||
<a href="/<%= @featured_story.cached_user.username %>">
|
||||
<%= @featured_story.cached_user.name %><%= render "shared/pro_checkmark" if @featured_story.cached_user.pro %>・<time datetime="<%= @featured_story.published_timestamp %>"><%= @featured_story.readable_publish_date %></time>
|
||||
<span class="time-ago-indicator-initial-placeholder" data-seconds="<%= @featured_story.published_at_int %>"></span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="featured-tags tags">
|
||||
<% @featured_story.cached_tag_list_array.each do |tag| %>
|
||||
<a href="/t/<%= tag %>"><span class="tag">#<%= tag %></span></a>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if @featured_story.comments_count > 0 %>
|
||||
<div class="article-engagement-count comments-count featured-engagement-count">
|
||||
<a href="<%= @featured_story.path %>#comments"><img src="<%= asset_path("comments-bubble.png") %>" alt="chat" />
|
||||
<span class="engagement-count-number"><%= @featured_story.comments_count %></span>
|
||||
</a>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if @featured_story.positive_reactions_count > 0 %>
|
||||
<div class="article-engagement-count reactions-count featured-engagement-count" data-reaction-count data-reactable-id="<%= @featured_story.id %>">
|
||||
<a href="<%= @featured_story.path %>"><img src="<%= asset_path("reactions-stack.png") %>" alt="heart" /><span id="engagement-count-number-<%= @featured_story.id %>" class="engagement-count-number">
|
||||
<%= @featured_story.positive_reactions_count %>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<% end %>
|
||||
<a
|
||||
href="<%= @featured_story.path %>"
|
||||
class="article-reading-time">
|
||||
<%= @featured_story.reading_time < 1 ? 1 : @featured_story.reading_time %> min read
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="article-engagement-count engage-button bookmark-button featured-engagement-count"
|
||||
data-reactable-id="<%= @featured_story.id %>"
|
||||
aria-label="Save to reading list"
|
||||
title="Save to reading list">
|
||||
<span class="bm-initial">SAVE</span>
|
||||
<span class="bm-success">SAVED</span>
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<% @stories.each_with_index do |story, i| %>
|
||||
<% next if story.id == @featured_story.id %>
|
||||
<% if !user_signed_in? && i == 4 %>
|
||||
<%= render "stories/sign_in_invitation" %>
|
||||
</a>
|
||||
<% end %>
|
||||
|
||||
<div id="article-index-podcast-div"></div>
|
||||
|
||||
<div class="substories" id="substories">
|
||||
<% if @stories.any? %>
|
||||
<% @stories.each_with_index do |story, i| %>
|
||||
<% next if story.id == @featured_story.id %>
|
||||
<% if !user_signed_in? && i == 4 %>
|
||||
<%= render "stories/sign_in_invitation" %>
|
||||
<% end %>
|
||||
<%= render "articles/single_story", story: story %>
|
||||
<% end %>
|
||||
<%= render "articles/single_story", story: story %>
|
||||
<% if @stories.size > 1 %>
|
||||
<div class="placeholder-div"></div>
|
||||
<% end %>
|
||||
<div class="single-article-small-pic" id="article-index-hidden-div" style="display:none"></div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if @stories.size > 1 %>
|
||||
<div class="placeholder-div"></div>
|
||||
<% end %>
|
||||
<div class="single-article-small-pic" id="article-index-hidden-div" style="display:none"></div>
|
||||
</div>
|
||||
|
|
@ -138,31 +138,6 @@ RSpec.describe "StoriesIndex", type: :request do
|
|||
expect(response.body).not_to include(CGI.escapeHTML("Super-puper"))
|
||||
end
|
||||
end
|
||||
|
||||
describe "when authenticated" do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
before do
|
||||
sign_in user
|
||||
end
|
||||
|
||||
it "contains the stories correctly serialized" do
|
||||
# we control titles to avoid escaping errors with apostrophes and such
|
||||
article = create(:article, featured: true)
|
||||
article.update_columns(title: "abc")
|
||||
|
||||
articles = create_list(:article, 2)
|
||||
articles.each { |a| a.update_columns(title: "abc") }
|
||||
|
||||
get root_path
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
stories = controller.instance_variable_get(:@stories) # cheating a bit ;)
|
||||
expected_result = ArticleDecorator.decorate_collection(stories).
|
||||
to_json(controller.class.const_get(:DEFAULT_HOME_FEED_ATTRIBUTES_FOR_SERIALIZATION))
|
||||
expect(response.body).to include(ERB::Util.html_escape(expected_result))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET query page" do
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue