feat: organize in feeds, sort by date (#12)

* feat: organize in feeds, sort by date

* fix: propname

* wip: cruddy loading screen

* fix: actions

* fix: caching format

* fix: typo

* feat: display feedname

* build: local tooling

* fix: date sort

* ci: add lint
This commit is contained in:
Marc Cataford 2021-07-25 14:18:16 -04:00 committed by GitHub
parent 38e944437a
commit c46205f220
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 7663 additions and 176 deletions

View file

@ -26,6 +26,27 @@ jobs:
- name: Install dependencies
if: steps.cache-restore.outputs.cache-hit != 'true'
run: yarn
lint:
runs-on: ubuntu-latest
name: Lint
needs: setup
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
id: node-setup
with:
node-version: ${{ env.NODE_VERSION }}
- name: Yarn cache
uses: actions/cache@v2
id: yarn-cache-restore
with:
path: |
.yarn
key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-${{ env.NODE_VERSION }}
- name: Lint
run: |
yarn
yarn lint
build:
runs-on: ubuntu-latest
name: Build
@ -69,20 +90,26 @@ jobs:
id: node-setup
with:
node-version: ${{ env.NODE_VERSION }}
- name: Yarn cache
uses: actions/cache@v2
id: yarn-cache-restore
with:
path: |
.yarn
key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-${{ env.NODE_VERSION }}
- run: yarn
- name: Build Artifacts
uses: actions/download-artifact@v2
with:
name: build-artifacts
path: dist
- name: Netlify CLI setup
run: npm install -g netlify-cli
- name: Deploy
id: preview-deploy
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
run: |
netlify deploy --dir=dist > output.log
yarn netlify deploy --dir=dist > output.log
echo "::set-output name=draft-url::$(grep 'Website Draft URL' output.log)"
- name: Report
uses: actions/github-script@v2
@ -108,6 +135,14 @@ jobs:
id: node-setup
with:
node-version: ${{ env.NODE_VERSION }}
- name: Yarn cache
uses: actions/cache@v2
id: yarn-cache-restore
with:
path: |
.yarn
key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-${{ env.NODE_VERSION }}
- run: yarn
- name: Build Artifacts
uses: actions/download-artifact@v2
with:
@ -121,4 +156,4 @@ jobs:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
run: |
netlify deploy --dir=dist --prod
yarn netlify deploy --dir=dist --prod

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
v16.1.0

9
netlify.toml Normal file
View file

@ -0,0 +1,9 @@
[functions]
directory = "netlify/functions/"
[dev]
command = "yarn start:parcel"
publish = "dist"
targetPort = 1234
port = 8080
framework = "parcel"

View file

@ -4,12 +4,14 @@
"version": "1.0.0",
"license": "GPL-3.0",
"scripts": {
"start": "parcel src/index.html --https --host localhost.localdomain",
"start": "netlify dev",
"start:parcel": "parcel serve src/index.html",
"lint": "eslint src",
"lint:fix": "eslint src netlify --fix",
"types": "tsc --noEmit",
"clean": "rm -rf dist/*",
"build": "parcel build src/index.html",
"build:watch": "parcel watch src/index.html",
"build:bundlesize": "parcel build src/index.html --reporter @parcel/reporter-bundle-analyzer"
},
"devDependencies": {
@ -30,6 +32,7 @@
"eslint-plugin-react": "^7.24.0",
"eslint-plugin-react-hooks": "^4.2.0",
"jest": "^27.0.6",
"netlify-cli": "^5.2.3",
"parcel": "^2.0.0-beta.2",
"prettier": "^2.3.2",
"typescript": "^4.3.5"

View file

@ -10,7 +10,7 @@ import useAppState from './utils/useAppState'
export default function App(): ReactNode {
const [state, actions] = useAppState()
const { setActivePanel, setRssItems, setFeedUrls } = actions
const { setActivePanel, setFeeds, setFeedUrls } = actions
useEffect(() => {
if (state.loaded) return
@ -20,12 +20,14 @@ export default function App(): ReactNode {
useEffect(() => {
const fetch = async () => {
const feedItems = await fetchFeeds(state.feedUrls)
setRssItems(feedItems)
const feeds = await fetchFeeds(state.feedUrls)
setFeeds(feeds)
}
fetch()
}, [state.feedUrls, setRssItems])
}, [state.feedUrls, setFeeds])
if (!state.feeds) return 'loading'
return (
<>
@ -34,7 +36,7 @@ export default function App(): ReactNode {
setActivePanel={setActivePanel}
/>
{state.activePanel === panelIdentifiers.FEEDS ? (
<FeedsPanel items={state.rssItems} />
<FeedsPanel feeds={state.feeds} />
) : null}
{state.activePanel === panelIdentifiers.SETTINGS ? (
<SettingsPanel

View file

@ -3,15 +3,17 @@ import Box from '@material-ui/core/Box'
import Card from '@material-ui/core/Card'
import { makeStyles } from '@material-ui/core/styles'
import type { Item } from './types'
import sortFeedItemsByDate from './utils/sortFeedItemsByDate'
import type { Feed } from './types'
interface Props {
items: Item[]
feeds: Feed[]
}
interface CardProps {
title: string
url: string
published: Date
feedTitle: string
}
const useStyles = makeStyles({
@ -24,22 +26,28 @@ const useStyles = makeStyles({
})
function ItemCard(props: CardProps): ReactNode {
const { title, url, published } = props
const { title, url, published, feedTitle } = props
const classes = useStyles()
const formattedDate = (new Date(published)).toLocaleString('en-GB', { timeZone: 'UTC' })
const formattedDate = new Date(published).toLocaleString('en-GB', {
timeZone: 'UTC',
})
return (
<Card className={classes.root}>
<a href={url}>{title}</a>
<span>{formattedDate}</span>
<span>{`${feedTitle} - ${formattedDate}`}</span>
</Card>
)
}
export default function FeedsPanel(props: Props): ReactNode {
const { items } = props
const { feeds } = props
const flattenedItems = sortFeedItemsByDate(feeds)
return (
<Box display="flex" flexDirection="column">
{items.map((item) => (
{flattenedItems.map((item) => (
<ItemCard
{...item}
key={`feed_item_${item.title.replace(' ', '_')}`}

View file

@ -8,6 +8,6 @@
</style>
<body>
<div id="app"></div>
<script type="module" src="./index.ts"></script>
<script type="module" src="./index.tsx"></script>
</body>
</html>

View file

@ -3,4 +3,4 @@ import ReactDOM from 'react-dom'
import App from './App'
ReactDOM.render( <App/>, document.getElementById('app'),);
ReactDOM.render(<App />, document.getElementById('app'))

View file

@ -1,7 +1,14 @@
export interface Feed {
title: string
lastPull: string
items: Item[]
}
export interface Item {
title: string
url: string
published: Date
source: string
}
export interface State {

View file

@ -1,20 +1,24 @@
import axios from 'axios'
import { parseFeed } from 'htmlparser2'
import type { Item } from '../types'
import type { Feed } from '../types'
import { restoreRssData, storeRssData } from './persistence'
function processFeedXML(feed) {
return feed.items.reduce((items, feedItem) => {
items.push({
title: feedItem.title,
url: feedItem.link,
published: feedItem.pubDate,
})
return {
title: feed.title,
lastPull: Date.now(),
items: feed.items.reduce((items, feedItem) => {
items.push({
title: feedItem.title,
url: feedItem.link,
published: new Date(feedItem.pubDate),
})
return items
}, [])
return items
}, []),
}
}
function getRefetchThreshold() {
@ -23,6 +27,23 @@ function getRefetchThreshold() {
return refetchThreshold
}
function mergeFeeds(first, second) {
// Assuming `second` is newer.
const seen = new Set(items.map((item) => item.url))
const mergedItems = newFeedItems.reduce((updatedItems, item) => {
if (!seen.has(item.url)) {
updatedItems.push(item)
seen.add(item.url)
}
return updatedItems
}, [])
return {
...second,
items: mergedItems,
}
}
/*
* Fetches RSS feeds from the given url list. If feed data exists in
* localStorage, it is used as a basis for the final list and any new
@ -34,47 +55,37 @@ function getRefetchThreshold() {
export default async function fetchFeeds(
feedUrls: string[],
forceRefetch = false,
): Item[] {
const feed = await Promise.all(
): Feed[] {
const feeds = await Promise.all(
feedUrls.map(async (url: string) => {
const storedFeedData = restoreRssData(url)
const items = storedFeedData?.items || []
const lastPush = storedFeedData?.lastPush
if (!forceRefetch && lastPush > getRefetchThreshold()) return items
// Skip refetch if not stale / not forced
const lastPull = storedFeedData?.lastPull
if (!forceRefetch && lastPull > getRefetchThreshold())
return storedFeedData
const response = await axios.get('/.netlify/functions/rss-proxy', {
params: { url },
})
const availableFeedItems = [...items]
try {
const newFeedData = parseFeed(response.data)
const newFeedItems = processFeedXML(newFeedData)
const seen = new Set(availableFeedItems.map((item) => item.url))
const newFeed = processFeedXML(newFeedData)
const mergedFeeds = storedFeedData
? mergeFeeds(storedFeedData, newFeed)
: newFeed
newFeedItems.forEach((item) => {
if (seen.has(item.url)) return
availableFeedItems.push(item)
seen.add(item.url)
})
storeRssData(url, mergedFeeds)
return mergedFeeds
} catch (e) {
// eslint-disable-next-line no-console
console.error(e.response)
}
storeRssData(url, availableFeedItems)
return availableFeedItems
return storedFeedData
}),
)
// TODO: Flattening to be part of the above.
const flattenedFeed = feed.reduce((acc, items) => {
acc.push(...items)
return acc
}, [])
return flattenedFeed
return feeds
}

View file

@ -27,7 +27,7 @@ export function restoreSettings(): Settings {
export function storeRssData(url: string, items: Item[]): void {
const key = RSS_DATA_KEY_PREFIX + md5(url)
storeToLocal(key, { items, lastPush: Date.now() })
storeToLocal(key, items)
}
export function restoreRssData(url: string): RSSData {

View file

@ -0,0 +1,16 @@
import type { Feed, Item } from '../types'
export default function sortFeedItemsByDate(feeds: Feed[]): Item[] {
const flattened = feeds.reduce((flattenedFeeds, feed) => {
const items = feed.items.map((item) => ({
...item,
feedTitle: feed.title,
}))
flattenedFeeds.push(...items)
return flattenedFeeds
}, [])
return flattened.sort((first, second) =>
first.published > second.published ? -1 : 1,
)
}

View file

@ -5,12 +5,12 @@ import { panelIdentifiers } from '../constants'
export const SET_PANEL_IDENTIFIER = 'setPanelIdentifier'
export const SET_FEED_URLS = 'setFeedUrls'
export const SET_RSS_ITEMS = 'setRssItems'
export const SET_FEEDS = 'setFeeds'
export const defaultState = {
loaded: false,
activePanel: panelIdentifiers.FEEDS,
rssItems: [],
feeds: [],
feedUrls: [],
}
@ -20,8 +20,8 @@ function reducer(state, action) {
return { ...state, activePanel: action.payload.panelIdentifier }
case SET_FEED_URLS:
return { ...state, loaded: true, feedUrls: action.payload.feedUrls }
case SET_RSS_ITEMS:
return { ...state, rssItems: action.payload.rssItems }
case SET_FEEDS:
return { ...state, feeds: action.payload.feeds }
default:
return state
}
@ -41,17 +41,17 @@ function setFeedUrls(feedUrls: string[]) {
}
}
function setRssItems(rssItems) {
function setFeeds(feeds) {
return {
type: SET_RSS_ITEMS,
payload: { rssItems },
type: SET_FEEDS,
payload: { feeds },
}
}
type Action =
| ReturnType<setRssItems>
| ReturnType<setFeeds>
| ReturnType<setFeedUrls>
| ReturnType<setACtivePanel>
| ReturnType<setActivePanel>
interface ActionSet {
[key: string]: Action
@ -64,7 +64,7 @@ export default function useAppState(): [State, ActionSet] {
setActivePanel: (panelIdentifier) =>
dispatch(setActivePanel(panelIdentifier)),
setFeedUrls: (feedUrls) => dispatch(setFeedUrls(feedUrls)),
setRssItems: (rssItems) => dispatch(setRssItems(rssItems)),
setFeeds: (feeds) => dispatch(setFeeds(feeds)),
}
const stableActions = useRef(actions)

7621
yarn.lock

File diff suppressed because it is too large Load diff