build: switch linterformatter to rome (#120)
* build: replace eslint with rome * chore: run lint+formatter * chore: more cleanup
This commit is contained in:
parent
fc531cd84a
commit
19cb3dba19
17 changed files with 538 additions and 3044 deletions
|
@ -1,6 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
extends: '@tophat',
|
|
||||||
rules: {
|
|
||||||
"react/react-in-jsx-scope": "off"
|
|
||||||
}
|
|
||||||
}
|
|
19
package.json
19
package.json
|
@ -10,8 +10,8 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start:app": "yarn workspace app start",
|
"start:app": "yarn workspace app start",
|
||||||
"start:api": "yarn workspace api start",
|
"start:api": "yarn workspace api start",
|
||||||
"lint": "eslint packages/**/*.ts",
|
"lint": "yarn rome format packages/**/src/*.ts && yarn rome check packages/**/src/*.ts",
|
||||||
"lint:fix": "eslint packages/**/*.ts netlify --fix",
|
"lint:fix": "yarn rome format packages/**/src/*.ts --write && yarn rome check packages/**/src/*.ts --apply",
|
||||||
"types": "tsc --noEmit",
|
"types": "tsc --noEmit",
|
||||||
"clean": "rm -rf dist/*",
|
"clean": "rm -rf dist/*",
|
||||||
"build:app": "yarn workspace app build",
|
"build:app": "yarn workspace app build",
|
||||||
|
@ -22,25 +22,12 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@parcel/reporter-bundle-analyzer": "^2.9.3",
|
"@parcel/reporter-bundle-analyzer": "^2.9.3",
|
||||||
"@parcel/validator-typescript": "^2.9.3",
|
"@parcel/validator-typescript": "^2.9.3",
|
||||||
"@tophat/eslint-config": "3.3.0",
|
|
||||||
"@tophat/eslint-import-resolver-require": "0.1.3",
|
|
||||||
"@typescript-eslint/eslint-plugin": "5.48.2",
|
|
||||||
"@typescript-eslint/parser": "5.48.2",
|
|
||||||
"esbuild": "^0.17.3",
|
"esbuild": "^0.17.3",
|
||||||
"eslint": "8.32.0",
|
|
||||||
"eslint-config-prettier": "8.5.0",
|
|
||||||
"eslint-import-resolver-node": "0.3.6",
|
|
||||||
"eslint-import-resolver-typescript": "3.5.2",
|
|
||||||
"eslint-plugin-import": "2.26.0",
|
|
||||||
"eslint-plugin-jest": "27.2.1",
|
|
||||||
"eslint-plugin-jsx-a11y": "6.6.1",
|
|
||||||
"eslint-plugin-prettier": "4.2.1",
|
|
||||||
"eslint-plugin-react": "7.31.10",
|
|
||||||
"eslint-plugin-react-hooks": "4.6.0",
|
|
||||||
"jest": "29.3.1",
|
"jest": "29.3.1",
|
||||||
"netlify-cli": "^15.7.0",
|
"netlify-cli": "^15.7.0",
|
||||||
"parcel": "^2.9.3",
|
"parcel": "^2.9.3",
|
||||||
"prettier": "2.7.1",
|
"prettier": "2.7.1",
|
||||||
|
"rome": "^12.1.3",
|
||||||
"typescript": "^4.9.4"
|
"typescript": "^4.9.4"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
import {
|
import {
|
||||||
type Express,
|
type Express,
|
||||||
type Request,
|
type Request,
|
||||||
type Response,
|
type Response,
|
||||||
default as express,
|
default as express,
|
||||||
} from 'express'
|
} from "express";
|
||||||
|
|
||||||
const app: Express = express()
|
const app: Express = express();
|
||||||
|
|
||||||
app.get('/', (req: Request, res: Response) => {
|
app.get("/", (req: Request, res: Response) => {
|
||||||
res.send('ok')
|
res.send("ok");
|
||||||
})
|
});
|
||||||
|
|
||||||
app.listen(8081, () => {})
|
app.listen(8081, () => {});
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
import { FunctionComponent } from 'preact'
|
import { FunctionComponent } from "preact";
|
||||||
|
|
||||||
import FeedsPanel from './FeedsPanel'
|
import FeedsPanel from "./FeedsPanel";
|
||||||
import NavigationBar from './NavigationBar'
|
import NavigationBar from "./NavigationBar";
|
||||||
import SettingsPanel from './SettingsPanel'
|
import SettingsPanel from "./SettingsPanel";
|
||||||
import useNavigation, { routes } from './hooks/useNavigation'
|
import useNavigation, { routes } from "./hooks/useNavigation";
|
||||||
|
|
||||||
export default function App(): FunctionComponent {
|
export default function App(): FunctionComponent {
|
||||||
const { location } = useNavigation()
|
const { location } = useNavigation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NavigationBar />
|
<NavigationBar />
|
||||||
{location === routes.FEEDS ? <FeedsPanel /> : null}
|
{location === routes.FEEDS ? <FeedsPanel /> : null}
|
||||||
{location === routes.SETTINGS ? <SettingsPanel /> : null}
|
{location === routes.SETTINGS ? <SettingsPanel /> : null}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,76 +1,75 @@
|
||||||
import Box from '@material-ui/core/Box'
|
import Box from "@material-ui/core/Box";
|
||||||
import Card from '@material-ui/core/Card'
|
import Card from "@material-ui/core/Card";
|
||||||
import Typography from '@material-ui/core/Typography'
|
import Typography from "@material-ui/core/Typography";
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from "@material-ui/core/styles";
|
||||||
import { FunctionComponent } from 'preact'
|
import { FunctionComponent } from "preact";
|
||||||
|
|
||||||
import useRSSFeeds from './hooks/useRSSFeeds'
|
import useRSSFeeds from "./hooks/useRSSFeeds";
|
||||||
import useSettings from './hooks/useSettings'
|
import useSettings from "./hooks/useSettings";
|
||||||
import sortFeedItemsByDate from './utils'
|
import sortFeedItemsByDate from "./utils";
|
||||||
|
|
||||||
interface CardProps {
|
interface CardProps {
|
||||||
title: string
|
title: string;
|
||||||
url: string
|
url: string;
|
||||||
published: Date
|
published: Date;
|
||||||
feedTitle: string
|
feedTitle: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
root: {
|
root: {
|
||||||
margin: '5px',
|
margin: "5px",
|
||||||
padding: '10px',
|
padding: "10px",
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexDirection: 'column',
|
flexDirection: "column",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
function ItemCard(props: CardProps) {
|
function ItemCard(props: CardProps) {
|
||||||
const { title, url, published, feedTitle } = props
|
const { title, url, published, feedTitle } = props;
|
||||||
const classes = useStyles()
|
const classes = useStyles();
|
||||||
|
|
||||||
const formattedDate = new Date(published).toLocaleString('en-GB', {
|
const formattedDate = new Date(published).toLocaleString("en-GB", {
|
||||||
timeZone: 'UTC',
|
timeZone: "UTC",
|
||||||
})
|
});
|
||||||
return (
|
return (
|
||||||
<Card className={classes.root}>
|
<Card className={classes.root}>
|
||||||
<a href={url}>{title}</a>
|
<a href={url}>{title}</a>
|
||||||
<span>{`${feedTitle} - ${formattedDate}`}</span>
|
<span>{`${feedTitle} - ${formattedDate}`}</span>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoItemsNotice() {
|
function NoItemsNotice() {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
alignSelf="center"
|
alignSelf="center"
|
||||||
display="flex"
|
display="flex"
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
mt="50px"
|
mt="50px"
|
||||||
>
|
>
|
||||||
<Typography variant="h6">Nothing to see here!</Typography>
|
<Typography variant="h6">Nothing to see here!</Typography>
|
||||||
<Typography>
|
<Typography>
|
||||||
Add some feeds in the <strong>Settings</strong> panel to get
|
Add some feeds in the <strong>Settings</strong> panel to get started!
|
||||||
started!
|
</Typography>
|
||||||
</Typography>
|
</Box>
|
||||||
</Box>
|
);
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FeedsPanel(): FunctionComponent {
|
export default function FeedsPanel(): FunctionComponent {
|
||||||
const { getSettings } = useSettings()
|
const { getSettings } = useSettings();
|
||||||
const settings = getSettings()
|
const settings = getSettings();
|
||||||
const { feeds } = useRSSFeeds(settings.feedUrls)
|
const { feeds } = useRSSFeeds(settings.feedUrls);
|
||||||
|
|
||||||
const flattenedItems = sortFeedItemsByDate(feeds)
|
const flattenedItems = sortFeedItemsByDate(feeds);
|
||||||
|
|
||||||
const cardList = flattenedItems.map((item) => (
|
const cardList = flattenedItems.map((item) => (
|
||||||
<ItemCard {...item} key={`feed_item_${item.title.replace(' ', '_')}`} />
|
<ItemCard {...item} key={`feed_item_${item.title.replace(" ", "_")}`} />
|
||||||
))
|
));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box display="flex" flexDirection="column">
|
<Box display="flex" flexDirection="column">
|
||||||
{cardList.length > 0 ? cardList : <NoItemsNotice />}
|
{cardList.length > 0 ? cardList : <NoItemsNotice />}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,55 +1,51 @@
|
||||||
import AppBar from '@material-ui/core/AppBar'
|
import AppBar from "@material-ui/core/AppBar";
|
||||||
import Button from '@material-ui/core/Button'
|
import Button from "@material-ui/core/Button";
|
||||||
import Toolbar from '@material-ui/core/Toolbar'
|
import Toolbar from "@material-ui/core/Toolbar";
|
||||||
import Typography from '@material-ui/core/Typography'
|
import Typography from "@material-ui/core/Typography";
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from "@material-ui/core/styles";
|
||||||
import { FunctionComponent } from 'preact'
|
import { FunctionComponent } from "preact";
|
||||||
|
|
||||||
import useNavigation, { routes } from './hooks/useNavigation'
|
import useNavigation, { routes } from "./hooks/useNavigation";
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => ({
|
const useStyles = makeStyles((theme) => ({
|
||||||
offset: theme.mixins.toolbar,
|
offset: theme.mixins.toolbar,
|
||||||
title: { flexGrow: 1 },
|
title: { flexGrow: 1 },
|
||||||
}))
|
}));
|
||||||
|
|
||||||
const routePrettyNames = {
|
const routePrettyNames = {
|
||||||
[routes.SETTINGS]: 'Settings',
|
[routes.SETTINGS]: "Settings",
|
||||||
[routes.FEEDS]: 'Your feeds',
|
[routes.FEEDS]: "Your feeds",
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function NavigationBar(): FunctionComponent {
|
export default function NavigationBar(): FunctionComponent {
|
||||||
const { location, navigate } = useNavigation()
|
const { location, navigate } = useNavigation();
|
||||||
|
|
||||||
const classes = useStyles()
|
const classes = useStyles();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppBar position="fixed">
|
<AppBar position="fixed">
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<Typography
|
<Typography edge="start" variant="h6" className={classes.title}>
|
||||||
edge="start"
|
{routePrettyNames[location]}
|
||||||
variant="h6"
|
</Typography>
|
||||||
className={classes.title}
|
<Button
|
||||||
>
|
key={"navigation-feeds"}
|
||||||
{routePrettyNames[location]}
|
color="inherit"
|
||||||
</Typography>
|
onClick={() => navigate(routes.FEEDS)}
|
||||||
<Button
|
>
|
||||||
key={'navigation-feeds'}
|
{routePrettyNames[routes.FEEDS]}
|
||||||
color="inherit"
|
</Button>
|
||||||
onClick={() => navigate(routes.FEEDS)}
|
<Button
|
||||||
>
|
key={"navigation-settings"}
|
||||||
{routePrettyNames[routes.FEEDS]}
|
color="inherit"
|
||||||
</Button>
|
onClick={() => navigate(routes.SETTINGS)}
|
||||||
<Button
|
>
|
||||||
key={'navigation-settings'}
|
{routePrettyNames[routes.SETTINGS]}
|
||||||
color="inherit"
|
</Button>
|
||||||
onClick={() => navigate(routes.SETTINGS)}
|
</Toolbar>
|
||||||
>
|
</AppBar>
|
||||||
{routePrettyNames[routes.SETTINGS]}
|
<div className={classes.offset} />
|
||||||
</Button>
|
</>
|
||||||
</Toolbar>
|
);
|
||||||
</AppBar>
|
|
||||||
<div className={classes.offset} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,79 +1,77 @@
|
||||||
import Box from '@material-ui/core/Box'
|
import Box from "@material-ui/core/Box";
|
||||||
import Button from '@material-ui/core/Button'
|
import Button from "@material-ui/core/Button";
|
||||||
import Card from '@material-ui/core/Card'
|
import Card from "@material-ui/core/Card";
|
||||||
import TextField from '@material-ui/core/TextField'
|
import TextField from "@material-ui/core/TextField";
|
||||||
import Typography from '@material-ui/core/Typography'
|
import Typography from "@material-ui/core/Typography";
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from "@material-ui/core/styles";
|
||||||
import CheckCircleOutlineIcon from '@material-ui/icons/CheckCircleOutline'
|
import CheckCircleOutlineIcon from "@material-ui/icons/CheckCircleOutline";
|
||||||
import ErrorOutlineIcon from '@material-ui/icons/ErrorOutline'
|
import ErrorOutlineIcon from "@material-ui/icons/ErrorOutline";
|
||||||
import { FunctionComponent } from 'preact'
|
import { FunctionComponent } from "preact";
|
||||||
import { useState } from 'preact/hooks'
|
import { useState } from "preact/hooks";
|
||||||
|
|
||||||
import useSettings from './hooks/useSettings'
|
import useSettings from "./hooks/useSettings";
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
urlCard: {
|
urlCard: {
|
||||||
margin: '5px',
|
margin: "5px",
|
||||||
padding: '5px',
|
padding: "5px",
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
gap: '5px',
|
gap: "5px",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
function isValidUrl(url: string): boolean {
|
function isValidUrl(url: string): boolean {
|
||||||
const urlPattern = /(https?:\/\/)?(www\.)?[\w.-_]+\.[a-zA-Z]{2,3}/
|
const urlPattern = /(https?:\/\/)?(www\.)?[\w.-_]+\.[a-zA-Z]{2,3}/;
|
||||||
|
|
||||||
return urlPattern.test(url)
|
return urlPattern.test(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPanel(): FunctionComponent {
|
export default function SettingsPanel(): FunctionComponent {
|
||||||
const { getSettings, setSettings } = useSettings()
|
const { getSettings, setSettings } = useSettings();
|
||||||
const settings = getSettings()
|
const settings = getSettings();
|
||||||
const [feedUrlsForm, setFeedUrlsForm] = useState(settings.feedUrls)
|
const [feedUrlsForm, setFeedUrlsForm] = useState(settings.feedUrls);
|
||||||
|
|
||||||
const classes = useStyles()
|
const classes = useStyles();
|
||||||
const urlCards = feedUrlsForm.map((url) => (
|
const urlCards = feedUrlsForm.map((url) => (
|
||||||
<Card key={`url_${url}`} variant="outlined" className={classes.urlCard}>
|
<Card key={`url_${url}`} variant="outlined" className={classes.urlCard}>
|
||||||
{isValidUrl(url) ? (
|
{isValidUrl(url) ? (
|
||||||
<CheckCircleOutlineIcon color="primary" />
|
<CheckCircleOutlineIcon color="primary" />
|
||||||
) : (
|
) : (
|
||||||
<ErrorOutlineIcon color="error" />
|
<ErrorOutlineIcon color="error" />
|
||||||
)}{' '}
|
)}{" "}
|
||||||
{url}
|
{url}
|
||||||
</Card>
|
</Card>
|
||||||
))
|
));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box display="flex" flexDirection="column">
|
<Box display="flex" flexDirection="column">
|
||||||
<Typography paragraph>
|
<Typography paragraph>
|
||||||
{
|
{"Enter URLs to fetch feeds from. Invalid URLs are discarded on save."}
|
||||||
'Enter URLs to fetch feeds from. Invalid URLs are discarded on save.'
|
</Typography>
|
||||||
}
|
<TextField
|
||||||
</Typography>
|
multiline
|
||||||
<TextField
|
rows={5}
|
||||||
multiline
|
variant="outlined"
|
||||||
rows={5}
|
label="Feed URLs"
|
||||||
variant="outlined"
|
fullWidth
|
||||||
label="Feed URLs"
|
value={feedUrlsForm.join("\n")}
|
||||||
fullWidth
|
onChange={(v) => setFeedUrlsForm(v.target.value.split("\n"))}
|
||||||
value={feedUrlsForm.join('\n')}
|
/>
|
||||||
onChange={(v) => setFeedUrlsForm(v.target.value.split('\n'))}
|
<Box display="flex" flexDirection="column">
|
||||||
/>
|
{urlCards}
|
||||||
<Box display="flex" flexDirection="column">
|
</Box>
|
||||||
{urlCards}
|
<Button
|
||||||
</Box>
|
variant="contained"
|
||||||
<Button
|
color="primary"
|
||||||
variant="contained"
|
onClick={() => {
|
||||||
color="primary"
|
const validUrls = feedUrlsForm.filter(isValidUrl);
|
||||||
onClick={() => {
|
setSettings<string[]>("feedUrls", validUrls);
|
||||||
const validUrls = feedUrlsForm.filter(isValidUrl)
|
setFeedUrlsForm(validUrls);
|
||||||
setSettings<string[]>('feedUrls', validUrls)
|
}}
|
||||||
setFeedUrlsForm(validUrls)
|
>
|
||||||
}}
|
Save
|
||||||
>
|
</Button>
|
||||||
Save
|
</Box>
|
||||||
</Button>
|
);
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
export const panelIdentifiers = {
|
export const panelIdentifiers = {
|
||||||
FEEDS: 'feeds',
|
FEEDS: "feeds",
|
||||||
SETTINGS: 'settings',
|
SETTINGS: "settings",
|
||||||
}
|
};
|
||||||
|
|
||||||
export const readablePanelIdentifiers = {
|
export const readablePanelIdentifiers = {
|
||||||
[panelIdentifiers.FEEDS]: 'Feeds',
|
[panelIdentifiers.FEEDS]: "Feeds",
|
||||||
[panelIdentifiers.SETTINGS]: 'Settings',
|
[panelIdentifiers.SETTINGS]: "Settings",
|
||||||
}
|
};
|
||||||
|
|
|
@ -1,26 +1,26 @@
|
||||||
interface LocalStorageHookReturnType {
|
interface LocalStorageHookReturnType {
|
||||||
getValue: <T>(k: string) => T
|
getValue: <T>(k: string) => T;
|
||||||
setValue: <T>(k: string, v: T) => void
|
setValue: <T>(k: string, v: T) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Encapsulates Local Storage interactions.
|
* Encapsulates Local Storage interactions.
|
||||||
*/
|
*/
|
||||||
export default function useLocalStorage(
|
export default function useLocalStorage(
|
||||||
{ isJSON }: { isJSON: boolean } = { isJSON: false },
|
{ isJSON }: { isJSON: boolean } = { isJSON: false },
|
||||||
): LocalStorageHookReturnType {
|
): LocalStorageHookReturnType {
|
||||||
if (!window.localStorage) throw new Error('Local Storage is not available')
|
if (!window.localStorage) throw new Error("Local Storage is not available");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getValue: <T>(key: string): T => {
|
getValue: <T>(key: string): T => {
|
||||||
const raw = window.localStorage.getItem(key)
|
const raw = window.localStorage.getItem(key);
|
||||||
return (isJSON ? JSON.parse(raw) : raw) as T
|
return (isJSON ? JSON.parse(raw) : raw) as T;
|
||||||
},
|
},
|
||||||
setValue: <T>(key: string, value: T) => {
|
setValue: <T>(key: string, value: T) => {
|
||||||
window.localStorage.setItem(
|
window.localStorage.setItem(
|
||||||
key,
|
key,
|
||||||
(isJSON ? JSON.stringify(value) : value) as string,
|
(isJSON ? JSON.stringify(value) : value) as string,
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,54 +1,53 @@
|
||||||
import { ComponentChildren, FunctionComponent, createContext } from 'preact'
|
import { ComponentChildren, FunctionComponent, createContext } from "preact";
|
||||||
import { useCallback, useContext, useState } from 'preact/hooks'
|
import { useCallback, useContext, useState } from "preact/hooks";
|
||||||
|
|
||||||
interface INavigationContext {
|
interface INavigationContext {
|
||||||
location: string
|
location: string;
|
||||||
navigate: (url: string) => void
|
navigate: (url: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NavigationContext = createContext(null)
|
const NavigationContext = createContext(null);
|
||||||
|
|
||||||
export default function useNavigation(): INavigationContext {
|
export default function useNavigation(): INavigationContext {
|
||||||
const context = useContext(NavigationContext)
|
const context = useContext(NavigationContext);
|
||||||
|
|
||||||
if (!context) throw Error('Invalid navigation context')
|
if (!context) throw Error("Invalid navigation context");
|
||||||
|
|
||||||
return context
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const routes = {
|
export const routes = {
|
||||||
FEEDS: '/feeds/',
|
FEEDS: "/feeds/",
|
||||||
SETTINGS: '/settings/',
|
SETTINGS: "/settings/",
|
||||||
}
|
};
|
||||||
|
|
||||||
export function NavigationProvider({
|
export function NavigationProvider({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: ComponentChildren
|
children: ComponentChildren;
|
||||||
}): FunctionComponent<{ children: ComponentChildren }> {
|
}): FunctionComponent<{ children: ComponentChildren }> {
|
||||||
const [location, setLocation] = useState<string>(window.location.pathname)
|
const [location, setLocation] = useState<string>(window.location.pathname);
|
||||||
|
|
||||||
const navigate = useCallback(
|
const navigate = useCallback(
|
||||||
(url: string) => {
|
(url: string) => {
|
||||||
window.history.pushState({}, '', url)
|
window.history.pushState({}, "", url);
|
||||||
setLocation(url)
|
setLocation(url);
|
||||||
},
|
},
|
||||||
[setLocation],
|
[setLocation],
|
||||||
)
|
);
|
||||||
|
|
||||||
const suffixedLocation = location.endsWith('/') ? location : `${location}/`
|
const suffixedLocation = location.endsWith("/") ? location : `${location}/`;
|
||||||
|
|
||||||
if (suffixedLocation !== location) {
|
if (suffixedLocation !== location) {
|
||||||
window.history.replaceState({}, '', suffixedLocation)
|
window.history.replaceState({}, "", suffixedLocation);
|
||||||
setLocation(suffixedLocation)
|
setLocation(suffixedLocation);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Object.values(routes).includes(suffixedLocation))
|
if (!Object.values(routes).includes(suffixedLocation)) navigate(routes.FEEDS);
|
||||||
navigate(routes.FEEDS)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavigationContext.Provider value={{ location, navigate }}>
|
<NavigationContext.Provider value={{ location, navigate }}>
|
||||||
{children}
|
{children}
|
||||||
</NavigationContext.Provider>
|
</NavigationContext.Provider>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,100 +1,98 @@
|
||||||
import { parseFeed } from 'htmlparser2'
|
import { parseFeed } from "htmlparser2";
|
||||||
import { useQueries } from 'react-query'
|
import { useQueries } from "react-query";
|
||||||
|
|
||||||
import { Feed } from '../types'
|
import { Feed } from "../types";
|
||||||
import { isDev } from '../utils'
|
import { isDev } from "../utils";
|
||||||
|
|
||||||
import useLocalStorage from './useLocalStorage'
|
import useLocalStorage from "./useLocalStorage";
|
||||||
|
|
||||||
function mergeFeeds(first, second) {
|
function mergeFeeds(first, second) {
|
||||||
// Assuming `second` is newer.
|
// Assuming `second` is newer.
|
||||||
const seen = new Set(first.items.map((item) => item.url))
|
const seen = new Set(first.items.map((item) => item.url));
|
||||||
const mergedItems = second.items.reduce(
|
const mergedItems = second.items.reduce(
|
||||||
(updatedItems, item) => {
|
(updatedItems, item) => {
|
||||||
if (!seen.has(item.url)) {
|
if (!seen.has(item.url)) {
|
||||||
updatedItems.push(item)
|
updatedItems.push(item);
|
||||||
seen.add(item.url)
|
seen.add(item.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatedItems
|
return updatedItems;
|
||||||
},
|
},
|
||||||
[...first.items],
|
[...first.items],
|
||||||
)
|
);
|
||||||
return {
|
return {
|
||||||
...second,
|
...second,
|
||||||
items: mergedItems,
|
items: mergedItems,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function processFeedXML(feed): Feed {
|
function processFeedXML(feed): Feed {
|
||||||
return {
|
return {
|
||||||
title: feed.title,
|
title: feed.title,
|
||||||
lastPull: String(Date.now()),
|
lastPull: String(Date.now()),
|
||||||
items: feed.items.reduce((items, feedItem) => {
|
items: feed.items.reduce((items, feedItem) => {
|
||||||
items.push({
|
items.push({
|
||||||
title: feedItem.title,
|
title: feedItem.title,
|
||||||
url: feedItem.link,
|
url: feedItem.link,
|
||||||
published: new Date(feedItem.pubDate),
|
published: new Date(feedItem.pubDate),
|
||||||
})
|
});
|
||||||
|
|
||||||
return items
|
return items;
|
||||||
}, []),
|
}, []),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchFeed(
|
async function fetchFeed(
|
||||||
url: string,
|
url: string,
|
||||||
persistedData: Feed | null,
|
persistedData: Feed | null,
|
||||||
): Promise<Feed> {
|
): Promise<Feed> {
|
||||||
const response = await fetch(`/.netlify/functions/rss-proxy?url=${url}`)
|
const response = await fetch(`/.netlify/functions/rss-proxy?url=${url}`);
|
||||||
|
|
||||||
const responseData = await response.text()
|
const responseData = await response.text();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newFeedData = parseFeed(responseData)
|
const newFeedData = parseFeed(responseData);
|
||||||
const newFeed = processFeedXML(newFeedData)
|
const newFeed = processFeedXML(newFeedData);
|
||||||
const mergedFeeds = persistedData
|
const mergedFeeds = persistedData
|
||||||
? mergeFeeds(persistedData, newFeed)
|
? mergeFeeds(persistedData, newFeed)
|
||||||
: newFeed
|
: newFeed;
|
||||||
|
|
||||||
return mergedFeeds
|
return mergedFeeds;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isDev()) {
|
if (isDev()) {
|
||||||
// eslint-disable-next-line no-console
|
console.error(e);
|
||||||
console.error(e)
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return persistedData
|
return persistedData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useRSSFeeds(urls: string[]): { feeds: Feed[] } {
|
export default function useRSSFeeds(urls: string[]): { feeds: Feed[] } {
|
||||||
const { getValue, setValue } = useLocalStorage({ isJSON: true })
|
const { getValue, setValue } = useLocalStorage({ isJSON: true });
|
||||||
|
|
||||||
const queries = useQueries(
|
const queries = useQueries(
|
||||||
urls.map((feedUrl: string) => {
|
urls.map((feedUrl: string) => {
|
||||||
const localStorageKey = `feed_${feedUrl}`
|
const localStorageKey = `feed_${feedUrl}`;
|
||||||
const persistedData = getValue<Feed>(localStorageKey)
|
const persistedData = getValue<Feed>(localStorageKey);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
queryKey: ['feed', feedUrl],
|
queryKey: ["feed", feedUrl],
|
||||||
queryFn: () => fetchFeed(feedUrl, persistedData),
|
queryFn: () => fetchFeed(feedUrl, persistedData),
|
||||||
staleTime: 30000,
|
staleTime: 30000,
|
||||||
onSuccess: (data: Feed) => {
|
onSuccess: (data: Feed) => {
|
||||||
setValue(localStorageKey, data)
|
setValue(localStorageKey, data);
|
||||||
},
|
},
|
||||||
initialData: persistedData ?? undefined,
|
initialData: persistedData ?? undefined,
|
||||||
initialDataUpdatedAt:
|
initialDataUpdatedAt: Number(persistedData?.lastPull) ?? undefined,
|
||||||
Number(persistedData?.lastPull) ?? undefined,
|
};
|
||||||
}
|
}),
|
||||||
}),
|
);
|
||||||
)
|
|
||||||
|
|
||||||
const fetchedFeeds = queries.reduce((fetchedFeeds: Feed[], current) => {
|
const fetchedFeeds = queries.reduce((fetchedFeeds: Feed[], current) => {
|
||||||
if (current.isSuccess && current.data) fetchedFeeds.push(current.data)
|
if (current.isSuccess && current.data) fetchedFeeds.push(current.data);
|
||||||
|
|
||||||
return fetchedFeeds
|
return fetchedFeeds;
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return { feeds: fetchedFeeds }
|
return { feeds: fetchedFeeds };
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,25 +1,25 @@
|
||||||
import { Settings } from '../types'
|
import { Settings } from "../types";
|
||||||
|
|
||||||
import useLocalStorage from './useLocalStorage'
|
import useLocalStorage from "./useLocalStorage";
|
||||||
|
|
||||||
const defaultSettings = {
|
const defaultSettings = {
|
||||||
feedUrls: [],
|
feedUrls: [],
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function useSettings(): {
|
export default function useSettings(): {
|
||||||
getSettings: () => Settings
|
getSettings: () => Settings;
|
||||||
setSettings: <T>(k: string, value: T) => void
|
setSettings: <T>(k: string, value: T) => void;
|
||||||
} {
|
} {
|
||||||
const { getValue, setValue } = useLocalStorage({ isJSON: true })
|
const { getValue, setValue } = useLocalStorage({ isJSON: true });
|
||||||
|
|
||||||
const getSettings = (): Settings =>
|
const getSettings = (): Settings =>
|
||||||
getValue<Settings>('settings') ?? defaultSettings
|
getValue<Settings>("settings") ?? defaultSettings;
|
||||||
|
|
||||||
const setSettings = <T>(key: string, value: T) => {
|
const setSettings = <T>(key: string, value: T) => {
|
||||||
const current = getSettings()
|
const current = getSettings();
|
||||||
|
|
||||||
setValue<Settings>('settings', { ...current, [key]: value })
|
setValue<Settings>("settings", { ...current, [key]: value });
|
||||||
}
|
};
|
||||||
|
|
||||||
return { getSettings, setSettings }
|
return { getSettings, setSettings };
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +1,20 @@
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === "development") {
|
||||||
require('preact/debug')
|
require("preact/debug");
|
||||||
}
|
}
|
||||||
|
|
||||||
import { render } from 'preact'
|
import { render } from "preact";
|
||||||
import { QueryClient, QueryClientProvider } from 'react-query'
|
import { QueryClient, QueryClientProvider } from "react-query";
|
||||||
|
|
||||||
import App from './App'
|
import App from "./App";
|
||||||
import { NavigationProvider } from './hooks/useNavigation'
|
import { NavigationProvider } from "./hooks/useNavigation";
|
||||||
|
|
||||||
const queryClient = new QueryClient()
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<NavigationProvider>
|
<NavigationProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<App />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</NavigationProvider>,
|
</NavigationProvider>,
|
||||||
document.getElementById('app'),
|
document.getElementById("app"),
|
||||||
)
|
);
|
||||||
|
|
|
@ -1,28 +1,28 @@
|
||||||
export interface Feed {
|
export interface Feed {
|
||||||
title: string
|
title: string;
|
||||||
lastPull: string
|
lastPull: string;
|
||||||
items: Item[]
|
items: Item[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Item {
|
export interface Item {
|
||||||
title: string
|
title: string;
|
||||||
url: string
|
url: string;
|
||||||
published: Date
|
published: Date;
|
||||||
source: string
|
source: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
loaded: boolean
|
loaded: boolean;
|
||||||
rssItems: Item[]
|
rssItems: Item[];
|
||||||
feedUrls: string[]
|
feedUrls: string[];
|
||||||
activePanel: string
|
activePanel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RSSData {
|
export interface RSSData {
|
||||||
items: Item[]
|
items: Item[];
|
||||||
lastPushed: Date
|
lastPushed: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
feedUrls: string[]
|
feedUrls: string[];
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +1,20 @@
|
||||||
import type { Feed, Item } from './types'
|
import type { Feed, Item } from "./types";
|
||||||
|
|
||||||
export default function sortFeedItemsByDate(feeds: Feed[]): Item[] {
|
export default function sortFeedItemsByDate(feeds: Feed[]): Item[] {
|
||||||
const flattened = feeds.reduce((flattenedFeeds, feed) => {
|
const flattened = feeds.reduce((flattenedFeeds, feed) => {
|
||||||
const items = feed.items.map((item) => ({
|
const items = feed.items.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
feedTitle: feed.title,
|
feedTitle: feed.title,
|
||||||
}))
|
}));
|
||||||
flattenedFeeds.push(...items)
|
flattenedFeeds.push(...items);
|
||||||
return flattenedFeeds
|
return flattenedFeeds;
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return flattened.sort((first, second) =>
|
return flattened.sort((first, second) =>
|
||||||
first.published > second.published ? -1 : 1,
|
first.published > second.published ? -1 : 1,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isDev(): boolean {
|
export function isDev(): boolean {
|
||||||
return process.env.NODE_ENV === 'development'
|
return process.env.NODE_ENV === "development";
|
||||||
}
|
}
|
||||||
|
|
12
rome.json
Normal file
12
rome.json
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://docs.rome.tools/schemas/12.1.3/schema.json",
|
||||||
|
"organizeImports": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue