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:
parent
38e944437a
commit
c46205f220
14 changed files with 7663 additions and 176 deletions
43
.github/workflows/main.yml
vendored
43
.github/workflows/main.yml
vendored
|
@ -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
1
.nvmrc
Normal file
|
@ -0,0 +1 @@
|
|||
v16.1.0
|
9
netlify.toml
Normal file
9
netlify.toml
Normal file
|
@ -0,0 +1,9 @@
|
|||
[functions]
|
||||
directory = "netlify/functions/"
|
||||
|
||||
[dev]
|
||||
command = "yarn start:parcel"
|
||||
publish = "dist"
|
||||
targetPort = 1234
|
||||
port = 8080
|
||||
framework = "parcel"
|
|
@ -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"
|
||||
|
|
12
src/App.tsx
12
src/App.tsx
|
@ -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
|
||||
|
|
|
@ -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(' ', '_')}`}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'))
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
16
src/utils/sortFeedItemsByDate.ts
Normal file
16
src/utils/sortFeedItemsByDate.ts
Normal 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,
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue